feat(fediverse): media posting for e2ee rooms [kakashi]

feat(fediverse): direct messaging and follower-only posting [kakashi]
feat(config): emoji customization available in config [kakashi]
fix(cmd): unroll by text [kakashi]
refactor(reacts): timeline minimum +4 events => timeline minimum +1 events [kakashi]
chore(deps): upgrade matrix-js-sdk, olm, qs
chore(package): bump version
This commit is contained in:
vulet 2023-08-23 15:25:48 +08:00
parent 5924009154
commit 8e2ce18f26
11 changed files with 333 additions and 476 deletions

View file

@ -1,9 +1,61 @@
const qs = require('qs');
const crypto = require('crypto');
const FormData = require('form-data');
const emojis = { public: '🌐', unlisted: '📝', private: '🔒️', direct: '✉️' };
exports.visibilityEmoji = (v) => emojis[v] || v;
const mediaPathRegex = /^\/_matrix\/media\/r0\/download\/[^/]+\/[^/]+\/?$/;
const decryptMedia = (media, file) => {
const { v, key: { alg, ext, k, }, iv } = file;
if (v !== 'v2' || ext !== true || alg !== 'A256CTR')
throw new Error('Unsupported file encryption');
const key = Buffer.from(k, 'base64');
const _iv = Buffer.from(iv, 'base64');
const cipher = crypto.createDecipheriv('aes-256-ctr', key, _iv);
const data = Buffer.concat([ cipher.update(media.data), cipher.final() ]);
return Object.assign({}, media, { data });
};
const getMediaInfoFromEvent = async (roomId, event_id) => {
const event = await matrix.utils.fetchEncryptedOrNot(roomId, { event_id });
if (event.getType() !== 'm.room.message') throw new Error('Invalid type');
const content = event.getContent();
if (content.msgtype !== 'm.image') throw new Error('Invalid msgtype');
if (content.url) return { url: getMediaUrl(content.url) };
if (content.file) return {
url: getMediaUrl(content.file.url),
filename: content.body,
mimetype: content.info ? content.info.mimetype : null,
file: content.file
};
throw new Error('Invalid event');
};
const getMediaUrl = string => {
let url = new URL(string);
if (url.protocol === 'mxc:' && url.hostname && url.pathname)
url = new URL(`${config.matrix.domain}/_matrix/media/r0/download/${url.hostname}${url.pathname}`);
if (url.protocol !== 'https:' ||
!config.matrix.domains.includes(url.hostname) ||
!mediaPathRegex.test(url.pathname))
throw new Error('Invalid URL');
return url.toString();
};
const getMedia = async (roomId, string) => {
let opts = {};
if (string.startsWith('mxe://'))
opts = await getMediaInfoFromEvent(roomId, string.substring(6));
else
opts.url = getMediaUrl(string);
const media = await mediaDownload(opts);
return opts.file ? decryptMedia(media, opts.file) : media;
};
const getFilename = (header) => {
if (typeof header !== 'string') return null;
try {
@ -14,15 +66,14 @@ const getFilename = (header) => {
}
};
const mediaDownload = async (url, { whitelist, blacklist }) => {
const media = await axios({ method: 'GET', url, responseType: 'arraybuffer' });
if (media.statusText !== 'OK' || blacklist.includes(media.headers['content-type'])) throw media;
if (whitelist.length && !whitelist.includes(media.headers['content-type'])) throw media;
return {
data: media.data,
filename: getFilename(media.headers['content-disposition']),
mimetype: media.headers['content-type'],
};
const mediaDownload = async (opts) => {
const { whitelist, blacklist } = config.fediverse.mimetypes;
const media = await axios({ method: 'GET', url: opts.url, responseType: 'arraybuffer' });
const filename = opts.filename || getFilename(media.headers['content-disposition']);
const mimetype = opts.mimetype || media.headers['content-type'];
if (media.statusText !== 'OK' || blacklist.includes(mimetype)) throw media;
if (whitelist.length && !whitelist.includes(mimetype)) throw media;
return { data: media.data, filename, mimetype };
};
const mediaUpload = async ({ domain }, { data, filename, mimetype }) => {
@ -41,10 +92,10 @@ const mediaUpload = async ({ domain }, { data, filename, mimetype }) => {
return upload.data.id;
};
const run = async (roomId, event, content, replyId, mediaURL, subject) => {
const run = async (roomId, event, content, replyId, mediaURL, subject, visibility) => {
let mediaId = null;
if (mediaURL) {
const media = await mediaDownload(mediaURL, config.fediverse.mimetypes);
const media = await getMedia(roomId, mediaURL);
mediaId = await mediaUpload(config.fediverse, media);
}
if (replyId) content = await fediverse.utils.getStatusMentions(replyId, event).then(m => m.concat(content).join(' '));
@ -55,6 +106,7 @@ const run = async (roomId, event, content, replyId, mediaURL, subject) => {
data: qs.stringify({
status: content,
content_type: 'text/markdown',
visibility: visibility || undefined,
media_ids: mediaURL && [mediaId] || undefined,
in_reply_to_id: replyId || undefined,
spoiler_text: subject || undefined,
@ -63,28 +115,16 @@ const run = async (roomId, event, content, replyId, mediaURL, subject) => {
return fediverse.utils.sendEventWithMeta(roomId, `<a href="${response.data.url}">${response.data.id}</a>`, `redact ${response.data.id}`);
};
exports.runQuery = async (roomId, event, userInput, { isReply, hasMedia, hasSubject }) => {
exports.runQuery = async (roomId, event, userInput, { isReply, hasMedia, hasSubject, visibility }) => {
try {
const chunks = userInput.trim().split(' ');
if (!chunks.length || chunks.length < !!isReply + !!hasMedia) throw '';
let replyId = null;
let mediaURL = null;
const subject = hasSubject ? config.fediverse.subject : null;
if (isReply) {
replyId = chunks[0];
chunks.shift();
}
if (hasMedia) {
let url = new URL(chunks[0]);
chunks.shift();
if (url.protocol === 'mxc:' && url.hostname && url.pathname)
url = new URL(`${config.matrix.domain}/_matrix/media/r0/download/${url.hostname}${url.pathname}`);
if (url.protocol !== 'https:') throw '';
if (!config.matrix.domains.includes(url.hostname)) throw '';
if (!/^\/_matrix\/media\/r0\/download\/[^/]+\/[^/]+\/?$/.test(url.pathname)) throw '';
mediaURL = url.toString();
}
return await run(roomId, event, chunks.join(' '), replyId, mediaURL, subject);
if (isReply) replyId = chunks.shift();
if (hasMedia) mediaURL = chunks.shift();
return await run(roomId, event, chunks.join(' '), replyId, mediaURL, subject, visibility);
} catch (e) {
return matrixClient.sendHtmlNotice(roomId, 'Sad!', '<strong>Sad!</strong>').catch(() => {});
}

View file

@ -7,17 +7,19 @@ exports.runQuery = function (roomId, event, userInput) {
instance.get(`/api/v1/statuses/${userInput}/context`)
.then(async (response) => {
let story = [];
const rel = event.getContent()['m.relates_to'];
const eventId = rel && rel.event_id ? rel.event_id : event.getId();
const original = await instance.get(`/api/v1/statuses/${userInput}`);
const ancestors = response.data.ancestors;
const descendants = response.data.descendants;
story = [...story, ancestors, original.data, descendants];
const book = story.flat();
await fediverse.utils.thread(roomId, event, '<br><hr><h3>...Beginning thread...</h3><hr><br>');
await fediverse.utils.thread(roomId, eventId, '<br><hr><h3>...Beginning thread...</h3><hr><br>');
for (const [i, entry] of book.entries()) {
entry.label = 'thread';
fediverse.utils.formatter(entry, roomId, event)
fediverse.utils.formatter(entry, roomId, eventId);
}
await fediverse.utils.thread(roomId, event, '<br><hr><h3>...Thread ended...</h3><hr><br>');
await fediverse.utils.thread(roomId, eventId, '<br><hr><h3>...Thread ended...</h3><hr><br>');
})
.catch((e) => {
matrix.utils.addReact(event, '❌');

View file

@ -8,7 +8,7 @@ const sendEventWithMeta = async (roomId, content, meta) => {
});
};
const thread = async (roomId, event, content, meta) => {
const thread = async (roomId, eventId, content, meta) => {
await matrixClient.sendEvent(roomId, 'm.room.message', {
body: content.replace(/<[^<]+?>/g, ''),
msgtype: 'm.notice',
@ -17,7 +17,7 @@ const thread = async (roomId, event, content, meta) => {
format: 'org.matrix.custom.html',
'm.relates_to': {
rel_type: 'm.thread',
event_id: event['event']['content']['m.relates_to']['event_id'],
event_id: eventId,
},
})
};
@ -78,6 +78,18 @@ const notifyFormatter = (res, roomId) => {
</blockquote>`;
sendEventWithMeta(roomId, content, meta);
break;
case 'pleroma:emoji_reaction':
fediverse.auth.me !== res.account.url ? res.meta = 'react' : res.meta = 'redact';
meta = `${res.meta} ${res.status.id}`;
content = `${userDetails}
<font color="#03b381"><b>has <a href="${config.fediverse.domain}/notice/${res.status.id}">reacted</a> with
${ res.emoji_url ? `<a href="${res.emoji_url}">${res.emoji}</a>` : `<span>${res.emoji}</span>` }
to your post:</font><blockquote><i>${res.status.content}</i><br>
${hasAttachment(res)}
<br>(id: ${res.status.id}) ${registrar.post.visibilityEmoji(res.status.visibility)}
</blockquote>`;
sendEventWithMeta(roomId, content, meta);
break;
default:
return console.log('Unknown notification type.');
}

View file

@ -3,6 +3,8 @@ exports.runQuery = function (roomId) {
' ',
'<blockquote><b>fediverse commands<br>'
+ '+post [your message] : post<br>'
+ '+direct [@recipient] [message] : direct message<br>'
+ '+private [message] : follower-only message<br>'
+ '+redact [post id] : delete post<br>'
+ '+follow [user id] : follow<br>'
+ '+unfollow [user id] : unfollow<br>'