Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat.: Add weixin kf adapter support. #246

Draft
wants to merge 1 commit into
base: master
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions adapters/weixin-kf/.npmignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
.DS_Store
tsconfig.tsbuildinfo
43 changes: 43 additions & 0 deletions adapters/weixin-kf/package.json
Original file line number Diff line number Diff line change
@@ -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 <[email protected]>",
"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"
}
}
5 changes: 5 additions & 0 deletions adapters/weixin-kf/readme.md
Original file line number Diff line number Diff line change
@@ -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)
109 changes: 109 additions & 0 deletions adapters/weixin-kf/src/bot.ts
Original file line number Diff line number Diff line change
@@ -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<C extends Context = Context> extends Bot<C, WechatOfficialBot.Config> {
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<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,
},
})
}

$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<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/'),
])
}
128 changes: 128 additions & 0 deletions adapters/weixin-kf/src/http.ts
Original file line number Diff line number Diff line change
@@ -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<C extends Context = Context> extends Adapter<C, WechatOfficialBot<C>> {
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<ReadableStream>(`/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()
}
}
14 changes: 14 additions & 0 deletions adapters/weixin-kf/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