diff --git a/adapters/rocketchat/.npmignore b/adapters/rocketchat/.npmignore new file mode 100644 index 00000000..7e5fcbc1 --- /dev/null +++ b/adapters/rocketchat/.npmignore @@ -0,0 +1,2 @@ +.DS_Store +tsconfig.tsbuildinfo diff --git a/adapters/rocketchat/package.json b/adapters/rocketchat/package.json new file mode 100644 index 00000000..c123818a --- /dev/null +++ b/adapters/rocketchat/package.json @@ -0,0 +1,31 @@ +{ + "name": "@satorijs/adapter-rocketchat", + "description": "Rocket Chat Adapter for Satorijs", + "version": "1.0.0", + "main": "lib/index.js", + "typings": "lib/index.d.ts", + "files": [ + "lib" + ], + "author": "LittleC ", + "license": "MIT", + "repository": { + "type": "git", + "url": "git+https://github.com/satorijs/satori.git", + "directory": "adapters/rocketchat" + }, + "bugs": { + "url": "https://github.com/satorijs/satori/issues" + }, + "homepage": "https://koishi.chat/plugins/adapter/rocketchat.html", + "keywords": [ + "bot", + "rocketchat", + "adapter", + "chatbot", + "satori" + ], + "peerDependencies": { + "@satorijs/satori": "^2.6.1" + } +} diff --git a/adapters/rocketchat/readme.md b/adapters/rocketchat/readme.md new file mode 100644 index 00000000..43090784 --- /dev/null +++ b/adapters/rocketchat/readme.md @@ -0,0 +1,5 @@ +# [@satorijs/adapter-rocketchat](https://koishi.chat/plugins/adapter/rocketchat.html) + +LINE adapter for [Satori](https://github.com/satorijs/satori). + +- [Documentation](https://koishi.chat/plugins/adapter/rocketchat.html) diff --git a/adapters/rocketchat/src/bot.ts b/adapters/rocketchat/src/bot.ts new file mode 100644 index 00000000..9956309b --- /dev/null +++ b/adapters/rocketchat/src/bot.ts @@ -0,0 +1,75 @@ +import { Bot, Context, omit, Quester, Schema } from '@satorijs/satori' +import { WsServer } from './ws' +import { Internal } from './internal' +import { RocketChatMessageEncoder } from './message' + +export class RocketChatBot extends Bot { + static MessageEncoder = RocketChatMessageEncoder + public http: Quester + public internal: Internal + public logger = this.ctx.logger('rocketchat') + public token = '' + public endpoint: string + constructor(ctx: Context, config: RocketChatBot.Config) { + super(ctx, config) + this.endpoint = (this.config.endpoint || `https://${this.config.host}`) + this.http = ctx.http.extend({ + endpoint: this.endpoint, + }).extend(config) + this.internal = new Internal(this.http) + ctx.plugin(WsServer, this) + } + + callMethod(method: string, params: any[]) { + const id = Math.random().toString().slice(2) + this.socket.send(JSON.stringify({ + 'msg': 'method', + method, + id, + params, + })) + return id + } + + subscribe(name: string, params: any[]) { + const id = Math.random().toString().slice(2) + this.socket.send(JSON.stringify({ + 'msg': 'sub', + name, + id, + params, + })) + return id + } + + async initliaze() { + const data = await this.internal.login(this.config.username, this.config.password) + this.token = data.authToken + this.selfId = data.userId + this.http.config.headers['X-Auth-Token'] = this.token + this.http.config.headers['X-User-Id'] = this.selfId + // const statistics = await this.internal.statistics() + // this.logger.info('statistics: %s', JSON.stringify(statistics, null, 2)) + // this.userId = `@${this.config.username}:${this.config.host}` + this.platform = 'rocketchat' + this.username = data.me.name + } +} + +export namespace RocketChatBot { + export interface Config extends Bot.Config, Quester.Config { + username: string + password: string + endpoint: string + host: string + } + export const Config: Schema = Schema.intersect([ + Schema.object({ + username: Schema.string().required(), + password: Schema.string().role('secret').required(), + host: Schema.string().description('Matrix Homeserver 域名。').required(), + endpoint: Schema.string().description('Matrix Homeserver 地址。默认为 `https://{host}`。'), + ...omit(Quester.Config.dict, ['endpoint']), + }), + ] as const) +} diff --git a/adapters/rocketchat/src/index.ts b/adapters/rocketchat/src/index.ts new file mode 100644 index 00000000..90d70513 --- /dev/null +++ b/adapters/rocketchat/src/index.ts @@ -0,0 +1,9 @@ +import { RocketChatBot } from './bot' + +export * from './bot' +export * from './utils' +export * from './message' +export * from './ws' +export * from './types' + +export default RocketChatBot diff --git a/adapters/rocketchat/src/internal.ts b/adapters/rocketchat/src/internal.ts new file mode 100644 index 00000000..5a42f451 --- /dev/null +++ b/adapters/rocketchat/src/internal.ts @@ -0,0 +1,25 @@ +import { Quester } from '@satorijs/satori' + +export class Internal { + constructor(public http: Quester) { } + + /** https://developer.rocket.chat/reference/api/realtime-api/method-calls/authentication/login */ + async login(user: string, password: string) { + const { data } = await this.http.post('/api/v1/login', { + user, password, + }) + return data + } + + /** https://developer.rocket.chat/reference/api/rest-api/endpoints/statistics/stats-endpoints/get-statistics */ + async statistics() { + const { data } = await this.http.get('/api/v1/statistics') + return data + } + + /** https://developer.rocket.chat/reference/api/rest-api/endpoints/rooms/rooms-endpoints/get-rooms */ + async getRooms() { + const { update } = await this.http.get('/api/v1/rooms.get') + return update + } +} diff --git a/adapters/rocketchat/src/message.ts b/adapters/rocketchat/src/message.ts new file mode 100644 index 00000000..41e5c880 --- /dev/null +++ b/adapters/rocketchat/src/message.ts @@ -0,0 +1,110 @@ +import { h, MessageEncoder } from '@satorijs/satori' +import { RocketChatBot } from './bot' +import FormData from 'form-data' + +export const escape = (val: string) => + val + .replace(/(?[\](#!@]/g, '\u200B$&') + .replace(/([\\`*_{}])/g, '\\$&') + .replace(/([\-\*]|\d\.) /g, '\u200B$&') + +export const unescape = (val: string) => + val + .replace(/\u200b([\*_~`])/g, '$1') +export class RocketChatMessageEncoder extends MessageEncoder { + buffer = '' + addition: Record = {} + async flush() { + if (!this.buffer.length) return + /** https://developer.rocket.chat/reference/api/rest-api/endpoints/messaging/chat-endpoints/send-message */ + await this.bot.http.post('/api/v1/chat.sendMessage', { + message: { + ...this.addition, + rid: this.channelId, + // msg: this.buffer, + blocks: [{ + type: 'section', + text: { + type: 'mrkdwn', + text: this.buffer, + }, + }], + }, + }) + } + + async sendAsset(element: h) { + if (this.buffer.length) await this.flush() + const { attrs } = element + const { filename, data, mime } = await this.bot.ctx.http.file(attrs.url, attrs) + const form = new FormData() + // https://github.com/form-data/form-data/issues/468 + const value = process.env.KOISHI_ENV === 'browser' + ? new Blob([data], { type: mime }) + : Buffer.from(data) + form.append('file', value, attrs.file || filename) + // form.append('channels', this.channelId) + // if (this.thread_ts) form.append('thread_ts', this.thread_ts) + const sent = await this.bot.http.post<{ + ok: boolean + file: File + }>(`/api/v1/rooms.upload/${this.channelId}`, form, { + headers: form.getHeaders(), + }) + if (sent.ok) { + const session = this.bot.session() + // adaptSentAsset(sent.file, session) + session.app.emit(session, 'send', session) + this.results.push(session) + } + } + + async visit(element: h) { + const { type, attrs, children } = element + if (type === 'text') { + this.buffer += escape(attrs.content) + } else if (type === 'image' && attrs.url) { + await this.sendAsset(element) + } else if (type === 'sharp' && attrs.id) { + this.buffer += ` #${attrs.id} ` + } else if (type === 'at') { + if (attrs.id) this.buffer += ` @${attrs.id} ` + if (attrs.type === 'all') this.buffer += ` @all ` + if (attrs.type === 'here') this.buffer += ` @here ` + } 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 === '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 += `<${attrs.href}|` + await this.render(children) + this.buffer += `>` + } else if (type === 'quote') { + this.addition.tmid = attrs.id + } else if (type === 'p') { + this.buffer += `\n` + await this.render(children) + } else if (type === 'face') { + this.buffer += `:${attrs.id}:` + } else if (type === 'author') { + this.addition = { + alias: attrs.nickname, + avatar: attrs.avatar, + } + } else if (type === 'message') { + await this.render(children) + } + } +} diff --git a/adapters/rocketchat/src/types/blocks.ts b/adapters/rocketchat/src/types/blocks.ts new file mode 100644 index 00000000..a4f85650 --- /dev/null +++ b/adapters/rocketchat/src/types/blocks.ts @@ -0,0 +1,156 @@ +export type Blockquote = { + type: 'BLOCKQUOTE' + value: Paragraph[] +} +export type OrderedList = { + type: 'ORDERED_LIST' + value: ListItem[] +} +export type UnorderedList = { + type: 'UNORDERED_LIST' + value: ListItem[] +} +export type ListItem = { + type: 'LIST_ITEM' + value: Inlines[] + number?: number +} +export type Tasks = { + type: 'TASKS' + value: Task[] +} +export type Task = { + type: 'TASK' + status: boolean + value: Inlines[] +} +export type CodeLine = { + type: 'CODE_LINE' + value: Plain +} +export type Color = { + type: 'COLOR' + value: { + r: number + g: number + b: number + a: number + } +} +export type BigEmoji = { + type: 'BIG_EMOJI' + value: [Emoji] | [Emoji, Emoji] | [Emoji, Emoji, Emoji] +} +export type Emoji = { + type: 'EMOJI' + value: Plain + shortCode: string +} | { + type: 'EMOJI' + value: undefined + unicode: string +} +export type Code = { + type: 'CODE' + language: string | undefined + value: CodeLine[] +} +export type InlineCode = { + type: 'INLINE_CODE' + value: Plain +} +export type Heading = { + type: 'HEADING' + level: 1 | 2 | 3 | 4 + value: Plain[] +} +export type Quote = { + type: 'QUOTE' + value: Paragraph[] +} +export type Markup = Italic | Strike | Bold | Plain +export type MarkupExcluding = Exclude +export type Bold = { + type: 'BOLD' + value: (MarkupExcluding | Link | Emoji | UserMention | ChannelMention)[] +} +export type Italic = { + type: 'ITALIC' + value: (MarkupExcluding | Link | Emoji | UserMention | ChannelMention)[] +} +export type Strike = { + type: 'STRIKE' + value: (MarkupExcluding | Link | Emoji | UserMention | ChannelMention)[] +} +export type Plain = { + type: 'PLAIN_TEXT' + value: string +} +export type LineBreak = { + type: 'LINE_BREAK' + value: undefined +} +export type KaTeX = { + type: 'KATEX' + value: string +} +export type InlineKaTeX = { + type: 'INLINE_KATEX' + value: string +} +export type Paragraph = { + type: 'PARAGRAPH' + value: Exclude[] +} +export type Image = { + type: 'IMAGE' + value: { + src: Plain + label: Markup + } +} +export type Link = { + type: 'LINK' + value: { + src: Plain + label: Markup | Markup[] + } +} +export type UserMention = { + type: 'MENTION_USER' + value: Plain +} +export type ChannelMention = { + type: 'MENTION_CHANNEL' + value: Plain +} +export type Types = { + BOLD: Bold + PARAGRAPH: Paragraph + PLAIN_TEXT: Plain + ITALIC: Italic + STRIKE: Strike + CODE: Code + CODE_LINE: CodeLine + INLINE_CODE: InlineCode + HEADING: Heading + QUOTE: Quote + LINK: Link + MENTION_USER: UserMention + MENTION_CHANNEL: ChannelMention + EMOJI: Emoji + BIG_EMOJI: BigEmoji + COLOR: Color + TASKS: Tasks + TASK: Task + UNORDERED_LIST: UnorderedList + ORDERED_LIST: OrderedList + LIST_ITEM: ListItem + IMAGE: Image + LINE_BREAK: LineBreak +} +export type ASTNode = BigEmoji | Bold | Paragraph | Plain | Italic | Strike | Code | CodeLine | InlineCode | Heading | Quote | Link | UserMention | ChannelMention | Emoji | Color | Tasks +export type TypesKeys = keyof Types +export type Inlines = Bold | Plain | Italic | Strike | InlineCode | Image | Link | UserMention | ChannelMention | Emoji | Color | InlineKaTeX +export type Blocks = Code | Heading | Quote | ListItem | Tasks | OrderedList | UnorderedList | LineBreak | KaTeX +export type Root = (Paragraph | Blocks)[] | [BigEmoji] diff --git a/adapters/rocketchat/src/types/index.ts b/adapters/rocketchat/src/types/index.ts new file mode 100644 index 00000000..c375062d --- /dev/null +++ b/adapters/rocketchat/src/types/index.ts @@ -0,0 +1,99 @@ +import { Root } from './blocks' + +export interface IRocketChatRecord { + _id: string + _updatedAt: Date +} +export type RoomID = string +type MentionType = 'user' | 'team' + +interface IUser extends IRocketChatRecord{ + _id: string + username?: string +} + +export interface IMessage extends IRocketChatRecord { + rid: RoomID + msg: string + tmid?: string + tshow?: boolean + // ts: Date; + ts: {$date: number} + mentions?: ({ + type: MentionType + } & { + _id: string + username?: string + name?: string + })[] + + groupable?: boolean + channels?: { + _id: RoomID + name?: string + }[] + u: Required<{ + _id: string + username?: string + }> & { + name?: string + } + // blocks?: MessageSurfaceLayout; + alias?: string + md?: Root + + _hidden?: boolean + imported?: boolean + replies?: IUser['_id'][] + location?: { + type: 'Point' + coordinates: [number, number] + } + starred?: { _id: IUser['_id'] }[] + pinned?: boolean + pinnedAt?: Date + pinnedBy?: Pick + unread?: boolean + temp?: boolean + drid?: RoomID + tlm?: Date + + dcount?: number + tcount?: number + // t?: MessageTypesValues; + // e2e?: 'pending' | 'done'; + // otrAck?: string; + + // urls?: MessageUrl[]; + + // fileUpload?: { + // publicFilePath: string; + // type?: string; + // size?: number; + // }; + // files?: FileProp[]; + // attachments?: MessageAttachment[]; + + reactions?: { + [key: string]: { names?: (string | undefined)[]; usernames: string[]; federationReactionEventIds?: Record } + } + + private?: boolean + /* @deprecated */ + bot?: boolean + sentByEmail?: boolean + webRtcCallEndTs?: Date + role?: string + + avatar?: string + emoji?: string + + // Tokenization fields + // tokens?: Token[]; + html?: string + // Messages sent from visitors have this field + token?: string + federation?: { + eventId: string + } +} diff --git a/adapters/rocketchat/src/utils.ts b/adapters/rocketchat/src/utils.ts new file mode 100644 index 00000000..1a0b975f --- /dev/null +++ b/adapters/rocketchat/src/utils.ts @@ -0,0 +1,54 @@ +import { h } from '@satorijs/satori' +import { RocketChatBot } from './bot' +import { IMessage } from './types' + +export async function nicknameToUserId(bot: RocketChatBot, nickname: string) { + /** https://developer.rocket.chat/reference/api/rest-api/endpoints/user-management/users-endpoints/get-users-info */ + const data = await bot.http.get('/api/v1/users.info', { + params: { + username: nickname, + }, + }) + return data.user._id +} + +export async function decodeMessage(bot: RocketChatBot, message: IMessage) { + // @ts-ignore + if (message.t) return + const session = bot.session() + session.channelId = message.rid + session.messageId = message._id + session.userId = message.u._id + session.timestamp = message.ts.$date + // @ts-ignore + session.type = message.editedAt ? 'message-updated' : 'message' + session.isDirect = false + session.elements = [] + + for (const item of message.md) { + if (item.type === 'PARAGRAPH') { + let sliced = false + for (const [idx, child] of item.value.entries()) { + if (child.type === 'PLAIN_TEXT') { + session.elements.push(h.text( + child.value.slice(sliced ? 1 : 0), + )) + } else if (child.type === 'MENTION_USER') { + if (idx === 0) sliced = true + const userId = message.mentions.find(v => v.username === child.value.value)._id + if (userId === 'all' || userId === 'here') { + session.elements.push(h.at({ type: userId })) + } else { + session.elements.push(h.at(userId)) + } + } + } + } + } + + if (message.tmid) { + session.elements = [h.quote(message.tmid), ...session.elements] + } + + return session +} diff --git a/adapters/rocketchat/src/ws.ts b/adapters/rocketchat/src/ws.ts new file mode 100644 index 00000000..d4369301 --- /dev/null +++ b/adapters/rocketchat/src/ws.ts @@ -0,0 +1,51 @@ +import { Adapter } from '@satorijs/satori' +import { RocketChatBot } from './bot' +import { decodeMessage } from './utils' + +export class WsServer extends Adapter.WsClient { + async prepare() { + const endpoint = `wss://${this.bot.config.host}/websocket` + return this.bot.http.ws(endpoint) + } + + accept() { + let loginQuery + this.bot.socket.addEventListener('message', async ({ data }) => { + const parsed = JSON.parse(data.toString()) + this.bot.logger.debug(require('util').inspect(parsed, false, null, true)) + if (parsed.msg === 'ping') { + this.bot.socket.send(JSON.stringify({ + msg: 'pong', + })) + } else if (parsed.msg === 'connected') { + await this.bot.initliaze() + loginQuery = this.bot.callMethod('login', [ + { + resume: this.bot.token, + }, + ]) + } else if (parsed.msg === 'result' && parsed.id === loginQuery) { + this.bot.online() + const rooms = await this.bot.internal.getRooms() + for (const room of rooms) { + this.bot.logger.debug('subscribe to room: %s', room._id) + this.bot.subscribe('stream-room-messages', [ + room._id, + false, + ]) + } + } else if (parsed.msg === 'changed' && parsed.collection === 'stream-room-messages') { + const message = parsed.fields.args[0] + if (message.u._id === this.bot.selfId) return + const session = await decodeMessage(this.bot, message) + if (session) this.bot.dispatch(session) + this.bot.logger.debug(require('util').inspect(session, false, 3, true)) + } + }) + this.bot.socket.send(JSON.stringify({ + 'msg': 'connect', + 'version': '1', + 'support': ['1'], + })) + } +} diff --git a/adapters/rocketchat/tsconfig.json b/adapters/rocketchat/tsconfig.json new file mode 100644 index 00000000..a14e38f4 --- /dev/null +++ b/adapters/rocketchat/tsconfig.json @@ -0,0 +1,10 @@ +{ + "extends": "../../tsconfig.base", + "compilerOptions": { + "outDir": "lib", + "rootDir": "src", + }, + "include": [ + "src", + ], +}