import { createClient } from "matrix-js-sdk"; import EventEmitter from "events"; export default class matrix extends EventEmitter { constructor(options) { super(); this.options = options || {}; this.baseUrl = options.baseUrl || "https://matrix.org"; this.token = options.token || null; this.userId = options.userId || null; if (!this.userId && this.token) { // Try to derive userId if not provided but we have a token? // Actually usually you need userId to init client or it can be inferred? // sdk.createClient({ baseUrl, accessToken, userId }) } this.set = this.options.set || "all"; this.network = "Matrix"; // Custom logger to suppress FetchHttpApi debug spam const silentLogger = { debug: () => {}, // Suppress debug info: (msg, ...args) => console.info(`[Matrix] ${msg}`, ...args), warn: (msg, ...args) => console.warn(`[Matrix] ${msg}`, ...args), error: (msg, ...args) => console.error(`[Matrix] ${msg}`, ...args), trace: () => {}, getChild: () => silentLogger }; this.client = createClient({ baseUrl: this.baseUrl, accessToken: this.token, userId: this.userId, logger: silentLogger }); this.server = { set: this.set, channel: new Map(), user: new Map(), me: {}, download: (mxcUrl) => this.download(mxcUrl) }; return (async () => { await this.start(); return this; })(); } async start() { await this.client.startClient({ initialSyncLimit: 10 }); this.client.once('sync', async (state, prevState, res) => { if (state === 'PREPARED') { const me = await this.client.getProfileInfo(this.client.getUserId()); this.server.me = { nickname: me.displayname || this.client.getUserId(), username: this.client.getUserId(), account: this.client.getUserId(), prefix: `${this.client.getUserId()}!matrix`, // simplified id: this.client.getUserId() }; this.emit("data", ["ready", "matrix"]); if (this.options.channels) { for (const channel of this.options.channels) { try { await this.client.joinRoom(channel); console.log(`[Matrix] Joined room: ${channel}`); } catch (e) { console.error(`[Matrix] Failed to join room ${channel}:`, e); } } } } }); this.client.on("Room.timeline", (event, room, toStartOfTimeline) => { if (toStartOfTimeline) return; if (event.getType() !== "m.room.message") return; // Ignore own messages if (event.getSender() === this.client.getUserId()) return; const content = event.getContent(); if (!content || !content.body) return; if (['m.text', 'm.notice', 'm.emote'].indexOf(content.msgtype) === -1) return; const age = Date.now() - event.getTs(); if (age > 60000) return; // Ignore old events (1m) // Check if DM (<= 2 members) const memberCount = room.getJoinedMemberCount(); // if (memberCount <= 2) { // We can log it if needed, but return early to avoid trigger processing // console.log(`[Matrix] Ignoring DM from ${event.getSender()}`); // return; // } (async () => { try { const msgData = await this.reply(event, room); this.emit("data", ["message", msgData]); } catch (err) { console.error(`[Matrix] Error processing message:`, err); } })(); }); // Error handling? // this.client.on("error", ...); } async reply(event, room) { const senderId = event.getSender(); const senderMember = room.getMember(senderId); const senderName = senderMember ? senderMember.name : senderId; // fallback const content = event.getContent(); let message = content.body; if (content.msgtype === 'm.emote') { message = `* ${senderName} ${message}`; } // Handle replies let replyTo = null; const relation = content['m.relates_to']; if (relation && relation['m.in_reply_to']) { const relatedEventId = relation['m.in_reply_to'].event_id; try { let relatedEvent = room.findEventById(relatedEventId); if (!relatedEvent) { try { const fetched = await this.client.fetchRoomEvent(room.roomId, relatedEventId); if (fetched.getContent) { relatedEvent = fetched; } else { // Create a mock object if it's raw JSON relatedEvent = { getContent: () => fetched.content, getSender: () => fetched.sender, getType: () => fetched.type }; } } catch (fetchErr) { console.error(`[Matrix] Failed to fetch remote event ${relatedEventId}:`, fetchErr); } } if (relatedEvent) { const relatedContent = relatedEvent.getContent(); const isMedia = ['m.image', 'm.video', 'm.audio', 'm.file'].includes(relatedContent.msgtype); let mediaUrl = null; let mxcUrl = null; if (isMedia && relatedContent.url) { mxcUrl = relatedContent.url; mediaUrl = this.client.mxcUrlToHttp(relatedContent.url); } replyTo = { sender: relatedEvent.getSender(), message: relatedContent.body, url: mediaUrl, mxcUrl: mxcUrl, type: relatedContent.msgtype, filename: relatedContent.filename || relatedContent.body, // fallback to body if filename missing filename: relatedContent.filename || relatedContent.body, // fallback to body if filename missing mimetype: relatedContent.info?.mimetype || null }; } } catch (e) { console.warn(`[Matrix] Failed to resolve reply event ${relatedEventId}:`, e); } } return { type: "matrix", network: "Matrix", channel: room.name || "Room", channelid: room.roomId, user: { prefix: `${senderId}!${senderId}`, nick: senderName, username: senderId, // Matrix users are defined by ID usually account: senderId }, message: message, replyTo: replyTo, time: ~~(event.getTs() / 1000), raw: event, self: this.server, reply: (msg) => this.send(room.roomId, this.format(msg)), replyAction: (msg) => this.send(room.roomId, this.format(msg), "emote"), replyNotice: (msg) => this.send(room.roomId, this.format(msg), "notice") }; } async download(mxcUrl) { if (!mxcUrl) throw new Error("No MXC URL provided"); const accessToken = this.client.getAccessToken(); if (!accessToken) console.warn("[Matrix] No access token available for download!"); const tryDownload = async (url) => { const res = await fetch(url, { headers: { "Authorization": `Bearer ${accessToken}` } }); if (!res.ok) { const errText = await res.text(); throw new Error(`HTTP Error ${res.status}: ${res.statusText} - ${errText}`); } return await res.arrayBuffer(); } try { // Parse MXC const cleanMxc = mxcUrl.replace('mxc://', ''); const [serverName, mediaId] = cleanMxc.split('/'); if (!serverName || !mediaId) { throw new Error(`Invalid MXC URL format: ${mxcUrl}`); } // Strategy 1: Authenticated Client API (MSC3916 / Matrix 1.11+) const baseUrl = this.client.baseUrl; const authUrl = `${baseUrl}/_matrix/client/v1/media/download/${serverName}/${mediaId}`; try { const ab = await tryDownload(authUrl); return Buffer.from(ab); } catch (e1) { // console.warn(`[Matrix] Authenticated Client API failed: ${e1.message}`); } // Strategy 2: Legacy Media API (v3) with Auth const v3Url = `${baseUrl}/_matrix/media/v3/download/${serverName}/${mediaId}`; try { const ab = await tryDownload(v3Url); return Buffer.from(ab); } catch (e2) { // console.warn(`[Matrix] Legacy v3 API failed: ${e2.message}`); } throw new Error("All download strategies failed."); } catch (e) { console.error(`[Matrix] Download failed:`, e); throw e; } } format(msg) { if (typeof msg === 'object') return msg; // Should probably be stringified or handled? // simple bbcode to markdown/html or just strip? // Matrix supports HTML. // Reuse logic from others if needed, but for now simple replacement return msg.toString() .replace(/\[b\](.*?)\[\/b\]/g, "**$1**") .replace(/\[i\](.*?)\[\/i\]/g, "*$1*"); } async send(roomId, msg, type = "text") { let content = { msgtype: "m.text", body: "" }; if (type === 'emote') content.msgtype = 'm.emote'; if (type === 'notice') content.msgtype = 'm.notice'; if (typeof msg === 'object') { content.body = msg.body || JSON.stringify(msg); if (msg.formatted_body) { content.format = "org.matrix.custom.html"; content.formatted_body = msg.formatted_body; } } else { // String handling content.body = msg.toString(); // Simple auto-formatting check if (content.body.includes('**') || content.body.includes('*')) { content.format = "org.matrix.custom.html"; content.formatted_body = content.body .replace(/\*\*(.*?)\*\*/g, "$1") .replace(/\*(.*?)\*/g, "$1"); } } try { // Ensure roomId is valid if (!roomId) throw new Error("No roomId provided for send"); await this.client.sendEvent(roomId, "m.room.message", content, ""); } catch (e) { console.error("[Matrix] Failed to send message:", e); } } async sendmsg(mode, recipient, msg) { if (recipient.startsWith('@')) { console.log(`[Matrix] Sending DM to ${recipient}`); try { // Always create a fresh DM room const createOpts = { visibility: "private", invite: [recipient], is_direct: true }; const result = await this.client.createRoom(createOpts); if (result && result.room_id) { await this.send(result.room_id, msg); console.log(`[Matrix] Sent DM to ${recipient} in new room ${result.room_id}`); } } catch(e) { console.error(`[Matrix] Failed to send DM to ${recipient}:`, e); } return; } // recipient should be a roomId for channel/room messages await this.send(recipient, msg); } }