@dsh-overdrive/gateway 0.1.3 → 0.1.5

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 (67) hide show
  1. package/dist/adapter.d.ts +7 -1
  2. package/dist/adapters/cli.d.ts +4 -1
  3. package/dist/adapters/cli.js +1 -1
  4. package/dist/adapters/cli.js.map +1 -1
  5. package/dist/adapters/dingtalk.d.ts +30 -1
  6. package/dist/adapters/dingtalk.js +97 -12
  7. package/dist/adapters/dingtalk.js.map +1 -1
  8. package/dist/adapters/discord.d.ts +4 -1
  9. package/dist/adapters/discord.js +4 -1
  10. package/dist/adapters/discord.js.map +1 -1
  11. package/dist/adapters/feishu.d.ts +13 -1
  12. package/dist/adapters/feishu.js +71 -11
  13. package/dist/adapters/feishu.js.map +1 -1
  14. package/dist/adapters/slack.d.ts +4 -1
  15. package/dist/adapters/slack.js +8 -3
  16. package/dist/adapters/slack.js.map +1 -1
  17. package/dist/adapters/telegram.d.ts +11 -2
  18. package/dist/adapters/telegram.js +22 -5
  19. package/dist/adapters/telegram.js.map +1 -1
  20. package/dist/adapters/wecom.d.ts +6 -1
  21. package/dist/adapters/wecom.js +12 -9
  22. package/dist/adapters/wecom.js.map +1 -1
  23. package/dist/adapters/whatsapp.d.ts +5 -2
  24. package/dist/adapters/whatsapp.js +16 -13
  25. package/dist/adapters/whatsapp.js.map +1 -1
  26. package/dist/asr.d.ts +19 -0
  27. package/dist/asr.js +63 -0
  28. package/dist/asr.js.map +1 -0
  29. package/dist/commands.d.ts +5 -0
  30. package/dist/commands.js +7 -0
  31. package/dist/commands.js.map +1 -1
  32. package/dist/config.d.ts +3 -0
  33. package/dist/config.js +3 -0
  34. package/dist/config.js.map +1 -1
  35. package/dist/index.d.ts +5 -0
  36. package/dist/index.js +45 -3
  37. package/dist/index.js.map +1 -1
  38. package/dist/pending-buttons.d.ts +19 -0
  39. package/dist/pending-buttons.js +40 -0
  40. package/dist/pending-buttons.js.map +1 -0
  41. package/dist/session.d.ts +6 -2
  42. package/dist/session.js +8 -3
  43. package/dist/session.js.map +1 -1
  44. package/package.json +3 -3
  45. package/src/adapter.ts +8 -1
  46. package/src/adapters/cli.ts +3 -3
  47. package/src/adapters/dingtalk.ts +108 -13
  48. package/src/adapters/discord.ts +6 -3
  49. package/src/adapters/feishu.ts +77 -12
  50. package/src/adapters/slack.ts +14 -4
  51. package/src/adapters/telegram.ts +29 -8
  52. package/src/adapters/wecom.ts +13 -11
  53. package/src/adapters/whatsapp.ts +18 -15
  54. package/src/asr.ts +83 -0
  55. package/src/commands.ts +8 -1
  56. package/src/config.ts +6 -0
  57. package/src/index.ts +49 -3
  58. package/src/pending-buttons.ts +45 -0
  59. package/src/session.ts +9 -3
  60. package/test/adapters.dingtalk.test.ts +40 -1
  61. package/test/adapters.feishu.test.ts +37 -1
  62. package/test/asr.test.ts +77 -0
  63. package/test/commands.test.ts +6 -0
  64. package/test/multi.test.ts +29 -8
  65. package/test/pending-buttons.test.ts +61 -0
  66. package/test/session.test.ts +7 -2
  67. package/test/streaming.test.ts +162 -162
@@ -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
 
