Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(matrix): support matrix adapter #131

Merged
merged 2 commits into from
Jul 9, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 33 additions & 0 deletions adapters/matrix/package.json
Original file line number Diff line number Diff line change
@@ -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 <[email protected]>",
"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"
}
}
168 changes: 168 additions & 0 deletions adapters/matrix/src/bot.ts
Original file line number Diff line number Diff line change
@@ -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<MatrixBot.Config> {
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<Universal.Message> {
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<Universal.User> {
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<Universal.User[]> {
return []
}

async deleteFriend(): Promise<void> { }

async getGuild(guildId: string): Promise<Universal.Guild> {
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<Universal.Channel> {
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<Universal.Guild[]> {
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<Universal.Channel[]> {
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<void> { }

// 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<Config> = 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'
90 changes: 90 additions & 0 deletions adapters/matrix/src/http.ts
Original file line number Diff line number Diff line change
@@ -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<MatrixBot> {
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<void> {
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' }
}
}
15 changes: 15 additions & 0 deletions adapters/matrix/src/index.ts
Original file line number Diff line number Diff line change
@@ -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
Loading
Loading