Add consent-gated media exchange library
This commit is contained in:
1 parent
b8e4a1bb88
commit
bf474dfe10
42 files changed
+2533
-9
No files matched your search
@@ -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.
|
||||
|
||||
@@ -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).
|
||||
|
||||
Reference in new issue
Block a user