@dsh-overdrive/gateway 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +31 -0
- package/src/adapter.ts +26 -0
- package/src/adapters/cli.ts +37 -0
- package/src/adapters/dingtalk.ts +111 -0
- package/src/adapters/discord.ts +117 -0
- package/src/adapters/feishu.ts +126 -0
- package/src/adapters/slack.ts +105 -0
- package/src/adapters/telegram.ts +112 -0
- package/src/adapters/wecom.ts +187 -0
- package/src/adapters/whatsapp.ts +231 -0
- package/src/commands.ts +32 -0
- package/src/config.ts +90 -0
- package/src/index.ts +220 -0
- package/src/session.ts +17 -0
- package/src/status.ts +64 -0
- package/src/trajectory.ts +45 -0
- package/test/adapters.dingtalk.test.ts +25 -0
- package/test/adapters.discord.test.ts +41 -0
- package/test/adapters.feishu.test.ts +30 -0
- package/test/adapters.slack.test.ts +45 -0
- package/test/adapters.telegram.test.ts +37 -0
- package/test/adapters.wecom.test.ts +62 -0
- package/test/adapters.whatsapp.test.ts +138 -0
- package/test/commands.test.ts +18 -0
- package/test/config.test.ts +30 -0
- package/test/multi.test.ts +86 -0
- package/test/outbound.test.ts +29 -0
- package/test/session.test.ts +21 -0
- package/test/status.test.ts +41 -0
- package/test/streaming.test.ts +162 -0
- package/test/trajectory.test.ts +58 -0
- package/tsconfig.json +5 -0
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
import { createHash, createDecipheriv, createCipheriv, randomBytes } from 'node:crypto';
|
|
2
|
+
import { createServer, type IncomingMessage, type ServerResponse } from 'node:http';
|
|
3
|
+
import type { Adapter, NormalizedMessage, OutboundButton, OutboundPayload } from '../adapter.js';
|
|
4
|
+
|
|
5
|
+
// ── 纯函数:AES-256-CBC 加解密(企业微信协议)──────────────────
|
|
6
|
+
|
|
7
|
+
export function deriveAesKey(encodingAESKey: string): Buffer {
|
|
8
|
+
return Buffer.from(encodingAESKey + '=', 'base64'); // 43 位 + '=' → 32 字节
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function pkcs7Unpad(buf: Buffer): Buffer {
|
|
12
|
+
const pad = buf[buf.length - 1];
|
|
13
|
+
if (pad < 1 || pad > 32) throw new Error('invalid pkcs7 padding');
|
|
14
|
+
return buf.subarray(0, buf.length - pad);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function pkcs7Pad(buf: Buffer): Buffer {
|
|
18
|
+
const pad = 32 - (buf.length % 32);
|
|
19
|
+
return Buffer.concat([buf, Buffer.alloc(pad, pad)]);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function decryptWeComPayload(encodingAESKey: string, encrypted: string): { message: string; receiveId: string } {
|
|
23
|
+
const key = deriveAesKey(encodingAESKey);
|
|
24
|
+
const iv = key.subarray(0, 16);
|
|
25
|
+
const decipher = createDecipheriv('aes-256-cbc', key, iv);
|
|
26
|
+
decipher.setAutoPadding(false);
|
|
27
|
+
const plain = Buffer.concat([decipher.update(Buffer.from(encrypted, 'base64')), decipher.final()]);
|
|
28
|
+
const unpadded = pkcs7Unpad(plain);
|
|
29
|
+
// 结构:random(16) + msgLen(4, big-endian) + msg + receiveId
|
|
30
|
+
const msgLen = unpadded.readUInt32BE(16);
|
|
31
|
+
const message = unpadded.subarray(20, 20 + msgLen).toString('utf8');
|
|
32
|
+
const receiveId = unpadded.subarray(20 + msgLen).toString('utf8');
|
|
33
|
+
return { message, receiveId };
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function encryptWeComPayload(encodingAESKey: string, message: string, receiveId: string): { encrypted: string } {
|
|
37
|
+
const key = deriveAesKey(encodingAESKey);
|
|
38
|
+
const iv = key.subarray(0, 16);
|
|
39
|
+
const msgBuf = Buffer.from(message, 'utf8');
|
|
40
|
+
const head = Buffer.alloc(20);
|
|
41
|
+
randomBytes(16).copy(head, 0);
|
|
42
|
+
head.writeUInt32BE(msgBuf.length, 16);
|
|
43
|
+
const plain = pkcs7Pad(Buffer.concat([head, msgBuf, Buffer.from(receiveId, 'utf8')]));
|
|
44
|
+
const cipher = createCipheriv('aes-256-cbc', key, iv);
|
|
45
|
+
cipher.setAutoPadding(false);
|
|
46
|
+
const encrypted = Buffer.concat([cipher.update(plain), cipher.final()]);
|
|
47
|
+
return { encrypted: encrypted.toString('base64') };
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function weComSignature(token: string, timestamp: string, nonce: string, encrypt: string): string {
|
|
51
|
+
const arr = [token, timestamp, nonce, encrypt].sort();
|
|
52
|
+
return createHash('sha1').update(arr.join('')).digest('hex');
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// ── 纯函数:XML 消息解析 ─────────────────────────────────────
|
|
56
|
+
|
|
57
|
+
export function parseWeComXmlMessage(xml: string): NormalizedMessage | null {
|
|
58
|
+
const get = (tag: string): string => {
|
|
59
|
+
const m = xml.match(new RegExp(`<${tag}><!\\[CDATA\\[([\\s\\S]*?)\\]\\]></${tag}>`));
|
|
60
|
+
return m ? m[1] : '';
|
|
61
|
+
};
|
|
62
|
+
const msgType = get('MsgType');
|
|
63
|
+
if (msgType !== 'text') return null;
|
|
64
|
+
const from = get('FromUserName');
|
|
65
|
+
const content = get('Content');
|
|
66
|
+
if (!from || !content) return null;
|
|
67
|
+
return { chatId: from, userId: from, text: content };
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// ── 纯函数:审批编号回复 ─────────────────────────────────────
|
|
71
|
+
|
|
72
|
+
export function buildNumberedText(text: string, buttons: OutboundButton[]): string {
|
|
73
|
+
if (buttons.length === 0) return text;
|
|
74
|
+
const options = buttons.map((b, i) => `${i + 1}) ${b.label}`).join('\n');
|
|
75
|
+
return `${text}\n\n${options}\n\n回复数字选择。`;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export function matchNumberedButton(text: string, buttons: OutboundButton[]): OutboundButton | undefined {
|
|
79
|
+
const n = Number(text.trim());
|
|
80
|
+
if (!Number.isInteger(n) || n < 1 || n > buttons.length) return undefined;
|
|
81
|
+
return buttons[n - 1];
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// ── 适配器(自带回调 HTTP 服务器)────────────────────────────
|
|
85
|
+
|
|
86
|
+
export interface WeComAdapterOptions {
|
|
87
|
+
corpId: string;
|
|
88
|
+
secret: string;
|
|
89
|
+
agentId: string;
|
|
90
|
+
token: string;
|
|
91
|
+
encodingAESKey: string;
|
|
92
|
+
callbackPort: number;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export class WeComAdapter implements Adapter {
|
|
96
|
+
readonly id = 'wecom';
|
|
97
|
+
private server?: ReturnType<typeof createServer>;
|
|
98
|
+
private connected = false;
|
|
99
|
+
private messageCb?: (msg: NormalizedMessage) => void;
|
|
100
|
+
private replyCb?: (buttonId: string) => void;
|
|
101
|
+
private readonly pendingButtons = new Map<string, OutboundButton[]>();
|
|
102
|
+
|
|
103
|
+
constructor(private readonly opts: WeComAdapterOptions) {}
|
|
104
|
+
|
|
105
|
+
async connect(): Promise<void> {
|
|
106
|
+
this.server = createServer((req, res) => void this.route(req, res));
|
|
107
|
+
await new Promise<void>((resolve) => this.server!.listen(this.opts.callbackPort, '0.0.0.0', () => resolve()));
|
|
108
|
+
this.connected = true;
|
|
109
|
+
console.log(`[wecom] 回调服务器已启动 http://0.0.0.0:${this.opts.callbackPort}(需公网可达并配置为企业微信回调 URL)`);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
private async route(req: IncomingMessage, res: ServerResponse): Promise<void> {
|
|
113
|
+
const url = new URL(req.url ?? '/', 'http://localhost');
|
|
114
|
+
const params = url.searchParams;
|
|
115
|
+
if (req.method === 'GET') {
|
|
116
|
+
// URL 验证:回显解密后的 echostr
|
|
117
|
+
const msgSignature = params.get('msg_signature') ?? '';
|
|
118
|
+
const timestamp = params.get('timestamp') ?? '';
|
|
119
|
+
const nonce = params.get('nonce') ?? '';
|
|
120
|
+
const echostr = params.get('echostr') ?? '';
|
|
121
|
+
const sign = weComSignature(this.opts.token, timestamp, nonce, echostr);
|
|
122
|
+
if (sign !== msgSignature) { res.writeHead(403); res.end('invalid signature'); return; }
|
|
123
|
+
const { message } = decryptWeComPayload(this.opts.encodingAESKey, echostr);
|
|
124
|
+
res.writeHead(200, { 'content-type': 'text/plain' });
|
|
125
|
+
res.end(message);
|
|
126
|
+
return;
|
|
127
|
+
}
|
|
128
|
+
if (req.method === 'POST') {
|
|
129
|
+
const raw = await readBody(req);
|
|
130
|
+
const msgSignature = params.get('msg_signature') ?? '';
|
|
131
|
+
const timestamp = params.get('timestamp') ?? '';
|
|
132
|
+
const nonce = params.get('nonce') ?? '';
|
|
133
|
+
const encrypt = (raw.match(/<Encrypt><!\[CDATA\[([\s\S]*?)\]\]><\/Encrypt>/) ?? [])[1] ?? '';
|
|
134
|
+
const sign = weComSignature(this.opts.token, timestamp, nonce, encrypt);
|
|
135
|
+
if (sign !== msgSignature) { res.writeHead(403); res.end('invalid signature'); return; }
|
|
136
|
+
const { message } = decryptWeComPayload(this.opts.encodingAESKey, encrypt);
|
|
137
|
+
const normalized = parseWeComXmlMessage(message);
|
|
138
|
+
res.writeHead(200, { 'content-type': 'text/plain' });
|
|
139
|
+
res.end('success'); // 先应答,避免企业微信重试
|
|
140
|
+
if (!normalized) return;
|
|
141
|
+
const pending = this.pendingButtons.get(normalized.chatId);
|
|
142
|
+
if (pending) {
|
|
143
|
+
const button = matchNumberedButton(normalized.text, pending);
|
|
144
|
+
if (button) {
|
|
145
|
+
this.pendingButtons.delete(normalized.chatId);
|
|
146
|
+
this.replyCb?.(button.id);
|
|
147
|
+
return;
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
this.messageCb?.(normalized);
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
async send(chatId: string, payload: OutboundPayload): Promise<void> {
|
|
155
|
+
if (payload.buttons?.length) this.pendingButtons.set(chatId, payload.buttons);
|
|
156
|
+
const text = buildNumberedText(payload.text, payload.buttons ?? []);
|
|
157
|
+
const token = await this.fetchAccessToken();
|
|
158
|
+
const res = await fetch(`https://qyapi.weixin.qq.com/cgi-bin/message/send?access_token=${encodeURIComponent(token)}`, {
|
|
159
|
+
method: 'POST',
|
|
160
|
+
body: JSON.stringify({
|
|
161
|
+
touser: chatId,
|
|
162
|
+
msgtype: 'text',
|
|
163
|
+
agentid: this.opts.agentId,
|
|
164
|
+
text: { content: text },
|
|
165
|
+
}),
|
|
166
|
+
headers: { 'content-type': 'application/json' },
|
|
167
|
+
}).then((r) => r.json() as Promise<{ errcode?: number; errmsg?: string }>);
|
|
168
|
+
if (res.errcode !== 0) throw new Error(`企业微信发送失败: ${res.errcode} ${res.errmsg}`);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
private async fetchAccessToken(): Promise<string> {
|
|
172
|
+
const url = `https://qyapi.weixin.qq.com/cgi-bin/gettoken?corpid=${encodeURIComponent(this.opts.corpId)}&corpsecret=${encodeURIComponent(this.opts.secret)}`;
|
|
173
|
+
const data = (await fetch(url).then((r) => r.json())) as { access_token?: string; errcode?: number };
|
|
174
|
+
if (!data.access_token) throw new Error(`企业微信 token 获取失败: ${data.errcode}`);
|
|
175
|
+
return data.access_token;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
onMessage(cb: (msg: NormalizedMessage) => void): void { this.messageCb = cb; }
|
|
179
|
+
onReply(cb: (buttonId: string) => void): void { this.replyCb = cb; }
|
|
180
|
+
status(): { connected: boolean } { return { connected: this.connected }; }
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
async function readBody(req: IncomingMessage): Promise<string> {
|
|
184
|
+
const chunks: Buffer[] = [];
|
|
185
|
+
for await (const chunk of req) chunks.push(chunk as Buffer);
|
|
186
|
+
return Buffer.concat(chunks).toString('utf8');
|
|
187
|
+
}
|
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
import makeWASocket, {
|
|
2
|
+
useMultiFileAuthState,
|
|
3
|
+
DisconnectReason,
|
|
4
|
+
generateWAMessageFromContent,
|
|
5
|
+
type WASocket,
|
|
6
|
+
type AnyMessageContent,
|
|
7
|
+
} from '@whiskeysockets/baileys';
|
|
8
|
+
import * as qrcode from 'qrcode-terminal';
|
|
9
|
+
import pino from 'pino';
|
|
10
|
+
import type { Adapter, NormalizedMessage, OutboundButton, OutboundPayload } from '../adapter.js';
|
|
11
|
+
|
|
12
|
+
// ── 纯函数(可单测)────────────────────────────────────────────
|
|
13
|
+
|
|
14
|
+
export interface RawWhatsAppMessage {
|
|
15
|
+
key?: { remoteJid?: string; participant?: string; fromMe?: boolean };
|
|
16
|
+
message?: {
|
|
17
|
+
conversation?: string;
|
|
18
|
+
extendedTextMessage?: { text?: string };
|
|
19
|
+
imageMessage?: { url?: string; directPath?: string; caption?: string; mimetype?: string };
|
|
20
|
+
audioMessage?: { url?: string; directPath?: string; mimetype?: string };
|
|
21
|
+
videoMessage?: { url?: string; directPath?: string; mimetype?: string; caption?: string };
|
|
22
|
+
documentMessage?: { url?: string; directPath?: string; mimetype?: string; fileName?: string };
|
|
23
|
+
interactiveResponseMessage?: { nativeFlowResponseMessage?: { paramsJson?: string } };
|
|
24
|
+
} & Record<string, unknown>;
|
|
25
|
+
messageType?: string;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function extractWhatsAppText(raw: RawWhatsAppMessage): string | null {
|
|
29
|
+
const msg = raw.message;
|
|
30
|
+
if (!msg) return null;
|
|
31
|
+
if (typeof msg.conversation === 'string' && msg.conversation) return msg.conversation;
|
|
32
|
+
if (msg.extendedTextMessage && typeof msg.extendedTextMessage.text === 'string') return msg.extendedTextMessage.text;
|
|
33
|
+
return null;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** 纯函数:imageMessage 的下载 URL(Baileys 已解析的 url 优先,退 directPath);无图返回 undefined。 */
|
|
37
|
+
export function whatsappImageUrl(raw: RawWhatsAppMessage): string | undefined {
|
|
38
|
+
const img = raw.message?.imageMessage;
|
|
39
|
+
return img?.url ?? img?.directPath ?? undefined;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function waMediaUrl(m: { url?: string; directPath?: string } | undefined): string | undefined {
|
|
43
|
+
return m?.url ?? m?.directPath ?? undefined;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** 媒体消息 → media 引用(kind + url):image/voice/video/file;无 url 视为无法透传,返回 undefined。 */
|
|
47
|
+
function whatsappMedia(raw: RawWhatsAppMessage): NormalizedMessage['media'] {
|
|
48
|
+
const msg = raw.message;
|
|
49
|
+
if (!msg) return undefined;
|
|
50
|
+
if (msg.imageMessage) {
|
|
51
|
+
const url = whatsappImageUrl(raw);
|
|
52
|
+
return url ? { kind: 'image', url } : undefined;
|
|
53
|
+
}
|
|
54
|
+
if (msg.audioMessage) {
|
|
55
|
+
const url = waMediaUrl(msg.audioMessage);
|
|
56
|
+
return url ? { kind: 'voice', url } : undefined;
|
|
57
|
+
}
|
|
58
|
+
if (msg.videoMessage) {
|
|
59
|
+
const url = waMediaUrl(msg.videoMessage);
|
|
60
|
+
return url ? { kind: 'video', url } : undefined;
|
|
61
|
+
}
|
|
62
|
+
if (msg.documentMessage) {
|
|
63
|
+
const url = waMediaUrl(msg.documentMessage);
|
|
64
|
+
return url ? { kind: 'file', url } : undefined;
|
|
65
|
+
}
|
|
66
|
+
return undefined;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function normalizeWhatsAppMessage(raw: RawWhatsAppMessage):
|
|
70
|
+
| { kind: 'message'; msg: NormalizedMessage }
|
|
71
|
+
| null {
|
|
72
|
+
if (raw.key?.fromMe) return null;
|
|
73
|
+
const media = whatsappMedia(raw);
|
|
74
|
+
const text = extractWhatsAppText(raw) ?? raw.message?.imageMessage?.caption ?? '';
|
|
75
|
+
if (!text && !media) return null;
|
|
76
|
+
const remoteJid = raw.key?.remoteJid;
|
|
77
|
+
if (!remoteJid) return null;
|
|
78
|
+
const userId = raw.key?.participant ?? remoteJid;
|
|
79
|
+
const msg: NormalizedMessage = { chatId: remoteJid, userId, text };
|
|
80
|
+
if (media) msg.media = media;
|
|
81
|
+
return { kind: 'message', msg };
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export function buildNumberedReply(text: string, buttons: OutboundButton[]): string {
|
|
85
|
+
const options = buttons.map((b, i) => `${i + 1}) ${b.label}`).join('\n');
|
|
86
|
+
return `${text}\n\n${options}\n\n回复数字选择。`;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export function matchNumberedReply(text: string, buttons: OutboundButton[]): OutboundButton | undefined {
|
|
90
|
+
const n = Number(text.trim());
|
|
91
|
+
if (!Number.isInteger(n) || n < 1 || n > buttons.length) return undefined;
|
|
92
|
+
return buttons[n - 1];
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export interface NativeFlowButton {
|
|
96
|
+
name: string;
|
|
97
|
+
buttonParamsJson: string;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** WhatsApp 原生交互按钮:nativeFlowMessage.buttons 数组({ name: 'quick_reply', buttonParamsJson: JSON({id, display_text}) })。 */
|
|
101
|
+
export function buildNativeFlowButtons(buttons: OutboundButton[]): NativeFlowButton[] {
|
|
102
|
+
return buttons.map((b) => ({
|
|
103
|
+
name: 'quick_reply',
|
|
104
|
+
buttonParamsJson: JSON.stringify({ id: b.id, display_text: b.label }),
|
|
105
|
+
}));
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** 解析原生按钮响应:interactiveResponseMessage.nativeFlowResponseMessage.paramsJson 中的 id;非交互/非法 JSON/缺 id 返回 null。 */
|
|
109
|
+
export function parseNativeButtonResponse(raw: RawWhatsAppMessage): string | null {
|
|
110
|
+
const params = raw.message?.interactiveResponseMessage?.nativeFlowResponseMessage?.paramsJson;
|
|
111
|
+
if (!params) return null;
|
|
112
|
+
try {
|
|
113
|
+
const parsed = JSON.parse(params) as { id?: string };
|
|
114
|
+
return parsed.id ?? null;
|
|
115
|
+
} catch {
|
|
116
|
+
return null;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// ── 适配器(真实连接,薄层)────────────────────────────────────
|
|
121
|
+
|
|
122
|
+
export interface WhatsAppAdapterOptions {
|
|
123
|
+
authDir: string;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export class WhatsAppAdapter implements Adapter {
|
|
127
|
+
readonly id = 'whatsapp';
|
|
128
|
+
private sock?: WASocket;
|
|
129
|
+
private connected = false;
|
|
130
|
+
private messageCb?: (msg: NormalizedMessage) => void;
|
|
131
|
+
private replyCb?: (buttonId: string) => void;
|
|
132
|
+
/** chatId → 当前 pending 按钮(编号回复 → 按钮 id) */
|
|
133
|
+
private readonly pendingButtons = new Map<string, OutboundButton[]>();
|
|
134
|
+
|
|
135
|
+
constructor(private readonly opts: WhatsAppAdapterOptions) {}
|
|
136
|
+
|
|
137
|
+
async connect(): Promise<void> {
|
|
138
|
+
const { state, saveCreds } = await useMultiFileAuthState(this.opts.authDir);
|
|
139
|
+
const sock = makeWASocket({
|
|
140
|
+
auth: state,
|
|
141
|
+
printQRInTerminal: false,
|
|
142
|
+
logger: pino({ level: 'silent' }),
|
|
143
|
+
browser: ['dsh-overdrive', 'Chrome', '120.0.0.0'],
|
|
144
|
+
});
|
|
145
|
+
this.sock = sock;
|
|
146
|
+
|
|
147
|
+
sock.ev.on('creds.update', saveCreds);
|
|
148
|
+
sock.ev.on('connection.update', (update) => {
|
|
149
|
+
if (update.qr) {
|
|
150
|
+
qrcode.generate(update.qr, { small: true });
|
|
151
|
+
console.log('[whatsapp] 请用 WhatsApp 扫上方二维码完成配对(重启应用可重新生成)');
|
|
152
|
+
}
|
|
153
|
+
if (update.connection === 'open') {
|
|
154
|
+
this.connected = true;
|
|
155
|
+
console.log('[whatsapp] 已连接 WhatsApp');
|
|
156
|
+
}
|
|
157
|
+
if (update.connection === 'close') {
|
|
158
|
+
this.connected = false;
|
|
159
|
+
const status = (update.lastDisconnect?.error as { output?: { statusCode?: number } } | undefined)?.output?.statusCode;
|
|
160
|
+
if (status === DisconnectReason.loggedOut) {
|
|
161
|
+
console.error('[whatsapp] 已登出:删除 data/whatsapp 目录后重启可重新扫码');
|
|
162
|
+
return;
|
|
163
|
+
}
|
|
164
|
+
console.warn('[whatsapp] 连接断开,3s 后重连…');
|
|
165
|
+
setTimeout(() => void this.connect().catch((e) => console.error('[whatsapp] 重连失败:', e)), 3000);
|
|
166
|
+
}
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
sock.ev.on('messages.upsert', ({ messages }) => {
|
|
170
|
+
for (const raw of messages) {
|
|
171
|
+
const waRaw = raw as RawWhatsAppMessage;
|
|
172
|
+
// 原生交互按钮响应:interactiveResponseMessage → 按钮 id(优先于编号回复兜底)
|
|
173
|
+
const buttonId = parseNativeButtonResponse(waRaw);
|
|
174
|
+
if (buttonId) {
|
|
175
|
+
const chatId = waRaw.key?.remoteJid;
|
|
176
|
+
if (chatId) this.pendingButtons.delete(chatId);
|
|
177
|
+
this.replyCb?.(buttonId);
|
|
178
|
+
continue;
|
|
179
|
+
}
|
|
180
|
+
const normalized = normalizeWhatsAppMessage(waRaw);
|
|
181
|
+
if (!normalized) continue;
|
|
182
|
+
// 编号回复兜底:若该 chat 有 pending 按钮且消息是数字,转成按钮点击
|
|
183
|
+
const pending = this.pendingButtons.get(normalized.msg.chatId);
|
|
184
|
+
if (pending) {
|
|
185
|
+
const button = matchNumberedReply(normalized.msg.text, pending);
|
|
186
|
+
if (button) {
|
|
187
|
+
this.pendingButtons.delete(normalized.msg.chatId);
|
|
188
|
+
this.replyCb?.(button.id);
|
|
189
|
+
continue;
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
this.messageCb?.(normalized.msg);
|
|
193
|
+
}
|
|
194
|
+
});
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
async send(chatId: string, payload: OutboundPayload): Promise<void> {
|
|
198
|
+
if (!this.sock) return;
|
|
199
|
+
if (payload.buttons?.length) {
|
|
200
|
+
this.pendingButtons.set(chatId, payload.buttons);
|
|
201
|
+
// 原生交互按钮优先:Baileys 6.x 的 AnyMessageContent 无 interactive 键,
|
|
202
|
+
// 以 proto.IMessage(interactiveMessage.nativeFlowMessage)+ relayMessage 发送。
|
|
203
|
+
const userJid = this.sock.user?.id;
|
|
204
|
+
if (userJid) {
|
|
205
|
+
const waMsg = generateWAMessageFromContent(
|
|
206
|
+
chatId,
|
|
207
|
+
{
|
|
208
|
+
interactiveMessage: {
|
|
209
|
+
body: { text: payload.text },
|
|
210
|
+
nativeFlowMessage: { buttons: buildNativeFlowButtons(payload.buttons) },
|
|
211
|
+
},
|
|
212
|
+
},
|
|
213
|
+
{ userJid },
|
|
214
|
+
);
|
|
215
|
+
if (waMsg.message) {
|
|
216
|
+
await this.sock.relayMessage(chatId, waMsg.message, { messageId: waMsg.key.id ?? undefined });
|
|
217
|
+
return;
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
// 兜底:连接未就绪(无 userJid)或生成失败时退回编号文本方案
|
|
221
|
+
const text = buildNumberedReply(payload.text, payload.buttons);
|
|
222
|
+
await this.sock.sendMessage(chatId, { text } satisfies AnyMessageContent);
|
|
223
|
+
return;
|
|
224
|
+
}
|
|
225
|
+
await this.sock.sendMessage(chatId, { text: payload.text } satisfies AnyMessageContent);
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
onMessage(cb: (msg: NormalizedMessage) => void): void { this.messageCb = cb; }
|
|
229
|
+
onReply(cb: (buttonId: string) => void): void { this.replyCb = cb; }
|
|
230
|
+
status(): { connected: boolean } { return { connected: this.connected }; }
|
|
231
|
+
}
|
package/src/commands.ts
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
export type ParsedCommand =
|
|
2
|
+
| { kind: 'trace' }
|
|
3
|
+
| { kind: 'new' }
|
|
4
|
+
| { kind: 'agents' }
|
|
5
|
+
| { kind: 'help' }
|
|
6
|
+
| { kind: 'task'; prompt: string }
|
|
7
|
+
| { kind: 'cron'; schedule: string; prompt: string };
|
|
8
|
+
|
|
9
|
+
// cron 语法:/cron <分 时 日 月 周> <需求>(schedule 为 5 个空白分隔字段)
|
|
10
|
+
const CRON_RE = /^\/cron\s+(\S+\s+\S+\s+\S+\s+\S+\s+\S+)\s+(.+)$/;
|
|
11
|
+
|
|
12
|
+
export function parseCommand(text: string): ParsedCommand | null {
|
|
13
|
+
const trimmed = text.trim();
|
|
14
|
+
if (trimmed === '/trace') return { kind: 'trace' };
|
|
15
|
+
if (trimmed === '/new') return { kind: 'new' };
|
|
16
|
+
if (trimmed === '/agents') return { kind: 'agents' };
|
|
17
|
+
if (trimmed === '/help') return { kind: 'help' };
|
|
18
|
+
const task = trimmed.match(/^\/task\s+(.+)$/);
|
|
19
|
+
if (task) return { kind: 'task', prompt: task[1] };
|
|
20
|
+
const cron = trimmed.match(CRON_RE);
|
|
21
|
+
if (cron) return { kind: 'cron', schedule: cron[1], prompt: cron[2] };
|
|
22
|
+
return null;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export const HELP_TEXT = [
|
|
26
|
+
'/help — 帮助',
|
|
27
|
+
'/trace — 查看最近一轮轨迹',
|
|
28
|
+
'/task <需求> — 派子任务',
|
|
29
|
+
'/cron <分 时 日 月 周> <需求> — 定时任务',
|
|
30
|
+
'/agents — 查看子任务状态',
|
|
31
|
+
'/new — 重置会话',
|
|
32
|
+
].join('\n');
|
package/src/config.ts
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import type { Adapter } from './adapter.js';
|
|
2
|
+
import { CliAdapter } from './adapters/cli.js';
|
|
3
|
+
import { WhatsAppAdapter } from './adapters/whatsapp.js';
|
|
4
|
+
import { TelegramAdapter } from './adapters/telegram.js';
|
|
5
|
+
import { DiscordAdapter } from './adapters/discord.js';
|
|
6
|
+
import { SlackAdapter } from './adapters/slack.js';
|
|
7
|
+
import { FeishuAdapter } from './adapters/feishu.js';
|
|
8
|
+
import { DingTalkAdapter } from './adapters/dingtalk.js';
|
|
9
|
+
import { WeComAdapter } from './adapters/wecom.js';
|
|
10
|
+
|
|
11
|
+
/** 平台适配器需要的全部环境变量(缺省为 undefined = 不启用该平台)。 */
|
|
12
|
+
export interface AdapterEnv {
|
|
13
|
+
whatsappDataDir?: string;
|
|
14
|
+
telegramBotToken?: string;
|
|
15
|
+
discordBotToken?: string;
|
|
16
|
+
slackBotToken?: string;
|
|
17
|
+
slackAppToken?: string;
|
|
18
|
+
feishuAppId?: string;
|
|
19
|
+
feishuAppSecret?: string;
|
|
20
|
+
dingtalkClientId?: string;
|
|
21
|
+
dingtalkClientSecret?: string;
|
|
22
|
+
wecomCorpId?: string;
|
|
23
|
+
wecomSecret?: string;
|
|
24
|
+
wecomAgentId?: string;
|
|
25
|
+
wecomToken?: string;
|
|
26
|
+
wecomEncodingAESKey?: string;
|
|
27
|
+
wecomCallbackPort?: string;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function parseAdapterIds(raw: string): string[] {
|
|
31
|
+
const ids = raw.split(',').map((s) => s.trim()).filter(Boolean);
|
|
32
|
+
return ids.length > 0 ? ids : ['cli'];
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** 按 id 创建适配器实例;依赖注入 env 便于测试。 */
|
|
36
|
+
export function createAdapter(id: string, env: AdapterEnv): Adapter {
|
|
37
|
+
switch (id) {
|
|
38
|
+
case 'cli':
|
|
39
|
+
return new CliAdapter();
|
|
40
|
+
case 'whatsapp':
|
|
41
|
+
return new WhatsAppAdapter({ authDir: env.whatsappDataDir ?? 'data/whatsapp' });
|
|
42
|
+
case 'telegram':
|
|
43
|
+
if (!env.telegramBotToken) throw new Error('telegram 适配器需要 TELEGRAM_BOT_TOKEN');
|
|
44
|
+
return new TelegramAdapter({ token: env.telegramBotToken });
|
|
45
|
+
case 'discord':
|
|
46
|
+
if (!env.discordBotToken) throw new Error('discord 适配器需要 DISCORD_BOT_TOKEN');
|
|
47
|
+
return new DiscordAdapter({ token: env.discordBotToken });
|
|
48
|
+
case 'slack':
|
|
49
|
+
if (!env.slackBotToken || !env.slackAppToken) throw new Error('slack 适配器需要 SLACK_BOT_TOKEN 与 SLACK_APP_TOKEN');
|
|
50
|
+
return new SlackAdapter({ botToken: env.slackBotToken, appToken: env.slackAppToken });
|
|
51
|
+
case 'feishu':
|
|
52
|
+
if (!env.feishuAppId || !env.feishuAppSecret) throw new Error('feishu 适配器需要 FEISHU_APP_ID / FEISHU_APP_SECRET');
|
|
53
|
+
return new FeishuAdapter({ appId: env.feishuAppId, appSecret: env.feishuAppSecret });
|
|
54
|
+
case 'dingtalk':
|
|
55
|
+
if (!env.dingtalkClientId || !env.dingtalkClientSecret) throw new Error('dingtalk 适配器需要 DINGTALK_CLIENT_ID / DINGTALK_CLIENT_SECRET');
|
|
56
|
+
return new DingTalkAdapter({ clientId: env.dingtalkClientId, clientSecret: env.dingtalkClientSecret });
|
|
57
|
+
case 'wecom':
|
|
58
|
+
if (!env.wecomCorpId || !env.wecomSecret || !env.wecomAgentId || !env.wecomToken || !env.wecomEncodingAESKey) {
|
|
59
|
+
throw new Error('wecom 适配器需要 WECOM_CORP_ID / WECOM_SECRET / WECOM_AGENT_ID / WECOM_TOKEN / WECOM_ENCODING_AES_KEY');
|
|
60
|
+
}
|
|
61
|
+
return new WeComAdapter({
|
|
62
|
+
corpId: env.wecomCorpId, secret: env.wecomSecret, agentId: env.wecomAgentId,
|
|
63
|
+
token: env.wecomToken, encodingAESKey: env.wecomEncodingAESKey,
|
|
64
|
+
callbackPort: Number(env.wecomCallbackPort ?? 3193),
|
|
65
|
+
});
|
|
66
|
+
default:
|
|
67
|
+
throw new Error(`unknown adapter: ${id}`);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** 从 process.env 读适配器配置。 */
|
|
72
|
+
export function adapterEnvFromProcess(env: NodeJS.ProcessEnv = process.env): AdapterEnv {
|
|
73
|
+
return {
|
|
74
|
+
whatsappDataDir: env.WHATSAPP_DATA_DIR,
|
|
75
|
+
telegramBotToken: env.TELEGRAM_BOT_TOKEN,
|
|
76
|
+
discordBotToken: env.DISCORD_BOT_TOKEN,
|
|
77
|
+
slackBotToken: env.SLACK_BOT_TOKEN,
|
|
78
|
+
slackAppToken: env.SLACK_APP_TOKEN,
|
|
79
|
+
feishuAppId: env.FEISHU_APP_ID,
|
|
80
|
+
feishuAppSecret: env.FEISHU_APP_SECRET,
|
|
81
|
+
dingtalkClientId: env.DINGTALK_CLIENT_ID,
|
|
82
|
+
dingtalkClientSecret: env.DINGTALK_CLIENT_SECRET,
|
|
83
|
+
wecomCorpId: env.WECOM_CORP_ID,
|
|
84
|
+
wecomSecret: env.WECOM_SECRET,
|
|
85
|
+
wecomAgentId: env.WECOM_AGENT_ID,
|
|
86
|
+
wecomToken: env.WECOM_TOKEN,
|
|
87
|
+
wecomEncodingAESKey: env.WECOM_ENCODING_AES_KEY,
|
|
88
|
+
wecomCallbackPort: env.WECOM_CALLBACK_PORT,
|
|
89
|
+
};
|
|
90
|
+
}
|