@@ -128,9 +129,9 @@ export class WhatsAppAdapter implements Adapter {
128
129
  private sock?: WASocket;
129
130
  private connected = false;
130
131
  private messageCb?: (msg: NormalizedMessage) => void;
131
- private replyCb?: (buttonId: string) => void;
132
- /** chatId → 当前 pending 按钮(编号回复 → 按钮 id */
133
- private readonly pendingButtons = new Map<string, OutboundButton[]>();
132
+ private replyCb?: (buttonId: string, sender: { chatId: string; userId: string }) => void;
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,23 @@ 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
- this.replyCb?.(buttonId);
177
+ if (chatId) this.pendingButtons.consume(chatId);
178
+ this.replyCb?.(buttonId, {
179
+ chatId: chatId ?? '',
180
+ userId: waRaw.key?.participant ?? chatId ?? '',
181
+ });
178
182
  continue;
179
183
  }
180
184
  const normalized = normalizeWhatsAppMessage(waRaw);
181
185
  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
- }
186
+ // 编号回复兜底:若该 chat 有 pending 按钮且消息是数字(TTL 内),转成按钮点击
187
+ const button = this.pendingButtons.match(normalized.msg.chatId, normalized.msg.text);
188
+ if (button) {
189
+ this.replyCb?.(button.id, {
190
+ chatId: normalized.msg.chatId,
191
+ userId: normalized.msg.userId,
192
+ });
193
+ continue;
191
194
  }
192
195
  this.messageCb?.(normalized.msg);
193
196
  }
@@ -226,6 +229,6 @@ export class WhatsAppAdapter implements Adapter {
226
229
  }
227
230
 
228
231
  onMessage(cb: (msg: NormalizedMessage) => void): void { this.messageCb = cb; }
229
- onReply(cb: (buttonId: string) => void): void { this.replyCb = cb; }
232
+ onReply(cb: (buttonId: string, sender: { chatId: string; userId: string }) => void): void { this.replyCb = cb; }
230
233
  status(): { connected: boolean } { return { connected: this.connected }; }
231
234
  }
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,10 @@ export function planOutbound(ev: ServerEvent): { payload: OutboundPayload } | nu
64
65
 
65
66
  export interface WireOptions {
66
67
  allowlist: string[];
68
+ /** 开发逃生口:ALLOW_ALL=1 跳过白名单(生产勿开)。 */
69
+ allowAll?: boolean;
70
+ /** ASR 转写器;配置了 API key 时启用,语音消息转成文本再发给 agent。 */
71
+ asr?: AsrTranscriber;
67
72
  }
68
73
 
69
74
  /** 命令面分发:/trace /new /task /cron /agents /help(M4)。 */
@@ -96,6 +101,21 @@ async function handleCommand(
96
101
  await adapter.send(chatId, { text: '⏰ 定时任务已注册' });
97
102
  return;
98
103
  }
104
+ case 'crons': {
105
+ const res = await client.listTasks();
106
+ const text = res.tasks.length
107
+ ? res.tasks.map((task) => `- \`${task.id}\` ${task.schedule} — ${task.prompt}`).join('\n')
108
+ : '暂无定时任务。';
109
+ await adapter.send(chatId, { text: `⏰ 定时任务(${res.tasks.length}):\n${text}` });
110
+ return;
111
+ }
112
+ case 'cronrm': {
113
+ const res = await client.removeTask(command.taskId);
114
+ await adapter.send(chatId, {
115
+ text: res.ok ? `🗑️ 已删除定时任务 \`${command.taskId}\`` : `未找到定时任务 \`${command.taskId}\``,
116
+ });
117
+ return;
118
+ }
99
119
  case 'agents': {
100
120
  await adapter.send(chatId, { text: '(M4 简化)子任务状态由 agent 汇报,/task 派发' });
101
121
  return;
@@ -113,7 +133,7 @@ export async function wireAdapter(
113
133
  client: GatewayClient,
114
134
  opts: WireOptions,
115
135
  ): Promise<void> {
116
- const allow = new Allowlist(opts.allowlist);
136
+ const allow = new Allowlist(opts.allowlist, opts.allowAll);
117
137
  const chatIds = new Map<string, string>();
118
138
  const aggregator = new TrajectoryAggregator();
119
139
  const deltas = new DeltaTracker();
@@ -135,6 +155,16 @@ export async function wireAdapter(
135
155
  return;
136
156
  }
137
157
 
158
+ // ASR 语音转写:配置了 API key 时把语音消息转成文本;失败/未配置走原降级路径
159
+ if (msg.media?.kind === 'voice' && opts.asr?.enabled) {
160
+ const transcript = await opts.asr.transcribe(msg.media);
161
+ if (transcript) {
162
+ msg.text = msg.text ? `${msg.text}\n[语音转写] ${transcript}` : `[语音转写] ${transcript}`;
163
+ msg.media = undefined;
164
+ console.log(`[gateway][${adapter.id}] 语音转写: ${transcript.slice(0, 60)}`);
165
+ }
166
+ }
167
+
138
168
  await client.upsertSession({ platform: adapter.id, channel: msg.chatId, user: msg.userId });
139
169
  console.log(`[gateway][${adapter.id}] upsertSession OK -> ${key}`);
140
170
  await client.sendMessage(key, { text: msg.text, media: msg.media });
@@ -146,13 +176,22 @@ export async function wireAdapter(
146
176
  }
147
177
  });
