@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.
Files changed (51) hide show
  1. package/dist/adapters/dingtalk.d.ts +21 -0
  2. package/dist/adapters/dingtalk.js +85 -12
  3. package/dist/adapters/dingtalk.js.map +1 -1
  4. package/dist/adapters/feishu.d.ts +9 -0
  5. package/dist/adapters/feishu.js +65 -11
  6. package/dist/adapters/feishu.js.map +1 -1
  7. package/dist/adapters/telegram.d.ts +7 -1
  8. package/dist/adapters/telegram.js +17 -4
  9. package/dist/adapters/telegram.js.map +1 -1
  10. package/dist/adapters/wecom.d.ts +2 -0
  11. package/dist/adapters/wecom.js +12 -9
  12. package/dist/adapters/wecom.js.map +1 -1
  13. package/dist/adapters/whatsapp.d.ts +1 -1
  14. package/dist/adapters/whatsapp.js +9 -12
  15. package/dist/adapters/whatsapp.js.map +1 -1
  16. package/dist/asr.d.ts +19 -0
  17. package/dist/asr.js +63 -0
  18. package/dist/asr.js.map +1 -0
  19. package/dist/commands.d.ts +5 -0
  20. package/dist/commands.js +7 -0
  21. package/dist/commands.js.map +1 -1
  22. package/dist/config.d.ts +3 -0
  23. package/dist/config.js +3 -0
  24. package/dist/config.js.map +1 -1
  25. package/dist/index.d.ts +3 -0
  26. package/dist/index.js +33 -1
  27. package/dist/index.js.map +1 -1
  28. package/dist/pending-buttons.d.ts +19 -0
  29. package/dist/pending-buttons.js +40 -0
  30. package/dist/pending-buttons.js.map +1 -0
  31. package/dist/status.d.ts +3 -4
  32. package/dist/status.js +4 -5
  33. package/dist/status.js.map +1 -1
  34. package/package.json +2 -2
  35. package/src/adapters/dingtalk.ts +86 -11
  36. package/src/adapters/feishu.ts +64 -10
  37. package/src/adapters/telegram.ts +22 -5
  38. package/src/adapters/wecom.ts +11 -9
  39. package/src/adapters/whatsapp.ts +9 -12
  40. package/src/asr.ts +83 -0
  41. package/src/commands.ts +8 -1
  42. package/src/config.ts +6 -0
  43. package/src/index.ts +35 -1
  44. package/src/pending-buttons.ts +45 -0
  45. package/src/status.ts +4 -5
  46. package/test/adapters.dingtalk.test.ts +34 -1
  47. package/test/adapters.feishu.test.ts +37 -1
  48. package/test/asr.test.ts +77 -0
  49. package/test/commands.test.ts +6 -0
  50. package/test/pending-buttons.test.ts +61 -0
  51. package/web/console.html +151 -0
@@ -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 Map<string, OutboundButton[]>();
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 pending = this.pendingButtons.get(chatId);
82
- if (pending) {
83
- const button = matchNumberedButton(normalized.text, pending);
84
- if (button) {
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) this.pendingButtons.set(chatId, payload.buttons);
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);
@@ -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) media = { kind: 'voice' };
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 = msg.media?.kind === 'image' ? telegramPhotoFileId(raw.message?.photo ?? []) : undefined;
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);
@@ -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 Map<string, OutboundButton[]>();
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 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
- }
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
 
@@ -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 Map<string, OutboundButton[]>();
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.delete(chatId);
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 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
- }
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 → ../../web/console.html = packages/web/console.html
18
- * - dist 运行(node packages/gateway/dist/index.js):import.meta.url = packages/gateway/dist/status.js → ../../web/console.html = packages/web/console.html
19
- * 两种形态下 `../../web/console.html` 均解析到 packages/web/console.html(plan 中的
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('../../web/console.html', import.meta.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 { buildNumberedText, parseFeishuTextMessage } from '../src/adapters/feishu.js';
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
+ });