Add consent-gated media exchange library

This commit is contained in:
jjbrunton committed 2026-08-09 19:24:38 +01:00
1 parent b8e4a1bb88
commit bf474dfe10
42 files changed
+2533 -9

No files matched your search

+81
View File
@@ -528,6 +528,87 @@ export class MatrixClient {
return res.event_id;
}
/** Upload an image to the Matrix content repository and return its mxc URI. */
async uploadImage(bytes: Buffer, mimeType: string, fileName: string): Promise<string> {
const candidates = [
'/_matrix/media/v3/upload',
'/_matrix/client/v1/media/upload',
];
for (const endpoint of candidates) {
const url = new URL(this.creds.homeserver + endpoint);
url.searchParams.set('filename', fileName);
await this.acquireRequestSlot();
const response = await this.timedFetch(url.toString(), {
method: 'POST',
headers: {
Authorization: `Bearer ${this.creds.accessToken}`,
'Content-Type': mimeType,
},
body: Uint8Array.from(bytes).buffer,
});
if (response === null) throw new Error('Matrix media upload timed out');
if (response.status === 401 || response.status === 403) {
const failure = await readMatrixFailure(response);
if (isMatrixAuthenticationFailure(response.status, failure.errcode)) {
throw new MatrixAuthError(`Matrix media upload → ${response.status} (token rejected)`);
}
throw new Error(`Matrix media upload → ${response.status}: ${failure.text.slice(0, 200)}`);
}
if (response.status === 404 || response.status === 405) continue;
if (response.status === 429) {
const failure = await response.json().catch(() => ({})) as {
retry_after_ms?: unknown;
errcode?: unknown;
['com.reddit.error.code']?: unknown;
};
throw new MatrixRateLimitError(
'Matrix media upload rate limited',
typeof failure.retry_after_ms === 'number' ? failure.retry_after_ms : 500,
typeof failure.errcode === 'string' ? failure.errcode : null,
typeof failure['com.reddit.error.code'] === 'string' ? failure['com.reddit.error.code'] : null,
);
}
if (!response.ok) {
const failure = await response.text().catch(() => '');
throw new Error(`Matrix media upload → ${response.status}: ${failure.slice(0, 200)}`);
}
const payload = await response.json() as { content_uri?: unknown };
if (typeof payload.content_uri !== 'string' || !payload.content_uri.startsWith('mxc://')) {
throw new Error('Matrix media upload returned no content URI');
}
return payload.content_uri;
}
throw new Error('Matrix homeserver does not expose a supported media upload endpoint');
}
async sendImageMessage(
roomId: string,
body: string,
mxcUri: string,
info: { mimeType: string; size: number; width: number; height: number },
txnId?: string,
): Promise<string> {
const id = txnId ?? `rbot-image-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
const response = await this.request<{ event_id: string }>(
'PUT',
`/_matrix/client/v3/rooms/${encodeURIComponent(roomId)}/send/m.room.message/${encodeURIComponent(id)}`,
{
body: {
msgtype: 'm.image',
body,
url: mxcUri,
info: {
mimetype: info.mimeType,
size: info.size,
w: info.width,
h: info.height,
},
},
},
);
return response.event_id;
}
/**
* Convert an mxc:// URI to an HTTPS download URL on this homeserver.
* Reddit's media is served from the same Matrix server.
+43 -2
View File
@@ -582,7 +582,7 @@ export class ChatService {
* `https://chat.reddit.com/room/<roomId>` URL (web UI compatibility) or
* a raw Matrix room ID like `!abc:reddit.com`.
*/
async sendChatMessage(chatUrlOrRoomId: string, message: string): Promise<boolean> {
async sendChatMessage(chatUrlOrRoomId: string, message: string, txnId?: string): Promise<boolean> {
if (!this.accountAuth?.isFullyAuthenticated()) return false;
const active = this.getInvitationCooldown();
if (active && this.blocksExistingRoomSends(active)) {
@@ -599,7 +599,7 @@ export class ChatService {
this.logger.error(`[chat] cannot extract room ID from "${chatUrlOrRoomId}"`);
return false;
}
const eventId = await this.withAuth(() => this.matrix.sendTextMessage(roomId, message));
const eventId = await this.withAuth(() => this.matrix.sendTextMessage(roomId, message, txnId));
this.logger.info(`[chat] sent message to ${roomId} (event_id=${eventId})`);
const chat = this.cache.get().find((c) => c.roomId === roomId);
if (chat) {
@@ -619,6 +619,47 @@ export class ChatService {
});
}
async sendChatImage(
chatUrlOrRoomId: string,
image: { bytes: Buffer; mimeType: string; fileName: string; width: number; height: number },
txnId: string,
existingMxcUri?: string | null,
): Promise<{ eventId: string; mxcUri: string } | null> {
if (!this.accountAuth?.isFullyAuthenticated()) return null;
const active = this.getInvitationCooldown();
if (active && this.blocksExistingRoomSends(active)) return null;
return this.enqueue(async () => {
try {
const roomId = ChatService.extractRoomId(chatUrlOrRoomId);
if (!roomId) return null;
const mxcUri = existingMxcUri
?? await this.withAuth(() => this.matrix.uploadImage(image.bytes, image.mimeType, image.fileName));
const eventId = await this.withAuth(() => this.matrix.sendImageMessage(
roomId,
image.fileName,
mxcUri,
{ mimeType: image.mimeType, size: image.bytes.length, width: image.width, height: image.height },
txnId,
));
this.logger.info(`[chat] sent image to ${roomId} (event_id=${eventId})`);
const chat = this.cache.get().find((item) => item.roomId === roomId);
if (chat) {
chat.lastMessageSnippet = '[Image]';
chat.lastMessageTime = Date.now();
chat.isFromUs = true;
}
return { eventId, mxcUri };
} catch (error) {
if (error instanceof MatrixRateLimitError) {
this.setInvitationCooldown(error, 'all-sends');
return null;
}
this.logger.error('[chat] sendChatImage failed:', error);
return null;
}
});
}
/**
* Start a brand-new DM with the given Reddit user and send the opening
* line. Returns the new chat (or null on failure).