Skip to content

Commit

Permalink
Merge remote-tracking branch 'origin/wechat-official'
Browse files Browse the repository at this point in the history
  • Loading branch information
shigma committed Aug 24, 2023
2 parents fe43d5e + 2805aa1 commit 6f8fd7e
Show file tree
Hide file tree
Showing 8 changed files with 570 additions and 0 deletions.
38 changes: 38 additions & 0 deletions adapters/wechat-official/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
{
"name": "@satorijs/adapter-wechat-official",
"description": "Wechat Official Adapter for Satorijs",
"version": "1.0.0",
"main": "lib/index.js",
"typings": "lib/index.d.ts",
"files": [
"lib"
],
"author": "LittleC <[email protected]>",
"license": "MIT",
"repository": {
"type": "git",
"url": "git+https://github.com/satorijs/satori.git",
"directory": "adapters/wechat-official"
},
"bugs": {
"url": "https://github.com/satorijs/satori/issues"
},
"homepage": "https://koishi.chat/plugins/adapter/wechat-official.html",
"keywords": [
"bot",
"wechatofficial",
"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"
}
}
105 changes: 105 additions & 0 deletions adapters/wechat-official/src/bot.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
import { Bot, Context, Logger, Quester, Schema } from '@satorijs/satori'
import { HttpServer } from './http'
import { WechatOfficialMessageEncoder } from './message'
// import { Internal } from './types/internal'

export class WechatOfficialBot extends Bot<WechatOfficialBot.Config> {
static MessageEncoder = WechatOfficialMessageEncoder
http: Quester
// internal: Internal
refreshTokenTimer: NodeJS.Timeout
logger = new Logger('wo')
constructor(ctx: Context, config: WechatOfficialBot.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<void> {
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,
},
})
}
}

export namespace WechatOfficialBot {
export interface Config extends Bot.Config, Quester.Config {
appid: string
secret: string
token: string
aesKey: string
customerService: boolean
account: string
}

export const Config: Schema<Config> = 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/'),
])
}

WechatOfficialBot.prototype.platform = 'wechatofficial'
127 changes: 127 additions & 0 deletions adapters/wechat-official/src/http.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
import { Adapter } from '@satorijs/satori'
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.Server<WechatOfficialBot> {
constructor() {
super()
}

async start(bot: WechatOfficialBot) {
bot.selfId = bot.config.account
bot.platform = 'wechatofficial'
await bot.refreshToken()
await bot.ensureCustom()
// https://developers.weixin.qq.com/doc/offiaccount/Basic_Information/Access_Overview.html
bot.ctx.router.get('/wechatofficial', async (ctx) => {
let success = false
const { signature, timestamp, nonce, echostr } = ctx.request.query

for (const localBot of this.bots.filter(v => v.platform === 'wechatofficial')) {
const localSign = getSignature(localBot.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.router.post('/wechatofficial', 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', 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)

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('timeout')
}, 4500)
resolveFunction = (text: string) => {
resolve(text)
clearTimeout(timeout)
}
})
if (session) {
session.wechatOfficialResolve = resolveFunction
localBot.dispatch(session)
// localBot.logger.debug(require('util').inspect(session, false, null, true))
}
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.router.get('/wechatofficial/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<ReadableStream>(`/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()
}
}
14 changes: 14 additions & 0 deletions adapters/wechat-official/src/index.ts
Original file line number Diff line number Diff line change
@@ -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
}
}
Loading

0 comments on commit 6f8fd7e

Please sign in to comment.