307 lines
7.8 KiB
JavaScript
307 lines
7.8 KiB
JavaScript
|
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);
|
||
|
}
|
||
|
}
|