matrixssbook/mxbridge.js

307 lines
7.8 KiB
JavaScript
Raw Normal View History

2023-05-25 02:04:06 +00:00
const { Cli, Bridge, WeakEvent, Intent, AppServiceRegistration } = require("matrix-appservice-bridge");
const { LogService } = require("matrix-bot-sdk");
const EventEmitter = require("events");
/**
* @typedef {{
* localpart?: string,
* regex?: {type: "users"|"rooms"|"aliases", regex: string, exclusive?: boolean}[],
* }} Registration
*/
/**
* @typedef {{
* localpart: string,
* username?: string,
* avatar?: string,
* }} User
*/
/**
* @typedef {(config: Record<string, number>) => {
* homeserver: string,
* homeserver_url: string,
* }} EvalConfig
*/
const defaultEvalConfig = config => ({
enabled: true,
homeserver: config.homeserver,
homeserver_url: config.homeserver_url,
})
module.exports.MxBridge = class MxBridge extends EventEmitter {
/**
* @param {string} registrationPath
* @param {string} schema
* @param {Record<string, unknown>} defaults
* @param {Registration} registration
* @param {EvalConfig} getValues
*/
constructor(registrationPath, schema, defaults, registration, getValues = defaultEvalConfig) {
super();
let bridgeResolve;
/** @type {Promise<Bridge>} */
this.bridge = new Promise(r => bridgeResolve = r);
this.avatars = {};
new Cli({
registrationPath,
bridgeConfig: {
schema,
defaults,
},
generateRegistration(reg, cb) {
reg.setId(AppServiceRegistration.generateToken());
reg.setHomeserverToken(AppServiceRegistration.generateToken());
reg.setAppServiceToken(AppServiceRegistration.generateToken());
if(registration.localpart) {
reg.setSenderLocalpart(registration.localpart);
}
if(Array.isArray(registration.regex)) {
registration.regex.forEach(r => {
reg.addRegexPattern(r.type, r.regex, r.exclusive);
});
}
cb(reg);
},
run: async (port, config) => {
// don't emit "config" synchronously, give a consumer a chance to attach a listener for it
await new Promise(r => setImmediate(r));
this.emit("config", config);
const values = getValues(config);
const bridge = new Bridge({
homeserverUrl: values.homeserver_url,
domain: values.homeserver,
registration: registrationPath,
disableStores: true,
controller: {
onUserQuery(u) {
return {};
},
onEvent: async (req, ctx) => {
const evt = req.getData();
// console.log("got event", evt);
/**
* @event MxBridge#evt
* @param {WeakEvent} event
* @param {Bridge} bridge
*/
this.emit("evt", evt, bridge);
},
}
});
console.log("Matrix appservice listening on port", port);
await bridge.run(port);
bridgeResolve(bridge);
/**
* @event MxBridge#login
* @param {Bridge} bridge
* @param {Record<string, unknown>} config
*/
this.emit("login", bridge, config);
}
}).run();
this.on("evt", async (evt, bridge) => {
// the bridge user should autojoin on invites
if(
evt.type === "m.room.member" &&
evt.state_key === bridge.getBot().getUserId() &&
evt.content?.membership === "invite"
) {
console.log("got invite! joining")
await bridge.getIntent().join(evt.room_id);
/**
* @event MxBridge#room
* @param {string} room_id
* @param {Bridge} bridge
*/
this.emit("room", evt.room_id, bridge);
}
if(
evt.type === "m.room.message" &&
evt.content
) {
if(evt.content["m.relates_to"]?.["rel_type"] === "m.replace" && evt.content["m.new_content"]) {
/**
* @event MxBridge#edit
* @param {object} content
* @param {string} replaces
* @param {WeakEvent} event
* @param {Bridge} bridge
*/
this.emit("edit", evt.content["m.new_content"], evt.content["m.relates_to"].event_id, evt, bridge);
} else {
/**
* @event MxBridge#message
* @param {object} content
* @param {WeakEvent} event
* @param {Bridge} bridge
*/
this.emit("message", evt.content, evt, bridge);
}
}
if(
evt.type === "m.room.redaction" &&
evt.redacts
) {
/**
* @event MxBridge#redact
* @param {string} redacts
* @param {WeakEvent} event
* @param {Bridge} bridge
*/
this.emit("redact", evt.redacts, evt, bridge);
}
if(
evt.type === "m.reaction" &&
evt.content?.["m.relates_to"]?.rel_type === "m.annotation"
) {
/**
* @event MxBridge#react
* @param {string} reacts_to
* @param {string} reaction
* @param {WeakEvent} event
* @param {Bridge} bridge
*/
this.emit("react", evt.content["m.relates_to"].event_id, evt.content["m.relates_to"].key, evt, bridge);
}
})
}
/**
* Uploads a user's avatar to the content repository if it isn't already
* @param {string} url
* @param {Intent} intent
*/
async ensureUploaded(url, intent) {
if(url in this.avatars) {
return this.avatars[url];
}
console.log("fetching file", url);
const res = await fetch(url);
const blob = await res.blob();
const buffer = Buffer.from(await blob.arrayBuffer());
console.log("uploading to matrix: content type", blob.type);
const mxc = await intent.uploadContent(buffer, blob.type);
console.log("got mxc url", mxc);
this.avatars[url] = mxc;
/**
* @event MxBridge#avatarupload
* @param {string} mxc Matrix content repository URL
* @param {string} url Original HTTP URL
* @param {{[key: string]: string}} avatars The current avatar cache
*/
this.emit("avatarupload", mxc, url, this.avatars);
return mxc;
}
/**
* @param {string} room
* @param {User} user
* @param {string} body
* @param {string?} formatted_body
*/
async sendMessage(room, user, body, formatted_body) {
const bridge = await this.bridge;
const intent = bridge.getIntentFromLocalpart(user.localpart);
let avatar;
if(user.avatar) {
avatar = await this.ensureUploaded(user.avatar, intent);
}
await intent.setRoomUserProfile(room, {
displayname: user.username,
avatar_url: avatar,
});
// look. this error. it's annoying. and it is of no consequence.
// the user already existing is the *goal*. but there's no switch to turn it off.
// so i'm making one, ok?
const oldError = LogService.error;
LogService.error = function(logType, msg, err) {
if(logType === "Appservice" && msg === "Error registering user: User ID is in use") {
return;
}
if(logType === "MatrixHttpClient" && msg.startsWith("(REQ-") && err.errcode === "M_USER_IN_USE") {
return;
}
return oldError.apply(this, arguments);
}
const { event_id } = await intent.sendMessage(room, {
msgtype: "m.text",
body,
...(formatted_body !== undefined ? {
format: "org.matrix.custom.html",
formatted_body
} : {})
})
// put that back where it came from or so help me
LogService.error = oldError;
return event_id;
}
/**
* @param {string} room
* @param {User} user
* @param {string} eventId
* @param {string} body
* @param {string?} formatted_body
*/
async editMessage(room, user, eventId, body, formatted_body) {
const bridge = await this.bridge;
const intent = bridge.getIntentFromLocalpart(user.localpart);
const { event_id } = intent.sendMessage(room, {
body: "* " + body,
msgtype: "m.text",
"m.new_content": {
msgtype: "m.text",
body,
...(formatted_body !== undefined ? {
format: "org.matrix.custom.html",
formatted_body
} : {})
},
"m.relates_to": {
event_id: eventId,
rel_type: "m.replace",
}
})
return event_id;
}
/**
* @param {string} room
* @param {User} user
* @param {string} event_id
*/
async redact(room, user, event_id) {
const bridge = await this.bridge;
const intent = bridge.getIntentFromLocalpart(user.localpart);
return await intent.matrixClient.redactEvent(room, event_id);
}
async getMxcToHttp() {
const bridge = await this.bridge;
return url => bridge.getBot().getClient().mxcToHttp(url);
}
}