@dsh-overdrive/gateway 0.1.2 → 0.1.4
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/dist/adapters/dingtalk.d.ts +21 -0
- package/dist/adapters/dingtalk.js +85 -12
- package/dist/adapters/dingtalk.js.map +1 -1
- package/dist/adapters/feishu.d.ts +9 -0
- package/dist/adapters/feishu.js +65 -11
- package/dist/adapters/feishu.js.map +1 -1
- package/dist/adapters/telegram.d.ts +7 -1
- package/dist/adapters/telegram.js +17 -4
- package/dist/adapters/telegram.js.map +1 -1
- package/dist/adapters/wecom.d.ts +2 -0
- package/dist/adapters/wecom.js +12 -9
- package/dist/adapters/wecom.js.map +1 -1
- package/dist/adapters/whatsapp.d.ts +1 -1
- package/dist/adapters/whatsapp.js +9 -12
- package/dist/adapters/whatsapp.js.map +1 -1
- package/dist/asr.d.ts +19 -0
- package/dist/asr.js +63 -0
- package/dist/asr.js.map +1 -0
- package/dist/commands.d.ts +5 -0
- package/dist/commands.js +7 -0
- package/dist/commands.js.map +1 -1
- package/dist/config.d.ts +3 -0
- package/dist/config.js +3 -0
- package/dist/config.js.map +1 -1
- package/dist/index.d.ts +3 -0
- package/dist/index.js +33 -1
- package/dist/index.js.map +1 -1
- package/dist/pending-buttons.d.ts +19 -0
- package/dist/pending-buttons.js +40 -0
- package/dist/pending-buttons.js.map +1 -0
- package/dist/status.d.ts +3 -4
- package/dist/status.js +4 -5
- package/dist/status.js.map +1 -1
- package/package.json +2 -2
- package/src/adapters/dingtalk.ts +86 -11
- package/src/adapters/feishu.ts +64 -10
- package/src/adapters/telegram.ts +22 -5
- package/src/adapters/wecom.ts +11 -9
- package/src/adapters/whatsapp.ts +9 -12
- package/src/asr.ts +83 -0
- package/src/commands.ts +8 -1
- package/src/config.ts +6 -0
- package/src/index.ts +35 -1
- package/src/pending-buttons.ts +45 -0
- package/src/status.ts +4 -5
- package/test/adapters.dingtalk.test.ts +34 -1
- package/test/adapters.feishu.test.ts +37 -1
- package/test/asr.test.ts +77 -0
- package/test/commands.test.ts +6 -0
- package/test/pending-buttons.test.ts +61 -0
- package/web/console.html +151 -0
package/src/adapters/feishu.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import lark from '@larksuiteoapi/node-sdk';
|
|
2
2
|
import type { Adapter, NormalizedMessage, OutboundButton, OutboundPayload } from '../adapter.js';
|
|
3
|
+
import { PendingButtons } from '../pending-buttons.js';
|
|
3
4
|
|
|
4
5
|
// @larksuiteoapi/node-sdk 是 CommonJS 包(main=lib/index.js,无 "type":"module"):
|
|
5
6
|
// Node 原生 ESM 下必须 default 导入后解构(同 M2b 的 @slack/bolt 处理)。
|
|
@@ -46,6 +47,42 @@ export function matchNumberedButton(text: string, buttons: OutboundButton[]): Ou
|
|
|
46
47
|
return buttons[n - 1];
|
|
47
48
|
}
|
|
48
49
|
|
|
50
|
+
/** 按钮 id("approve:<reqId>" / "reject:<reqId>")→ 卡片按钮 value。 */
|
|
51
|
+
export function buttonValue(button: OutboundButton): { action: string; reqId: string } {
|
|
52
|
+
const idx = button.id.indexOf(':');
|
|
53
|
+
return {
|
|
54
|
+
action: idx >= 0 ? button.id.slice(0, idx) : button.id,
|
|
55
|
+
reqId: idx >= 0 ? button.id.slice(idx + 1) : '',
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** 交互卡片 JSON(msg_type: interactive)。原生按钮点击走 card.action.trigger 回调。 */
|
|
60
|
+
export function buildApprovalCard(text: string, buttons: OutboundButton[]): string {
|
|
61
|
+
const actions = buttons.map((b) => ({
|
|
62
|
+
tag: 'button',
|
|
63
|
+
text: { tag: 'plain_text', content: b.label },
|
|
64
|
+
type: b.id.startsWith('approve:') ? 'primary' : 'default',
|
|
65
|
+
value: buttonValue(b),
|
|
66
|
+
}));
|
|
67
|
+
const card = {
|
|
68
|
+
config: { wide_screen_mode: true },
|
|
69
|
+
header: { title: { tag: 'plain_text', content: text.slice(0, 60) }, template: 'blue' },
|
|
70
|
+
elements: [
|
|
71
|
+
{ tag: 'div', text: { tag: 'lark_md', content: text } },
|
|
72
|
+
{ tag: 'action', actions },
|
|
73
|
+
],
|
|
74
|
+
};
|
|
75
|
+
return JSON.stringify(card);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** 卡片回调 value → 按钮 id("approve:<reqId>");缺字段返回 null。 */
|
|
79
|
+
export function cardActionToButtonId(value: unknown): string | null {
|
|
80
|
+
if (!value || typeof value !== 'object') return null;
|
|
81
|
+
const { action, reqId } = value as { action?: unknown; reqId?: unknown };
|
|
82
|
+
if ((action !== 'approve' && action !== 'reject') || typeof reqId !== 'string' || !reqId) return null;
|
|
83
|
+
return `${action}:${reqId}`;
|
|
84
|
+
}
|
|
85
|
+
|
|
49
86
|
// ── 适配器 ────────────────────────────────────────────────────
|
|
50
87
|
|
|
51
88
|
export interface FeishuAdapterOptions {
|
|
@@ -60,7 +97,7 @@ export class FeishuAdapter implements Adapter {
|
|
|
60
97
|
private connected = false;
|
|
61
98
|
private messageCb?: (msg: NormalizedMessage) => void;
|
|
62
99
|
private replyCb?: (buttonId: string) => void;
|
|
63
|
-
private readonly pendingButtons = new
|
|
100
|
+
private readonly pendingButtons = new PendingButtons();
|
|
64
101
|
/** chatId → 最近一条入站消息的 message_id(send 优先 reply,缺失则 create 兜底) */
|
|
65
102
|
private readonly lastMessageIds = new Map<string, string>();
|
|
66
103
|
|
|
@@ -78,17 +115,18 @@ export class FeishuAdapter implements Adapter {
|
|
|
78
115
|
const normalized = parseFeishuTextMessage(data);
|
|
79
116
|
if (!normalized) return;
|
|
80
117
|
const chatId = normalized.chatId;
|
|
81
|
-
const
|
|
82
|
-
if (
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
this.pendingButtons.delete(chatId);
|
|
86
|
-
this.replyCb?.(button.id);
|
|
87
|
-
return;
|
|
88
|
-
}
|
|
118
|
+
const button = this.pendingButtons.match(chatId, normalized.text);
|
|
119
|
+
if (button) {
|
|
120
|
+
this.replyCb?.(button.id);
|
|
121
|
+
return;
|
|
89
122
|
}
|
|
90
123
|
this.messageCb?.(normalized);
|
|
91
124
|
},
|
|
125
|
+
// 原生交互卡片按钮回调 → 审批应答(Roadmap v0.2)
|
|
126
|
+
'card.action.trigger': async (data: { action?: { value?: unknown } }) => {
|
|
127
|
+
const buttonId = cardActionToButtonId(data?.action?.value);
|
|
128
|
+
if (buttonId) this.replyCb?.(buttonId);
|
|
129
|
+
},
|
|
92
130
|
});
|
|
93
131
|
this.ws = new WSClient({
|
|
94
132
|
appId: this.opts.appId,
|
|
@@ -101,7 +139,23 @@ export class FeishuAdapter implements Adapter {
|
|
|
101
139
|
}
|
|
102
140
|
|
|
103
141
|
async send(chatId: string, payload: OutboundPayload): Promise<void> {
|
|
104
|
-
if (payload.buttons?.length)
|
|
142
|
+
if (payload.buttons?.length) {
|
|
143
|
+
this.pendingButtons.set(chatId, payload.buttons); // 卡片之外仍支持编号回复兜底
|
|
144
|
+
const content = buildApprovalCard(payload.text, payload.buttons);
|
|
145
|
+
const messageId = this.lastMessageIds.get(chatId);
|
|
146
|
+
if (messageId) {
|
|
147
|
+
await this.client.im.message.reply({
|
|
148
|
+
path: { message_id: messageId },
|
|
149
|
+
data: { msg_type: 'interactive', content },
|
|
150
|
+
});
|
|
151
|
+
} else {
|
|
152
|
+
await this.client.im.message.create({
|
|
153
|
+
params: { receive_id_type: 'chat_id' },
|
|
154
|
+
data: { receive_id: chatId, msg_type: 'interactive', content },
|
|
155
|
+
});
|
|
156
|
+
}
|
|
157
|
+
return;
|
|
158
|
+
}
|
|
105
159
|
const text = buildNumberedText(payload.text, payload.buttons ?? []);
|
|
106
160
|
const content = JSON.stringify({ text });
|
|
107
161
|
const messageId = this.lastMessageIds.get(chatId);
|
package/src/adapters/telegram.ts
CHANGED
|
@@ -10,8 +10,8 @@ export interface RawTelegramMessage {
|
|
|
10
10
|
text?: string;
|
|
11
11
|
caption?: string;
|
|
12
12
|
photo?: Array<{ file_id?: string }>;
|
|
13
|
-
voice?: { file_id?: string };
|
|
14
|
-
audio?: { file_id?: string };
|
|
13
|
+
voice?: { file_id?: string; mime_type?: string };
|
|
14
|
+
audio?: { file_id?: string; mime_type?: string };
|
|
15
15
|
video?: { file_id?: string };
|
|
16
16
|
document?: { file_id?: string };
|
|
17
17
|
};
|
|
@@ -22,6 +22,16 @@ export function telegramPhotoFileId(photo: Array<{ file_id?: string }>): string
|
|
|
22
22
|
return photo[photo.length - 1]?.file_id;
|
|
23
23
|
}
|
|
24
24
|
|
|
25
|
+
/** 纯函数:语音消息的 file_id(voice 或 audio);无返回 undefined。 */
|
|
26
|
+
export function telegramVoiceFileId(raw: RawTelegramMessage): string | undefined {
|
|
27
|
+
return raw.message?.voice?.file_id ?? raw.message?.audio?.file_id;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** 纯函数:语音消息的 MIME(voice 优先,audio 兜底)。 */
|
|
31
|
+
export function telegramVoiceMime(raw: RawTelegramMessage): string | undefined {
|
|
32
|
+
return raw.message?.voice?.mime_type ?? raw.message?.audio?.mime_type;
|
|
33
|
+
}
|
|
34
|
+
|
|
25
35
|
/** 纯函数:Telegram 文件下载 URL 模板。file_path 需 getFile(file_id) 换取(真实调用在 adapter 薄层)。 */
|
|
26
36
|
export function telegramImageUrl(token: string, filePath: string): string {
|
|
27
37
|
return `https://api.telegram.org/file/bot${token}/${filePath}`;
|
|
@@ -34,7 +44,9 @@ export function normalizeTelegramMessage(raw: RawTelegramMessage): NormalizedMes
|
|
|
34
44
|
const text = msg.text ?? msg.caption ?? '';
|
|
35
45
|
let media: NormalizedMessage['media'];
|
|
36
46
|
if (msg.photo?.length) media = { kind: 'image' }; // url 由 adapter getFile 薄层填充
|
|
37
|
-
else if (msg.voice || msg.audio)
|
|
47
|
+
else if (msg.voice || msg.audio) {
|
|
48
|
+
media = { kind: 'voice', mime: telegramVoiceMime(raw) }; // url 由 adapter getFile 薄层填充(ASR 用)
|
|
49
|
+
}
|
|
38
50
|
else if (msg.video) media = { kind: 'video' };
|
|
39
51
|
else if (msg.document) media = { kind: 'file' };
|
|
40
52
|
if (!text && !media) return null;
|
|
@@ -80,11 +92,16 @@ export class TelegramAdapter implements Adapter {
|
|
|
80
92
|
void this.bot.start(); // 长轮询(自托管无需 webhook)
|
|
81
93
|
}
|
|
82
94
|
|
|
83
|
-
/** 薄层:photo → getFile(file_id) 换 file_path → 下载 URL 填充 msg.media.url(纯函数只做模板)。 */
|
|
95
|
+
/** 薄层:photo/voice/audio → getFile(file_id) 换 file_path → 下载 URL 填充 msg.media.url(纯函数只做模板)。 */
|
|
84
96
|
private async handleMessage(raw: RawTelegramMessage): Promise<void> {
|
|
85
97
|
const msg = normalizeTelegramMessage(raw);
|
|
86
98
|
if (!msg) return;
|
|
87
|
-
const fileId =
|
|
99
|
+
const fileId =
|
|
100
|
+
msg.media?.kind === 'image'
|
|
101
|
+
? telegramPhotoFileId(raw.message?.photo ?? [])
|
|
102
|
+
: msg.media?.kind === 'voice'
|
|
103
|
+
? telegramVoiceFileId(raw)
|
|
104
|
+
: undefined;
|
|
88
105
|
if (fileId) {
|
|
89
106
|
try {
|
|
90
107
|
const file = await this.bot.api.getFile(fileId);
|
package/src/adapters/wecom.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { createHash, createDecipheriv, createCipheriv, randomBytes } from 'node:crypto';
|
|
2
2
|
import { createServer, type IncomingMessage, type ServerResponse } from 'node:http';
|
|
3
3
|
import type { Adapter, NormalizedMessage, OutboundButton, OutboundPayload } from '../adapter.js';
|
|
4
|
+
import { PendingButtons } from '../pending-buttons.js';
|
|
4
5
|
|
|
5
6
|
// ── 纯函数:AES-256-CBC 加解密(企业微信协议)──────────────────
|
|
6
7
|
|
|
@@ -98,7 +99,9 @@ export class WeComAdapter implements Adapter {
|
|
|
98
99
|
private connected = false;
|
|
99
100
|
private messageCb?: (msg: NormalizedMessage) => void;
|
|
100
101
|
private replyCb?: (buttonId: string) => void;
|
|
101
|
-
private readonly pendingButtons = new
|
|
102
|
+
private readonly pendingButtons = new PendingButtons();
|
|
103
|
+
/** access_token 缓存:企业微信 token 有效期 7200s,且有获取频率限制,必须复用。 */
|
|
104
|
+
private tokenCache?: { token: string; expiresAt: number };
|
|
102
105
|
|
|
103
106
|
constructor(private readonly opts: WeComAdapterOptions) {}
|
|
104
107
|
|
|
@@ -138,14 +141,10 @@ export class WeComAdapter implements Adapter {
|
|
|
138
141
|
res.writeHead(200, { 'content-type': 'text/plain' });
|
|
139
142
|
res.end('success'); // 先应答,避免企业微信重试
|
|
140
143
|
if (!normalized) return;
|
|
141
|
-
const
|
|
142
|
-
if (
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
this.pendingButtons.delete(normalized.chatId);
|
|
146
|
-
this.replyCb?.(button.id);
|
|
147
|
-
return;
|
|
148
|
-
}
|
|
144
|
+
const button = this.pendingButtons.match(normalized.chatId, normalized.text);
|
|
145
|
+
if (button) {
|
|
146
|
+
this.replyCb?.(button.id);
|
|
147
|
+
return;
|
|
149
148
|
}
|
|
150
149
|
this.messageCb?.(normalized);
|
|
151
150
|
}
|
|
@@ -169,9 +168,12 @@ export class WeComAdapter implements Adapter {
|
|
|
169
168
|
}
|
|
170
169
|
|
|
171
170
|
private async fetchAccessToken(): Promise<string> {
|
|
171
|
+
if (this.tokenCache && this.tokenCache.expiresAt > Date.now()) return this.tokenCache.token;
|
|
172
172
|
const url = `https://qyapi.weixin.qq.com/cgi-bin/gettoken?corpid=${encodeURIComponent(this.opts.corpId)}&corpsecret=${encodeURIComponent(this.opts.secret)}`;
|
|
173
173
|
const data = (await fetch(url).then((r) => r.json())) as { access_token?: string; errcode?: number };
|
|
174
174
|
if (!data.access_token) throw new Error(`企业微信 token 获取失败: ${data.errcode}`);
|
|
175
|
+
// 官方有效期 7200s;留 200s 余量,避免临界过期
|
|
176
|
+
this.tokenCache = { token: data.access_token, expiresAt: Date.now() + 7_000_000 };
|
|
175
177
|
return data.access_token;
|
|
176
178
|
}
|
|
177
179
|
|
package/src/adapters/whatsapp.ts
CHANGED
|
@@ -8,6 +8,7 @@ import makeWASocket, {
|
|
|
8
8
|
import * as qrcode from 'qrcode-terminal';
|
|
9
9
|
import pino from 'pino';
|
|
10
10
|
import type { Adapter, NormalizedMessage, OutboundButton, OutboundPayload } from '../adapter.js';
|
|
11
|
+
import { PendingButtons } from '../pending-buttons.js';
|
|
11
12
|
|
|
12
13
|
// ── 纯函数(可单测)────────────────────────────────────────────
|
|
13
14
|
|
|
@@ -129,8 +130,8 @@ export class WhatsAppAdapter implements Adapter {
|
|
|
129
130
|
private connected = false;
|
|
130
131
|
private messageCb?: (msg: NormalizedMessage) => void;
|
|
131
132
|
private replyCb?: (buttonId: string) => void;
|
|
132
|
-
/** chatId → 当前 pending 按钮(编号回复 → 按钮 id
|
|
133
|
-
private readonly pendingButtons = new
|
|
133
|
+
/** chatId → 当前 pending 按钮(编号回复 → 按钮 id,带 TTL 防过期误吞) */
|
|
134
|
+
private readonly pendingButtons = new PendingButtons();
|
|
134
135
|
|
|
135
136
|
constructor(private readonly opts: WhatsAppAdapterOptions) {}
|
|
136
137
|
|
|
@@ -173,21 +174,17 @@ export class WhatsAppAdapter implements Adapter {
|
|
|
173
174
|
const buttonId = parseNativeButtonResponse(waRaw);
|
|
174
175
|
if (buttonId) {
|
|
175
176
|
const chatId = waRaw.key?.remoteJid;
|
|
176
|
-
if (chatId) this.pendingButtons.
|
|
177
|
+
if (chatId) this.pendingButtons.consume(chatId);
|
|
177
178
|
this.replyCb?.(buttonId);
|
|
178
179
|
continue;
|
|
179
180
|
}
|
|
180
181
|
const normalized = normalizeWhatsAppMessage(waRaw);
|
|
181
182
|
if (!normalized) continue;
|
|
182
|
-
// 编号回复兜底:若该 chat 有 pending
|
|
183
|
-
const
|
|
184
|
-
if (
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
this.pendingButtons.delete(normalized.msg.chatId);
|
|
188
|
-
this.replyCb?.(button.id);
|
|
189
|
-
continue;
|
|
190
|
-
}
|
|
183
|
+
// 编号回复兜底:若该 chat 有 pending 按钮且消息是数字(TTL 内),转成按钮点击
|
|
184
|
+
const button = this.pendingButtons.match(normalized.msg.chatId, normalized.msg.text);
|
|
185
|
+
if (button) {
|
|
186
|
+
this.replyCb?.(button.id);
|
|
187
|
+
continue;
|
|
191
188
|
}
|
|
192
189
|
this.messageCb?.(normalized.msg);
|
|
193
190
|
}
|
package/src/asr.ts
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
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
CHANGED
|
@@ -4,7 +4,9 @@ export type ParsedCommand =
|
|
|
4
4
|
| { kind: 'agents' }
|
|
5
5
|
| { kind: 'help' }
|
|
6
6
|
| { kind: 'task'; prompt: string }
|
|
7
|
-
| { kind: 'cron'; schedule: string; prompt: string }
|
|
7
|
+
| { kind: 'cron'; schedule: string; prompt: string }
|
|
8
|
+
| { kind: 'crons' }
|
|
9
|
+
| { kind: 'cronrm'; taskId: string };
|
|
8
10
|
|
|
9
11
|
// cron 语法:/cron <分 时 日 月 周> <需求>(schedule 为 5 个空白分隔字段)
|
|
10
12
|
const CRON_RE = /^\/cron\s+(\S+\s+\S+\s+\S+\s+\S+\s+\S+)\s+(.+)$/;
|
|
@@ -15,10 +17,13 @@ export function parseCommand(text: string): ParsedCommand | null {
|
|
|
15
17
|
if (trimmed === '/new') return { kind: 'new' };
|
|
16
18
|
if (trimmed === '/agents') return { kind: 'agents' };
|
|
17
19
|
if (trimmed === '/help') return { kind: 'help' };
|
|
20
|
+
if (trimmed === '/crons') return { kind: 'crons' };
|
|
18
21
|
const task = trimmed.match(/^\/task\s+(.+)$/);
|
|
19
22
|
if (task) return { kind: 'task', prompt: task[1] };
|
|
20
23
|
const cron = trimmed.match(CRON_RE);
|
|
21
24
|
if (cron) return { kind: 'cron', schedule: cron[1], prompt: cron[2] };
|
|
25
|
+
const cronrm = trimmed.match(/^\/cronrm\s+(\S+)$/);
|
|
26
|
+
if (cronrm) return { kind: 'cronrm', taskId: cronrm[1] };
|
|
22
27
|
return null;
|
|
23
28
|
}
|
|
24
29
|
|
|
@@ -27,6 +32,8 @@ export const HELP_TEXT = [
|
|
|
27
32
|
'/trace — 查看最近一轮轨迹',
|
|
28
33
|
'/task <需求> — 派子任务',
|
|
29
34
|
'/cron <分 时 日 月 周> <需求> — 定时任务',
|
|
35
|
+
'/crons — 查看定时任务列表',
|
|
36
|
+
'/cronrm <任务id> — 删除定时任务',
|
|
30
37
|
'/agents — 查看子任务状态',
|
|
31
38
|
'/new — 重置会话',
|
|
32
39
|
].join('\n');
|
package/src/config.ts
CHANGED
|
@@ -25,6 +25,9 @@ export interface AdapterEnv {
|
|
|
25
25
|
wecomToken?: string;
|
|
26
26
|
wecomEncodingAESKey?: string;
|
|
27
27
|
wecomCallbackPort?: string;
|
|
28
|
+
asrApiKey?: string;
|
|
29
|
+
asrBaseUrl?: string;
|
|
30
|
+
asrModel?: string;
|
|
28
31
|
}
|
|
29
32
|
|
|
30
33
|
export function parseAdapterIds(raw: string): string[] {
|
|
@@ -86,5 +89,8 @@ export function adapterEnvFromProcess(env: NodeJS.ProcessEnv = process.env): Ada
|
|
|
86
89
|
wecomToken: env.WECOM_TOKEN,
|
|
87
90
|
wecomEncodingAESKey: env.WECOM_ENCODING_AES_KEY,
|
|
88
91
|
wecomCallbackPort: env.WECOM_CALLBACK_PORT,
|
|
92
|
+
asrApiKey: env.ASR_API_KEY,
|
|
93
|
+
asrBaseUrl: env.ASR_BASE_URL,
|
|
94
|
+
asrModel: env.ASR_MODEL,
|
|
89
95
|
};
|
|
90
96
|
}
|
package/src/index.ts
CHANGED
|
@@ -6,6 +6,7 @@ import { CliAdapter } from './adapters/cli.js';
|
|
|
6
6
|
import { parseCommand, HELP_TEXT, type ParsedCommand } from './commands.js';
|
|
7
7
|
import { TrajectoryAggregator, formatTrajectorySummary } from './trajectory.js';
|
|
8
8
|
import { createStatusServer } from './status.js';
|
|
9
|
+
import { createTranscriber, type AsrTranscriber } from './asr.js';
|
|
9
10
|
|
|
10
11
|
/**
|
|
11
12
|
* message.delta → 打字指示去重:同一 turn 内首个 delta 触发一次 typing,
|
|
@@ -64,6 +65,8 @@ export function planOutbound(ev: ServerEvent): { payload: OutboundPayload } | nu
|
|
|
64
65
|
|
|
65
66
|
export interface WireOptions {
|
|
66
67
|
allowlist: string[];
|
|
68
|
+
/** ASR 转写器;配置了 API key 时启用,语音消息转成文本再发给 agent。 */
|
|
69
|
+
asr?: AsrTranscriber;
|
|
67
70
|
}
|
|
68
71
|
|
|
69
72
|
/** 命令面分发:/trace /new /task /cron /agents /help(M4)。 */
|
|
@@ -96,6 +99,21 @@ async function handleCommand(
|
|
|
96
99
|
await adapter.send(chatId, { text: '⏰ 定时任务已注册' });
|
|
97
100
|
return;
|
|
98
101
|
}
|
|
102
|
+
case 'crons': {
|
|
103
|
+
const res = await client.listTasks();
|
|
104
|
+
const text = res.tasks.length
|
|
105
|
+
? res.tasks.map((task) => `- \`${task.id}\` ${task.schedule} — ${task.prompt}`).join('\n')
|
|
106
|
+
: '暂无定时任务。';
|
|
107
|
+
await adapter.send(chatId, { text: `⏰ 定时任务(${res.tasks.length}):\n${text}` });
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
110
|
+
case 'cronrm': {
|
|
111
|
+
const res = await client.removeTask(command.taskId);
|
|
112
|
+
await adapter.send(chatId, {
|
|
113
|
+
text: res.ok ? `🗑️ 已删除定时任务 \`${command.taskId}\`` : `未找到定时任务 \`${command.taskId}\``,
|
|
114
|
+
});
|
|
115
|
+
return;
|
|
116
|
+
}
|
|
99
117
|
case 'agents': {
|
|
100
118
|
await adapter.send(chatId, { text: '(M4 简化)子任务状态由 agent 汇报,/task 派发' });
|
|
101
119
|
return;
|
|
@@ -135,6 +153,16 @@ export async function wireAdapter(
|
|
|
135
153
|
return;
|
|
136
154
|
}
|
|
137
155
|
|
|
156
|
+
// ASR 语音转写:配置了 API key 时把语音消息转成文本;失败/未配置走原降级路径
|
|
157
|
+
if (msg.media?.kind === 'voice' && opts.asr?.enabled) {
|
|
158
|
+
const transcript = await opts.asr.transcribe(msg.media);
|
|
159
|
+
if (transcript) {
|
|
160
|
+
msg.text = msg.text ? `${msg.text}\n[语音转写] ${transcript}` : `[语音转写] ${transcript}`;
|
|
161
|
+
msg.media = undefined;
|
|
162
|
+
console.log(`[gateway][${adapter.id}] 语音转写: ${transcript.slice(0, 60)}`);
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
138
166
|
await client.upsertSession({ platform: adapter.id, channel: msg.chatId, user: msg.userId });
|
|
139
167
|
console.log(`[gateway][${adapter.id}] upsertSession OK -> ${key}`);
|
|
140
168
|
await client.sendMessage(key, { text: msg.text, media: msg.media });
|
|
@@ -195,6 +223,12 @@ async function main(): Promise<void> {
|
|
|
195
223
|
.split(',').map((s) => s.trim()).filter(Boolean);
|
|
196
224
|
const adapterIds = parseAdapterIds(process.env.GATEWAY_ADAPTERS ?? 'cli');
|
|
197
225
|
const env = adapterEnvFromProcess();
|
|
226
|
+
const asr = createTranscriber({
|
|
227
|
+
apiKey: env.asrApiKey,
|
|
228
|
+
baseUrl: env.asrBaseUrl,
|
|
229
|
+
model: env.asrModel,
|
|
230
|
+
});
|
|
231
|
+
if (asr.enabled) console.log('[gateway] ASR 语音转写已启用');
|
|
198
232
|
|
|
199
233
|
const client = new GatewayClient(dshBaseUrl, dshToken);
|
|
200
234
|
await client.health(); // 确认 DSH 侧(或 mock)活着
|
|
@@ -202,7 +236,7 @@ async function main(): Promise<void> {
|
|
|
202
236
|
const adapters: Adapter[] = adapterIds.map((id) => createAdapter(id, env));
|
|
203
237
|
for (const adapter of adapters) {
|
|
204
238
|
await adapter.connect();
|
|
205
|
-
await wireAdapter(adapter, client, { allowlist });
|
|
239
|
+
await wireAdapter(adapter, client, { allowlist, asr });
|
|
206
240
|
console.log(`[gateway] ${adapter.id} 适配器已就绪`);
|
|
207
241
|
}
|
|
208
242
|
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import type { OutboundButton } from './adapter.js';
|
|
2
|
+
|
|
3
|
+
export const PENDING_BUTTONS_TTL_MS = 5 * 60_000;
|
|
4
|
+
|
|
5
|
+
interface PendingButtonEntry {
|
|
6
|
+
buttons: OutboundButton[];
|
|
7
|
+
expiresAt: number;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* 编号回复兜底的按钮暂存(带 TTL)。
|
|
12
|
+
*
|
|
13
|
+
* 审批/危险操作按钮发出后,用户在聊天里回复数字("1"/"2"…)选择;
|
|
14
|
+
* 若按钮长期不消费,后续的普通数字消息会被误判成按钮回复。
|
|
15
|
+
* 本类在 TTL(默认 5 分钟)后自动失效,杜绝"过期按钮吞消息"。
|
|
16
|
+
*/
|
|
17
|
+
export class PendingButtons {
|
|
18
|
+
private readonly map = new Map<string, PendingButtonEntry>();
|
|
19
|
+
|
|
20
|
+
constructor(private readonly ttlMs: number = PENDING_BUTTONS_TTL_MS) {}
|
|
21
|
+
|
|
22
|
+
set(chatId: string, buttons: OutboundButton[]): void {
|
|
23
|
+
this.map.set(chatId, { buttons, expiresAt: Date.now() + this.ttlMs });
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** 数字回复命中:返回匹配按钮并消费(删除);无 pending / 已过期 / 非数字或越界返回 undefined。 */
|
|
27
|
+
match(chatId: string, text: string): OutboundButton | undefined {
|
|
28
|
+
const entry = this.map.get(chatId);
|
|
29
|
+
if (!entry) return undefined;
|
|
30
|
+
if (entry.expiresAt < Date.now()) {
|
|
31
|
+
this.map.delete(chatId);
|
|
32
|
+
return undefined;
|
|
33
|
+
}
|
|
34
|
+
const n = Number(text.trim());
|
|
35
|
+
if (!Number.isInteger(n) || n < 1 || n > entry.buttons.length) return undefined;
|
|
36
|
+
const button = entry.buttons[n - 1];
|
|
37
|
+
if (button) this.map.delete(chatId);
|
|
38
|
+
return button;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** 消费原生按钮点击(如 WhatsApp 原生交互按钮):删除该 chat 的 pending。 */
|
|
42
|
+
consume(chatId: string): void {
|
|
43
|
+
this.map.delete(chatId);
|
|
44
|
+
}
|
|
45
|
+
}
|
package/src/status.ts
CHANGED
|
@@ -14,10 +14,9 @@ export interface StatusServerOptions {
|
|
|
14
14
|
* 健康控制台:GET / 与 /console 返回静态页,GET /api/status 返回 DSH 健康 + 适配器状态。
|
|
15
15
|
*
|
|
16
16
|
* console.html 读取路径说明(与 dist 产物核对过):
|
|
17
|
-
* - src 运行(vitest):import.meta.url = packages/gateway/src/status.ts →
|
|
18
|
-
* - dist 运行(node packages/gateway/dist/index.js):import.meta.url = packages/gateway/dist/status.js →
|
|
19
|
-
*
|
|
20
|
-
* ../../../web/console.html 会多上一级到仓库根目录,不正确,已修正)。
|
|
17
|
+
* - src 运行(vitest):import.meta.url = packages/gateway/src/status.ts → ../web/console.html = packages/gateway/web/console.html
|
|
18
|
+
* - dist 运行(node packages/gateway/dist/index.js):import.meta.url = packages/gateway/dist/status.js → ../web/console.html = packages/gateway/web/console.html
|
|
19
|
+
* - npm 安装(@dsh-overdrive/gateway):console.html 随包分发,两种形态均可命中。
|
|
21
20
|
*/
|
|
22
21
|
export function createStatusServer(opts: StatusServerOptions): {
|
|
23
22
|
server: Server;
|
|
@@ -43,7 +42,7 @@ export function createStatusServer(opts: StatusServerOptions): {
|
|
|
43
42
|
}
|
|
44
43
|
if (url.pathname === '/' || url.pathname === '/console') {
|
|
45
44
|
const html = await readFile(
|
|
46
|
-
fileURLToPath(new URL('
|
|
45
|
+
fileURLToPath(new URL('../web/console.html', import.meta.url)),
|
|
47
46
|
'utf8',
|
|
48
47
|
).catch(() => '<h1>console.html not found</h1>');
|
|
49
48
|
res.writeHead(200, { 'content-type': 'text/html; charset=utf-8' });
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { describe, expect, it } from 'vitest';
|
|
2
|
-
import { buildReplyBody, parseBotMessage } from '../src/adapters/dingtalk.js';
|
|
2
|
+
import { buildActionCard, buildReplyBody, buttonCallbackData, parseBotMessage, parseCardCallback } from '../src/adapters/dingtalk.js';
|
|
3
3
|
|
|
4
4
|
describe('parseBotMessage(RobotMessage → NormalizedMessage)', () => {
|
|
5
5
|
it('文本消息', () => {
|
|
@@ -23,3 +23,36 @@ describe('buildReplyBody(sessionWebhook 回发载荷)', () => {
|
|
|
23
23
|
expect(buildReplyBody('hi')).toEqual({ msgtype: 'text', text: { content: 'hi' } });
|
|
24
24
|
});
|
|
25
25
|
});
|
|
26
|
+
|
|
27
|
+
describe('buildActionCard(钉钉 actionCard)', () => {
|
|
28
|
+
it('生成带 cardCallbackData 的按钮', () => {
|
|
29
|
+
const card = buildActionCard('需要批准', [
|
|
30
|
+
{ id: 'approve:r1', label: '✅ 同意' },
|
|
31
|
+
{ id: 'reject:r1', label: '🚫 拒绝' },
|
|
32
|
+
]);
|
|
33
|
+
expect(card.msgtype).toBe('actionCard');
|
|
34
|
+
expect(card.actionCard.btns).toHaveLength(2);
|
|
35
|
+
expect(card.actionCard.btns[0].actionURL).toContain('cardCallbackData=');
|
|
36
|
+
expect(decodeURIComponent(card.actionCard.btns[0].actionURL.split('cardCallbackData=')[1])).toBe('{"action":"approve","reqId":"r1"}');
|
|
37
|
+
});
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
describe('parseCardCallback(TOPIC_CARD 回调载荷 → 按钮 id)', () => {
|
|
41
|
+
it('识别 cardCallbackData 字段', () => {
|
|
42
|
+
expect(parseCardCallback({ cardPrivateData: { cardCallbackData: '{"action":"approve","reqId":"r1"}' } })).toBe('approve:r1');
|
|
43
|
+
});
|
|
44
|
+
it('识别 params / cardActionData 字段与嵌套结构', () => {
|
|
45
|
+
expect(parseCardCallback({ cardPrivateData: { params: '{"action":"reject","reqId":"r9"}' } })).toBe('reject:r9');
|
|
46
|
+
expect(parseCardCallback({ a: { b: { cardActionData: '{"action":"approve","reqId":"x"}' } } })).toBe('approve:x');
|
|
47
|
+
});
|
|
48
|
+
it('非法载荷返回 null', () => {
|
|
49
|
+
expect(parseCardCallback(null)).toBeNull();
|
|
50
|
+
expect(parseCardCallback({ cardPrivateData: { cardCallbackData: 'not-json' } })).toBeNull();
|
|
51
|
+
expect(parseCardCallback({ cardPrivateData: { cardCallbackData: '{"action":"other","reqId":"r1"}' } })).toBeNull();
|
|
52
|
+
expect(parseCardCallback({ cardPrivateData: { cardCallbackData: '{"action":"approve"}' } })).toBeNull();
|
|
53
|
+
});
|
|
54
|
+
it('buttonCallbackData 与 parseCardCallback 往返一致', () => {
|
|
55
|
+
const data = buttonCallbackData({ id: 'reject:r7', label: 'x' });
|
|
56
|
+
expect(parseCardCallback({ cardPrivateData: { cardCallbackData: data } })).toBe('reject:r7');
|
|
57
|
+
});
|
|
58
|
+
});
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import { describe, expect, it } from 'vitest';
|
|
2
|
-
import {
|
|
2
|
+
import {
|
|
3
|
+
buildApprovalCard, buildNumberedText, cardActionToButtonId, parseFeishuTextMessage,
|
|
4
|
+
} from '../src/adapters/feishu.js';
|
|
3
5
|
|
|
4
6
|
describe('parseFeishuTextMessage(im.message.receive_v1 载荷 → NormalizedMessage)', () => {
|
|
5
7
|
it('文本私聊消息', () => {
|
|
@@ -28,3 +30,37 @@ describe('buildNumberedText(审批编号回复)', () => {
|
|
|
28
30
|
expect(text).toContain('2) 🚫 拒绝');
|
|
29
31
|
});
|
|
30
32
|
});
|
|
33
|
+
|
|
34
|
+
describe('buildApprovalCard(飞书原生交互卡片)', () => {
|
|
35
|
+
it('生成 interactive 卡片 JSON:header + 文本 + action 按钮', () => {
|
|
36
|
+
const content = buildApprovalCard('需要批准:执行危险操作', [
|
|
37
|
+
{ id: 'approve:r1', label: '✅ 同意' },
|
|
38
|
+
{ id: 'reject:r1', label: '🚫 拒绝' },
|
|
39
|
+
]);
|
|
40
|
+
const card = JSON.parse(content);
|
|
41
|
+
expect(card.config.wide_screen_mode).toBe(true);
|
|
42
|
+
expect(card.header.title.content).toContain('需要批准');
|
|
43
|
+
const actions = card.elements.find((e: { tag: string }) => e.tag === 'action').actions;
|
|
44
|
+
expect(actions).toHaveLength(2);
|
|
45
|
+
expect(actions[0]).toMatchObject({
|
|
46
|
+
tag: 'button',
|
|
47
|
+
type: 'primary', // approve 主按钮
|
|
48
|
+
value: { action: 'approve', reqId: 'r1' },
|
|
49
|
+
});
|
|
50
|
+
expect(actions[1].value).toEqual({ action: 'reject', reqId: 'r1' });
|
|
51
|
+
});
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
describe('cardActionToButtonId(卡片回调 → 按钮 id)', () => {
|
|
55
|
+
it('approve/reject 值还原为按钮 id', () => {
|
|
56
|
+
expect(cardActionToButtonId({ action: 'approve', reqId: 'r1' })).toBe('approve:r1');
|
|
57
|
+
expect(cardActionToButtonId({ action: 'reject', reqId: 'r9' })).toBe('reject:r9');
|
|
58
|
+
});
|
|
59
|
+
it('非法值返回 null', () => {
|
|
60
|
+
expect(cardActionToButtonId(null)).toBeNull();
|
|
61
|
+
expect(cardActionToButtonId({})).toBeNull();
|
|
62
|
+
expect(cardActionToButtonId({ action: 'other', reqId: 'r1' })).toBeNull();
|
|
63
|
+
expect(cardActionToButtonId({ action: 'approve' })).toBeNull();
|
|
64
|
+
expect(cardActionToButtonId('str')).toBeNull();
|
|
65
|
+
});
|
|
66
|
+
});
|