Commit 03986b90 authored by nanahira's avatar nanahira

yeah finally comes

parent a66ae4f8
......@@ -2,6 +2,7 @@ module.exports = {
parser: '@typescript-eslint/parser',
parserOptions: {
project: 'tsconfig.json',
tsconfigRootDir : __dirname,
sourceType: 'module',
},
plugins: ['@typescript-eslint/eslint-plugin'],
......
......@@ -54,6 +54,6 @@ deploy_npm:
script:
- apt update;apt -y install coreutils
- echo $NPMRC | base64 --decode > ~/.npmrc
- npm publish . --access public
- npm publish . --access public && curl -X PUT "https://registry-direct.npmmirror.com/$(cat package.json | jq '.name' | sed 's/\"//g')/sync?sync_upstream=true" || true
only:
- master
......@@ -16,38 +16,34 @@ npm install koishi-plugin-adapter-wecom
```ts
{
bots: [
corpId: 'corpId', // 企业 ID,在企业信息 https://work.weixin.qq.com/wework_admin/frame#profile 中查看
agentId: 'agentId', // 应用 ID,在应用管理中查看
secret: 'secret', // 应用密钥,在应用管理中查看
path: '/wecom', // 回调 API 路径
token: 'token', // 回调 API token,在应用消息接收设置中设置并填入
encodingAESKey: 'encodingAESKey', // 加密密钥,在应用消息接收设置中设置并填入
menus: [
{
corpId: 'corpId', // 企业 ID,在企业信息 https://work.weixin.qq.com/wework_admin/frame#profile 中查看
agentId: 'agentId', // 应用 ID,在应用管理中查看
secret: 'secret', // 应用密钥,在应用管理中查看
menus: [
{
type: 'view', // 打开网页
name: 'MyCard', // 按钮名称
url: 'https://mycard.moe', // 网页地址
},
{
type: 'click', // 运行指令
name: '看图', // 按钮名称
command: 'media -c 10000000', // 指令
},
{
type: 'parent', // 子指令
name: '更多', // 按钮名称
children: [
{ type: 'click', name: '点击事件', command: 'markdown' },
{ type: 'scancode_push', name: '扫一扫' }, // 扫一扫并打开网页。
{ type: 'scancode_waitmsg', name: '扫一扫2', command: 'scan' }, // 扫一扫并运行指令。
{ type: 'location_select', name: '地理位置', command: 'location' }, // 选择地理位置并运行指令。
],
},
type: 'view', // 打开网页
name: 'MyCard', // 按钮名称
url: 'https://mycard.moe', // 网页地址
},
{
type: 'click', // 运行指令
name: '看图', // 按钮名称
command: 'media -c 10000000', // 指令
},
{
type: 'parent', // 子指令
name: '更多', // 按钮名称
children: [
{ type: 'click', name: '点击事件', command: 'markdown' },
{ type: 'scancode_push', name: '扫一扫' }, // 扫一扫并打开网页。
{ type: 'scancode_waitmsg', name: '扫一扫2', command: 'scan' }, // 扫一扫并运行指令。
{ type: 'location_select', name: '地理位置', command: 'location' }, // 选择地理位置并运行指令。
],
},
],
path: '/wecom', // 回调 API 路径
token: 'token', // 回调 API token,在应用消息接收设置中设置并填入
encodingAESKey: 'encodingAESKey', // 加密密钥,在应用消息接收设置中设置并填入
}
```
......
import { App, segment } from 'koishi';
import ExtrasInDev from './extras';
import { adapterPlugin } from '../src';
import WeComBot from '../src';
import * as Help from '@koishijs/plugin-help';
import * as fs from 'fs';
import AragamiPlugin from 'koishi-plugin-cache-aragami';
const app = new App({
port: 3000,
......@@ -9,37 +11,37 @@ const app = new App({
prefix: '.',
});
app.plugin(Help);
// Some extras
app.plugin(ExtrasInDev);
app.plugin(AragamiPlugin);
// Target plugin
app.plugin(adapterPlugin, {
bots: [
app.plugin(WeComBot, {
corpId: 'corpId', // 企业 ID,在企业信息 https://work.weixin.qq.com/wework_admin/frame#profile 中查看
agentId: 'agentId', // 应用 ID,在应用管理中查看
secret: 'secret', // 应用密钥,在应用管理中查看
menus: [
{
type: 'view', // 打开网页
name: 'MyCard', // 按钮名称
url: 'https://mycard.moe', // 网页地址
},
{
corpId: 'corpId', // 企业 ID,在企业信息 https://work.weixin.qq.com/wework_admin/frame#profile 中查看
agentId: 'agentId', // 应用 ID,在应用管理中查看
secret: 'secret', // 应用密钥,在应用管理中查看
menus: [
{
type: 'view', // 打开网页
name: 'MyCard', // 按钮名称
url: 'https://mycard.moe', // 网页地址
},
{
type: 'click', // 运行指令
name: '看图', // 按钮名称
command: 'media -c 10000000', // 指令
},
{
type: 'parent', // 子指令
name: '更多', // 按钮名称
children: [
{ type: 'click', name: '点击事件', command: 'markdown' },
{ type: 'scancode_push', name: '扫一扫' }, // 扫一扫并打开网页。
{ type: 'scancode_waitmsg', name: '扫一扫2', command: 'scan' }, // 扫一扫并运行指令。
{ type: 'location_select', name: '地理位置', command: 'location' }, // 选择地理位置并运行指令。
],
},
type: 'click', // 运行指令
name: '看图', // 按钮名称
command: 'media -c 10000000', // 指令
},
{
type: 'parent', // 子指令
name: '更多', // 按钮名称
children: [
{ type: 'click', name: '梦梦女装', command: 'markdown' },
{ type: 'scancode_push', name: '扫一扫' }, // 扫一扫并打开网页。
{ type: 'scancode_waitmsg', name: '扫一扫2', command: 'scan' }, // 扫一扫并运行指令。
{ type: 'location_select', name: '地理位置', command: 'location' }, // 选择地理位置并运行指令。
{ type: 'click', name: '点菜', command: 'dish' }, // 点菜。
],
},
],
......@@ -54,6 +56,10 @@ app.on('wecom/location', (session) => {
);
});
app.on('wecom/enter-agent', (session) => {
console.log(`Got enter-agent for ${session.userId}`);
});
app.on('wecom/click', (session) => {
console.log(`${session.userId} clicked ${session.wecom.EventKey}`);
});
......@@ -69,7 +75,7 @@ app
);
app.command('markdown').action(async () => {
return `${segment('markdown')}# 女装\n> 今天梦梦女装了吗?`;
return segment('message', { markdown: 1 }, ['# 女装\n> 今天梦梦女装了吗?']);
});
app
......@@ -93,4 +99,78 @@ app
}),
);
app.command('dish').action(async (argv) => {
await argv.session.send(
segment(
'wecom:card',
{
card_type: 'multiple_interaction',
},
[
segment('main_title', {
title: '晚上想吃什么?',
desc: '有很多好吃的呢!',
}),
segment('select_list', {}, [
segment(
'',
{
question_key: 'main',
title: '主食',
},
[
segment('', { id: 'rice', text: '米饭' }),
segment('', { id: 'noodle', text: '面条' }),
],
),
segment(
'',
{
question_key: 'meat',
title: '肉类',
},
[
segment('', { id: 'beef', text: '牛肉' }),
segment('', { id: 'pork', text: '猪肉' }),
segment('', { id: 'chicken', text: '鸡肉' }),
],
),
segment(
'',
{
question_key: 'soup',
title: '',
},
[
segment('option_list', {}, [
segment('', { id: 'tomato', text: '番茄汤' }),
segment('', { id: 'mushroom', text: '蘑菇汤' }),
segment('', { id: 'fish', text: '鱼汤' }),
]),
],
),
]),
segment('submit_button', {
text: '点菜!',
key: 'submit',
}),
],
),
);
const result = await argv.session.prompt();
console.log(result?.toString());
const elem = segment.normalize(result)[0];
const mainDish = elem.children.find((e) => e.attrs.key === 'main').children[0]
.attrs.id;
const meat = elem.children.find((e) => e.attrs.key === 'meat').children[0]
.attrs.id;
const soup = elem.children.find((e) => e.attrs.key === 'soup').children[0]
.attrs.id;
return `你点的是:${mainDish}${meat}${soup}`;
});
app.on('bot-status-updated', (bot) => {
console.log(`Bot ${bot.selfId} status updated: ${bot.status}`);
});
app.start();
......@@ -3,16 +3,17 @@ npm install --save \
koishi-thirdeye
npm install --save-peer \
koishi@latest
koishi
npm i --save-exact --save-dev eslint@8.22.0
npm install --save-dev \
@types/node \
typescript \
'@typescript-eslint/eslint-plugin@^4.28.2' \
'@typescript-eslint/parser@^4.28.2 '\
'eslint@^7.30.0' \
'@typescript-eslint/eslint-plugin@^5.0.0' \
'@typescript-eslint/parser@^5.0.0 '\
'eslint-config-prettier@^8.3.0' \
'eslint-plugin-prettier@^3.4.0' \
'eslint-plugin-prettier@^4.0.0' \
prettier \
raw-loader \
ts-loader \
......@@ -20,10 +21,10 @@ npm install --save-dev \
webpack-cli \
ws \
ts-node \
@koishijs/plugin-help \
@koishijs/plugin-console \
@koishijs/plugin-sandbox \
@koishijs/plugin-database-memory \
@koishijs/plugin-cache-lru \
jest \
ts-jest \
@types/jest
This diff is collapsed.
......@@ -45,34 +45,48 @@
"testEnvironment": "node"
},
"peerDependencies": {
"koishi": "^4.7.5"
"koishi": "^4.10.6",
"koishi-plugin-cache-aragami": "^2.5.0"
},
"devDependencies": {
"@types/jest": "^27.4.1",
"@types/node": "^17.0.21",
"@typescript-eslint/eslint-plugin": "^4.33.0",
"@typescript-eslint/parser": "^4.33.0",
"@koishijs/plugin-help": "^2.0.0",
"@types/jest": "^27.5.2",
"@types/node": "^17.0.45",
"@typescript-eslint/eslint-plugin": "^5.46.1",
"@typescript-eslint/parser": "^5.46.1",
"esbuild-loader": "^2.20.0",
"eslint": "^7.32.0",
"eslint": "8.22.0",
"eslint-config-prettier": "^8.5.0",
"eslint-plugin-prettier": "^3.4.1",
"eslint-plugin-prettier": "^4.2.1",
"jest": "^27.5.1",
"prettier": "^2.6.0",
"prettier": "^2.8.1",
"raw-loader": "^4.0.2",
"ts-jest": "^27.1.3",
"ts-loader": "^9.2.8",
"ts-node": "^10.7.0",
"typescript": "^4.6.2",
"webpack": "^5.70.0",
"webpack-cli": "^4.9.2",
"ws": "^8.5.0"
"ts-jest": "^27.1.5",
"ts-loader": "^9.4.2",
"ts-node": "^10.9.1",
"typescript": "^4.9.4",
"webpack": "^5.75.0",
"webpack-cli": "^4.10.0",
"ws": "^8.11.0"
},
"dependencies": {
"@wecom/crypto": "^1.0.1",
"crypto-random-string": "3.3.1",
"fast-xml-parser": "^4.0.6",
"file-type": "^16.5.3",
"form-data": "^4.0.0",
"moment": "^2.29.1",
"koishi-thirdeye": "^11.1.14",
"raw-body": "^2.5.1"
},
"koishi": {
"category": "adapter",
"service": {
"implements": [
"adapter"
],
"required": [
"aragami"
]
}
}
}
import { Adapter, Logger } from 'koishi';
import { DefinePlugin, InjectLogger, KoaContext } from 'koishi-thirdeye';
import type WeComBot from './index';
import { XMLParser } from 'fast-xml-parser';
import {
WecomEventBody,
WecomEventResponse,
WecomReceiveMessageDto,
WecomRegisterDto,
} from './def';
import { decrypt, getSignature } from '@wecom/crypto';
import rawBody from 'raw-body';
import { dispatchSession } from './utils';
@DefinePlugin()
export class WeComAdapter extends Adapter.Server<WeComBot> {
private xmlParser = new XMLParser();
@InjectLogger()
private logger: Logger;
private checkSignature(
token: string,
dto: WecomReceiveMessageDto,
body: string,
) {
const { msg_signature, timestamp, nonce } = dto;
const signature = getSignature(token, timestamp, nonce, body);
return signature === msg_signature;
}
async start(bot: WeComBot) {
bot.ctx.router.get(bot.config.path, this.koaGetHandler.bind(this));
bot.ctx.router.post(bot.config.path, this.koaPostHandler.bind(this));
return bot.initialize();
}
async stop(bot: WeComBot) {}
async koaGetHandler(ctx: KoaContext) {
const query = ctx.request.query as unknown as WecomRegisterDto;
const { echostr } = query;
const bot = this.bots.find((bot) =>
this.checkSignature(bot.config.token, query, echostr),
);
if (!bot) {
this.logger.warn('Bot not found.');
ctx.status = 404;
ctx.body = 'Bot not found.';
return;
}
const decrypted = decrypt(bot.config.encodingAESKey, echostr);
const message = decrypted?.message;
if (!message) {
this.logger.warn('Invalid message: %s', decrypted);
ctx.status = 400;
ctx.body = 'invalid message';
return;
}
this.logger.success(`Registered bot ${bot.selfId}: ${message}`);
ctx.body = message;
}
async koaPostHandler(ctx: KoaContext) {
const query = ctx.request.query as unknown as WecomReceiveMessageDto;
const rawData = (await rawBody(ctx.req)).toString('utf8').trim();
const { xml: parsedData } = (await this.xmlParser.parse(rawData)) as {
xml: WecomEventResponse;
};
if (!parsedData?.Encrypt) {
this.logger.warn('Invalid xml: %s', rawData);
ctx.status = 400;
ctx.body = 'invalid message';
return;
}
const bot = this.bots.find(
(bot) =>
bot.config.agentId === parsedData.AgentID?.toString() &&
bot.config.corpId === parsedData.ToUserName,
);
if (!bot) {
ctx.status = 404;
ctx.body = 'Bot not found.';
return;
}
if (!this.checkSignature(bot.config.token, query, parsedData.Encrypt)) {
this.logger.warn(`Invalid signature for bot ${bot.selfId}`);
ctx.status = 403;
ctx.body = 'invalid signature';
return;
}
parsedData.data = decrypt(bot.config.encodingAESKey, parsedData.Encrypt);
if (!parsedData.data) {
this.logger.warn('Invalid decrypted message: %s', parsedData.Encrypt);
ctx.status = 400;
ctx.body = 'invalid message';
return;
}
parsedData.body = this.xmlParser.parse(parsedData.data.message)
.xml as WecomEventBody;
if (!parsedData.body) {
this.logger.warn(
'Invalid decrypted xml message: %s',
parsedData.data.message,
);
}
dispatchSession(bot, parsedData);
ctx.body = 'success';
ctx.status = 200;
}
}
This diff is collapsed.
......@@ -37,6 +37,10 @@ export interface WecomEventBody {
Event?: string;
AgentID: number;
MsgId?: number;
Latitude?: number;
Longitude?: number;
Precision?: number;
EventKey?: string;
}
export interface WecomChatBody extends WecomEventBody {
......@@ -80,10 +84,11 @@ export interface TokenReturnMessage extends WecomApiResponse {
export interface OutMessage {
msgtype: string;
agentid: string;
agentid?: string;
touser?: string;
toparty?: string;
totag?: string;
[key: string]: any;
}
export interface CommonOutMessage extends OutMessage {
......
......@@ -38,16 +38,14 @@ export interface CardEventOptionIds {
OptionId: string | string[];
}
export interface WecomCardEventBody {
export interface CardMenuEvent extends EventKeyBody {
TaskId: string;
CardType: string;
SelectedItems?: CardEventSelectedItems;
ResponseCode: number;
}
export interface CardEvent extends EventKeyBody {
TaskId: string;
CardType: string;
ResponseCode: number;
export interface CardEvent extends CardMenuEvent {
SelectedItems: CardEventSelectedItems;
}
// eslint-disable-next-line @typescript-eslint/ban-types
......
import { Adapter, Logger } from 'koishi';
import { AdapterConfig, dispatchSession } from './utils';
import { BotConfig, WeComBot } from './bot';
import { decrypt, getSignature } from '@wecom/crypto';
import {
WecomEventBody,
WecomEventResponse,
WecomReceiveMessageDto,
WecomRegisterDto,
WecomResponse,
} from './def';
import rawBody from 'raw-body';
import { XMLParser } from 'fast-xml-parser';
const logger = new Logger('wecom');
export default class HttpServer extends Adapter<BotConfig, AdapterConfig> {
private xmlParser = new XMLParser();
static schema = BotConfig;
stop() {
logger.debug('http server closing');
}
private checkSignature(dto: WecomReceiveMessageDto, body: string) {
const { msg_signature, timestamp, nonce } = dto;
const signature = getSignature(this.config.token, timestamp, nonce, body);
return {
valid: signature === msg_signature,
signature,
msg_signature,
};
}
public bots: WeComBot[];
async connect(bot: WeComBot) {
return bot.initialize();
}
async start() {
const { path } = this.config;
this.ctx.router.get(path, async (ctx) => {
const query = ctx.request.query as unknown as WecomRegisterDto;
const { echostr } = query;
const signatureResult = this.checkSignature(query, echostr);
if (!signatureResult.valid) {
logger.warn(
`Invalid signature: ${signatureResult.msg_signature} vs ${signatureResult.signature}`,
);
ctx.status = 403;
ctx.body = 'invalid signature';
return;
}
const decrypted = decrypt(this.config.encodingAESKey, echostr);
const message = decrypted?.message;
if (!message) {
logger.warn('Invalid message: %s', decrypted);
ctx.status = 400;
ctx.body = 'invalid message';
return;
}
logger.success(`Registered: ${message}`);
ctx.body = message;
});
this.ctx.router.post(path, async (ctx) => {
const query = ctx.request.query as unknown as WecomReceiveMessageDto;
const rawData = (await rawBody(ctx.req)).toString('utf8').trim();
const { xml: parsedData } = (await this.xmlParser.parse(rawData)) as {
xml: WecomEventResponse;
};
if (!parsedData?.Encrypt) {
logger.warn('Invalid xml: %s', rawData);
ctx.status = 400;
ctx.body = 'invalid message';
return;
}
const signatureResult = this.checkSignature(query, parsedData.Encrypt);
if (!signatureResult.valid) {
logger.warn(
`Invalid signature: ${signatureResult.msg_signature} vs ${signatureResult.signature}`,
);
ctx.status = 403;
ctx.body = 'invalid signature';
return;
}
const bot = this.bots.find(
(bot) =>
bot.config.agentId === parsedData.AgentID?.toString() &&
bot.config.corpId === parsedData.ToUserName,
);
if (!bot) {
ctx.status = 404;
ctx.body = 'Bot not found.';
return;
}
parsedData.data = decrypt(this.config.encodingAESKey, parsedData.Encrypt);
if (!parsedData.data) {
logger.warn('Invalid decrypted message: %s', parsedData.Encrypt);
ctx.status = 400;
ctx.body = 'invalid message';
return;
}
parsedData.body = this.xmlParser.parse(parsedData.data.message)
.xml as WecomEventBody;
if (!parsedData.body) {
logger.warn(
'Invalid decrypted xml message: %s',
parsedData.data.message,
);
}
dispatchSession(bot, parsedData);
ctx.body = 'success';
ctx.status = 200;
});
}
}
This diff is collapsed.
import { Dict, Element, Messenger, segment } from 'koishi';
import type WeComBot from './index';
import {
CommonOutMessage,
ImageOutMessage,
OutMessage,
TextOutMessage,
VideoOutMessage,
WecomMediaUploadResponse,
WecomSendMessageResponse,
} from './def';
import FormData from 'form-data';
import fs from 'fs';
import path from 'path';
import FileType from 'file-type';
import cryptoRandomString from 'crypto-random-string';
export class WeComMessenger extends Messenger<WeComBot> {
private buffer = '';
async sendGenericMessage(messageInfo: OutMessage): Promise<string> {
const token = await this.bot.getToken();
if (!token) {
this.bot.logger.error(`Missing token.`);
return undefined;
}
const payload = {
agentid: this.bot.selfId,
touser: this.channelId,
...messageInfo,
};
const data = await this.bot.http.post<WecomSendMessageResponse>(
'https://qyapi.weixin.qq.com/cgi-bin/message/send',
payload,
{ params: { access_token: token } },
);
if (data.errcode) {
this.bot.logger.error(
`Failed to send message ${JSON.stringify(payload)}: ${data.errmsg}`,
);
}
if (data.invaliduser) {
this.bot.logger.error(`Invalid users: ${data.invaliduser}`);
}
if (data.invalidparty) {
this.bot.logger.error(`Invalid parties: ${data.invalidparty}`);
}
if (data.invalidtag) {
this.bot.logger.error(`Invalid tags: ${data.invalidtag}`);
}
return data.msgid;
}
async uploadMedia(
content: Buffer,
type = 'image',
fileName?: string,
): Promise<string> {
const token = await this.bot.getToken();
if (!token) {
this.bot.logger.error(`Missing token.`);
return undefined;
}
const form = new FormData();
form.append('media', content, fileName);
const data = await this.bot.http.post<WecomMediaUploadResponse>(
'https://qyapi.weixin.qq.com/cgi-bin/media/upload',
form,
{ params: { access_token: token, type }, headers: form.getHeaders() },
);
if (data.errcode) {
this.bot.logger.error(
`Failed to upload media ${fileName}: ${data.errmsg}`,
);
}
return data.media_id;
}
async sendMarkdownMessage(message: string, extras: any = {}) {
const messageInfo: CommonOutMessage = {
msgtype: 'markdown',
markdown: { content: message },
...extras,
};
return this.sendGenericMessage(messageInfo);
}
async sendTextMessage(message: string, extras: any = {}) {
const messageInfo: TextOutMessage = {
msgtype: 'text',
text: { content: message },
...extras,
};
return this.sendGenericMessage(messageInfo);
}
async sendMediaMessage(
type: string,
fileName: string,
message: Buffer,
extras: any = {},
) {
const mediaId = await this.uploadMedia(message, type, fileName);
if (!mediaId) {
return;
}
const messageInfo: ImageOutMessage | VideoOutMessage = {
msgtype: type,
[type]: { media_id: mediaId },
...extras,
};
return this.sendGenericMessage(messageInfo);
}
async prepareBufferAndFilename(type: string, data: Dict<string>) {
const { url } = data;
if (!url) {
return;
}
let buffer: Buffer;
if (url.startsWith('file://')) {
buffer = await fs.promises.readFile(url.slice(7));
} else if (url.startsWith('base64://')) {
buffer = Buffer.from(data.url.slice(9), 'base64');
} else {
buffer = await this.bot.http.get(url, {
responseType: 'arraybuffer',
headers: { accept: type + '/*' },
});
}
let filename = data.file;
if (!filename) {
if (!url.startsWith('base64://')) {
filename = path.basename(url);
} else {
const fileType = await FileType.fromBuffer(buffer);
if (fileType) {
filename = `media.${fileType.ext}`;
} else {
filename = 'media.bin';
}
}
}
return { buffer, filename };
}
private text(content: string) {
this.buffer += content;
}
private readonly cardPropertyArrayKeyList = {
action_menu: 'action_list',
checkbox: 'option_list',
button_selection: 'option_list',
};
private readonly cardPropertyArrayPartentKeyList = {
select_list: 'option_list',
};
private parseCardProperties(element: Element, parent?: Element) {
const data: any = { ...element.attrs };
const arrayKey =
this.cardPropertyArrayKeyList[element.type] ||
this.cardPropertyArrayPartentKeyList[parent?.type || ''];
for (const prop of element.children) {
if (arrayKey && prop.type !== arrayKey) {
data[arrayKey] ??= [];
data[arrayKey].push(this.parseCardProperties(prop, element));
}
if (!prop.type) {
continue;
}
let value: any;
if (prop.type.endsWith('_list')) {
value = prop.children.map((item) =>
this.parseCardProperties(item, prop),
);
} else {
value = this.parseCardProperties(prop, element);
}
data[prop.type] = value;
}
if (!data.task_id && element.type.startsWith('wecom:')) {
data.task_id = cryptoRandomString({ length: 127, type: 'alphanumeric' });
}
return data;
}
async flush() {
const content = this.buffer.trim();
if (content) {
const elem = segment.text(content);
if (this.isMarkdown) {
elem.type = 'markdown';
}
await this.post(elem);
this.buffer = '';
}
}
private async post(element: Element) {
try {
const messageId = await this.postElement(element);
if (!messageId) return;
const session = this.bot.session();
session.messageId = messageId;
session.app.emit(session, 'send', session);
this.results.push(session);
} catch (e) {
this.errors.push(e);
}
}
private async postElement(element: Element) {
const { type, attrs } = element;
switch (type) {
case 'text':
return this.sendTextMessage(attrs.content);
case 'markdown':
return this.sendMarkdownMessage(attrs.content);
case 'image':
case 'video':
case 'file':
case 'voice':
if (!attrs.url) {
return;
}
const { buffer, filename } = await this.prepareBufferAndFilename(
type,
attrs,
);
const mediaId = await this.uploadMedia(buffer, type, filename);
if (!mediaId) {
break;
}
return this.sendMediaMessage(type, filename, buffer);
case 'wecom:card':
return this.sendGenericMessage({
msgtype: 'template_card',
template_card: this.parseCardProperties(element),
});
default:
if (type.startsWith('wecom:')) {
const wecomMessageType = type.slice(6);
return this.sendGenericMessage({
msgtype: wecomMessageType,
[wecomMessageType]: attrs,
});
}
return;
}
}
private isMarkdown = false;
async visit(element: segment) {
const { type, attrs, children } = element;
switch (type) {
case 'text':
this.text(attrs.content);
break;
case 'p':
await this.render(children);
this.text('\n');
break;
case 'a':
await this.render(children);
if (attrs.href) this.text(` (${attrs.href}) `);
break;
case 'at':
if (attrs.id) {
this.text(`@${attrs.id}`);
} else if (attrs.type === 'all') {
this.text('@全体成员');
} else if (attrs.type === 'here') {
this.text('@在线成员');
} else if (attrs.role) {
this.text(`@${attrs.role}`);
}
break;
case 'sharp':
this.text(` #${attrs.name} `);
break;
case 'message':
await this.flush();
const prevIsMarkdown = this.isMarkdown;
this.isMarkdown =
prevIsMarkdown || !!(attrs.markdown && attrs.markdown !== 0);
await this.render(children);
await this.flush();
this.isMarkdown = prevIsMarkdown;
break;
default:
if (
type.startsWith('wecom:') ||
['image', 'video', 'file', 'voice'].includes(type)
) {
await this.flush();
await this.post(element);
}
await this.render(children);
}
}
}
import { Bot, Schema, segment, Session } from 'koishi';
import { WeComBot } from './bot';
import { makeArray, segment, Session, Universal } from 'koishi';
import {
CardEvent,
CardMenuEvent,
WecomChatBody,
WecomEventResponse,
WecomLinkBody,
......@@ -10,26 +11,10 @@ import {
WecomVideoBody,
WecomVoiceBody,
} from './def';
import { CacheKey } from 'koishi-plugin-cache-aragami';
import type WeComBot from './index';
export interface AdapterConfig {
path?: string;
token?: string;
encodingAESKey?: string;
}
export const AdapterConfig: Schema<AdapterConfig> = Schema.object({
path: Schema.string().description('企业微信回调路径。').default('/wecom'),
token: Schema.string()
.role('secret')
.required()
.description('应用消息上报 token。'),
encodingAESKey: Schema.string()
.role('secret')
.required()
.description('应用消息上播 AES 密钥。'),
});
export function adaptUser(user: WeComUser): Bot.User {
export function adaptUser(user: WeComUser): Universal.User {
return {
userId: user.userid,
username: user.name,
......@@ -52,7 +37,7 @@ export function adaptSession(bot: WeComBot, input: WecomEventResponse) {
channelId: body.FromUserName,
timestamp: body.CreateTime,
};
if (body.MsgType === 'event') {
if (body.MsgType === 'event' && !body.Event.startsWith('template_card_')) {
session.type = `wecom/${body.Event.replace('LOCATION', 'location')}`;
} else {
switch (body.MsgType) {
......@@ -62,36 +47,72 @@ export function adaptSession(bot: WeComBot, input: WecomEventResponse) {
break;
case 'image':
const imageBody = body as WecomPicBody;
session.content = segment('image', {
url: imageBody.PicUrl,
});
session.content = segment.image(imageBody.PicUrl).toString();
break;
case 'link':
const linkBody = body as WecomLinkBody;
session.content = linkBody.Url;
session.content = segment(
'a',
{ href: linkBody.Url, picUrl: linkBody.PicUrl },
linkBody.Title,
).toString();
break;
case 'video':
const videoBody = body as WecomVideoBody;
session.content = segment('wecom:video', {
mediaId: videoBody.MediaId,
thumbMediaId: videoBody.ThumbMediaId,
});
}).toString();
break;
case 'voice':
const voiceBody = body as WecomVoiceBody;
session.content = segment('voice', {
session.content = segment('wecom:voice', {
mediaId: voiceBody.MediaId,
format: voiceBody.Format,
});
}).toString();
break;
case 'location':
const locationBody = body as WecomLocationMessageBody;
session.content = segment('location', {
session.content = segment('wecom:location', {
latitude: locationBody.Location_X,
longitude: locationBody.Location_Y,
scale: locationBody.Scale,
label: locationBody.Label,
});
}).toString();
break;
case 'event':
switch (body.Event) {
case 'template_card_event':
const cardEvent = body as unknown as CardEvent;
session.content = segment(
'wecom:response',
{
key: cardEvent.EventKey,
id: cardEvent.TaskId,
type: cardEvent.CardType,
code: cardEvent.ResponseCode,
},
makeArray(cardEvent.SelectedItems.SelectedItem).map((item) =>
segment(
'question',
{ key: item.QuestionKey },
makeArray(item.OptionIds.OptionId).map((option) =>
segment('option', { id: option }),
),
),
),
).toString();
break;
case 'template_card_menu_event':
const cardMenuEvent = body as unknown as CardMenuEvent;
session.content = segment('wecom:menu', {
key: cardMenuEvent.EventKey,
id: cardMenuEvent.TaskId,
type: cardMenuEvent.CardType,
code: cardMenuEvent.ResponseCode,
}).toString();
break;
}
break;
default:
return;
......@@ -110,7 +131,7 @@ export function dispatchSession(bot: WeComBot, message: WecomEventResponse) {
if (!payload) return;
const session = new Session(bot, payload);
session.wecom = message.body;
bot.adapter.dispatch(session);
bot.dispatch(session);
if (
message.body.Event &&
[
......@@ -127,3 +148,9 @@ export function dispatchSession(bot: WeComBot, message: WecomEventResponse) {
bot.handleMenuEvent(message.body.Event, session).then();
}
}
export class WeComToken {
@CacheKey()
agentId: string;
token: string;
}
import { App } from 'koishi';
import TargetPlugin from '../src';
import TargetPlugin from '../src/prev';
describe('Test of plugin.', () => {
let app: App;
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment