@dsh-overdrive/gateway 0.3.0 → 0.3.1
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 +9 -4
- package/src/adapter.ts +0 -42
- package/src/adapters/cli.ts +0 -37
- package/src/adapters/dingtalk.ts +0 -206
- package/src/adapters/discord.ts +0 -127
- package/src/adapters/feishu.ts +0 -224
- package/src/adapters/slack.ts +0 -123
- package/src/adapters/telegram.ts +0 -142
- package/src/adapters/wechat.ts +0 -247
- package/src/adapters/wecom.ts +0 -218
- package/src/adapters/whatsapp.ts +0 -249
- package/src/asr.ts +0 -83
- package/src/commands.ts +0 -98
- package/src/config.ts +0 -104
- package/src/feed.ts +0 -190
- package/src/index.ts +0 -510
- package/src/memory.ts +0 -176
- package/src/mention.ts +0 -51
- package/src/pending-buttons.ts +0 -65
- package/src/session.ts +0 -23
- package/src/setup.ts +0 -252
- package/src/status.ts +0 -63
- package/src/text.ts +0 -32
- package/src/trajectory.ts +0 -45
- package/test/adapters.dingtalk.test.ts +0 -64
- package/test/adapters.discord.test.ts +0 -41
- package/test/adapters.feishu.test.ts +0 -66
- package/test/adapters.slack.test.ts +0 -45
- package/test/adapters.telegram.test.ts +0 -37
- package/test/adapters.wechat.test.ts +0 -78
- package/test/adapters.wecom.test.ts +0 -62
- package/test/adapters.whatsapp.test.ts +0 -138
- package/test/asr.test.ts +0 -77
- package/test/commands.test.ts +0 -53
- package/test/config.test.ts +0 -30
- package/test/feed.test.ts +0 -111
- package/test/memory.test.ts +0 -79
- package/test/mention.test.ts +0 -54
- package/test/multi.test.ts +0 -284
- package/test/outbound.test.ts +0 -29
- package/test/pending-buttons.test.ts +0 -100
- package/test/session.test.ts +0 -26
- package/test/status.test.ts +0 -41
- package/test/streaming.test.ts +0 -162
- package/test/text.test.ts +0 -20
- package/test/trajectory.test.ts +0 -58
- package/tsconfig.json +0 -5
package/src/adapters/wecom.ts
DELETED
|
@@ -1,218 +0,0 @@
|
|
|
1
|
-
import { createHash, createDecipheriv, createCipheriv, randomBytes } from 'node:crypto';
|
|
2
|
-
import { readFileSync } from 'node:fs';
|
|
3
|
-
import { createServer, type IncomingMessage, type ServerResponse } from 'node:http';
|
|
4
|
-
import type { Adapter, NormalizedMessage, OutboundButton, OutboundPayload } from '../adapter.js';
|
|
5
|
-
import { PendingButtons } from '../pending-buttons.js';
|
|
6
|
-
|
|
7
|
-
// ── 纯函数:AES-256-CBC 加解密(企业微信协议)──────────────────
|
|
8
|
-
|
|
9
|
-
export function deriveAesKey(encodingAESKey: string): Buffer {
|
|
10
|
-
return Buffer.from(encodingAESKey + '=', 'base64'); // 43 位 + '=' → 32 字节
|
|
11
|
-
}
|
|
12
|
-
|
|
13
|
-
export function pkcs7Unpad(buf: Buffer): Buffer {
|
|
14
|
-
const pad = buf[buf.length - 1];
|
|
15
|
-
if (pad < 1 || pad > 32) throw new Error('invalid pkcs7 padding');
|
|
16
|
-
return buf.subarray(0, buf.length - pad);
|
|
17
|
-
}
|
|
18
|
-
|
|
19
|
-
export function pkcs7Pad(buf: Buffer): Buffer {
|
|
20
|
-
const pad = 32 - (buf.length % 32);
|
|
21
|
-
return Buffer.concat([buf, Buffer.alloc(pad, pad)]);
|
|
22
|
-
}
|
|
23
|
-
|
|
24
|
-
export function decryptWeComPayload(encodingAESKey: string, encrypted: string): { message: string; receiveId: string } {
|
|
25
|
-
const key = deriveAesKey(encodingAESKey);
|
|
26
|
-
const iv = key.subarray(0, 16);
|
|
27
|
-
const decipher = createDecipheriv('aes-256-cbc', key, iv);
|
|
28
|
-
decipher.setAutoPadding(false);
|
|
29
|
-
const plain = Buffer.concat([decipher.update(Buffer.from(encrypted, 'base64')), decipher.final()]);
|
|
30
|
-
const unpadded = pkcs7Unpad(plain);
|
|
31
|
-
// 结构:random(16) + msgLen(4, big-endian) + msg + receiveId
|
|
32
|
-
const msgLen = unpadded.readUInt32BE(16);
|
|
33
|
-
const message = unpadded.subarray(20, 20 + msgLen).toString('utf8');
|
|
34
|
-
const receiveId = unpadded.subarray(20 + msgLen).toString('utf8');
|
|
35
|
-
return { message, receiveId };
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
export function encryptWeComPayload(encodingAESKey: string, message: string, receiveId: string): { encrypted: string } {
|
|
39
|
-
const key = deriveAesKey(encodingAESKey);
|
|
40
|
-
const iv = key.subarray(0, 16);
|
|
41
|
-
const msgBuf = Buffer.from(message, 'utf8');
|
|
42
|
-
const head = Buffer.alloc(20);
|
|
43
|
-
randomBytes(16).copy(head, 0);
|
|
44
|
-
head.writeUInt32BE(msgBuf.length, 16);
|
|
45
|
-
const plain = pkcs7Pad(Buffer.concat([head, msgBuf, Buffer.from(receiveId, 'utf8')]));
|
|
46
|
-
const cipher = createCipheriv('aes-256-cbc', key, iv);
|
|
47
|
-
cipher.setAutoPadding(false);
|
|
48
|
-
const encrypted = Buffer.concat([cipher.update(plain), cipher.final()]);
|
|
49
|
-
return { encrypted: encrypted.toString('base64') };
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
export function weComSignature(token: string, timestamp: string, nonce: string, encrypt: string): string {
|
|
53
|
-
const arr = [token, timestamp, nonce, encrypt].sort();
|
|
54
|
-
return createHash('sha1').update(arr.join('')).digest('hex');
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
// ── 纯函数:XML 消息解析 ─────────────────────────────────────
|
|
58
|
-
|
|
59
|
-
export function parseWeComXmlMessage(xml: string): NormalizedMessage | null {
|
|
60
|
-
const get = (tag: string): string => {
|
|
61
|
-
const m = xml.match(new RegExp(`<${tag}><!\\[CDATA\\[([\\s\\S]*?)\\]\\]></${tag}>`));
|
|
62
|
-
return m ? m[1] : '';
|
|
63
|
-
};
|
|
64
|
-
const msgType = get('MsgType');
|
|
65
|
-
if (msgType !== 'text') return null;
|
|
66
|
-
const from = get('FromUserName');
|
|
67
|
-
const content = get('Content');
|
|
68
|
-
if (!from || !content) return null;
|
|
69
|
-
return { chatId: from, userId: from, text: content };
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
// ── 纯函数:审批编号回复 ─────────────────────────────────────
|
|
73
|
-
|
|
74
|
-
export function buildNumberedText(text: string, buttons: OutboundButton[]): string {
|
|
75
|
-
if (buttons.length === 0) return text;
|
|
76
|
-
const options = buttons.map((b, i) => `${i + 1}) ${b.label}`).join('\n');
|
|
77
|
-
return `${text}\n\n${options}\n\n回复数字选择。`;
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
export function matchNumberedButton(text: string, buttons: OutboundButton[]): OutboundButton | undefined {
|
|
81
|
-
const n = Number(text.trim());
|
|
82
|
-
if (!Number.isInteger(n) || n < 1 || n > buttons.length) return undefined;
|
|
83
|
-
return buttons[n - 1];
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
// ── 适配器(自带回调 HTTP 服务器)────────────────────────────
|
|
87
|
-
|
|
88
|
-
export interface WeComAdapterOptions {
|
|
89
|
-
corpId: string;
|
|
90
|
-
secret: string;
|
|
91
|
-
agentId: string;
|
|
92
|
-
token: string;
|
|
93
|
-
encodingAESKey: string;
|
|
94
|
-
callbackPort: number;
|
|
95
|
-
}
|
|
96
|
-
|
|
97
|
-
export class WeComAdapter implements Adapter {
|
|
98
|
-
readonly id = 'wecom';
|
|
99
|
-
private server?: ReturnType<typeof createServer>;
|
|
100
|
-
private connected = false;
|
|
101
|
-
private messageCb?: (msg: NormalizedMessage) => void;
|
|
102
|
-
private replyCb?: (buttonId: string, sender: { chatId: string; userId: string }) => void;
|
|
103
|
-
private readonly pendingButtons = new PendingButtons();
|
|
104
|
-
/** access_token 缓存:企业微信 token 有效期 7200s,且有获取频率限制,必须复用。 */
|
|
105
|
-
private tokenCache?: { token: string; expiresAt: number };
|
|
106
|
-
|
|
107
|
-
constructor(private readonly opts: WeComAdapterOptions) {}
|
|
108
|
-
|
|
109
|
-
async connect(): Promise<void> {
|
|
110
|
-
this.server = createServer((req, res) => void this.route(req, res));
|
|
111
|
-
await new Promise<void>((resolve) => this.server!.listen(this.opts.callbackPort, '0.0.0.0', () => resolve()));
|
|
112
|
-
this.connected = true;
|
|
113
|
-
console.log(`[wecom] 回调服务器已启动 http://0.0.0.0:${this.opts.callbackPort}(需公网可达并配置为企业微信回调 URL)`);
|
|
114
|
-
}
|
|
115
|
-
|
|
116
|
-
private async route(req: IncomingMessage, res: ServerResponse): Promise<void> {
|
|
117
|
-
const url = new URL(req.url ?? '/', 'http://localhost');
|
|
118
|
-
const params = url.searchParams;
|
|
119
|
-
if (req.method === 'GET') {
|
|
120
|
-
// URL 验证:回显解密后的 echostr
|
|
121
|
-
const msgSignature = params.get('msg_signature') ?? '';
|
|
122
|
-
const timestamp = params.get('timestamp') ?? '';
|
|
123
|
-
const nonce = params.get('nonce') ?? '';
|
|
124
|
-
const echostr = params.get('echostr') ?? '';
|
|
125
|
-
const sign = weComSignature(this.opts.token, timestamp, nonce, echostr);
|
|
126
|
-
if (sign !== msgSignature) { res.writeHead(403); res.end('invalid signature'); return; }
|
|
127
|
-
const { message } = decryptWeComPayload(this.opts.encodingAESKey, echostr);
|
|
128
|
-
res.writeHead(200, { 'content-type': 'text/plain' });
|
|
129
|
-
res.end(message);
|
|
130
|
-
return;
|
|
131
|
-
}
|
|
132
|
-
if (req.method === 'POST') {
|
|
133
|
-
const raw = await readBody(req);
|
|
134
|
-
const msgSignature = params.get('msg_signature') ?? '';
|
|
135
|
-
const timestamp = params.get('timestamp') ?? '';
|
|
136
|
-
const nonce = params.get('nonce') ?? '';
|
|
137
|
-
const encrypt = (raw.match(/<Encrypt><!\[CDATA\[([\s\S]*?)\]\]><\/Encrypt>/) ?? [])[1] ?? '';
|
|
138
|
-
const sign = weComSignature(this.opts.token, timestamp, nonce, encrypt);
|
|
139
|
-
if (sign !== msgSignature) { res.writeHead(403); res.end('invalid signature'); return; }
|
|
140
|
-
const { message } = decryptWeComPayload(this.opts.encodingAESKey, encrypt);
|
|
141
|
-
const normalized = parseWeComXmlMessage(message);
|
|
142
|
-
res.writeHead(200, { 'content-type': 'text/plain' });
|
|
143
|
-
res.end('success'); // 先应答,避免企业微信重试
|
|
144
|
-
if (!normalized) return;
|
|
145
|
-
const button = this.pendingButtons.match(normalized.chatId, normalized.text);
|
|
146
|
-
if (button) {
|
|
147
|
-
this.replyCb?.(button.id, { chatId: normalized.chatId, userId: normalized.userId });
|
|
148
|
-
return;
|
|
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 token = await this.fetchAccessToken();
|
|
157
|
-
if (payload.media) {
|
|
158
|
-
// 媒体发送:media/upload 换 media_id → message/send;失败降级为文本(含 📎 路径)
|
|
159
|
-
try {
|
|
160
|
-
const mediaType = payload.media.kind === 'image' ? 'image' : 'file';
|
|
161
|
-
const buf = readFileSync(payload.media.path);
|
|
162
|
-
const form = new FormData();
|
|
163
|
-
form.append('media', new Blob([buf]), payload.media.caption ?? payload.media.path.split('/').pop() ?? 'file');
|
|
164
|
-
const upload = (await fetch(
|
|
165
|
-
`https://qyapi.weixin.qq.com/cgi-bin/media/upload?access_token=${encodeURIComponent(token)}&type=${mediaType}`,
|
|
166
|
-
{ method: 'POST', body: form },
|
|
167
|
-
).then((r) => r.json())) as { media_id?: string; errcode?: number };
|
|
168
|
-
if (!upload.media_id) throw new Error(`media 上传失败: ${upload.errcode}`);
|
|
169
|
-
const sendRes = (await fetch(`https://qyapi.weixin.qq.com/cgi-bin/message/send?access_token=${encodeURIComponent(token)}`, {
|
|
170
|
-
method: 'POST',
|
|
171
|
-
body: JSON.stringify({
|
|
172
|
-
touser: chatId,
|
|
173
|
-
msgtype: mediaType,
|
|
174
|
-
agentid: this.opts.agentId,
|
|
175
|
-
[mediaType]: { media_id: upload.media_id },
|
|
176
|
-
}),
|
|
177
|
-
headers: { 'content-type': 'application/json' },
|
|
178
|
-
}).then((r) => r.json())) as { errcode?: number; errmsg?: string };
|
|
179
|
-
if (sendRes.errcode !== 0) throw new Error(`企业微信媒体发送失败: ${sendRes.errcode} ${sendRes.errmsg}`);
|
|
180
|
-
return;
|
|
181
|
-
} catch (error) {
|
|
182
|
-
console.warn(`[wecom] 媒体发送失败,降级为文本: ${error instanceof Error ? error.message : String(error)}`);
|
|
183
|
-
}
|
|
184
|
-
}
|
|
185
|
-
const text = buildNumberedText(payload.text, payload.buttons ?? []);
|
|
186
|
-
const res = await fetch(`https://qyapi.weixin.qq.com/cgi-bin/message/send?access_token=${encodeURIComponent(token)}`, {
|
|
187
|
-
method: 'POST',
|
|
188
|
-
body: JSON.stringify({
|
|
189
|
-
touser: chatId,
|
|
190
|
-
msgtype: 'text',
|
|
191
|
-
agentid: this.opts.agentId,
|
|
192
|
-
text: { content: text },
|
|
193
|
-
}),
|
|
194
|
-
headers: { 'content-type': 'application/json' },
|
|
195
|
-
}).then((r) => r.json() as Promise<{ errcode?: number; errmsg?: string }>);
|
|
196
|
-
if (res.errcode !== 0) throw new Error(`企业微信发送失败: ${res.errcode} ${res.errmsg}`);
|
|
197
|
-
}
|
|
198
|
-
|
|
199
|
-
private async fetchAccessToken(): Promise<string> {
|
|
200
|
-
if (this.tokenCache && this.tokenCache.expiresAt > Date.now()) return this.tokenCache.token;
|
|
201
|
-
const url = `https://qyapi.weixin.qq.com/cgi-bin/gettoken?corpid=${encodeURIComponent(this.opts.corpId)}&corpsecret=${encodeURIComponent(this.opts.secret)}`;
|
|
202
|
-
const data = (await fetch(url).then((r) => r.json())) as { access_token?: string; errcode?: number };
|
|
203
|
-
if (!data.access_token) throw new Error(`企业微信 token 获取失败: ${data.errcode}`);
|
|
204
|
-
// 官方有效期 7200s;留 200s 余量,避免临界过期
|
|
205
|
-
this.tokenCache = { token: data.access_token, expiresAt: Date.now() + 7_000_000 };
|
|
206
|
-
return data.access_token;
|
|
207
|
-
}
|
|
208
|
-
|
|
209
|
-
onMessage(cb: (msg: NormalizedMessage) => void): void { this.messageCb = cb; }
|
|
210
|
-
onReply(cb: (buttonId: string, sender: { chatId: string; userId: string }) => void): void { this.replyCb = cb; }
|
|
211
|
-
status(): { connected: boolean } { return { connected: this.connected }; }
|
|
212
|
-
}
|
|
213
|
-
|
|
214
|
-
async function readBody(req: IncomingMessage): Promise<string> {
|
|
215
|
-
const chunks: Buffer[] = [];
|
|
216
|
-
for await (const chunk of req) chunks.push(chunk as Buffer);
|
|
217
|
-
return Buffer.concat(chunks).toString('utf8');
|
|
218
|
-
}
|
package/src/adapters/whatsapp.ts
DELETED
|
@@ -1,249 +0,0 @@
|
|
|
1
|
-
import { readFileSync } from 'node:fs';
|
|
2
|
-
import makeWASocket, {
|
|
3
|
-
useMultiFileAuthState,
|
|
4
|
-
DisconnectReason,
|
|
5
|
-
generateWAMessageFromContent,
|
|
6
|
-
type WASocket,
|
|
7
|
-
type AnyMessageContent,
|
|
8
|
-
} from '@whiskeysockets/baileys';
|
|
9
|
-
import * as qrcode from 'qrcode-terminal';
|
|
10
|
-
import pino from 'pino';
|
|
11
|
-
import type { Adapter, NormalizedMessage, OutboundButton, OutboundPayload } from '../adapter.js';
|
|
12
|
-
import { PendingButtons } from '../pending-buttons.js';
|
|
13
|
-
|
|
14
|
-
// ── 纯函数(可单测)────────────────────────────────────────────
|
|
15
|
-
|
|
16
|
-
export interface RawWhatsAppMessage {
|
|
17
|
-
key?: { remoteJid?: string; participant?: string; fromMe?: boolean };
|
|
18
|
-
message?: {
|
|
19
|
-
conversation?: string;
|
|
20
|
-
extendedTextMessage?: { text?: string };
|
|
21
|
-
imageMessage?: { url?: string; directPath?: string; caption?: string; mimetype?: string };
|
|
22
|
-
audioMessage?: { url?: string; directPath?: string; mimetype?: string };
|
|
23
|
-
videoMessage?: { url?: string; directPath?: string; mimetype?: string; caption?: string };
|
|
24
|
-
documentMessage?: { url?: string; directPath?: string; mimetype?: string; fileName?: string };
|
|
25
|
-
interactiveResponseMessage?: { nativeFlowResponseMessage?: { paramsJson?: string } };
|
|
26
|
-
} & Record<string, unknown>;
|
|
27
|
-
messageType?: string;
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
export function extractWhatsAppText(raw: RawWhatsAppMessage): string | null {
|
|
31
|
-
const msg = raw.message;
|
|
32
|
-
if (!msg) return null;
|
|
33
|
-
if (typeof msg.conversation === 'string' && msg.conversation) return msg.conversation;
|
|
34
|
-
if (msg.extendedTextMessage && typeof msg.extendedTextMessage.text === 'string') return msg.extendedTextMessage.text;
|
|
35
|
-
return null;
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
/** 纯函数:imageMessage 的下载 URL(Baileys 已解析的 url 优先,退 directPath);无图返回 undefined。 */
|
|
39
|
-
export function whatsappImageUrl(raw: RawWhatsAppMessage): string | undefined {
|
|
40
|
-
const img = raw.message?.imageMessage;
|
|
41
|
-
return img?.url ?? img?.directPath ?? undefined;
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
function waMediaUrl(m: { url?: string; directPath?: string } | undefined): string | undefined {
|
|
45
|
-
return m?.url ?? m?.directPath ?? undefined;
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
/** 媒体消息 → media 引用(kind + url):image/voice/video/file;无 url 视为无法透传,返回 undefined。 */
|
|
49
|
-
function whatsappMedia(raw: RawWhatsAppMessage): NormalizedMessage['media'] {
|
|
50
|
-
const msg = raw.message;
|
|
51
|
-
if (!msg) return undefined;
|
|
52
|
-
if (msg.imageMessage) {
|
|
53
|
-
const url = whatsappImageUrl(raw);
|
|
54
|
-
return url ? { kind: 'image', url } : undefined;
|
|
55
|
-
}
|
|
56
|
-
if (msg.audioMessage) {
|
|
57
|
-
const url = waMediaUrl(msg.audioMessage);
|
|
58
|
-
return url ? { kind: 'voice', url } : undefined;
|
|
59
|
-
}
|
|
60
|
-
if (msg.videoMessage) {
|
|
61
|
-
const url = waMediaUrl(msg.videoMessage);
|
|
62
|
-
return url ? { kind: 'video', url } : undefined;
|
|
63
|
-
}
|
|
64
|
-
if (msg.documentMessage) {
|
|
65
|
-
const url = waMediaUrl(msg.documentMessage);
|
|
66
|
-
return url ? { kind: 'file', url } : undefined;
|
|
67
|
-
}
|
|
68
|
-
return undefined;
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
export function normalizeWhatsAppMessage(raw: RawWhatsAppMessage):
|
|
72
|
-
| { kind: 'message'; msg: NormalizedMessage }
|
|
73
|
-
| null {
|
|
74
|
-
if (raw.key?.fromMe) return null;
|
|
75
|
-
const media = whatsappMedia(raw);
|
|
76
|
-
const text = extractWhatsAppText(raw) ?? raw.message?.imageMessage?.caption ?? '';
|
|
77
|
-
if (!text && !media) return null;
|
|
78
|
-
const remoteJid = raw.key?.remoteJid;
|
|
79
|
-
if (!remoteJid) return null;
|
|
80
|
-
const userId = raw.key?.participant ?? remoteJid;
|
|
81
|
-
const msg: NormalizedMessage = { chatId: remoteJid, userId, text };
|
|
82
|
-
if (media) msg.media = media;
|
|
83
|
-
return { kind: 'message', msg };
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
export function buildNumberedReply(text: string, buttons: OutboundButton[]): string {
|
|
87
|
-
const options = buttons.map((b, i) => `${i + 1}) ${b.label}`).join('\n');
|
|
88
|
-
return `${text}\n\n${options}\n\n回复数字选择。`;
|
|
89
|
-
}
|
|
90
|
-
|
|
91
|
-
export function matchNumberedReply(text: string, buttons: OutboundButton[]): OutboundButton | undefined {
|
|
92
|
-
const n = Number(text.trim());
|
|
93
|
-
if (!Number.isInteger(n) || n < 1 || n > buttons.length) return undefined;
|
|
94
|
-
return buttons[n - 1];
|
|
95
|
-
}
|
|
96
|
-
|
|
97
|
-
export interface NativeFlowButton {
|
|
98
|
-
name: string;
|
|
99
|
-
buttonParamsJson: string;
|
|
100
|
-
}
|
|
101
|
-
|
|
102
|
-
/** WhatsApp 原生交互按钮:nativeFlowMessage.buttons 数组({ name: 'quick_reply', buttonParamsJson: JSON({id, display_text}) })。 */
|
|
103
|
-
export function buildNativeFlowButtons(buttons: OutboundButton[]): NativeFlowButton[] {
|
|
104
|
-
return buttons.map((b) => ({
|
|
105
|
-
name: 'quick_reply',
|
|
106
|
-
buttonParamsJson: JSON.stringify({ id: b.id, display_text: b.label }),
|
|
107
|
-
}));
|
|
108
|
-
}
|
|
109
|
-
|
|
110
|
-
/** 解析原生按钮响应:interactiveResponseMessage.nativeFlowResponseMessage.paramsJson 中的 id;非交互/非法 JSON/缺 id 返回 null。 */
|
|
111
|
-
export function parseNativeButtonResponse(raw: RawWhatsAppMessage): string | null {
|
|
112
|
-
const params = raw.message?.interactiveResponseMessage?.nativeFlowResponseMessage?.paramsJson;
|
|
113
|
-
if (!params) return null;
|
|
114
|
-
try {
|
|
115
|
-
const parsed = JSON.parse(params) as { id?: string };
|
|
116
|
-
return parsed.id ?? null;
|
|
117
|
-
} catch {
|
|
118
|
-
return null;
|
|
119
|
-
}
|
|
120
|
-
}
|
|
121
|
-
|
|
122
|
-
// ── 适配器(真实连接,薄层)────────────────────────────────────
|
|
123
|
-
|
|
124
|
-
export interface WhatsAppAdapterOptions {
|
|
125
|
-
authDir: string;
|
|
126
|
-
}
|
|
127
|
-
|
|
128
|
-
export class WhatsAppAdapter implements Adapter {
|
|
129
|
-
readonly id = 'whatsapp';
|
|
130
|
-
private sock?: WASocket;
|
|
131
|
-
private connected = false;
|
|
132
|
-
private messageCb?: (msg: NormalizedMessage) => void;
|
|
133
|
-
private replyCb?: (buttonId: string, sender: { chatId: string; userId: string }) => void;
|
|
134
|
-
/** chatId → 当前 pending 按钮(编号回复 → 按钮 id,带 TTL 防过期误吞) */
|
|
135
|
-
private readonly pendingButtons = new PendingButtons();
|
|
136
|
-
|
|
137
|
-
constructor(private readonly opts: WhatsAppAdapterOptions) {}
|
|
138
|
-
|
|
139
|
-
async connect(): Promise<void> {
|
|
140
|
-
const { state, saveCreds } = await useMultiFileAuthState(this.opts.authDir);
|
|
141
|
-
const sock = makeWASocket({
|
|
142
|
-
auth: state,
|
|
143
|
-
printQRInTerminal: false,
|
|
144
|
-
logger: pino({ level: 'silent' }),
|
|
145
|
-
browser: ['dsh-overdrive', 'Chrome', '120.0.0.0'],
|
|
146
|
-
});
|
|
147
|
-
this.sock = sock;
|
|
148
|
-
|
|
149
|
-
sock.ev.on('creds.update', saveCreds);
|
|
150
|
-
sock.ev.on('connection.update', (update) => {
|
|
151
|
-
if (update.qr) {
|
|
152
|
-
qrcode.generate(update.qr, { small: true });
|
|
153
|
-
console.log('[whatsapp] 请用 WhatsApp 扫上方二维码完成配对(重启应用可重新生成)');
|
|
154
|
-
}
|
|
155
|
-
if (update.connection === 'open') {
|
|
156
|
-
this.connected = true;
|
|
157
|
-
console.log('[whatsapp] 已连接 WhatsApp');
|
|
158
|
-
}
|
|
159
|
-
if (update.connection === 'close') {
|
|
160
|
-
this.connected = false;
|
|
161
|
-
const status = (update.lastDisconnect?.error as { output?: { statusCode?: number } } | undefined)?.output?.statusCode;
|
|
162
|
-
if (status === DisconnectReason.loggedOut) {
|
|
163
|
-
console.error('[whatsapp] 已登出:删除 data/whatsapp 目录后重启可重新扫码');
|
|
164
|
-
return;
|
|
165
|
-
}
|
|
166
|
-
console.warn('[whatsapp] 连接断开,3s 后重连…');
|
|
167
|
-
setTimeout(() => void this.connect().catch((e) => console.error('[whatsapp] 重连失败:', e)), 3000);
|
|
168
|
-
}
|
|
169
|
-
});
|
|
170
|
-
|
|
171
|
-
sock.ev.on('messages.upsert', ({ messages }) => {
|
|
172
|
-
for (const raw of messages) {
|
|
173
|
-
const waRaw = raw as RawWhatsAppMessage;
|
|
174
|
-
// 原生交互按钮响应:interactiveResponseMessage → 按钮 id(优先于编号回复兜底)
|
|
175
|
-
const buttonId = parseNativeButtonResponse(waRaw);
|
|
176
|
-
if (buttonId) {
|
|
177
|
-
const chatId = waRaw.key?.remoteJid;
|
|
178
|
-
if (chatId) this.pendingButtons.consume(chatId);
|
|
179
|
-
this.replyCb?.(buttonId, {
|
|
180
|
-
chatId: chatId ?? '',
|
|
181
|
-
userId: waRaw.key?.participant ?? chatId ?? '',
|
|
182
|
-
});
|
|
183
|
-
continue;
|
|
184
|
-
}
|
|
185
|
-
const normalized = normalizeWhatsAppMessage(waRaw);
|
|
186
|
-
if (!normalized) continue;
|
|
187
|
-
// 编号回复兜底:若该 chat 有 pending 按钮且消息是数字(TTL 内),转成按钮点击
|
|
188
|
-
const button = this.pendingButtons.match(normalized.msg.chatId, normalized.msg.text);
|
|
189
|
-
if (button) {
|
|
190
|
-
this.replyCb?.(button.id, {
|
|
191
|
-
chatId: normalized.msg.chatId,
|
|
192
|
-
userId: normalized.msg.userId,
|
|
193
|
-
});
|
|
194
|
-
continue;
|
|
195
|
-
}
|
|
196
|
-
this.messageCb?.(normalized.msg);
|
|
197
|
-
}
|
|
198
|
-
});
|
|
199
|
-
}
|
|
200
|
-
|
|
201
|
-
async send(chatId: string, payload: OutboundPayload): Promise<void> {
|
|
202
|
-
if (!this.sock) return;
|
|
203
|
-
if (payload.media) {
|
|
204
|
-
const { kind, path, caption } = payload.media;
|
|
205
|
-
const buf = readFileSync(path);
|
|
206
|
-
if (kind === 'image') {
|
|
207
|
-
await this.sock.sendMessage(chatId, { image: buf, caption: caption ?? '' } satisfies AnyMessageContent);
|
|
208
|
-
} else {
|
|
209
|
-
await this.sock.sendMessage(chatId, {
|
|
210
|
-
document: buf,
|
|
211
|
-
fileName: caption ?? path,
|
|
212
|
-
mimetype: 'application/octet-stream',
|
|
213
|
-
} satisfies AnyMessageContent);
|
|
214
|
-
}
|
|
215
|
-
return;
|
|
216
|
-
}
|
|
217
|
-
if (payload.buttons?.length) {
|
|
218
|
-
this.pendingButtons.set(chatId, payload.buttons);
|
|
219
|
-
// 原生交互按钮优先:Baileys 6.x 的 AnyMessageContent 无 interactive 键,
|
|
220
|
-
// 以 proto.IMessage(interactiveMessage.nativeFlowMessage)+ relayMessage 发送。
|
|
221
|
-
const userJid = this.sock.user?.id;
|
|
222
|
-
if (userJid) {
|
|
223
|
-
const waMsg = generateWAMessageFromContent(
|
|
224
|
-
chatId,
|
|
225
|
-
{
|
|
226
|
-
interactiveMessage: {
|
|
227
|
-
body: { text: payload.text },
|
|
228
|
-
nativeFlowMessage: { buttons: buildNativeFlowButtons(payload.buttons) },
|
|
229
|
-
},
|
|
230
|
-
},
|
|
231
|
-
{ userJid },
|
|
232
|
-
);
|
|
233
|
-
if (waMsg.message) {
|
|
234
|
-
await this.sock.relayMessage(chatId, waMsg.message, { messageId: waMsg.key.id ?? undefined });
|
|
235
|
-
return;
|
|
236
|
-
}
|
|
237
|
-
}
|
|
238
|
-
// 兜底:连接未就绪(无 userJid)或生成失败时退回编号文本方案
|
|
239
|
-
const text = buildNumberedReply(payload.text, payload.buttons);
|
|
240
|
-
await this.sock.sendMessage(chatId, { text } satisfies AnyMessageContent);
|
|
241
|
-
return;
|
|
242
|
-
}
|
|
243
|
-
await this.sock.sendMessage(chatId, { text: payload.text } satisfies AnyMessageContent);
|
|
244
|
-
}
|
|
245
|
-
|
|
246
|
-
onMessage(cb: (msg: NormalizedMessage) => void): void { this.messageCb = cb; }
|
|
247
|
-
onReply(cb: (buttonId: string, sender: { chatId: string; userId: string }) => void): void { this.replyCb = cb; }
|
|
248
|
-
status(): { connected: boolean } { return { connected: this.connected }; }
|
|
249
|
-
}
|
package/src/asr.ts
DELETED
|
@@ -1,83 +0,0 @@
|
|
|
1
|
-
// ASR 语音转写(Roadmap v0.2)。
|
|
2
|
-
// 通过 OpenAI 兼容的 /audio/transcriptions 端点(OpenAI / SiliconFlow / Groq 等)把
|
|
3
|
-
// 语音消息转成文本。未配置 API key 时完全禁用(保持原有"不支持转写"降级路径)。
|
|
4
|
-
|
|
5
|
-
export interface AsrConfig {
|
|
6
|
-
apiKey?: string;
|
|
7
|
-
/** OpenAI 兼容 API 根地址,默认 https://api.openai.com/v1 */
|
|
8
|
-
baseUrl?: string;
|
|
9
|
-
/** 转写模型,默认 whisper-1 */
|
|
10
|
-
model?: string;
|
|
11
|
-
}
|
|
12
|
-
|
|
13
|
-
export interface AsrVoiceInput {
|
|
14
|
-
url?: string;
|
|
15
|
-
mime?: string;
|
|
16
|
-
}
|
|
17
|
-
|
|
18
|
-
export interface AsrTranscriber {
|
|
19
|
-
readonly enabled: boolean;
|
|
20
|
-
/** 转写语音消息 → 文本;未启用 / 下载或转写失败 → null(调用方走原降级路径)。 */
|
|
21
|
-
transcribe(voice: AsrVoiceInput): Promise<string | null>;
|
|
22
|
-
}
|
|
23
|
-
|
|
24
|
-
const DEFAULT_BASE_URL = 'https://api.openai.com/v1';
|
|
25
|
-
const DEFAULT_MODEL = 'whisper-1';
|
|
26
|
-
|
|
27
|
-
/** MIME → 文件扩展名(OpenAI 转写接口要求带正确扩展名的文件名)。 */
|
|
28
|
-
export function extensionForMime(mime?: string): string {
|
|
29
|
-
if (!mime) return 'oga';
|
|
30
|
-
const map: Record<string, string> = {
|
|
31
|
-
'audio/ogg': 'ogg',
|
|
32
|
-
'audio/oga': 'oga',
|
|
33
|
-
'audio/opus': 'opus',
|
|
34
|
-
'audio/mpeg': 'mp3',
|
|
35
|
-
'audio/mp3': 'mp3',
|
|
36
|
-
'audio/mp4': 'm4a',
|
|
37
|
-
'audio/m4a': 'm4a',
|
|
38
|
-
'audio/wav': 'wav',
|
|
39
|
-
'audio/webm': 'webm',
|
|
40
|
-
'audio/aac': 'aac',
|
|
41
|
-
'audio/amr': 'amr',
|
|
42
|
-
};
|
|
43
|
-
const key = mime.split(';')[0].trim().toLowerCase();
|
|
44
|
-
return map[key] ?? 'oga';
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
export function createTranscriber(config: AsrConfig = {}): AsrTranscriber {
|
|
48
|
-
const enabled = Boolean(config.apiKey);
|
|
49
|
-
const baseUrl = (config.baseUrl ?? DEFAULT_BASE_URL).replace(/\/$/, '');
|
|
50
|
-
const model = config.model ?? DEFAULT_MODEL;
|
|
51
|
-
|
|
52
|
-
async function transcribe(voice: AsrVoiceInput): Promise<string | null> {
|
|
53
|
-
if (!enabled || !voice.url) return null;
|
|
54
|
-
try {
|
|
55
|
-
const audioRes = await fetch(voice.url);
|
|
56
|
-
if (!audioRes.ok) {
|
|
57
|
-
console.warn(`[asr] 下载音频失败: ${audioRes.status}`);
|
|
58
|
-
return null;
|
|
59
|
-
}
|
|
60
|
-
const audioBlob = await audioRes.blob();
|
|
61
|
-
const form = new FormData();
|
|
62
|
-
form.append('file', audioBlob, `voice.${extensionForMime(voice.mime)}`);
|
|
63
|
-
form.append('model', model);
|
|
64
|
-
const res = await fetch(`${baseUrl}/audio/transcriptions`, {
|
|
65
|
-
method: 'POST',
|
|
66
|
-
headers: { authorization: `Bearer ${config.apiKey}` },
|
|
67
|
-
body: form,
|
|
68
|
-
});
|
|
69
|
-
if (!res.ok) {
|
|
70
|
-
console.warn(`[asr] 转写失败 ${res.status}: ${(await res.text()).slice(0, 200)}`);
|
|
71
|
-
return null;
|
|
72
|
-
}
|
|
73
|
-
const data = (await res.json()) as { text?: string };
|
|
74
|
-
const text = data.text?.trim();
|
|
75
|
-
return text ? text : null;
|
|
76
|
-
} catch (error) {
|
|
77
|
-
console.warn(`[asr] 转写异常: ${error instanceof Error ? error.message : String(error)}`);
|
|
78
|
-
return null;
|
|
79
|
-
}
|
|
80
|
-
}
|
|
81
|
-
|
|
82
|
-
return { enabled, transcribe };
|
|
83
|
-
}
|
package/src/commands.ts
DELETED
|
@@ -1,98 +0,0 @@
|
|
|
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; timeZone?: string }
|
|
8
|
-
| { kind: 'crons' }
|
|
9
|
-
| { kind: 'cronrm'; taskId: string }
|
|
10
|
-
| { kind: 'context'; action: 'set' | 'clear' | 'show'; topic?: string }
|
|
11
|
-
| { kind: 'remember'; text: string }
|
|
12
|
-
| { kind: 'recall'; query: string }
|
|
13
|
-
| { kind: 'forget'; memoryId: string }
|
|
14
|
-
| { kind: 'remind'; text: string; inMinutes: number | null; atTime: string | null }
|
|
15
|
-
| { kind: 'send'; path: string }
|
|
16
|
-
| { kind: 'status' }
|
|
17
|
-
| { kind: 'feedadd'; url: string }
|
|
18
|
-
| { kind: 'feedlist' }
|
|
19
|
-
| { kind: 'feedrm'; feedId: string }
|
|
20
|
-
| { kind: 'digest' }
|
|
21
|
-
| { kind: 'digestdaily'; time: string };
|
|
22
|
-
|
|
23
|
-
// cron 语法:/cron <分 时 日 月 周> <需求> [--tz <IANA时区>]
|
|
24
|
-
const CRON_RE = /^\/cron\s+(\S+\s+\S+\s+\S+\s+\S+\s+\S+)\s+(.+?)(?:\s+--tz\s+(\S+))?$/;
|
|
25
|
-
// /remind in N 分钟/小时/天 <text>(也支持 min/minutes/hour/hours/day/days);或 /remind at HH:MM <text>
|
|
26
|
-
const REMIND_IN_RE = /^\/remind\s+in\s+(\d+)\s+(\S+)\s+(.+)$/i;
|
|
27
|
-
const REMIND_AT_RE = /^\/remind\s+at\s+(\d{1,2}:\d{2})\s+(.+)$/i;
|
|
28
|
-
|
|
29
|
-
export function parseCommand(text: string): ParsedCommand | null {
|
|
30
|
-
const trimmed = text.trim();
|
|
31
|
-
if (trimmed === '/trace') return { kind: 'trace' };
|
|
32
|
-
if (trimmed === '/new') return { kind: 'new' };
|
|
33
|
-
if (trimmed === '/agents') return { kind: 'agents' };
|
|
34
|
-
if (trimmed === '/help') return { kind: 'help' };
|
|
35
|
-
if (trimmed === '/crons') return { kind: 'crons' };
|
|
36
|
-
if (trimmed === '/status') return { kind: 'status' };
|
|
37
|
-
const task = trimmed.match(/^\/task\s+(.+)$/);
|
|
38
|
-
if (task) return { kind: 'task', prompt: task[1] };
|
|
39
|
-
const cron = trimmed.match(CRON_RE);
|
|
40
|
-
if (cron) return { kind: 'cron', schedule: cron[1], prompt: cron[2].trim(), timeZone: cron[3] };
|
|
41
|
-
const cronrm = trimmed.match(/^\/cronrm\s+(\S+)$/);
|
|
42
|
-
if (cronrm) return { kind: 'cronrm', taskId: cronrm[1] };
|
|
43
|
-
const context = trimmed.match(/^\/context\s+(.+)$/);
|
|
44
|
-
if (context) {
|
|
45
|
-
const topic = context[1].trim();
|
|
46
|
-
if (topic === 'off' || topic === '清除' || topic === 'clear') return { kind: 'context', action: 'clear' };
|
|
47
|
-
return { kind: 'context', action: 'set', topic };
|
|
48
|
-
}
|
|
49
|
-
if (trimmed === '/context') return { kind: 'context', action: 'show' };
|
|
50
|
-
const remember = trimmed.match(/^\/remember\s+(.+)$/);
|
|
51
|
-
if (remember) return { kind: 'remember', text: remember[1] };
|
|
52
|
-
const recall = trimmed.match(/^\/recall\s*(.*)$/);
|
|
53
|
-
if (recall) return { kind: 'recall', query: recall[1].trim() };
|
|
54
|
-
const forget = trimmed.match(/^\/forget\s+(\S+)$/);
|
|
55
|
-
if (forget) return { kind: 'forget', memoryId: forget[1] };
|
|
56
|
-
const remindIn = trimmed.match(REMIND_IN_RE);
|
|
57
|
-
if (remindIn) {
|
|
58
|
-
const unit = (remindIn[2] ?? '').toLowerCase();
|
|
59
|
-
const n = Number(remindIn[1]);
|
|
60
|
-
const minutes = /^(小|h|hour)/.test(unit) ? n * 60 : /^(天|d)/.test(unit) ? n * 1440 : n;
|
|
61
|
-
return { kind: 'remind', text: remindIn[3], inMinutes: minutes, atTime: null };
|
|
62
|
-
}
|
|
63
|
-
const remindAt = trimmed.match(REMIND_AT_RE);
|
|
64
|
-
if (remindAt) return { kind: 'remind', text: remindAt[2], inMinutes: null, atTime: remindAt[1] };
|
|
65
|
-
const send = trimmed.match(/^\/send\s+(.+)$/);
|
|
66
|
-
if (send) return { kind: 'send', path: send[1].trim() };
|
|
67
|
-
if (trimmed === '/digest') return { kind: 'digest' };
|
|
68
|
-
const digestDaily = trimmed.match(/^\/digest\s+daily\s+(\d{1,2}:\d{2})$/);
|
|
69
|
-
if (digestDaily) return { kind: 'digestdaily', time: digestDaily[1] };
|
|
70
|
-
const feedAdd = trimmed.match(/^\/feed\s+add\s+(\S+)$/i);
|
|
71
|
-
if (feedAdd) return { kind: 'feedadd', url: feedAdd[1] };
|
|
72
|
-
if (/^\/feed\s+list$/i.test(trimmed)) return { kind: 'feedlist' };
|
|
73
|
-
const feedRm = trimmed.match(/^\/feed\s+rm\s+(\S+)$/i);
|
|
74
|
-
if (feedRm) return { kind: 'feedrm', feedId: feedRm[1] };
|
|
75
|
-
return null;
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
export const HELP_TEXT = [
|
|
79
|
-
'/help — 帮助',
|
|
80
|
-
'/trace — 查看最近一轮轨迹',
|
|
81
|
-
'/task <需求> — 派子任务',
|
|
82
|
-
'/cron <分 时 日 月 周> <需求> [--tz 时区] — 定时任务',
|
|
83
|
-
'/crons — 查看定时任务列表',
|
|
84
|
-
'/cronrm <任务id> — 删除定时任务',
|
|
85
|
-
'/context <主题> — 绑定当前会话主题(off 清除)',
|
|
86
|
-
'/remind in 10 分钟 <提醒内容> — 一次性定时提醒(也支持 at HH:MM)',
|
|
87
|
-
'/remember <事实> — 记住关于我的事',
|
|
88
|
-
'/recall <关键词> — 回忆相关记忆',
|
|
89
|
-
'/forget <记忆id> — 删除一条记忆',
|
|
90
|
-
'/send <文件路径> — 把本地文件/图片发到当前聊天',
|
|
91
|
-
'/status — 查看运行状态',
|
|
92
|
-
'/digest — 立即生成今日摘要',
|
|
93
|
-
'/digest daily 09:00 — 每天定时生成摘要',
|
|
94
|
-
'/feed add <rss链接> — 订阅 RSS 推送',
|
|
95
|
-
'/feed list / rm <id> — 管理订阅',
|
|
96
|
-
'/agents — 查看子任务状态',
|
|
97
|
-
'/new — 重置会话',
|
|
98
|
-
].join('\n');
|