From f7464762b78827349113eb6e36577c4fa83b3ac1 Mon Sep 17 00:00:00 2001 From: firstmeet Date: Wed, 27 Nov 2024 11:27:57 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20add=20adapter=20for=20infoflow=20(?= =?UTF-8?q?=E5=A6=82=E6=B5=81)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 2 +- adapters/infoflow/package.json | 38 ++++++++++ adapters/infoflow/readme.md | 10 +++ adapters/infoflow/src/bot.ts | 61 ++++++++++++++++ adapters/infoflow/src/http.ts | 49 +++++++++++++ adapters/infoflow/src/index.ts | 5 ++ adapters/infoflow/src/message.ts | 89 +++++++++++++++++++++++ adapters/infoflow/src/type/api.ts | 18 +++++ adapters/infoflow/src/type/index.ts | 98 ++++++++++++++++++++++++++ adapters/infoflow/src/type/internal.ts | 66 +++++++++++++++++ adapters/infoflow/src/utils.ts | 85 ++++++++++++++++++++++ adapters/infoflow/tsconfig.json | 10 +++ 12 files changed, 530 insertions(+), 1 deletion(-) create mode 100644 adapters/infoflow/package.json create mode 100644 adapters/infoflow/readme.md create mode 100644 adapters/infoflow/src/bot.ts create mode 100644 adapters/infoflow/src/http.ts create mode 100644 adapters/infoflow/src/index.ts create mode 100644 adapters/infoflow/src/message.ts create mode 100644 adapters/infoflow/src/type/api.ts create mode 100644 adapters/infoflow/src/type/index.ts create mode 100644 adapters/infoflow/src/type/internal.ts create mode 100644 adapters/infoflow/src/utils.ts create mode 100644 adapters/infoflow/tsconfig.json diff --git a/README.md b/README.md index eb0c156f..99f8330a 100644 --- a/README.md +++ b/README.md @@ -26,7 +26,7 @@ - [x] WeCom (企业微信) - [x] Wechat Official (微信公众号) - [x] Zulip - + - [x] Infoflow(如流) ## Examples ### Basic usage diff --git a/adapters/infoflow/package.json b/adapters/infoflow/package.json new file mode 100644 index 00000000..14e97b59 --- /dev/null +++ b/adapters/infoflow/package.json @@ -0,0 +1,38 @@ +{ + "name": "koishi-plugin-infoflow-adapter", + "description": "如流适配器", + "version": "0.1.0", + "main": "lib/index.js", + "typings": "lib/index.d.ts", + "contributors": [ + "firstmeet <2022742378@qq.com>" + ], + "homepage": "https://github.com/firstmeet1108/infoflow-adapter", + "files": [ + "lib", + "dist" + ], + "license": "MIT", + "keywords": [ + "chatbot", + "koishi", + "plugin", + "infoflow-adapter", + "koishi-plugin", + "koishi-plugin-infoflow-adapter", + "koishi-plugin-infoflow", + "如流", + "如流适配器", + "如流适配器插件", + "百度" + ], + "peerDependencies": { + "koishi": "^4.18.1" + }, + "dependencies": { + "crypto-js": "^4.2.0" + }, + "devDependencies": { + "@types/crypto-js": "^4" + } +} diff --git a/adapters/infoflow/readme.md b/adapters/infoflow/readme.md new file mode 100644 index 00000000..0df69d6a --- /dev/null +++ b/adapters/infoflow/readme.md @@ -0,0 +1,10 @@ +# koishi-plugin-infoflow-adapter + +[![npm](https://img.shields.io/npm/v/koishi-plugin-infoflow-adapter?style=flat-square)](https://www.npmjs.com/package/koishi-plugin-infoflow-adapter) + +# 如流适配器 + +## 基础设置说明 + +- robotId 需要接收到推送消息后从消息体中提取 +- 其余信息从如流Bot设置中获取 diff --git a/adapters/infoflow/src/bot.ts b/adapters/infoflow/src/bot.ts new file mode 100644 index 00000000..90cec60f --- /dev/null +++ b/adapters/infoflow/src/bot.ts @@ -0,0 +1,61 @@ +import { Bot, Context, HTTP, Schema } from '@satorijs/core' +import { Internal } from './type' +import { HttpServer } from './http' +import { getParam } from './utils' +import { InfoflowMessageEncoder } from './message' +export class InfoflowBot extends Bot { + static inject = ['server', 'http'] + static MessageEncoder = InfoflowMessageEncoder + http: HTTP + internal: Internal + + constructor(ctx: C, config: InfoflowBot.Config) { + super(ctx, config, 'infoflow') + const { access_token } = getParam(config.targetUrl) as { access_token: string } + + this.http = ctx.http.extend({ + endpoint: 'http://apiin.im.baidu.com/api/', + }) + this.internal = new Internal(this, { + query: { + access_token, + }, + }) + + ctx.plugin(HttpServer, this) + } + + initialize() { + Promise.resolve().then(e => { + this.user.name = this.config.name + this.user.avatar = '' // 如流暂未支持获取机器人信息 + this.online() + }) + } +} + +export namespace InfoflowBot { + export interface Config { + name: string + robotId: string + targetUrl: string + selfUrl?: string + port?: number + token?: string + EncodingAESKey?: string + path?: string + } + + export const Config: Schema = Schema.object({ + targetUrl: Schema.string().description('目标 URL'), + name: Schema.string().description('机器人名称'), + robotId: Schema.string().description('机器人 ID'), + selfUrl: Schema.string().default('localhost').description('机器人 URL'), + port: Schema.number().default(80).description('端口号'), + token: Schema.string().description('安全令牌'), + EncodingAESKey: Schema.string().description('消息加解密密钥'), + path: Schema.string().default('/infoflow').description('路径'), + }) +} + +export default InfoflowBot diff --git a/adapters/infoflow/src/http.ts b/adapters/infoflow/src/http.ts new file mode 100644 index 00000000..175c5736 --- /dev/null +++ b/adapters/infoflow/src/http.ts @@ -0,0 +1,49 @@ +import { Adapter, Context } from '@satorijs/core' +import InfoflowBot from './bot' +import {} from '@cordisjs/plugin-server' +import { AESCipher, getParam, getSession, getSignature } from './utils' +import { rAt, requestUrlParam } from './type' + +export class HttpServer extends Adapter> { + static inject = ['server'] + cipher: AESCipher + constructor(ctx: C, bot: InfoflowBot) { + super(ctx) + } + + fork(ctx: C, bot: InfoflowBot) { + super.fork(ctx, bot) + const { EncodingAESKey } = bot.config + this.cipher = new AESCipher(EncodingAESKey) + return bot.initialize() + } + + async connect(bot: InfoflowBot) { + const { path } = bot.config + bot.ctx.server.post(path, (ctx) => { + const reqBody = ctx.request.body + // 验证 + if (reqBody.echostr) { + if (getSignature(reqBody.rn, reqBody.timestamp, bot.config.token) === reqBody.signature) ctx.body = reqBody.echostr + else throw new Error('签名错误') + return + } + + const param = getParam(ctx.request.url) as requestUrlParam + if (!param.signature || !param.timestamp || !param.rn || getSignature(param.rn, param.timestamp, bot.config.token) !== param.signature) { + ctx.body = 'fail' + ctx.status = 403 + return + } + const res = this.cipher.decrypt(reqBody) + const { message } = res + const { robotid } = message.body.find((item) => { + if (item.type === 'AT') { return '' + item?.robotid === bot.config.robotId } + }) as rAt + if (!robotid) return + const theBot = this.bots.find((item) => item.config.robotId === '' + robotid) + const session = getSession(theBot, message) + theBot.dispatch(session) + }) + } +} diff --git a/adapters/infoflow/src/index.ts b/adapters/infoflow/src/index.ts new file mode 100644 index 00000000..621c6d5f --- /dev/null +++ b/adapters/infoflow/src/index.ts @@ -0,0 +1,5 @@ +import { InfoflowBot } from './bot' + +export * from './bot' + +export default InfoflowBot diff --git a/adapters/infoflow/src/message.ts b/adapters/infoflow/src/message.ts new file mode 100644 index 00000000..08189044 --- /dev/null +++ b/adapters/infoflow/src/message.ts @@ -0,0 +1,89 @@ +import { Context, Element, MessageEncoder } from '@satorijs/core' +import InfoflowBot from './bot' +import { SendMessage, Text } from './type' +import { getBase64 } from './utils' + +export class InfoflowMessageEncoder extends MessageEncoder> { + private header: SendMessage['message']['header'] + private body: SendMessage['message']['body'] + async prepare() { + this.header = { + toid: [+this.channelId], + } + this.body = [] + } + + async visit(element: Element): Promise { + const { type, attrs, children } = element + if (type === 'text') { + this.body.push({ + type: 'TEXT', + content: attrs.content, + }) + } else if (type === 'at') { + if (attrs.type === 'all') { + this.body.push({ + type: 'AT', + atall: true, + }) + } else { + this.body.push({ + type: 'AT', + atuserids: attrs.id ? [attrs.id] : [], + }) + } + } else if (type === 'a') { + this.body.push({ + type: 'LINK', + href: attrs.href, + label: attrs.children ? attrs.children.toString() : attrs.href, + }) + } else if (type === 'p') { + if (!(this.body[this.body.length - 1].type === 'TEXT' && (this.body[this.body.length - 1] as Text).content.endsWith('\n'))) { + this.body.push({ + type: 'TEXT', + content: '\n', + }) + } + await this.render(children) + if (!(this.body[this.body.length - 1].type === 'TEXT' && (this.body[this.body.length - 1] as Text).content.endsWith('\n'))) { + this.body.push({ + type: 'TEXT', + content: '\n', + }) + } + } else if (type === 'br') { + this.body.push({ + type: 'TEXT', + content: '\n', + }) + } else if (type === 'quote') { + await this.flush() + } else if (type === 'img' || type === 'image') { + const res = await this.bot.ctx.http.get(attrs.src, { responseType: 'arraybuffer' }) + const encodeImage = getBase64(res) + this.body.push({ + type: 'IMAGE', + content: encodeImage, + }) + } else if (type === 'figure' || type === 'message') { + await this.render(children) + } else { + await this.render(children) + } + } + + async flush() { + await this.post() + this.body = [] + } + + async post() { + this.bot.internal.sendGroupMessage({ + message: { + header: this.header, + body: this.body, + }, + }) + } +} diff --git a/adapters/infoflow/src/type/api.ts b/adapters/infoflow/src/type/api.ts new file mode 100644 index 00000000..effe10fa --- /dev/null +++ b/adapters/infoflow/src/type/api.ts @@ -0,0 +1,18 @@ +import { Internal } from './internal' +import { SendMessage } from './index' + +Internal.define({ + '/msg/groupmsgsend': { + POST: 'sendGroupMessage', + }, +}) + +declare module './internal' { + interface Internal { + /** + * 发送群消息 + * @see https://qy.baidu.com/doc/index.html#/inner_serverapi/robot?id=%e5%8f%91%e9%80%81%e6%b6%88%e6%81%af-1 + */ + sendGroupMessage(query?: SendMessage) + } +} diff --git a/adapters/infoflow/src/type/index.ts b/adapters/infoflow/src/type/index.ts new file mode 100644 index 00000000..2861e47d --- /dev/null +++ b/adapters/infoflow/src/type/index.ts @@ -0,0 +1,98 @@ +export * from './internal' +export * from './api' + +export interface requestUrlParam{ + signature?: string + rn?: string + timestamp?: string + echostr?: string +} + +export interface MessageRequest { + eventtype: string + agentid: number + groupid: number + corpid: string + time: number + fromid: number + opencode: string + message: ReceiveMessage +} + +export type Text = { + type: 'TEXT' + content: string +} + +export type Link = { + type: 'LINK' + href: string + label?: string +} + +export type rAt = { + type: 'AT' + name: string + robotid?: number + userid?: string +} + +export type sAt = { + type: 'AT' + atuserids?: string[] + robotid?: number + atall?: boolean +} + +export type rImage = { + type: 'IMAGE' + downloadurl: string +} + +export type sImage = { + type: 'IMAGE' + content: string +} + +export type Md = { + type: 'MD' + content: string +} + +export type Command = { + type: 'command' + commandname: string +} + +export type r = Text | Link | rImage | Md | Command | rAt + +export type s = Text | Link | sImage | Md | Command | sAt + +export interface ReceiveMessage{ + header: { + fromuserid: string + toid: number + totype: string + msgtype: string + clientmsgid: number + messageid: number + msgseqid: string + at: object + compatible: string + offlinenotify: string + extra: string + servertime: number + clientime: number + updatetime: number + } + body: r[] +} + +export interface SendMessage { + message: { + header: { + toid: number | number[] + } + body: s[] + } +} diff --git a/adapters/infoflow/src/type/internal.ts b/adapters/infoflow/src/type/internal.ts new file mode 100644 index 00000000..1f363632 --- /dev/null +++ b/adapters/infoflow/src/type/internal.ts @@ -0,0 +1,66 @@ +import { Dict, HTTP, makeArray } from '@satorijs/core' +import { InfoflowBot } from '../bot' + +export interface Internal {} + +export interface BaseResponse { + /** error code. would be 0 if success, and non-0 if failed. */ + code: number + /** error message. would be 'success' if success. */ + msg: string +} + +type Method = 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH' + +export class Internal { + // private defaultParam: Dict + constructor(private bot: InfoflowBot, private defaultParam?: Dict) {} + + private processReponse(response: any): BaseResponse { + const { data: { fail } } = response + const errs = Object.values(fail) + if (errs.length === 0) { + return response + } else { + this.bot.logger.debug('response: %o', response) + throw new Error(`HTTP response with non-zero status (${errs[0]})`) + } + } + + static define(routes: Dict>>) { + for (const path in routes) { + for (const key in routes[path]) { + const method = key as Method + for (const name of makeArray(routes[path][method])) { + Internal.prototype[name] = async function (this: Internal, ...args: any[]) { + const raw = args.join(', ') + let url = path.replace(/\{([^}]+)\}/g, () => { + if (!args.length) throw new Error(`too few arguments for ${path}, received ${raw}`) + return args.shift() + }) + if (this.defaultParam.query) { + url += '?' + for (const i in this.defaultParam.query) { + url = url + `${i}=${this.defaultParam.query[i]}` + } + } + const config: HTTP.RequestConfig = {} + if (args.length === 1) { + if (method === 'GET' || method === 'DELETE') { + config.params = args[0] + } else { + config.data = args[0] + } + } else if (args.length === 2 && method !== 'GET' && method !== 'DELETE') { + config.data = args[0] + config.params = args[1] + } else if (args.length > 1) { + throw new Error(`too many arguments for ${path}, received ${raw}`) + } + return this.processReponse((await this.bot.http(method, url, config)).data) + } + } + } + } + } +} diff --git a/adapters/infoflow/src/utils.ts b/adapters/infoflow/src/utils.ts new file mode 100644 index 00000000..6572c818 --- /dev/null +++ b/adapters/infoflow/src/utils.ts @@ -0,0 +1,85 @@ +import CryptoJS from 'crypto-js' +import { Context, h } from '@satorijs/core' +import InfoflowBot from './bot' +import { MessageRequest, r, ReceiveMessage } from './type' +export class AESCipher { + key: CryptoJS.lib.WordArray + options: any + constructor(key: string) { + this.key = CryptoJS.enc.Base64.parse(key) + this.options = { + mode: CryptoJS.mode.ECB, + padding: CryptoJS.pad.Pkcs7, + } + } + + // 加密 + encrypt(data) { + const cipher = CryptoJS.AES.encrypt(data, this.key, this.options) + const base64Cipher = cipher.ciphertext.toString(CryptoJS.enc.Base64) + const resultCipher = base64Cipher + .replace(/\+/g, '-') + .replace(/\//g, '_') + .replace(/=+$/g, '') + return resultCipher + } + + // 解密 + decrypt(content): MessageRequest { + content = content + .replace(/-/g, '+') + .replace(/_/g, '/') + .padEnd(content.length + content.length % 4, '=') + const bytes = CryptoJS.AES.decrypt(content, this.key, this.options) + return JSON.parse(bytes.toString(CryptoJS.enc.Utf8)) + } +} + +export function getParam(url: string): any { + const search = url.split('?')[1] + if (!search) return {} + return search.split('&').reduce((pre, curr) => { + const [key, val] = curr.split('=') + return { ...pre, [key]: decodeURIComponent(val) } + }, {}) +} + +export function getSignature(rn: string, timestamp: string, token: string) { + return CryptoJS + .MD5(`${rn}${timestamp}${token}`) + .toString() +} + +export function getSession(bot: InfoflowBot, message: ReceiveMessage) { + const session = bot.session() + const { body, header } = message + session.setInternal('infoflow', body) + session.type = 'message' + session.elements = body.map(m2h) + session.channelId = header.toid.toString() + session.userId = header.fromuserid + session.messageId = header.clientmsgid.toString() + session.timestamp = header.servertime + session.guildId = header.toid.toString() + return session +} + +function m2h(item: r): h | null { + switch (item.type) { + case 'AT': + if (item.robotid) return null + return h.at(item.userid, { name: item.name }) + case 'IMAGE': + return h.image(item.downloadurl) + case 'LINK': + return h.text(item.label) + case 'command': + return h.text(item.commandname) + case 'TEXT': + return h.text(item.content) + } +} + +export function getBase64(data) { + return btoa(new Uint8Array(data).reduce((data, byte) => data + String.fromCharCode(byte), '')) +} diff --git a/adapters/infoflow/tsconfig.json b/adapters/infoflow/tsconfig.json new file mode 100644 index 00000000..a14e38f4 --- /dev/null +++ b/adapters/infoflow/tsconfig.json @@ -0,0 +1,10 @@ +{ + "extends": "../../tsconfig.base", + "compilerOptions": { + "outDir": "lib", + "rootDir": "src", + }, + "include": [ + "src", + ], +}