service-matrix/matrix/plugin.ts

515 lines
12 KiB
TypeScript
Raw Permalink Normal View History

2021-01-19 11:54:54 +00:00
import {
Plugin,
Configurable,
EventListener,
2023-08-02 17:25:37 +00:00
InjectService,
2021-01-19 11:54:54 +00:00
} from '@squeebot/core/lib/plugin';
import util from 'util';
2023-08-02 17:25:37 +00:00
import { promises as fs } from 'fs';
2021-01-19 11:54:54 +00:00
import {
MatrixClient,
AutojoinRoomsMixin,
SimpleFsStorageProvider,
LogService,
LogLevel,
2023-08-02 17:25:37 +00:00
RustSdkCryptoStorageProvider,
2024-04-07 17:50:19 +00:00
RichReply,
2021-01-19 11:54:54 +00:00
} from 'matrix-bot-sdk';
2023-08-02 17:25:37 +00:00
import { StoreType } from '@matrix-org/matrix-sdk-crypto-nodejs';
2021-01-19 11:54:54 +00:00
2024-04-07 17:50:19 +00:00
import { logger } from '@squeebot/core/lib/core';
2021-01-19 11:54:54 +00:00
import {
EMessageType,
Formatter,
IMessage,
IMessageTarget,
2023-08-02 17:25:37 +00:00
Protocol,
ProtocolFeatureFlag,
2021-01-19 11:54:54 +00:00
} from '@squeebot/core/lib/types';
2023-08-01 15:43:57 +00:00
import { MatrixFormatter } from './format';
2023-08-02 17:25:37 +00:00
import { resolve, join } from 'path';
2023-08-01 15:43:57 +00:00
2023-08-02 17:25:37 +00:00
/**
* Message adapter for Matrix protocol.
*/
2021-01-19 11:54:54 +00:00
class MatrixMessageAdapter implements IMessage {
public time: Date = new Date();
public resolved = false;
2024-04-07 17:50:19 +00:00
public isReply = false;
2021-01-19 11:54:54 +00:00
constructor(
public type: EMessageType,
public data: any,
public source: Protocol,
public sender: IMessageTarget,
2023-08-07 13:55:11 +00:00
public target?: IMessageTarget,
public direct = false
2023-08-02 17:25:37 +00:00
) {}
2021-01-19 11:54:54 +00:00
public get fullSenderID(): string {
return this.source.fullName + '/' + this.sender.id;
}
public get fullRoomID(): string {
if (!this.target) {
return this.source.fullName;
}
return this.source.fullName + '/' + this.target.id;
}
public get text(): string {
return this.data.content.body;
}
public resolve(...args: any[]): void {
this.resolved = true;
this.source.resolve(this, ...args);
}
public reject(error: Error): void {
this.resolved = true;
this.source.resolve(this, error.message);
}
2024-04-07 17:50:19 +00:00
public reply(...args: any[]): void {
this.isReply = true;
this.resolve(...args);
}
2021-01-19 11:54:54 +00:00
public mention(user: IMessageTarget): string {
2021-01-19 16:42:06 +00:00
return `<a href="https://matrix.to/#/${user.id}">${user.id}</a>`;
2021-01-19 11:54:54 +00:00
}
}
2023-08-02 17:25:37 +00:00
/**
* File system utilities for storing data outside of the configuration.
*/
class MatrixFileSystemUtils {
constructor(private config: any) {}
get baseDir() {
return join(resolve('.matrix'), this.config.name);
}
get storageFile() {
return join(this.baseDir, 'db.json');
}
get cryptoStorageDir() {
return join(this.baseDir, 'sled');
}
get tokenFile() {
return join(this.baseDir, 'token');
}
public async ensureMatrixDirectory() {
try {
await fs.mkdir(resolve(this.baseDir), { recursive: true });
} catch {}
}
public async readAuth(): Promise<Record<string, string>> {
try {
await fs.stat(this.tokenFile);
} catch {
return {};
}
return JSON.parse(await fs.readFile(this.tokenFile, { encoding: 'utf-8' }));
}
public async writeAuth(data: Record<string, string>): Promise<void> {
await fs.writeFile(this.tokenFile, JSON.stringify(data), {
encoding: 'utf-8',
});
}
}
2021-01-19 11:54:54 +00:00
class MatrixProtocol extends Protocol {
2023-08-02 17:25:37 +00:00
public static Features = [
ProtocolFeatureFlag.COLORS,
ProtocolFeatureFlag.FORMATTING,
ProtocolFeatureFlag.PLAIN,
ProtocolFeatureFlag.HTML,
ProtocolFeatureFlag.EMOJI,
ProtocolFeatureFlag.IMAGES,
ProtocolFeatureFlag.VOICE,
ProtocolFeatureFlag.VIDEO,
ProtocolFeatureFlag.THREADS,
ProtocolFeatureFlag.REACTIONS,
ProtocolFeatureFlag.MENTION,
2024-04-07 17:50:19 +00:00
ProtocolFeatureFlag.REPLY,
2023-08-02 17:25:37 +00:00
ProtocolFeatureFlag.OPTIONAL_ENCRYPTION,
ProtocolFeatureFlag.EVENT_MESSAGE,
ProtocolFeatureFlag.EVENT_ROOM_JOIN,
ProtocolFeatureFlag.EVENT_ROOM_LEAVE,
ProtocolFeatureFlag.KICK,
ProtocolFeatureFlag.BAN,
ProtocolFeatureFlag.MUTE,
];
2023-08-01 15:43:57 +00:00
public format: Formatter = new MatrixFormatter();
2021-01-19 11:54:54 +00:00
public type = 'MatrixProtocol';
public nameCache: Map<string, string> = new Map<string, string>();
private client?: MatrixClient;
2023-08-02 17:25:37 +00:00
private fs = new MatrixFileSystemUtils(this.config);
2021-01-19 11:54:54 +00:00
public async handler(roomId: any, event: any): Promise<void> {
// Don't handle events that don't have contents (they were probably redacted)
2023-08-02 17:25:37 +00:00
if (!this.client || !this.me || !event.content) {
return;
}
2021-01-19 11:54:54 +00:00
2024-06-07 20:10:57 +00:00
// Don't handle non-text events and quote replies
if (
!['m.text', 'm.emote'].includes(event.content.msgtype) ||
event.content['m.relates_to']?.['m.in_reply_to']
) {
2023-08-02 17:25:37 +00:00
return;
}
2021-01-19 11:54:54 +00:00
const msg = event.content.body;
2023-08-07 13:55:11 +00:00
const direct = this.client.dms.isDm(roomId);
2021-01-19 11:54:54 +00:00
// filter out events sent by the bot itself
2023-08-02 17:25:37 +00:00
if (event.sender === this.me.id || !msg) {
return;
}
2021-01-19 11:54:54 +00:00
const senderName = await this.getUserDisplayName(event.sender);
2023-08-07 13:55:11 +00:00
const roomName = direct
? senderName
: await this.getRoomDisplayName(roomId);
2021-01-19 11:54:54 +00:00
const newMessage = new MatrixMessageAdapter(
EMessageType.message,
event,
this,
2023-08-02 17:25:37 +00:00
{ id: event.sender, name: senderName },
2023-08-07 13:55:11 +00:00
{ id: roomId, name: roomName },
direct
2021-01-19 11:54:54 +00:00
);
this.plugin.stream.emitTo('channel', 'message', newMessage);
}
public async getUserDisplayName(id: string): Promise<string> {
if (this.nameCache.has(id)) {
return this.nameCache.get(id) as string;
}
2023-08-07 13:55:11 +00:00
try {
const profile = await this.client?.getUserProfile(id);
if (!profile || !profile.displayname) {
return id;
}
this.nameCache.set(id, profile.displayname);
return profile.displayname;
} catch {
2021-01-19 11:54:54 +00:00
return id;
}
}
public async getRoomDisplayName(id: string): Promise<string> {
if (this.nameCache.has(id)) {
return this.nameCache.get(id) as string;
}
2023-08-07 13:55:11 +00:00
try {
const roomState = await this.client?.getRoomStateEvent(
id,
'm.room.name',
''
);
if (!roomState || !roomState.name) {
return id;
}
this.nameCache.set(id, roomState.name);
return roomState.name;
} catch {
2021-01-19 11:54:54 +00:00
return id;
}
}
private async getSelf(): Promise<void> {
if (!this.client) {
return;
}
const id = await this.client.getUserId();
const name = await this.getUserDisplayName(id);
this.me = { id, name };
}
2023-08-02 17:25:37 +00:00
private async matrixLogin(
homeserverUrl: string,
username: string,
password: string,
deviceName: string
): Promise<{ accessToken: string; deviceId: string }> {
const body = {
type: 'm.login.password',
identifier: {
type: 'm.id.user',
user: username,
},
2024-04-07 17:50:19 +00:00
password,
2023-08-02 17:25:37 +00:00
initial_device_display_name: deviceName,
};
const response = await new MatrixClient(homeserverUrl, '').doRequest(
'POST',
'/_matrix/client/r0/login',
null,
body
);
const accessToken = response['access_token'];
const deviceId = response['device_id'];
if (!accessToken)
throw new Error('Expected access token in response - got nothing');
return { accessToken, deviceId };
}
public async start(...args: any[]): Promise<void> {
await this.fs.ensureMatrixDirectory();
2021-01-19 11:54:54 +00:00
LogService.setLevel(LogLevel.ERROR);
2023-08-02 17:25:37 +00:00
const storage = new SimpleFsStorageProvider(this.fs.storageFile);
const cryptoStorage = new RustSdkCryptoStorageProvider(
this.fs.cryptoStorageDir,
StoreType.Sled
);
let { accessToken, deviceId } = await this.fs.readAuth();
if (!accessToken) {
// No token, log in
const loginInfo = await this.matrixLogin(
this.config.homeserver,
this.config.username,
this.config.password,
'Squeebot-Matrix'
);
accessToken = loginInfo.accessToken;
deviceId = loginInfo.deviceId;
await this.fs.writeAuth({ accessToken, deviceId });
await cryptoStorage.setDeviceId(deviceId);
}
this.client = new MatrixClient(
this.config.homeserver,
accessToken,
storage,
!!this.config.encrypted ? cryptoStorage : undefined
);
AutojoinRoomsMixin.setupOnClient(this.client);
2021-01-19 11:54:54 +00:00
this.client.on('room.message', (...dargs) =>
2023-08-02 17:25:37 +00:00
this.handler(dargs[0], dargs[1]).catch((e) => logger.error(e))
);
this.client.on(
'room.failed_decryption',
async (roomId: string, event: any, e: Error) =>
logger.error(
`Failed to decrypt ${roomId} ${event['event_id']} because `,
e
)
);
try {
await this.client.start();
this.running = true;
logger.info(
'[%s] Protocol "%s" ready',
this.plugin.manifest.name,
this.config.name
);
this.getSelf();
} catch (err) {
this.stop(true);
logger.error(
'[%s] Protocol "%s" failed to start',
this.plugin.manifest.name,
this.config.name,
(err as Error).message
);
}
2021-01-19 11:54:54 +00:00
}
public stop(force = false): void {
if (!this.running && !force) {
return;
}
if (this.client) {
this.client.stop();
this.client = undefined;
}
this.running = false;
this.stopped = true;
if (force) {
this.failed = true;
}
this.emit('stopped');
}
2024-04-07 17:50:19 +00:00
public async resolve(
msg: MatrixMessageAdapter,
...data: any[]
): Promise<void> {
2021-10-02 08:21:49 +00:00
if (!msg.target) {
2021-01-19 11:54:54 +00:00
return;
}
2024-04-07 17:50:19 +00:00
const parts = this.getMessageParts(
`${this.fullName}/${msg.target.id}`,
...data
);
if (!parts) {
return;
}
const { roomId, msgtype, text, html } = parts;
const messageContents = {
// Assign properties generated by reply
...(msg.isReply
? RichReply.createFor(roomId, msg.data, text, html)
: {
body: text,
format: 'org.matrix.custom.html',
formatted_body: html,
}),
// Override message type to support notice
msgtype,
};
await this.client?.sendMessage(roomId, messageContents);
2021-10-02 08:21:49 +00:00
}
public async sendTo(target: string, ...data: any[]): Promise<boolean> {
2024-04-07 17:50:19 +00:00
const parts = this.getMessageParts(target, ...data);
if (!parts) {
return false;
}
const { roomId, msgtype, text, html } = parts;
await this.client?.sendMessage(roomId, {
msgtype,
body: text,
format: 'org.matrix.custom.html',
formatted_body: html,
});
return true;
}
private getMessageParts(target: string, ...data: any[]) {
2021-10-02 08:21:49 +00:00
let response = util.format(data[0], ...data.slice(1));
2021-10-02 08:36:22 +00:00
const rxSplit = target.split('/');
if (!response || !target || rxSplit.length !== 3) {
2024-04-07 17:50:19 +00:00
return undefined;
2021-10-02 08:21:49 +00:00
}
2021-01-19 11:54:54 +00:00
if (Array.isArray(data[0])) {
try {
response = this.format.compose(data[0]);
2021-10-02 08:21:49 +00:00
} catch (e: any) {
2023-08-02 17:25:37 +00:00
logger.error(
'[%s] Failed to compose message:',
this.fullName,
e.message
);
2024-04-07 17:50:19 +00:00
return undefined;
2021-01-19 11:54:54 +00:00
}
}
2023-08-07 13:55:11 +00:00
const msgtype =
response.startsWith('<m.emote>') && response.endsWith('</m.emote>')
? 'm.emote'
: 'm.text';
// TODO: make more generic
if (msgtype === 'm.emote') {
response = response.substring(9, response.length - 10);
}
2024-04-07 17:50:19 +00:00
return {
roomId: rxSplit[2],
2023-08-07 13:55:11 +00:00
msgtype,
2024-04-07 17:50:19 +00:00
response,
text: this.format.strip(response),
html: response.replace(/\n/g, '<br />'),
};
2021-01-19 11:54:54 +00:00
}
}
@InjectService(MatrixProtocol)
2023-08-02 17:25:37 +00:00
@Configurable({ instances: [] })
2021-01-19 11:54:54 +00:00
class MatrixServicePlugin extends Plugin {
initialize(): void {
const protoList = this.validateConfiguration();
this.startAll(protoList);
}
private startAll(list: any[]): void {
for (const ins of list) {
2023-08-02 17:25:37 +00:00
const newProto = new MatrixProtocol(this, ins, MatrixProtocol.Features);
2021-01-19 11:54:54 +00:00
logger.log('[%s] Starting Matrix service "%s".', this.name, ins.name);
this.monitor(newProto);
this.service?.use(newProto, true);
}
}
private monitor(proto: Protocol): void {
proto.on('running', () => this.emit('protocolNew', proto));
proto.on('stopped', () => this.emit('protocolExit', proto));
}
private validateConfiguration(): any[] {
if (!this.config.config.instances) {
throw new Error('Configuration incomplete!');
}
const instances = this.config.config.instances;
const runnables: any[] = [];
for (const ins of instances) {
if (ins.enabled === false) {
continue;
}
2023-08-02 17:25:37 +00:00
if (!ins.name || !ins.username || !ins.password || !ins.homeserver) {
2021-01-19 11:54:54 +00:00
throw new Error('Invalid instance configuration!');
}
runnables.push(ins);
}
return runnables;
}
@EventListener('pluginUnload')
public unloadEventHandler(plugin: string | Plugin): void {
if (plugin === this.name || plugin === this) {
2023-08-02 17:25:37 +00:00
this.config
.save()
.then(() =>
this.service?.stopAll().then(() => this.emit('pluginUnloaded', this))
);
2021-01-19 11:54:54 +00:00
}
}
}
module.exports = MatrixServicePlugin;