diff --git a/adapters/matrix/package.json b/adapters/matrix/package.json new file mode 100644 index 00000000..bcb63641 --- /dev/null +++ b/adapters/matrix/package.json @@ -0,0 +1,33 @@ +{ + "name": "@satorijs/adapter-matrix", + "description": "Matrix Adapter for Satorijs", + "version": "0.0.1", + "main": "lib/index.js", + "typings": "lib/index.d.ts", + "files": [ + "lib" + ], + "author": "Anillc ", + "license": "MIT", + "repository": { + "type": "git", + "url": "git+https://github.com/satorijs/satori.git", + "directory": "adapters/matrix" + }, + "bugs": { + "url": "https://github.com/satorijs/satori/issues" + }, + "keywords": [ + "bot", + "matrix", + "chatbot", + "satori" + ], + "peerDependencies": { + "@satorijs/satori": "^2.5.0" + }, + "dependencies": { + "image-size": "^1.0.2", + "html5parser": "^2.0.2" + } +} diff --git a/adapters/matrix/src/bot.ts b/adapters/matrix/src/bot.ts new file mode 100644 index 00000000..91f0fd2b --- /dev/null +++ b/adapters/matrix/src/bot.ts @@ -0,0 +1,168 @@ +import { Bot, Context, omit, Quester, Schema, Universal } from '@satorijs/satori' +import { HttpAdapter } from './http' +import { MatrixMessageEncoder } from './message' +import * as Matrix from './types' +import { adaptMessage, dispatchSession } from './utils' + +export class MatrixBot extends Bot { + static MessageEncoder = MatrixMessageEncoder + http: Quester + id: string + endpoint: string + rooms: string[] = [] + declare internal: Matrix.Internal + constructor(ctx: Context, config: MatrixBot.Config) { + super(ctx, config) + this.id = config.id + this.selfId = `@${this.id}:${this.config.host}` + this.userId = this.selfId + this.endpoint = (config.endpoint || `https://${config.host}`) + '/_matrix' + this.internal = new Matrix.Internal(this) + ctx.plugin(HttpAdapter, this) + } + + async initialize() { + let user: Matrix.User + try { + user = await this.internal.register(this.id, this.config.asToken) + } catch (e) { + if (e.response.status !== 400 && e.data.errcode !== 'M_USER_IN_USE') throw e + } + if (!user) user = await this.internal.login(this.id, this.config.asToken) + this.http = this.ctx.http.extend({ + ...this.config, + endpoint: this.endpoint, + headers: { + 'Authorization': `Bearer ${user.access_token}`, + }, + }) + if (this.config.name) { + await this.internal.setDisplayName(this.userId, this.config.name) + } + if (this.config.avatar) { + const { data, mime } = await this.http.file(this.config.avatar) + await this.internal.setAvatar(this.userId, Buffer.from(data), mime) + } + Object.assign(this, await this.getSelf()) + const sync = await this.syncRooms() + // dispatch invitiations + if (!sync?.rooms?.invite) return + setTimeout(() => Object.entries(sync.rooms.invite).forEach(([roomId, room]) => { + const event = room.invite_state.events.find(event => + event.type === 'm.room.member' && (event.content as Matrix.M_ROOM_MEMBER).membership === 'invite') + event.room_id = roomId + dispatchSession(this, event) + })) + } + + async getMessage(channelId: string, messageId: string): Promise { + const event = await this.internal.getEvent(channelId, messageId) + return await adaptMessage(this, event) + } + + async deleteMessage(channelId: string, messageId: string) { + await this.internal.redactEvent(channelId, messageId) + } + + async getSelf() { + return await this.getUser(this.userId) + } + + async getUser(userId: string): Promise { + const profile = await this.internal.getProfile(userId) + let avatar: string + if (profile.avatar_url) avatar = this.internal.getAssetUrl(profile.avatar_url) + return { + userId, + avatar, + username: userId, + nickname: profile.displayname, + } + } + + async getFriendList(): Promise { + return [] + } + + async deleteFriend(): Promise { } + + async getGuild(guildId: string): Promise { + const events = await this.internal.getState(guildId) + const guildName = (events.find(event => event.type === 'm.room.name')?.content as Matrix.M_ROOM_NAME)?.name + return { guildId, guildName } + } + + async getChannel(channelId: string): Promise { + const events = await this.internal.getState(channelId) + const channelName = (events.find(event => event.type === 'm.room.name')?.content as Matrix.M_ROOM_NAME)?.name + return { channelId, channelName } + } + + async getGuildList(): Promise { + const sync = await this.syncRooms() + const joined = sync?.rooms?.join + if (!joined) return [] + const result: string[] = [] + for (const [roomId, room] of Object.entries(joined)) { + const create = room.state?.events?.find(event => event.type === 'm.room.create') + const space = (create?.content as Matrix.M_ROOM_CREATE)?.type === 'm.space' + if (space) result.push(roomId) + } + return await Promise.all(result.map(this.getGuild.bind(this))) + } + + async getChannelList(guildId: string): Promise { + const state = await this.internal.getState(guildId) + const children = state + .filter(event => event.type === 'm.space.child') + .map(event => event.state_key) + .filter(roomId => this.rooms.includes(roomId)) + return await Promise.all(children.map(this.getChannel.bind(this))) + } + + async handleFriendRequest(): Promise { } + + // as utils.ts commented, messageId is roomId + async handleGuildRequest(messageId: string, approve: boolean, commit: string) { + if (approve) { + await this.internal.joinRoom(messageId, commit) + } else { + await this.internal.leaveRoom(messageId, commit) + } + this.syncRooms() + } + + // will be called after m.room.member received + async syncRooms() { + const sync = await this.internal.sync(true) + if (!sync?.rooms?.join) return + this.rooms = Object.keys(sync.rooms.join) + return sync + } +} + +export namespace MatrixBot { + export interface Config extends Bot.Config, Quester.Config { + name?: string + avatar?: string + id?: string + hsToken?: string + asToken?: string + host?: string + } + + export const Config: Schema = Schema.object({ + name: Schema.string().description('机器人的名称,如果设置了将会在启动时为机器人更改。'), + avatar: Schema.string().description('机器人的头像地址,如果设置了将会在启动时为机器人更改。'), + // eslint-disable-next-line + id: Schema.string().description('机器人的 ID。机器人最后的用户名将会是 @${id}:${host}。').required(), + host: Schema.string().description('Matrix homeserver 域名。').required(), + hsToken: Schema.string().description('hs_token').role('secret').required(), + asToken: Schema.string().description('as_token').role('secret').required(), + // eslint-disable-next-line + endpoint: Schema.string().description('Matrix homeserver 地址。默认为 https://${host}。'), + ...omit(Quester.Config.dict, ['endpoint']), + }) +} + +MatrixBot.prototype.platform = 'matrix' diff --git a/adapters/matrix/src/http.ts b/adapters/matrix/src/http.ts new file mode 100644 index 00000000..840088da --- /dev/null +++ b/adapters/matrix/src/http.ts @@ -0,0 +1,90 @@ +import { Adapter, Context, Logger } from '@satorijs/satori' +import { Context as KoaContext } from 'koa' +import { MatrixBot } from './bot' +import { dispatchSession } from './utils' +import { ClientEvent, M_ROOM_MEMBER } from './types' + +declare module 'koa' { + interface Context { + bots: MatrixBot[] + } +} + +const logger = new Logger('matrix') + +export class HttpAdapter extends Adapter.Server { + private txnId: string = null + + hook(callback: (ctx: KoaContext) => void) { + return (ctx: KoaContext) => { + const bots = this.bots.filter(bot => (bot instanceof MatrixBot) && (bot.config.hsToken === ctx.query.access_token)) + if (!bots.length) { + ctx.status = 403 + ctx.body = { errcode: 'M_FORBIDDEN' } + return + } + ctx.bots = bots + callback.call(this, ctx) + } + } + + public constructor(ctx: Context) { + super() + const put = (path: string, callback: (ctx: KoaContext) => void) => { + ctx.router.put(path, this.hook(callback).bind(this)) + ctx.router.put('/_matrix/app/v1' + path, this.hook(callback).bind(this)) + } + const get = (path: string, callback: (ctx: KoaContext) => void) => { + ctx.router.get(path, this.hook(callback).bind(this)) + ctx.router.get('/_matrix/app/v1' + path, this.hook(callback).bind(this)) + } + put('/transactions/:txnId', this.transactions) + get('/users/:userId', this.users) + get('/room/:roomAlias', this.rooms) + } + + async start(bot: MatrixBot): Promise { + try { + await bot.initialize() + bot.online() + } catch (e) { + logger.error('failed to initialize', e) + throw e + } + } + + private transactions(ctx: KoaContext) { + const { txnId } = ctx.params + const events = ctx.request.body.events as ClientEvent[] + ctx.body = {} + if (txnId === this.txnId) return + this.txnId = txnId + for (const event of events) { + const bots = ctx.bots + .filter(bot => bot.userId !== event.sender && bot.rooms.includes(event.room_id)) + let bot: MatrixBot + if (event.type === 'm.room.member' + && (event.content as M_ROOM_MEMBER).membership === 'invite' + && (bot = ctx.bots.find(bot => bot.userId === event.state_key)) + && !bots.includes(bot)) { + bots.push(bot) + } + bots.forEach(bot => dispatchSession(bot, event)) + } + } + + private users(ctx: KoaContext) { + const { userId } = ctx.params + if (!ctx.bots.find(bot => bot.userId === userId)) { + ctx.status = 404 + ctx.body = { 'errcode': 'CHAT.SATORI.NOT_FOUND' } + return + } + ctx.body = {} + } + + private rooms(ctx: KoaContext) { + ctx.status = 404 + ctx.body = { 'errcode': 'CHAT.SATORI.NOT_FOUND' } + } +} diff --git a/adapters/matrix/src/index.ts b/adapters/matrix/src/index.ts new file mode 100644 index 00000000..d5a12e4f --- /dev/null +++ b/adapters/matrix/src/index.ts @@ -0,0 +1,15 @@ +import { MatrixBot } from './bot' +import * as Matrix from './types' + +declare module '@satorijs/satori' { + interface Session { + matrix: Matrix.Internal & Matrix.ClientEvent + } +} + +export * from './bot' +export * from './http' +export * from './message' +export * from './types' +export * from './utils' +export default MatrixBot diff --git a/adapters/matrix/src/message.ts b/adapters/matrix/src/message.ts new file mode 100644 index 00000000..11e116d7 --- /dev/null +++ b/adapters/matrix/src/message.ts @@ -0,0 +1,94 @@ +import { MessageEncoder, segment, Universal } from '@satorijs/satori' +import { MatrixBot } from './bot' + +export class MatrixMessageEncoder extends MessageEncoder { + private buffer: string = '' + private reply: Universal.Message = null + + async sendMedia(url: string, type: 'file' | 'image' | 'video' | 'audio') { + try { + const session = this.bot.session(this.session) + const { data, filename, mime } = await this.bot.ctx.http.file(url) + const id = await this.bot.internal.sendMediaMessage( + this.channelId, this.bot.userId, type, Buffer.from(data), this.reply?.messageId, mime, filename, + ) + session.messageId = id + this.results.push(session) + this.reply = null + } catch (e) { + this.errors.push(e) + } + } + + async flush() { + if (!this.buffer) return + try { + const session = this.bot.session(this.session) + if (this.reply) { + this.buffer = `> <${this.reply.userId}> ${this.reply.content}\n\n` + this.buffer + } + const id = await this.bot.internal.sendTextMessage( + this.channelId, this.bot.userId, this.buffer, this.reply?.messageId, + ) + session.messageId = id + this.results.push(session) + this.buffer = '' + this.reply = null + } catch (e) { + this.errors.push(e) + } + } + + async visit(element: segment) { + const { type, attrs, children } = element + if (type === 'text') { + this.buffer += attrs.content.replace(/[\\*_`~|]/g, '\\$&') + } else if (type === 'b' || type === 'strong') { + this.buffer += '**' + await this.render(children) + this.buffer += '**' + } else if (type === 'i' || type === 'em') { + this.buffer += '*' + await this.render(children) + this.buffer += '*' + } else if (type === 'u' || type === 'ins') { + this.buffer += '__' + await this.render(children) + this.buffer += '__' + } else if (type === 's' || type === 'del') { + this.buffer += '~~' + await this.render(children) + this.buffer += '~~' + } else if (type === 'code') { + this.buffer += '`' + await this.render(children) + this.buffer += '`' + } else if (type === 'a') { + this.buffer += '[' + await this.render(children) + this.buffer += `](${attrs.href})` + } else if (type === 'p') { + await this.render(children) + this.buffer += '\n' + } else if (type === 'at') { + if (attrs.id) { + this.buffer += ` @${attrs.id} ` + } else if (attrs.type === 'all') { + this.buffer += ` @room ` + } + } else if (type === 'sharp' && attrs.id) { + this.buffer += ` #${attrs.id} ` + } else if ((type === 'image' || type === 'video' || type === 'record' || type === 'file') && attrs.url) { + await this.flush() + const matrixType = type === 'record' ? 'audio' : type + await this.sendMedia(attrs.url, matrixType) + } else if (type === 'quote') { + this.reply = await this.bot.getMessage(this.channelId, attrs.id) + } else if (type === 'message') { + await this.flush() + await this.render(children, true) + } else { + await this.render(children) + } + } +} diff --git a/adapters/matrix/src/types.ts b/adapters/matrix/src/types.ts new file mode 100644 index 00000000..b0c6707a --- /dev/null +++ b/adapters/matrix/src/types.ts @@ -0,0 +1,570 @@ +import imageSize from 'image-size' +import { Dict } from '@satorijs/satori' +import { MatrixBot } from './bot' + +export interface Transaction { + txnId: string + events: ClientEvent[] +} + +export interface ClientEvent { + content: EventContent + event_id: string + origin_server_ts: number + redacts?: string + room_id: string + sender: string + state_key?: string + type: string + unsigned?: UnsignedData +} + +export interface UnsignedData { + age?: number + prev_content?: EventContent + redacted_because?: ClientEvent + transaction_id?: string +} + +export interface PreviousRoom { + event_id: string + room_id: string +} + +export interface AllowCondition { + room_id?: string + type: 'm.room_membership' +} + +export interface Invite { + display_name: string + signed: Signed +} + +export interface Signed { + mxid: string + // signatures: Signatures + signatures: any + token: string +} + +export interface Notifications { + room?: number +} + +export interface ThumbnailInfo { + h?: number + mimetype?: string + size?: number + w?: number +} + +export interface ImageInfo { + h?: number + mimetype?: string + size?: number + // thumbnail_file?: EncryptedFile // end to end only + thumbnail_file?: any + thumbnail_info?: ThumbnailInfo + thumbnail_url?: string + w?: number +} + +export interface FileInfo { + mimetype?: string + size?: number + // thumbnail_file?: EncryptedFile // end to end only + thumbnail_file?: any + thumbnail_info?: ThumbnailInfo + thumbnail_url?: string +} + +export interface AudioInfo { + duration?: number + mimetype?: string + size?: number +} + +export interface LocationInfo { + // thumbnail_file?: EncryptedFile // end to end only + thumbnail_file?: any + thumbnail_info?: ThumbnailInfo + thumbnail_url?: string +} + +export interface VideoInfo { + duration?: number + h?: number + mimetype?: string + size?: number + // thumbnail_file?: EncryptedFile // end to end only + thumbnail_file?: any + thumbnail_info?: ThumbnailInfo + thumbnail_url?: string + w?: number +} + +export interface PublicKeys { + key_validity_url?: string + public_key: string +} + +export interface Profile { + avatar_url?: string + displayname?: string +} + +export interface User { + access_token?: string + device_id?: string + user_id?: string +} + +export interface RoomId { + room_id: string +} + +export interface Sync { + account_data?: AccountData + // device_lists? // end to end + // device_one_time_keys_count? // end to end + next_batch: string + presence?: Presence + rooms?: Rooms + // to_device? // end to end +} + +export interface AccountData { + events?: ClientEvent[] +} + +export interface Presence { + events?: ClientEvent[] +} + +export interface Rooms { + invite?: Dict + join?: Dict + knock?: Dict + leave?: Dict +} + +export interface InvitedRoom { + invite_state?: InviteState +} + +export interface InviteState { + events?: ClientEvent[] +} + +export interface JoinedRoom { + account_data?: AccountData + ephemeral?: Ephemeral + state?: State + summary?: RoomSummary + timeline?: Timeline + unread_notifications?: UnreadNotificationCounts + unread_thread_notifications?: Dict +} + +export interface Ephemeral { + events?: ClientEvent[] +} + +export interface State { + events?: ClientEvent[] +} + +export interface RoomSummary { + 'm.heroes'?: string[] + 'm.invited_member_count'?: number + 'm.joined_member_count'?: number +} + +export interface Timeline { + events: ClientEvent[] + limited?: boolean + prev_batch?: string +} + +export interface UnreadNotificationCounts { + highlight_count?: number + notification_count?: number +} + +export interface ThreadNotificationCounts { + highlight_count?: number + notification_count?: number +} + +export interface KnockedRoom { + knock_state?: KnockState +} + +export interface KnockState { + events?: ClientEvent[] +} + +export interface LeftRoom { + account_data?: AccountData + state?: State + timeline?: Timeline +} + +export interface EventContent {} + +export interface Relation { + event_id?: string + rel_type?: string + 'm.in_reply_to'?: { + event_id: string + } +} + +export interface M_ROOM_CANONICAL_ALIAS extends EventContent { + alias?: string + alt_aliases?: string[] +} + +export interface M_ROOM_CREATE extends EventContent { + creator: string + 'm.federate'?: boolean + predecessor?: PreviousRoom + room_version?: string + type?: string +} + +export interface M_ROOM_JOIN_RULES extends EventContent { + allow?: AllowCondition[] + join_rule?: 'public' | 'knock' | 'invite' | 'private' | 'restricted' +} + +export interface M_ROOM_MEMBER extends EventContent { + avatar_url?: string + displayname?: string[] + is_direct?: boolean + join_authorised_via_users_server?: string + membership?: 'invite' | 'join' | 'knock' | 'leave' | 'ban' + reason?: string + third_party_invite?: Invite +} + +export interface M_ROOM_POWER_LEVELS extends EventContent { + ban?: number + events?: Record + events_default?: number + invite?: number + kick?: number + notifications?: Notifications + redact?: number + state_default?: number + users?: Record + users_default?: number +} + +export interface M_ROOM_REDACTION extends EventContent { + reason?: string +} + +export interface M_ROOM_MESSAGE extends EventContent { + body: string + msgtype: string + 'm.relates_to'?: Relation + 'm.new_content'?: M_ROOM_MESSAGE +} + +export interface M_ROOM_MESSAGE_FEEDBACK extends EventContent { + target_event_id: string + type: 'delivered' | 'read' +} + +export interface M_ROOM_NAME extends EventContent { + name: string +} + +export interface M_ROOM_TOPIC extends EventContent { + topic: string +} + +export interface M_ROOM_AVATAR extends EventContent { + info?: ImageInfo + url: string +} + +export interface M_ROOM_PINNED_EVENTS extends EventContent { + pinned: string[] +} + +export interface M_TEXT extends M_ROOM_MESSAGE { + body: string + format?: 'org.matrix.custom.html' + formatted_body?: string + msgtype: 'm.text' +} + +export interface M_EMOTE extends M_ROOM_MESSAGE { + body: string + format?: 'org.matrix.custom.html' + formatted_body?: string + msgtype: 'm.emote' +} + +export interface M_NOTICE extends M_ROOM_MESSAGE { + body: string + format?: 'org.matrix.custom.html' + formatted_body?: string + msgtype: 'm.notice' +} + +export interface M_IMAGE extends M_ROOM_MESSAGE { + body: string + // file?: EncryptedFile // end to end only + file?: any + info?: ImageInfo + msgtype: 'm.image' + url?: string +} + +export interface M_FILE extends M_ROOM_MESSAGE { + body: string + // file?: EncryptedFile // end to end only + file?: any + filename?: string + info?: FileInfo + msgtype: 'm.file' + url?: string +} + +export interface M_AUDIO extends M_ROOM_MESSAGE { + body: string + // file?: EncryptedFile // end to end only + file?: any + filename?: string + info?: AudioInfo + msgtype: 'm.audio' + url?: string +} + +export interface M_LOCATION extends M_ROOM_MESSAGE { + body: string + geo_uri: string + info?: LocationInfo + msgtype: 'm.location' +} + +export interface M_VIDEO extends M_ROOM_MESSAGE { + body: string + // file?: EncryptedFile // end to end only + file?: any + info?: VideoInfo + msgtype: 'm.video' + url?: string +} + +export interface M_TYPING extends EventContent { + user_ids: string[] +} + +export interface M_RECEIPT extends EventContent { } + +export interface M_FULLY_READ extends EventContent { + event_id: string +} + +export interface M_PRESENCE extends EventContent { + avatar_url?: string + currently_actice?: boolean + displayname?: string + last_active_ago?: number + presence?: 'online' | 'offline' | 'unavailable' + status_msg?: string +} + +export interface M_ROOM_HISTORY_VISIBILITY extends EventContent { + history_visibility: 'invited' | 'joined' | 'shared' | 'world_readable' +} + +export interface M_ROOM_THIRD_PATRY_INVITE extends EventContent { + display_name: string + key_validity_url: string + public_key: string + public_keys: PublicKeys[] +} + +export interface M_ROOM_GUEST_ACCESS extends EventContent { + guest_access: 'can_join' | 'forbidden' +} + +export interface M_IGNORED_USER_LIST extends EventContent { + ignored_users: Record +} + +export interface M_STICKER extends EventContent { + body: string + info: ImageInfo + url: string +} + +export interface M_ROOM_SERVER_ACL extends EventContent { + allow?: string[] + allow_ip_literals?: boolean + deny?: string[] +} + +export interface M_ROOM_TOMBSTONE extends EventContent { + body: string + replacement_room: string +} + +export interface M_POLICY_RULE_USER extends EventContent { + entity: string + reason: string + recommendation: string +} + +export interface M_POLICY_RULE_ROOM extends EventContent { + entity: string + reason: string + recommendation: string +} + +export interface M_POLICY_RULE_SERVER extends EventContent { + entity: string + reason: string + recommendation: string +} + +export interface M_SPACE_CHILD extends EventContent { + order?: string + suggested?: boolean + via?: string[] +} + +export interface M_SPACE_PARENT extends EventContent { + canonical?: boolean + via?: string[] +} + +export class Internal { + private txnId = Math.round(Math.random() * 1000) + + constructor(public bot: MatrixBot) {} + + async uploadFile(filename: string, buffer: Buffer, mimetype?: string): Promise { + const headers = {} + if (mimetype) headers['content-type'] = mimetype + return (await this.bot.http.post(`/media/v3/upload?filename=${filename}`, buffer, { headers })).content_uri + } + + async sendTextMessage(roomId: string, userId: string, content: string, reply?: string): Promise { + const eventContent: M_TEXT = { + msgtype: 'm.text', + body: content, + } + if (reply) eventContent['m.relates_to'] = { 'm.in_reply_to': { 'event_id': reply } } + const response = await this.bot.http.put( + `/client/v3/rooms/${roomId}/send/m.room.message/${this.txnId++}?user_id=${userId}`, eventContent) + return response.event_id + } + + async sendMediaMessage( + roomId: string, userId: string, type: 'file' | 'image' | 'video' | 'audio', + buffer: Buffer, reply?: string, mimetype?: string, filename: string = 'file', + ): Promise { + const uri = await this.uploadFile(filename, buffer, mimetype) + let info: ImageInfo + if (type === 'image') { + const { width, height } = imageSize(buffer) + info = { + size: buffer.byteLength, + h: height, + w: width, + mimetype, + } + } + const eventContent = { + msgtype: `m.${type}`, + body: filename, + url: uri, + info, + } + if (reply) eventContent['m.relates_to'] = { 'm.in_reply_to': { 'event_id': reply } } + const response = await this.bot.http.put( + `/client/v3/rooms/${roomId}/send/m.room.message/${this.txnId++}?user_id=${userId}`, eventContent) + return response.event_id + } + + async getEvent(roomId: string, eventId: string): Promise { + return await this.bot.http.get(`/client/v3/rooms/${roomId}/event/${eventId}`) + } + + async redactEvent(roomId: string, eventId: string, reason?: string): Promise { + const event = await this.bot.http.put(`/client/v3/rooms/${roomId}/redact/${eventId}/${this.txnId++}`, { reason }) + return event.event_id + } + + async getProfile(userId: string): Promise { + return await this.bot.http.get(`/client/v3/profile/${userId}`) + } + + async setDisplayName(userId: string, displayname: string): Promise { + await this.bot.http.put(`/client/v3/profile/${userId}/displayname`, { displayname }) + } + + async setAvatar(userId: string, buffer: Buffer, mimetype: string): Promise { + const uri = await this.uploadFile('avatar', buffer, mimetype) + await this.bot.http.put(`/client/v3/profile/${userId}/avatar_url`, { avatar_url: uri }) + } + + async joinRoom(roomId: string, reason?: string): Promise { + return await this.bot.http.post(`/client/v3/join/${roomId}`, { reason }) + } + + async leaveRoom(roomId: string, reason?: string): Promise { + return await this.bot.http.post(`/client/v3/rooms/${roomId}/leave`, { reason }) + } + + async sync(fullSstate: boolean = false): Promise { + return await this.bot.http.get('/client/v3/sync', { + params: { full_state: fullSstate }, + }) + } + + async getState(roomId: string): Promise { + return await this.bot.http.get(`/client/v3/rooms/${roomId}/state`) + } + + async getJoinedRooms(): Promise { + return await this.bot.http.get('/client/v3/joined_rooms') + } + + async register(username: string, asToken: string): Promise { + return await this.bot.ctx.http.post(this.bot.endpoint + '/client/v3/register', { + type: 'm.login.application_service', + username, + }, { + headers: { + 'Authorization': `Bearer ${asToken}`, + }, + }) + } + + async login(username: string, asToken: string): Promise { + return await this.bot.ctx.http.post(this.bot.endpoint + '/client/v3/login', { + type: 'm.login.application_service', + identifier: { + type: 'm.id.user', + user: username, + }, + }, { + headers: { + 'Authorization': `Bearer ${asToken}`, + }, + }) + } + + getAssetUrl(mxc: string) { + // mxc:// + return `${this.bot.endpoint}/_matrix/media/v3/download/${mxc.substring(6)}` + } +} diff --git a/adapters/matrix/src/utils.ts b/adapters/matrix/src/utils.ts new file mode 100644 index 00000000..cf5bdaea --- /dev/null +++ b/adapters/matrix/src/utils.ts @@ -0,0 +1,253 @@ +import { defineProperty, segment, Session, Universal } from '@satorijs/satori' +import { MatrixBot } from './bot' +import * as Matrix from './types' +import { INode, ITag, parse, SyntaxKind } from 'html5parser' + +export function adaptAuthor(bot: MatrixBot, event: Matrix.ClientEvent): Universal.Author { + return { + userId: event.sender, + username: event.sender, + isBot: !!bot.ctx.bots.find(bot => bot.userId === event.sender), + } +} + +export async function adaptMessage(bot: MatrixBot, event: Matrix.ClientEvent, result: Universal.Message = {}): Promise { + result.subtype = 'group' + result.messageId = event.event_id + result.channelId = event.room_id + result.userId = event.sender + result.timestamp = event.origin_server_ts + result.author = adaptAuthor(bot, event) + const content = event.content as Matrix.M_ROOM_MESSAGE + const reply = content['m.relates_to']?.['m.in_reply_to'] + if (reply) { + result.quote = await bot.getMessage(event.room_id, reply.event_id) + } + switch (content.msgtype) { + case 'm.text': + case 'm.emote': + case 'm.notice': { + result.content = parsseContent(bot, content) + break + } + case 'm.image': + case 'm.file': + case 'm.audio': + case 'm.video': { + const url = bot.internal.getAssetUrl((content as any).url) + const type = content.msgtype.substring(2) + result.content = segment(type === 'audio' ? 'record' : type, { url }).toString() + break + } + default: + return null + } + // result.content is not a setter if result is a Universal.Message + result.elements ??= segment.parse(result.content) + return result +} + +export async function adaptSession(bot: MatrixBot, event: Matrix.ClientEvent): Promise { + const session = bot.session() + if (event.type === 'm.room.message') { + const content = event.content as Matrix.M_ROOM_MESSAGE + const newContent = content['m.new_content'] + if (newContent) { + session.type = 'message-update' + content.body = newContent.body + content.msgtype = newContent.msgtype + } else { + session.type = 'message' + } + if (!await adaptMessage(bot, event, session)) return null + return session + } + session.userId = event.sender + session.guildId = event.room_id + session.channelId = event.room_id + session.messageId = event.event_id + session.timestamp = event.origin_server_ts + session.author = adaptAuthor(bot, event) + switch (event.type) { + case 'm.room.redaction': + session.type = 'message-delete' + session.messageId = event.redacts + break + case 'm.room.member': { + bot.syncRooms() + const memberEvent = event.content as Matrix.M_ROOM_MEMBER + session.targetId = (memberEvent as any).state_key + session.operatorId = event.sender + session.messageId = event.event_id + if (memberEvent.reason) { + session.content = memberEvent.reason + } + switch (memberEvent.membership) { + case 'join': + session.type = 'guild-member-added' + break + case 'leave': + session.type = 'guild-member-deleted' + break + case 'ban': + session.type = 'guild-member' + session.subtype = 'ban' + break + case 'invite': + if (event.state_key === bot.userId) { + session.type = 'guild-request' + // Use room_id instead messageId because handleGuildRequest Only passes messageId. + // We need room_id and messageId to call getMessage and get the room_id. + // So I decided to pass room_id directly. + session.messageId = event.room_id + break + } + // fallthrough + default: + session.type = event.type + } + break + } + default: + session.type = event.type + } + return session +} + +export async function dispatchSession(bot: MatrixBot, event: Matrix.ClientEvent) { + const session = await adaptSession(bot, event) + if (!session) return + + defineProperty(session, 'matrix', Object.create(bot.internal)) + Object.assign(session.matrix, event) + bot.dispatch(session) +} + +function parsseContent(bot: MatrixBot, content: Matrix.M_ROOM_MESSAGE) { + if (content['format'] !== 'org.matrix.custom.html') { + return content.body + } + const { formatted_body } = content as Matrix.M_TEXT + let result = '' + + ;(function visit(nodes: INode[]) { + if (!nodes) return + for (const node of nodes) { + if (node.type === SyntaxKind.Text) { + result += segment.escape(decodeHE(node.value).trim() || '') + } else { + function tag(name: string) { + result += `<${name}>` + visit((node as ITag).body) + result += `` + } + switch (node.name) { + case 'del': tag('s'); break + case 'p': tag('p'); break + case 'b': tag('b'); break + case 'i': tag('i'); break + case 'u': tag('u'); break + case 'strong': tag('b'); break + case 'em': tag('em'); break + case 'strike': tag('s'); break + case 'code': tag('code'); break + case 'sup': tag('sup'); break + case 'sub': tag('sub'); break + case 'a': { + const href = node.attributeMap.href?.value.value || '#' + if (href.startsWith('https://matrix.to/#/@')) { + result += `` + visit(node.body) + result += '' + break + } else if (href.startsWith('https://matrix.to/#/#')) { + result += `` + visit(node.body) + result += '' + break + } + result += `` + visit(node.body) + result += '' + break + } + case 'li': { + visit(node.body) + result += '\n' + break + } + case 'hr': { + result += '\n\n' + break + } + case 'br': { + result += '\n' + break + } + case 'img': { + const src = node.attributeMap.src?.value.value + const alt = node.attributeMap.src?.value.value + if (!src) { + if (alt) result += alt + break + } + if (src.match(/^(data|https?):/)) { + result += `` + break + } else if (src.startsWith('mxc://')) { + result += `` + break + } + break + } + case 'blockquote': { + result += '> ' + visit(node.body) + break + } + case 'mx-reply': + // ignore + break + // div table thead tbody tr th td caption pre span + // details summary ul ol font h1 h2 h3 h4 h5 h6 + default: + visit(node.body) + } + } + } + })(parse(formatted_body, { setAttributeMap: true })) + return result +} + +// this is not a full list +const entities = { + nbsp: ' ', + cent: '¢', + pound: '£', + yen: '¥', + euro: '€', + copy: '©', + reg: '®', + lt: '<', + gt: '>', + quot: '"', + amp: '&', + apos: '\'', +} + +function decodeHE(text: string) { + const regex = /&(([a-z0-9]+)|(#[0-9]{1,6})|(#x[0-9a-fA-F]{1,6}));/ig + return text.replace(regex, (_1, _2, name: string, dec: string, hex: string) => { + if (name) { + if (name in entities) { + return entities[name] + } else { + return text + } + } else if (dec) { + return String.fromCharCode(+dec.substring(1)) + } else if (hex) { + return String.fromCharCode(parseInt(hex.substring(2), 16)) + } + }) +} diff --git a/adapters/matrix/tsconfig.json b/adapters/matrix/tsconfig.json new file mode 100644 index 00000000..74ac2c8d --- /dev/null +++ b/adapters/matrix/tsconfig.json @@ -0,0 +1,10 @@ +{ + "extends": "../../tsconfig.base", + "compilerOptions": { + "outDir": "lib", + "rootDir": "src", + }, + "include": [ + "src", + ], +} \ No newline at end of file