Skip to content

Commit

Permalink
feat(wecom): add adapter-wecom
Browse files Browse the repository at this point in the history
  • Loading branch information
XxLittleCxX committed Aug 6, 2023
1 parent 5a717e1 commit be95c64
Show file tree
Hide file tree
Showing 8 changed files with 520 additions and 0 deletions.
38 changes: 38 additions & 0 deletions adapters/wecom/package.json
Original file line number Diff line number Diff line change
@@ -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 <[email protected]>",
"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"
}
}
133 changes: 133 additions & 0 deletions adapters/wecom/src/bot.ts
Original file line number Diff line number Diff line change
@@ -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<WecomBot.Config> {
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<void> {
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<Universal.User> {
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<Universal.User> {
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<void> {
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<Config> = 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'
92 changes: 92 additions & 0 deletions adapters/wecom/src/http.ts
Original file line number Diff line number Diff line change
@@ -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<WecomBot> {
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)

Check failure on line 51 in adapters/wecom/src/http.ts

View workflow job for this annotation

GitHub Actions / lint

'id' is assigned a value but never used
// 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<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()
}
}
13 changes: 13 additions & 0 deletions adapters/wecom/src/index.ts
Original file line number Diff line number Diff line change
@@ -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
}
}
111 changes: 111 additions & 0 deletions adapters/wecom/src/message.ts
Original file line number Diff line number Diff line change
@@ -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<WecomBot> {
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<void> {
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)
}
}
}
Loading

0 comments on commit be95c64

Please sign in to comment.