import type { MatrixClient } from "@vector-im/matrix-bot-sdk"; import type { PollInput } from "axel-ia/plugin-sdk"; import { getMatrixRuntime } from "../runtime.js"; import { buildPollStartContent, M_POLL_START } from "./poll-types.js"; import { resolveMatrixClient, resolveMediaMaxBytes } from "./send/client.js"; import { buildReplyRelation, buildTextContent, buildThreadRelation, resolveMatrixMsgType, resolveMatrixVoiceDecision, } from "./send/formatting.js"; import { buildMediaContent, prepareImageInfo, resolveMediaDurationMs, uploadMediaMaybeEncrypted, } from "./send/media.js"; import { normalizeThreadId, resolveMatrixRoomId } from "./send/targets.js"; import { EventType, MsgType, RelationType, type MatrixOutboundContent, type MatrixSendOpts, type MatrixSendResult, type ReactionEventContent, } from "./send/types.js"; const MATRIX_TEXT_LIMIT = 4000; const getCore = () => getMatrixRuntime(); export type { MatrixSendOpts, MatrixSendResult } from "./send/types.js"; export { resolveMatrixRoomId } from "./send/targets.js"; export async function sendMessageMatrix( to: string, message: string, opts: MatrixSendOpts = {}, ): Promise { const trimmedMessage = message?.trim() ?? ""; if (!trimmedMessage && !opts.mediaUrl) { throw new Error("Matrix send requires text or media"); } const { client, stopOnDone } = await resolveMatrixClient({ client: opts.client, timeoutMs: opts.timeoutMs, }); try { const roomId = await resolveMatrixRoomId(client, to); const cfg = getCore().config.loadConfig(); const tableMode = getCore().channel.text.resolveMarkdownTableMode({ cfg, channel: "matrix", accountId: opts.accountId, }); const convertedMessage = getCore().channel.text.convertMarkdownTables( trimmedMessage, tableMode, ); const textLimit = getCore().channel.text.resolveTextChunkLimit(cfg, "matrix"); const chunkLimit = Math.min(textLimit, MATRIX_TEXT_LIMIT); const chunkMode = getCore().channel.text.resolveChunkMode(cfg, "matrix", opts.accountId); const chunks = getCore().channel.text.chunkMarkdownTextWithMode( convertedMessage, chunkLimit, chunkMode, ); const threadId = normalizeThreadId(opts.threadId); const relation = threadId ? buildThreadRelation(threadId, opts.replyToId) : buildReplyRelation(opts.replyToId); const sendContent = async (content: MatrixOutboundContent) => { // @vector-im/matrix-bot-sdk uses sendMessage differently const eventId = await client.sendMessage(roomId, content); return eventId; }; let lastMessageId = ""; if (opts.mediaUrl) { const maxBytes = resolveMediaMaxBytes(); const media = await getCore().media.loadWebMedia(opts.mediaUrl, maxBytes); const uploaded = await uploadMediaMaybeEncrypted(client, roomId, media.buffer, { contentType: media.contentType, filename: media.fileName, }); const durationMs = await resolveMediaDurationMs({ buffer: media.buffer, contentType: media.contentType, fileName: media.fileName, kind: media.kind, }); const baseMsgType = resolveMatrixMsgType(media.contentType, media.fileName); const { useVoice } = resolveMatrixVoiceDecision({ wantsVoice: opts.audioAsVoice === true, contentType: media.contentType, fileName: media.fileName, }); const msgtype = useVoice ? MsgType.Audio : baseMsgType; const isImage = msgtype === MsgType.Image; const imageInfo = isImage ? await prepareImageInfo({ buffer: media.buffer, client }) : undefined; const [firstChunk, ...rest] = chunks; const body = useVoice ? "Voice message" : (firstChunk ?? media.fileName ?? "(file)"); const content = buildMediaContent({ msgtype, body, url: uploaded.url, file: uploaded.file, filename: media.fileName, mimetype: media.contentType, size: media.buffer.byteLength, durationMs, relation, isVoice: useVoice, imageInfo, }); const eventId = await sendContent(content); lastMessageId = eventId ?? lastMessageId; const textChunks = useVoice ? chunks : rest; const followupRelation = threadId ? relation : undefined; for (const chunk of textChunks) { const text = chunk.trim(); if (!text) { continue; } const followup = buildTextContent(text, followupRelation); const followupEventId = await sendContent(followup); lastMessageId = followupEventId ?? lastMessageId; } } else { for (const chunk of chunks.length ? chunks : [""]) { const text = chunk.trim(); if (!text) { continue; } const content = buildTextContent(text, relation); const eventId = await sendContent(content); lastMessageId = eventId ?? lastMessageId; } } return { messageId: lastMessageId || "unknown", roomId, }; } finally { if (stopOnDone) { client.stop(); } } } export async function sendPollMatrix( to: string, poll: PollInput, opts: MatrixSendOpts = {}, ): Promise<{ eventId: string; roomId: string }> { if (!poll.question?.trim()) { throw new Error("Matrix poll requires a question"); } if (!poll.options?.length) { throw new Error("Matrix poll requires options"); } const { client, stopOnDone } = await resolveMatrixClient({ client: opts.client, timeoutMs: opts.timeoutMs, }); try { const roomId = await resolveMatrixRoomId(client, to); const pollContent = buildPollStartContent(poll); const threadId = normalizeThreadId(opts.threadId); const pollPayload = threadId ? { ...pollContent, "m.relates_to": buildThreadRelation(threadId) } : pollContent; // @vector-im/matrix-bot-sdk sendEvent returns eventId string directly const eventId = await client.sendEvent(roomId, M_POLL_START, pollPayload); return { eventId: eventId ?? "unknown", roomId, }; } finally { if (stopOnDone) { client.stop(); } } } export async function sendTypingMatrix( roomId: string, typing: boolean, timeoutMs?: number, client?: MatrixClient, ): Promise { const { client: resolved, stopOnDone } = await resolveMatrixClient({ client, timeoutMs, }); try { const resolvedTimeoutMs = typeof timeoutMs === "number" ? timeoutMs : 30_000; await resolved.setTyping(roomId, typing, resolvedTimeoutMs); } finally { if (stopOnDone) { resolved.stop(); } } } export async function sendReadReceiptMatrix( roomId: string, eventId: string, client?: MatrixClient, ): Promise { if (!eventId?.trim()) { return; } const { client: resolved, stopOnDone } = await resolveMatrixClient({ client, }); try { const resolvedRoom = await resolveMatrixRoomId(resolved, roomId); await resolved.sendReadReceipt(resolvedRoom, eventId.trim()); } finally { if (stopOnDone) { resolved.stop(); } } } export async function reactMatrixMessage( roomId: string, messageId: string, emoji: string, client?: MatrixClient, ): Promise { if (!emoji.trim()) { throw new Error("Matrix reaction requires an emoji"); } const { client: resolved, stopOnDone } = await resolveMatrixClient({ client, }); try { const resolvedRoom = await resolveMatrixRoomId(resolved, roomId); const reaction: ReactionEventContent = { "m.relates_to": { rel_type: RelationType.Annotation, event_id: messageId, key: emoji, }, }; await resolved.sendEvent(resolvedRoom, EventType.Reaction, reaction); } finally { if (stopOnDone) { resolved.stop(); } } }