diff --git a/adapters/weixin-kf/.npmignore b/adapters/weixin-kf/.npmignore new file mode 100644 index 00000000..7e5fcbc1 --- /dev/null +++ b/adapters/weixin-kf/.npmignore @@ -0,0 +1,2 @@ +.DS_Store +tsconfig.tsbuildinfo diff --git a/adapters/weixin-kf/package.json b/adapters/weixin-kf/package.json new file mode 100644 index 00000000..8af7e21a --- /dev/null +++ b/adapters/weixin-kf/package.json @@ -0,0 +1,43 @@ +{ + "name": "@satorijs/adapter-weixin-kf", + "description": "Weixin Kf Adapter for Satorijs", + "version": "0.0.1", + "main": "lib/index.js", + "typings": "lib/index.d.ts", + "files": [ + "lib", + "src" + ], + "author": "LittleC ", + "license": "MIT", + "repository": { + "type": "git", + "url": "git+https://github.com/satorijs/satori.git", + "directory": "adapters/weixin-kf" + }, + "bugs": { + "url": "https://github.com/satorijs/satori/issues" + }, + "homepage": "https://koishi.chat/plugins/adapter/weixin-kf.html", + "keywords": [ + "bot", + "wechat", + "weixin", + "official", + "chatbot", + "satori", + "im", + "chat" + ], + "devDependencies": { + "@cordisjs/server": "^0.1.8", + "@types/xml2js": "^0.4.14" + }, + "peerDependencies": { + "@satorijs/satori": "^3.6.3" + }, + "dependencies": { + "@wecom/crypto": "^1.0.1", + "xml2js": "^0.6.2" + } +} diff --git a/adapters/weixin-kf/readme.md b/adapters/weixin-kf/readme.md new file mode 100644 index 00000000..60816236 --- /dev/null +++ b/adapters/weixin-kf/readme.md @@ -0,0 +1,5 @@ +# [@satorijs/adapter-weixin-kf](https://koishi.chat/plugins/adapter/weixin-kf.html) + +Weixin Kf (微信客服) adapter for [Satori](https://github.com/satorijs/satori). + +- [Documentation](https://koishi.chat/plugins/adapter/weixin-kf.html) diff --git a/adapters/weixin-kf/src/bot.ts b/adapters/weixin-kf/src/bot.ts new file mode 100644 index 00000000..ef8cd5f4 --- /dev/null +++ b/adapters/weixin-kf/src/bot.ts @@ -0,0 +1,109 @@ +import { Bot, Context, Quester, Schema } from '@satorijs/satori' +import { HttpServer } from './http' +import { WechatOfficialMessageEncoder } from './message' +// import { Internal } from './types/internal' + +export class WechatKfBot extends Bot { + static inject = ['server', 'http'] + static MessageEncoder = WechatOfficialMessageEncoder + + http: Quester + // internal: Internal + refreshTokenTimer: NodeJS.Timeout + + constructor(ctx: C, config: WechatOfficialBot.Config) { + super(ctx, config, 'wechat-official') + this.selfId = config.account + 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 + /** https://developers.weixin.qq.com/doc/offiaccount/Basic_Information/Get_access_token.html */ + 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/token', { + params: { + grant_type: 'client_credential', + appid: this.config.appid, + secret: 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 + } + + /** https://developers.weixin.qq.com/doc/offiaccount/Customer_Service/Customer_Service_Management.html */ + async ensureCustom() { + if (!this.config.customerService) return + const data = await this.http.get<{ + kf_list: { + kf_account: string + kf_headimgurl: string + kf_id: number + kf_nick: string + }[] + }>('/cgi-bin/customservice/getkflist', { + params: { access_token: this.token }, + }) + if (data.kf_list.find(v => v.kf_nick === 'Koishi')) return + await this.http.post('/customservice/kfaccount/add', { + kf_account: 'koishi@' + this.config.account, + nickname: 'Koishi', + }, { + params: { access_token: this.token }, + }) + } + + async getMedia(mediaId: string) { + return await this.http.get('/cgi-bin/media/get', { + params: { + access_token: this.token, + media_id: mediaId, + }, + }) + } + + $toMediaUrl(mediaId: string) { + return `${this.ctx.server.config.selfUrl}/wechat-official/assets/${this.selfId}/${mediaId}` + } +} + +export namespace WechatOfficialBot { + export interface Config extends Quester.Config { + appid: string + secret: string + token: string + aesKey: string + customerService: boolean + account: string + } + + export const Config: Schema = Schema.intersect([ + Schema.object({ + account: Schema.string().required(), + appid: Schema.string().description('AppID').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'), + customerService: Schema.boolean().default(false).description('启用客服消息回复'), + }), + Quester.createConfig('https://api.weixin.qq.com/'), + ]) +} diff --git a/adapters/weixin-kf/src/http.ts b/adapters/weixin-kf/src/http.ts new file mode 100644 index 00000000..3929aebf --- /dev/null +++ b/adapters/weixin-kf/src/http.ts @@ -0,0 +1,128 @@ +import { Adapter, Context } from '@satorijs/satori' +import {} from '@cordisjs/server' +import { WechatOfficialBot } from './bot' +import xml2js from 'xml2js' +import { Message } from './types' +import { decodeMessage } from './utils' +import { decrypt, encrypt, getSignature } from '@wecom/crypto' + +export class HttpServer extends Adapter> { + static inject = ['server'] + + async connect(bot: WechatOfficialBot) { + await bot.refreshToken() + await bot.ensureCustom() + + // https://developers.weixin.qq.com/doc/offiaccount/Basic_Information/Access_Overview.html + bot.ctx.server.get('/wechat-official', async (ctx) => { + let success = false + const { signature, timestamp, nonce, echostr } = ctx.request.query + + for (const bot of this.bots) { + const localSign = getSignature(bot.config.token, timestamp?.toString(), nonce?.toString(), '') + if (localSign === signature) { + success = true + break + } + } + if (!success) return ctx.status = 403 + ctx.status = 200 + ctx.body = echostr + }) + + bot.ctx.server.post('/wechat-official', 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.ToUserName + 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', data2) + data = data2 + } + + bot.logger.debug('%c', ctx.request.rawBody) + + const session = await decodeMessage(localBot, data) + + let resolveFunction: (text: string) => void + const promise = new Promise((resolve, reject) => { + if (localBot.config.customerService) return resolve('success') + const timeout = setTimeout(() => { + ctx.status = 200 + ctx.body = 'success' + reject(new Error('timeout')) + }, 4500) + resolveFunction = (text: string) => { + resolve(text) + clearTimeout(timeout) + } + }) + if (session) { + session.wechatOfficialResolve = resolveFunction + localBot.dispatch(session) + // localBot.logger.debug(session) + } + try { + const result: any = await promise + if (localBot.config.aesKey) { + const builder = new xml2js.Builder({ + cdata: true, + headless: true, + }) + const encrypted = encrypt(localBot.config.aesKey, result, localBot.config.appid) + const sign = getSignature(localBot.config.token, timestamp?.toString(), nonce?.toString(), encrypted) + const xml = builder.buildObject({ + xml: { + Encrypt: encrypted, + Nonce: nonce, + TimeStamp: timestamp, + MsgSignature: sign, + }, + }) + return ctx.body = xml + } + + ctx.status = 200 + ctx.body = result + } catch (error) { + localBot.logger.warn('resolve timeout') + ctx.status = 200 + ctx.body = 'success' + } + }) + + bot.ctx.server.get('/wechat-official/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(`/cgi-bin/media/get`, { + method: 'GET', + responseType: 'stream', + params: { + access_token: localBot.token, + media_id: mediaId, + }, + }) + ctx.type = resp.headers.get('content-type') + ctx.set('date', resp.headers.get('date')) + ctx.set('cache-control', resp.headers.get('cache-control')) + ctx.response.body = resp.data + ctx.status = 200 + }) + + bot.online() + } +} diff --git a/adapters/weixin-kf/src/index.ts b/adapters/weixin-kf/src/index.ts new file mode 100644 index 00000000..89438d58 --- /dev/null +++ b/adapters/weixin-kf/src/index.ts @@ -0,0 +1,14 @@ +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 { + wechatOfficial?: Message + wechatOfficialResolve?: (value?: any) => void + } +} diff --git a/adapters/weixin-kf/src/message.ts b/adapters/weixin-kf/src/message.ts new file mode 100644 index 00000000..81bc5d74 --- /dev/null +++ b/adapters/weixin-kf/src/message.ts @@ -0,0 +1,151 @@ +import { Context, h, MessageEncoder } from '@satorijs/satori' +import { WechatOfficialBot } from './bot' +import xml2js from 'xml2js' +import { SendMessage } from './types' + +// https://developers.weixin.qq.com/doc/offiaccount/Message_Management/Passive_user_reply_message.html +export class WechatOfficialMessageEncoder extends MessageEncoder> { + buffer = '' + sent = false + + upsertSend() { + const session = this.bot.session() + session.type = 'message' + session.isDirect = true + session.userId = this.bot.selfId + session.timestamp = new Date().valueOf() + // session.app.emit(session, 'send', session) + // this.results.push(session.event.message) + } + + async sendByHttpResponse(payload: Partial) { + if (payload.MsgType === 'text' && !payload.Content.length) return + if (this.sent) { + this.bot.logger.error('flushed twice') + return + } + if (new Date().valueOf() - this.options.session.timestamp > 5000) { + this.bot.logger.error('timeout %c', this.options.session.timestamp) + return + } + payload = { + ToUserName: this.options.session.userId, + FromUserName: this.bot.selfId, + CreateTime: Math.floor(new Date().valueOf() / 1000), + ...payload, + } + const builder = new xml2js.Builder({ + cdata: true, + headless: true, + }) + const xml = builder.buildObject({ + xml: payload, + }) + this.options.session.wechatOfficialResolve(xml) + this.sent = true + + this.upsertSend() + } + + async sendByCustom(payload: any) { + if (payload.msgtype === 'text' && !payload.text.content) return + await this.bot.http.post('/cgi-bin/message/custom/send', { + touser: this.options.session.userId, + ...payload, + }, { + params: { access_token: this.bot.token }, + }) + + this.upsertSend() + } + + async flushMedia(element: h) { + if (!['audio', 'video', 'image', 'img'].includes(element.type)) return + let type = element.type + if (type === 'audio') type = 'voice' + if (type === 'img') type = 'image' + const [media] = await this.uploadMedia(element) + + if (this.options.session.wechatOfficialResolve && !this.bot.config.customerService) { + await this.sendByHttpResponse({ + // @ts-ignore + MsgType: type, + [type[0].toUpperCase() + type.slice(1)]: { + MediaId: media, + }, + }) + return + } + if (this.bot.config.customerService) { + await this.sendByCustom({ + msgtype: type, + [type]: { + media_id: media, + }, + }) + } + } + + async flush(): Promise { + if (this.options.session.wechatOfficialResolve && !this.bot.config.customerService) { + await this.sendByHttpResponse({ + MsgType: 'text', + Content: this.buffer, + }) + } + if (this.bot.config.customerService) { + await this.sendByCustom({ + msgtype: 'text', + text: { + content: this.buffer, + }, + }) + } + } + + // https://developers.weixin.qq.com/doc/offiaccount/Asset_Management/New_temporary_materials.html + 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.src || attrs.url, attrs) + const value = new Blob([data], { type: mime }) + 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, + }, + }) + if (resp.media_id) { + return [resp.media_id, uploadType] + } + } + + async visit(element: h) { + const { type, attrs, children } = element + if (type === 'text') { + this.buffer += attrs.content + } else if (type === 'br') { + this.buffer += '\n' + } else if (type === 'p') { + if (!this.buffer.endsWith('\n')) this.buffer += '\n' + await this.render(children) + if (!this.buffer.endsWith('\n')) this.buffer += '\n' + } else if (type === 'img' || type === 'image' || type === 'audio' || type === 'video') { + await this.flushMedia(element) + } else if (type === 'message') { + await this.flush() + await this.render(children) + await this.flush() + } + } +} diff --git a/adapters/weixin-kf/src/types.ts b/adapters/weixin-kf/src/types.ts new file mode 100644 index 00000000..578ef5f3 --- /dev/null +++ b/adapters/weixin-kf/src/types.ts @@ -0,0 +1,68 @@ +export interface BaseMessage { + ToUserName: string + FromUserName: string + CreateTime: number + MsgId: string + MsgDataId: 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/weixin-kf/src/utils.ts b/adapters/weixin-kf/src/utils.ts new file mode 100644 index 00000000..fbe3a6ab --- /dev/null +++ b/adapters/weixin-kf/src/utils.ts @@ -0,0 +1,55 @@ +import { Message } from './types' +import { WechatOfficialBot } from './bot' +import { Context, h } from '@satorijs/satori' + +export async function decodeMessage(bot: WechatOfficialBot, message: Message) { + const session = bot.session() + // https://developers.weixin.qq.com/doc/offiaccount/Message_Management/Receiving_standard_messages.html + session.timestamp = message.CreateTime * 1000 + session.wechatOfficial = message + session.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.$toMediaUrl(message.MediaId))] + // https://developers.weixin.qq.com/doc/offiaccount/Asset_Management/Get_temporary_materials.html + return session + } else if (message.MsgType === 'video') { + session.isDirect = true + session.type = 'message' + session.elements = [h.video(bot.$toMediaUrl(message.MediaId))] + // const { video_url } = await bot.getMedia(message.MediaId) + // session.elements = [h.video(video_url)] + return session + } else if (message.MsgType === 'location') { + session.isDirect = true + session.type = 'message' + session.elements = [h('wechat-official: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/weixin-kf/tsconfig.json b/adapters/weixin-kf/tsconfig.json new file mode 100644 index 00000000..74ac2c8d --- /dev/null +++ b/adapters/weixin-kf/tsconfig.json @@ -0,0 +1,10 @@ +{ + "extends": "../../tsconfig.base", + "compilerOptions": { + "outDir": "lib", + "rootDir": "src", + }, + "include": [ + "src", + ], +} \ No newline at end of file