148
178
 
149
- adapter.onReply(async (buttonId) => {
179
+ adapter.onReply(async (buttonId, sender) => {
150
180
  try {
151
181
  const idx = buttonId.indexOf(':');
152
182
  if (idx < 0) return;
153
183
  const action = buttonId.slice(0, idx) as 'approve' | 'reject';
154
184
  const reqId = buttonId.slice(idx + 1);
155
185
  if ((action === 'approve' || action === 'reject') && reqId) {
186
+ // 安全边界:审批按钮必须校验点击者(维护者评审指出:未授权用户可代授权)
187
+ const senderKey = buildSessionKey(adapter.id, { chatId: sender.chatId, userId: sender.userId });
188
+ if (!allow.allows(senderKey)) {
189
+ console.warn(`[gateway][${adapter.id}] 按钮点击者不在白名单,拒绝批准: ${senderKey}`);
190
+ if (sender.chatId) {
191
+ await adapter.send(sender.chatId, { text: '⛔ 你不在白名单里,不能批准该操作。' }).catch(() => undefined);
192
+ }
193
+ return;
194
+ }
156
195
  await client.resolveApproval(reqId, action);
157
196
  }
158
197
  } catch (error) {
@@ -193,8 +232,15 @@ async function main(): Promise<void> {
193
232
  const dshToken = process.env.DSH_TOKEN ?? 'dev-token';
194
233
  const allowlist = (process.env.ALLOWLIST ?? '')
195
234
  .split(',').map((s) => s.trim()).filter(Boolean);
235
+ const allowAll = process.env.ALLOW_ALL === '1';
196
236
  const adapterIds = parseAdapterIds(process.env.GATEWAY_ADAPTERS ?? 'cli');
197
237
  const env = adapterEnvFromProcess();
238
+ const asr = createTranscriber({
239
+ apiKey: env.asrApiKey,
240
+ baseUrl: env.asrBaseUrl,
241
+ model: env.asrModel,
242
+ });
243
+ if (asr.enabled) console.log('[gateway] ASR 语音转写已启用');
198
244
 
199
245
  const client = new GatewayClient(dshBaseUrl, dshToken);
200
246
  await client.health(); // 确认 DSH 侧(或 mock)活着
@@ -202,7 +248,7 @@ async function main(): Promise<void> {
202
248
  const adapters: Adapter[] = adapterIds.map((id) => createAdapter(id, env));
203
249
  for (const adapter of adapters) {
204
250
  await adapter.connect();
205
- await wireAdapter(adapter, client, { allowlist });
251
+ await wireAdapter(adapter, client, { allowlist, allowAll, asr });
206
252
  console.log(`[gateway] ${adapter.id} 适配器已就绪`);
207
253
  }
208
254
 
@@ -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/session.ts CHANGED
@@ -7,11 +7,17 @@ export function buildSessionKey(
7
7
  return sessionKey(adapterId, msg.chatId, msg.userId);
8
8
  }
9
9
 
10
- /** 空列表 = 开发模式放行所有;生产环境必须显式配置。 */
10
+ /**
11
+ * 白名单:默认 fail-closed —— 只有显式配置了条目才放行;
12
+ * 开发环境可用 ALLOW_ALL=1 显式放行所有(比空列表隐式放行安全得多)。
13
+ */
11
14
  export class Allowlist {
12
- constructor(private readonly entries: string[]) {}
15
+ constructor(
16
+ private readonly entries: string[],
17
+ private readonly allowAll = false,
18
+ ) {}
13
19
 
14
20
  allows(key: string): boolean {
15
- return this.entries.length === 0 || this.entries.includes(key);
21
+ return this.allowAll || (this.entries.length > 0 && this.entries.includes(key));
16
22
  }
17
23
  }
@@ -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,42 @@ 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"}' } })).toMatchObject({ buttonId: 'approve:r1' });
43
+ });
44
+ it('识别 params / cardActionData 字段与嵌套结构', () => {
45
+ expect(parseCardCallback({ cardPrivateData: { params: '{"action":"reject","reqId":"r9"}' } })).toMatchObject({ buttonId: 'reject:r9' });
46
+ expect(parseCardCallback({ a: { b: { cardActionData: '{"action":"approve","reqId":"x"}' } } })).toMatchObject({ buttonId: 'approve:x' });
47
+ });
48
+ it('带回会话与用户身份(用于白名单校验)', () => {
49
+ expect(parseCardCallback({
50
+ cardPrivateData: { cardCallbackData: '{"action":"approve","reqId":"r1"}', userId: 'u1' },
51
+ conversationId: 'cid1',
52
+ })).toEqual({ buttonId: 'approve:r1', chatId: 'cid1', userId: 'u1' });
53
+ });
54
+ it('非法载荷返回 null', () => {
55
+ expect(parseCardCallback(null)).toBeNull();
56
+ expect(parseCardCallback({ cardPrivateData: { cardCallbackData: 'not-json' } })).toBeNull();
57
+ expect(parseCardCallback({ cardPrivateData: { cardCallbackData: '{"action":"other","reqId":"r1"}' } })).toBeNull();
58
+ expect(parseCardCallback({ cardPrivateData: { cardCallbackData: '{"action":"approve"}' } })).toBeNull();
59
+ });
60
+ it('buttonCallbackData 与 parseCardCallback 往返一致', () => {
61
+ const data = buttonCallbackData({ id: 'reject:r7', label: 'x' });
62
+ expect(parseCardCallback({ cardPrivateData: { cardCallbackData: data } })).toMatchObject({ buttonId: 'reject:r7' });
63
+ });
64
+ });
@@ -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
+ });
@@ -0,0 +1,77 @@
1
+ import { afterEach, describe, expect, it, vi } from 'vitest';
2
+ import { createTranscriber, extensionForMime } from '../src/asr.js';
3
+
4
+ describe('extensionForMime', () => {
5
+ it('maps known audio mimes', () => {
6
+ expect(extensionForMime('audio/ogg')).toBe('ogg');
7
+ expect(extensionForMime('audio/mpeg')).toBe('mp3');
8
+ expect(extensionForMime('audio/mp4')).toBe('m4a');
9
+ expect(extensionForMime('audio/wav')).toBe('wav');
10
+ expect(extensionForMime('audio/webm')).toBe('webm');
11
+ });
12
+ it('ignores parameters and case', () => {
13
+ expect(extensionForMime('audio/OGG; codecs=opus')).toBe('ogg');
14
+ });
15
+ it('falls back for unknown or missing mime', () => {
16
+ expect(extensionForMime('application/x-foo')).toBe('oga');
17
+ expect(extensionForMime(undefined)).toBe('oga');
18
+ expect(extensionForMime('')).toBe('oga');
19
+ });
20
+ });
21
+
22
+ describe('createTranscriber.transcribe', () => {
23
+ afterEach(() => vi.unstubAllGlobals());
24
+
25
+ it('未配置 API key 时禁用且不发起请求', async () => {
26
+ const fetchMock = vi.fn()
27
+ vi.stubGlobal('fetch', fetchMock)
28
+ const asr = createTranscriber({})
29
+ expect(asr.enabled).toBe(false)
30
+ expect(await asr.transcribe({ url: 'https://x/v.ogg' })).toBeNull()
31
+ expect(fetchMock).not.toHaveBeenCalled()
32
+ })
33
+
34
+ it('下载音频并调用 OpenAI 兼容转写端点,返回文本', async () => {
35
+ const fetchMock = vi.fn()
36
+ .mockResolvedValueOnce({ ok: true, blob: async () => new Blob(['fake-audio']) })
37
+ .mockResolvedValueOnce({ ok: true, json: async () => ({ text: ' 你好世界 ' }) })
38
+ vi.stubGlobal('fetch', fetchMock)
39
+ const asr = createTranscriber({ apiKey: 'sk-test', baseUrl: 'https://api.siliconflow.cn/v1', model: 'whisper-1' })
40
+ expect(asr.enabled).toBe(true)
41
+ const text = await asr.transcribe({ url: 'https://x/v.ogg', mime: 'audio/ogg' })
42
+ expect(text).toBe('你好世界')
43
+
44
+ // 第一次 fetch:下载音频;第二次:转写端点
45
+ expect(fetchMock.mock.calls[0][0]).toBe('https://x/v.ogg')
46
+ const [url, init] = fetchMock.mock.calls[1]
47
+ expect(String(url)).toBe('https://api.siliconflow.cn/v1/audio/transcriptions')
48
+ expect(init.method).toBe('POST')
49
+ expect(init.headers.authorization).toBe('Bearer sk-test')
50
+ expect(init.body).toBeInstanceOf(FormData)
51
+ const filename = (init.body as FormData).get('file') as File
52
+ expect(filename.name).toBe('voice.ogg')
53
+ })
54
+
55
+ it('下载失败返回 null(不抛错)', async () => {
56
+ vi.stubGlobal('fetch', vi.fn().mockResolvedValueOnce({ ok: false, status: 403 }))
57
+ const asr = createTranscriber({ apiKey: 'sk-test' })
58
+ expect(await asr.transcribe({ url: 'https://x/v.ogg' })).toBeNull()
59
+ })
60
+
61
+ it('转写端点失败返回 null', async () => {
62
+ const fetchMock = vi.fn()
63
+ .mockResolvedValueOnce({ ok: true, blob: async () => new Blob(['x']) })
64
+ .mockResolvedValueOnce({ ok: false, status: 500, text: async () => 'server error' })
65
+ vi.stubGlobal('fetch', fetchMock)
66
+ const asr = createTranscriber({ apiKey: 'sk-test' })
67
+ expect(await asr.transcribe({ url: 'https://x/v.ogg' })).toBeNull()
68
+ })
69
+
70
+ it('无 url 返回 null', async () => {
71
+ const fetchMock = vi.fn()
72
+ vi.stubGlobal('fetch', fetchMock)
73
+ const asr = createTranscriber({ apiKey: 'sk-test' })
74
+ expect(await asr.transcribe({ mime: 'audio/ogg' })).toBeNull()
75
+ expect(fetchMock).not.toHaveBeenCalled()
76
+ })
77
+ })
@@ -10,9 +10,15 @@ describe('parseCommand', () => {
10
10
  expect(parseCommand('/task 调研竞品')).toEqual({ kind: 'task', prompt: '调研竞品' });
11
11
  expect(parseCommand('/cron 0 8 * * * 每日汇报')).toEqual({ kind: 'cron', schedule: '0 8 * * *', prompt: '每日汇报' });
12
12
  });
13
+ it('识别 /crons 与 /cronrm', () => {
14
+ expect(parseCommand('/crons')).toEqual({ kind: 'crons' });
15
+ expect(parseCommand('/cronrm cron-1')).toEqual({ kind: 'cronrm', taskId: 'cron-1' });
16
+ expect(parseCommand('/cronrm cron-123-abc')).toEqual({ kind: 'cronrm', taskId: 'cron-123-abc' });
17
+ });
13
18
  it('非命令返回 null', () => {
14
19
  expect(parseCommand('hello')).toBeNull();
15
20
  expect(parseCommand('/unknown')).toBeNull();
16
21
  expect(parseCommand('/task')).toBeNull(); // 缺参数
22
+ expect(parseCommand('/cronrm')).toBeNull(); // 缺任务 id
17
23
  });
18
24
  });
