diff --git a/src/clients/matrix.mjs b/src/clients/matrix.mjs
new file mode 100644
index 0000000..5fd1c3e
--- /dev/null
+++ b/src/clients/matrix.mjs
@@ -0,0 +1,361 @@
+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
+ };
+ }
+ } 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 {
+ // Try to find existing DM
+ // Note: implementation details vary by SDK version, simplified approach:
+ const accountData = this.client.getAccountData('m.direct');
+ let roomId;
+
+ if (accountData) {
+ const directRooms = accountData.getContent();
+ const rooms = directRooms[recipient];
+ if (rooms && rooms.length > 0) {
+ roomId = rooms[0]; // Pick first one
+ // Check if we are still in it? Assumed yes for now.
+ }
+ }
+
+ if (!roomId) {
+ // Create new DM
+ const createOpts = {
+ visibility: "private",
+ invite: [recipient],
+ is_direct: true
+ };
+ const result = await this.client.createRoom(createOpts);
+ roomId = result.room_id;
+
+ // Manually update m.direct to ensure we remember this room for this user
+ if (roomId) {
+ try {
+ const newDirects = accountData ? accountData.getContent() : {};
+ const userRooms = newDirects[recipient] || [];
+ if (!userRooms.includes(roomId)) {
+ userRooms.push(roomId);
+ newDirects[recipient] = userRooms;
+ await this.client.setAccountData('m.direct', newDirects);
+ console.log(`[Matrix] Updated m.direct for ${recipient} with ${roomId}`);
+ }
+ } catch (err) {
+ console.error(`[Matrix] Failed to update m.direct:`, err);
+ }
+ }
+ }
+
+ if (roomId) {
+ await this.send(roomId, msg);
+ }
+ } 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);
+ }
+}