From be95c646ad7cacf72c8e73597d1e432edc16fd1d Mon Sep 17 00:00:00 2001 From: LittleC Date: Mon, 7 Aug 2023 00:18:30 +0800 Subject: [PATCH] feat(wecom): add adapter-wecom --- adapters/wecom/package.json | 38 ++++++++++ adapters/wecom/src/bot.ts | 133 ++++++++++++++++++++++++++++++++++ adapters/wecom/src/http.ts | 92 +++++++++++++++++++++++ adapters/wecom/src/index.ts | 13 ++++ adapters/wecom/src/message.ts | 111 ++++++++++++++++++++++++++++ adapters/wecom/src/types.ts | 68 +++++++++++++++++ adapters/wecom/src/utils.ts | 55 ++++++++++++++ adapters/wecom/tsconfig.json | 10 +++ 8 files changed, 520 insertions(+) create mode 100644 adapters/wecom/package.json create mode 100644 adapters/wecom/src/bot.ts create mode 100644 adapters/wecom/src/http.ts create mode 100644 adapters/wecom/src/index.ts create mode 100644 adapters/wecom/src/message.ts create mode 100644 adapters/wecom/src/types.ts create mode 100644 adapters/wecom/src/utils.ts create mode 100644 adapters/wecom/tsconfig.json diff --git a/adapters/wecom/package.json b/adapters/wecom/package.json new file mode 100644 index 00000000..5b8ac3a3 --- /dev/null +++ b/adapters/wecom/package.json @@ -0,0 +1,38 @@ +{ + "name": "@satorijs/adapter-wecom", + "description": "Wecom 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/wecom" + }, + "bugs": { + "url": "https://github.com/satorijs/satori/issues" + }, + "homepage": "https://koishi.chat/plugins/adapter/wecom.html", + "keywords": [ + "bot", + "wecom", + "chatbot", + "satori" + ], + "peerDependencies": { + "@satorijs/satori": "^2.6.0" + }, + "dependencies": { + "@wecom/crypto": "^1.0.1", + "form-data": "^4.0.0", + "xml2js": "^0.6.2" + }, + "devDependencies": { + "@types/xml2js": "^0.4.11" + } +} diff --git a/adapters/wecom/src/bot.ts b/adapters/wecom/src/bot.ts new file mode 100644 index 00000000..378b0236 --- /dev/null +++ b/adapters/wecom/src/bot.ts @@ -0,0 +1,133 @@ +import { Bot, Context, Logger, Quester, Schema, Universal } from '@satorijs/satori' +import { HttpServer } from './http' +import { WecomMessageEncoder } from './message' + +export class WecomBot extends Bot { + static MessageEncoder = WecomMessageEncoder + http: Quester + // internal: Internal + refreshTokenTimer: NodeJS.Timeout + logger = new Logger('wecom') + constructor(ctx: Context, config: WecomBot.Config) { + super(ctx, config) + this.http = ctx.http.extend(config) + // this.internal = new Internal(this.http, this) + + ctx.plugin(HttpServer, this) + } + + // @ts-ignore + stop(): Promise { + clearTimeout(this.refreshTokenTimer) + } + + public token: string + /** hhttps://developer.work.weixin.qq.com/document/path/91039 */ + async refreshToken() { + const { access_token, expires_in, errcode, errmsg } = await this.http.get<{ + access_token: string + expires_in: number + errcode?: number + errmsg?: string + }>('/cgi-bin/gettoken', { + params: { + corpid: this.config.corpId, + corpsecret: this.config.secret, + }, + }) + if (errcode > 0) { + this.logger.error(errmsg) + return + } + this.token = access_token + this.logger.debug('token %o, expires in %d', access_token, expires_in) + this.refreshTokenTimer = setTimeout(this.refreshToken.bind(this), (expires_in - 10) * 1000) + return access_token + } + + async getMedia(mediaId: string) { + return await this.http.get('/cgi-bin/media/get', { + params: { + access_token: this.token, + media_id: mediaId, + }, + }) + } + + /** https://developer.work.weixin.qq.com/document/path/90196 */ + async getUser(userId: string, guildId?: string): Promise { + const data = await this.http.get('/cgi-bin/user/get', { + params: { + userid: userId, + access_token: this.token, + }, + }) + const { name, avatar } = data + return { + userId, + username: name, + avatar, + } + } + + /** https://developer.work.weixin.qq.com/document/path/90227 */ + async getSelf(): Promise { + const { square_logo_url, name } = await this.http.get<{ + errcode: number + errmsg: string + agentid: number + name: string + square_logo_url: string + description: string + allow_userinfos: any[] + allow_partys: any[] + close: number + redirect_domain: string + report_location_flag: number + isreportenter: number + home_url: string + }>('/cgi-bin/agent/get', { + params: { + access_token: this.token, + agentid: this.config.agentId, + }, + }) + return { + userId: this.config.agentId, + username: name, + avatar: square_logo_url, + } + } + + /** https://developer.work.weixin.qq.com/document/path/94867 */ + async deleteMessage(channelId: string, messageId: string): Promise { + await this.http.post('/cgi-bin/message/recall', { + msgid: messageId, + }, { + params: { access_token: this.token }, + }) + } +} + +export namespace WecomBot { + export interface Config extends Bot.Config, Quester.Config { + corpId: string + token: string + aesKey: string + agentId: string + secret: string + } + + export const Config: Schema = Schema.intersect([ + Schema.object({ + corpId: Schema.string().required(), + agentId: Schema.string().description('AgentID').required(), + secret: Schema.string().role('secret').description('AppSecret').required(), + token: Schema.string().role('secret').description('Webhook Token').required(), + aesKey: Schema.string().role('secret').description('EncodingAESKey'), + }), + Quester.createConfig('https://qyapi.weixin.qq.com/'), + ]) +} + +WecomBot.prototype.platform = 'wecom' diff --git a/adapters/wecom/src/http.ts b/adapters/wecom/src/http.ts new file mode 100644 index 00000000..abc9ac5e --- /dev/null +++ b/adapters/wecom/src/http.ts @@ -0,0 +1,92 @@ +import { Adapter } from '@satorijs/satori' +import { WecomBot } from './bot' +import xml2js from 'xml2js' +import { Message } from './types' +import { decodeMessage } from './utils' +import { decrypt, getSignature } from '@wecom/crypto' + +export class HttpServer extends Adapter.Server { + constructor() { + super() + } + + async start(bot: WecomBot) { + bot.selfId = bot.config.agentId + bot.platform = 'wecom' + + await bot.refreshToken() + const self = await bot.getSelf() + bot.avatar = self.avatar + bot.username = self.username + // https://developer.work.weixin.qq.com/document/10514 + bot.ctx.router.get('/wecom', async (ctx) => { + let success = false + const { msg_signature, timestamp, nonce, echostr } = ctx.request.query + + // for (const localBot of this.bots.filter(v => v.platform === 'wecom')) { + const localSign = getSignature(bot.config.token, timestamp?.toString(), nonce?.toString(), echostr?.toString()) + if (localSign === msg_signature) { + success = true + const dec = decrypt(bot.config.aesKey, echostr?.toString()) + ctx.body = dec.message + } + // } + if (!success) return ctx.status = 403 + ctx.status = 200 + }) + + bot.ctx.router.post('/wecom', async (ctx) => { + const { timestamp, nonce, msg_signature } = ctx.request.query + let { xml: data }: { + xml: Message + } = await xml2js.parseStringPromise(ctx.request.rawBody, { + explicitArray: false, + }) + const botId = data.AgentID + const localBot = this.bots.find((bot) => bot.selfId === botId) + + if (data.Encrypt) { + const localSign = getSignature(localBot.config.token, timestamp?.toString(), nonce?.toString(), data.Encrypt) + if (localSign !== msg_signature) return ctx.status = 403 + const { message, id } = decrypt(bot.config.aesKey, data.Encrypt) + // if (id !== localBot.config.appid) return ctx.status = 403 + const { xml: data2 } = await xml2js.parseStringPromise(message, { + explicitArray: false, + }) + bot.logger.debug('decrypted %c', require('util').inspect(data2, false, null, true)) + data = data2 + } + + bot.logger.debug('%c', require('util').inspect(ctx.request.rawBody, false, null, true)) + + const session = await decodeMessage(localBot, data) + if (session) { + localBot.dispatch(session) + localBot.logger.debug(require('util').inspect(session, false, null, true)) + } + ctx.status = 200 + ctx.body = 'success' + }) + /** https://developer.work.weixin.qq.com/document/path/90254 */ + bot.ctx.router.get('/wecom/assets/:self_id/:media_id', async (ctx) => { + const mediaId = ctx.params.media_id + const selfId = ctx.params.self_id + const localBot = this.bots.find((bot) => bot.selfId === selfId) + if (!localBot) return ctx.status = 404 + const resp = await localBot.http.axios(`/cgi-bin/media/get`, { + method: 'GET', + responseType: 'stream', + params: { + access_token: localBot.token, + media_id: mediaId, + }, + }) + ctx.type = resp.headers['content-type'] + ctx.set('date', resp.headers['date']) + ctx.set('cache-control', resp.headers['cache-control']) + ctx.response.body = resp.data + ctx.status = 200 + }) + bot.online() + } +} diff --git a/adapters/wecom/src/index.ts b/adapters/wecom/src/index.ts new file mode 100644 index 00000000..ab3df0ae --- /dev/null +++ b/adapters/wecom/src/index.ts @@ -0,0 +1,13 @@ +import { Message } from './types' + +export * from './bot' +export * from './utils' +export * from './types' +export * from './http' +export * from './message' + +declare module '@satorijs/core' { + interface Session { + wecom?: Message + } +} diff --git a/adapters/wecom/src/message.ts b/adapters/wecom/src/message.ts new file mode 100644 index 00000000..c945c23b --- /dev/null +++ b/adapters/wecom/src/message.ts @@ -0,0 +1,111 @@ +import { h, MessageEncoder } from '@satorijs/satori' +import { WecomBot } from './bot' +import FormData from 'form-data' + +/** https://developer.work.weixin.qq.com/document/path/90236#%E6%94%AF%E6%8C%81%E7%9A%84markdown%E8%AF%AD%E6%B3%95 */ + +export class WecomMessageEncoder extends MessageEncoder { + buffer = '' + upsertSend(msgId: string) { + const session = this.bot.session() + session.type = 'message' + session.messageId = msgId + session.isDirect = true + session.userId = this.bot.selfId + session.timestamp = new Date().valueOf() + session.app.emit(session, 'send', session) + this.results.push(session) + } + + /** https://developer.work.weixin.qq.com/document/path/90236 */ + async sendByCustom(payload: any) { + if (payload.msgtype === 'text' && !payload.text?.content) return + // if (payload.msgtype === "markdown" && !payload.markdown?.content) return; + const { msgid } = await this.bot.http.post('/cgi-bin/message/send', { + touser: this.options.session.userId, + agentid: this.bot.selfId, + ...payload, + }, { + params: { access_token: this.bot.token }, + }) + + this.upsertSend(msgid) + } + + async flushMedia(element: h) { + if (!['audio', 'video', 'image', 'file'].includes(element.type)) return + let type = element.type + if (type === 'audio') type = 'voice' + const [media] = await this.uploadMedia(element) + + await this.sendByCustom({ + msgtype: type, + [type]: { + media_id: media, + }, + }) + } + + async flush(): Promise { + await this.sendByCustom({ + msgtype: 'text', + text: { + content: this.buffer, + }, + }) + this.buffer = '' + } + + /** https://developer.work.weixin.qq.com/document/path/90253 */ + async uploadMedia(element: h) { + const { type, attrs } = element + const uploadType = type === 'audio' ? 'voice' : type + const form = new FormData() + + const { filename, data, mime } = await this.bot.ctx.http.file(attrs.url, attrs) + const value = process.env.KOISHI_ENV === 'browser' + ? new Blob([data], { type: mime }) + : Buffer.from(data) + + form.append('media', value, attrs.file || filename) + + const resp = await this.bot.http.post<{ + type: string + media_id: string + created_at: number + errcode: number + errmsg: string + }>('/cgi-bin/media/upload', form, { + params: { + access_token: this.bot.token, + type: uploadType, + }, + headers: form.getHeaders(), + }) + if (resp.media_id) { + return [resp.media_id, uploadType] + } + this.bot.logger.error(resp.errmsg) + } + + async visit(element: h) { + const { type, attrs, children } = element + if (type === 'text') { + this.buffer += attrs.content + } else if (type === 'p') { + await this.render(children) + this.buffer += '\n' + } else if (type === 'image' || type === 'audio' || type === 'video' || type === 'file') { + await this.flushMedia(element) + } else if (type === 'a' && attrs.href) { + await this.render(children) + this.buffer += ` (${attrs.href})` + } else if (type === 'message') { + await this.flush() + await this.render(children) + await this.flush() + } else { + await this.render(children) + } + } +} diff --git a/adapters/wecom/src/types.ts b/adapters/wecom/src/types.ts new file mode 100644 index 00000000..fdd0fa1c --- /dev/null +++ b/adapters/wecom/src/types.ts @@ -0,0 +1,68 @@ +export interface BaseMessage { + ToUserName: string + AgentID: string + FromUserName: string + CreateTime: number + MsgId: string + Idx?: string + Encrypt?: string +} + +export type Message = TextMessage | ImageMessage | VoiceMessage | VideoMessage | LocationMessage | EventMessage + +export interface TextMessage extends BaseMessage { + MsgType: 'text' + Content: string +} + +export interface ImageMessage extends BaseMessage { + MsgType: 'image' + PicUrl: string + MediaId: string +} + +export interface VoiceMessage extends BaseMessage { + MsgType: 'voice' + MediaId: string + Format: string + Recogonition?: string +} + +export interface VideoMessage extends BaseMessage { + MsgType: 'video' + MediaId: string + ThumbMediaId: string +} + +export interface EventMessage extends BaseMessage { + MsgType: 'event' + Event: 'subscribe' | 'unsubscribe' +} + +export interface LocationMessage extends BaseMessage { + MsgType: 'location' + Location_X: number + Location_Y: number + Scale: number + Label: string +} + +export interface BaseSendMessage { + ToUserName: string + FromUserName: string + CreateTime: number +} + +export type SendMessage = TextSendMessage | ImageSendMessage + +export interface TextSendMessage extends BaseSendMessage { + MsgType: 'text' + Content: string +} + +export interface ImageSendMessage extends BaseSendMessage { + MsgType: 'image' + Image: { + MediaId: string + } +} diff --git a/adapters/wecom/src/utils.ts b/adapters/wecom/src/utils.ts new file mode 100644 index 00000000..681d1b08 --- /dev/null +++ b/adapters/wecom/src/utils.ts @@ -0,0 +1,55 @@ +import { Message } from './types' +import { WecomBot } from './bot' +import { h } from '@satorijs/satori' +export async function decodeMessage(bot: WecomBot, message: Message) { + const session = bot.session() + session.timestamp = message.CreateTime * 1000 + // session.wechatOfficial = message + session.guildId = bot.config.corpId + session.userId = message.FromUserName + session.author = { + userId: message.FromUserName, + } + // session.channelId = session.userId + // session.guildId = session.userId + session.messageId = message.MsgId + if (message.MsgType === 'text') { + session.isDirect = true + session.type = 'message' + session.elements = [h.text(message.Content)] + return session + } else if (message.MsgType === 'image') { + session.isDirect = true + session.type = 'message' + session.elements = [h.image(message.PicUrl)] + return session + } else if (message.MsgType === 'voice') { + session.isDirect = true + session.type = 'message' + session.elements = [h.audio(`${bot.ctx.root.config.selfUrl}/wecom/assets/${bot.selfId}/${message.MediaId}`)] + // https://developer.work.weixin.qq.com/document/path/90254 + return session + } else if (message.MsgType === 'video') { + session.isDirect = true + session.type = 'message' + session.elements = [h.video(`${bot.ctx.root.config.selfUrl}/wecom/assets/${bot.selfId}/${message.MediaId}`)] + return session + } else if (message.MsgType === 'location') { + session.isDirect = true + session.type = 'message' + session.elements = [h('wecom:location', { + latitude: message.Location_X, + longitude: message.Location_Y, + label: message.Label, + })] + return session + } else if (message.MsgType === 'event') { + if (message.Event === 'subscribe') { + session.type = 'friend-added' + return session + } else if (message.Event === 'unsubscribe') { + session.type = 'friend-deleted' + return session + } + } +} diff --git a/adapters/wecom/tsconfig.json b/adapters/wecom/tsconfig.json new file mode 100644 index 00000000..74ac2c8d --- /dev/null +++ b/adapters/wecom/tsconfig.json @@ -0,0 +1,10 @@ +{ + "extends": "../../tsconfig.base", + "compilerOptions": { + "outDir": "lib", + "rootDir": "src", + }, + "include": [ + "src", + ], +} \ No newline at end of file