@@ -1,22 +1,24 @@
1
1
  import { describe, expect, it } from 'vitest';
2
2
  import { wireAdapter } from '../src/index.js';
3
3
  import { GatewayClient } from '@dsh-overdrive/sdk';
4
- import type { Adapter, NormalizedMessage, OutboundPayload } from '../src/adapter.js';
4
+ import type { Adapter, NormalizedMessage, OutboundPayload, ReplySender } from '../src/adapter.js';
5
5
 
6
6
  /** 可编程 FakeAdapter:验证 wiring 逻辑(白名单/会话键/错误兜底)。 */
7
7
  class FakeAdapter implements Adapter {
8
8
  readonly id: string;
9
9
  readonly sent: Array<{ chatId: string; payload: OutboundPayload }> = [];
10
10
  private messageCb?: (msg: NormalizedMessage) => Promise<void> | void;
11
- private replyCb?: (buttonId: string) => Promise<void> | void;
11
+ private replyCb?: (buttonId: string, sender: ReplySender) => Promise<void> | void;
12
12
  constructor(id: string) { this.id = id; }
13
13
  async connect(): Promise<void> {}
14
14
  async send(chatId: string, payload: OutboundPayload): Promise<void> { this.sent.push({ chatId, payload }); }
15
15
  onMessage(cb: (msg: NormalizedMessage) => void): void { this.messageCb = cb; }
16
- onReply(cb: (buttonId: string) => void): void { this.replyCb = cb; }
16
+ onReply(cb: (buttonId: string, sender: ReplySender) => void): void { this.replyCb = cb; }
17
17
  /** 测试助手:返回处理器 Promise,测试 await 以确保 async 接线(upsert→sendMessage / catch 兜底)跑完 */
18
18
  emit(msg: NormalizedMessage): Promise<void> | void { return this.messageCb?.(msg); }
19
- click(buttonId: string): Promise<void> | void { return this.replyCb?.(buttonId); }
19
+ click(buttonId: string, sender: ReplySender = { chatId: 'C1', userId: 'U1' }): Promise<void> | void {
20
+ return this.replyCb?.(buttonId, sender);
21
+ }
20
22
  }
21
23
 
22
24
  /** 假 DSH:记录调用。 */
@@ -63,22 +65,41 @@ describe('wireAdapter(多适配器装配核心)', () => {
63
65
  expect(messages).toEqual([{ sessionId: 'telegram:111:222', text: 'hello' }]);
64
66
  });
65
67
 
66
- it('按钮点击 resolveApproval', async () => {
68
+ it('按钮点击(白名单内)→ resolveApproval', async () => {
67
69
  const adapter = new FakeAdapter('discord');
68
70
  const { client, approvals } = fakeClient();
69
- await wireAdapter(adapter, client, { allowlist: [] });
71
+ await wireAdapter(adapter, client, { allowlist: ['discord:C1:U1'] });
70
72
 
71
- await adapter.click('approve:r1');
73
+ await adapter.click('approve:r1', { chatId: 'C1', userId: 'U1' });
72
74
  expect(approvals).toEqual([{ reqId: 'r1', decision: 'approve' }]);
73
75
  });
74
76
 
77
+ it('按钮点击(白名单外)→ 拒绝批准并回错误文本', async () => {
78
+ const adapter = new FakeAdapter('discord');
79
+ const { client, approvals } = fakeClient();
80
+ await wireAdapter(adapter, client, { allowlist: ['discord:C1:U1'] });
81
+
82
+ await adapter.click('approve:r1', { chatId: 'C1', userId: 'EVIL' });
83
+ expect(approvals).toEqual([]); // 未授权用户不能批准
84
+ expect(adapter.sent[0].payload.text).toContain('⛔');
85
+ });
86
+
87
+ it('空白名单 fail-closed:点击一律拒绝', async () => {
88
+ const adapter = new FakeAdapter('telegram');
89
+ const { client, approvals } = fakeClient();
90
+ await wireAdapter(adapter, client, { allowlist: [] });
91
+
92
+ await adapter.click('reject:r1', { chatId: 'C1', userId: 'U1' });
93
+ expect(approvals).toEqual([]);
94
+ });
95
+
75
96
  it('DSH 调用失败 → 回错误文本(不崩溃)', async () => {
76
97
  const adapter = new FakeAdapter('slack');
77
98
  const client = {
78
99
  upsertSession: async () => { throw new Error('dsh down'); },
79
100
  connect: async () => () => undefined,
80
101
  } as unknown as GatewayClient;
81
- await wireAdapter(adapter, client, { allowlist: [] });
102
+ await wireAdapter(adapter, client, { allowlist: [], allowAll: true });
82
103
 
83
104
  await adapter.emit({ chatId: 'C1', userId: 'U1', text: 'hi' });
84
105
  expect(adapter.sent[0].payload.text).toContain('❌');