@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
@@ -0,0 +1,40 @@
1
+ export const PENDING_BUTTONS_TTL_MS = 5 * 60_000;
2
+ /**
3
+ * 编号回复兜底的按钮暂存(带 TTL)。
4
+ *
5
+ * 审批/危险操作按钮发出后,用户在聊天里回复数字("1"/"2"…)选择;
6
+ * 若按钮长期不消费,后续的普通数字消息会被误判成按钮回复。
7
+ * 本类在 TTL(默认 5 分钟)后自动失效,杜绝"过期按钮吞消息"。
8
+ */
9
+ export class PendingButtons {
10
+ ttlMs;
11
+ map = new Map();
12
+ constructor(ttlMs = PENDING_BUTTONS_TTL_MS) {
13
+ this.ttlMs = ttlMs;
14
+ }
15
+ set(chatId, buttons) {
16
+ this.map.set(chatId, { buttons, expiresAt: Date.now() + this.ttlMs });
17
+ }
18
+ /** 数字回复命中:返回匹配按钮并消费(删除);无 pending / 已过期 / 非数字或越界返回 undefined。 */
19
+ match(chatId, text) {
20
+ const entry = this.map.get(chatId);
21
+ if (!entry)
22
+ return undefined;
23
+ if (entry.expiresAt < Date.now()) {
24
+ this.map.delete(chatId);
25
+ return undefined;
26
+ }
27
+ const n = Number(text.trim());
28
+ if (!Number.isInteger(n) || n < 1 || n > entry.buttons.length)
29
+ return undefined;
30
+ const button = entry.buttons[n - 1];
31
+ if (button)
32
+ this.map.delete(chatId);
33
+ return button;
34
+ }
35
+ /** 消费原生按钮点击(如 WhatsApp 原生交互按钮):删除该 chat 的 pending。 */
36
+ consume(chatId) {
37
+ this.map.delete(chatId);
38
+ }
39
+ }
40
+ //# sourceMappingURL=pending-buttons.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"pending-buttons.js","sourceRoot":"","sources":["../src/pending-buttons.ts"],"names":[],"mappings":"AAEA,MAAM,CAAC,MAAM,sBAAsB,GAAG,CAAC,GAAG,MAAM,CAAC;AAOjD;;;;;;GAMG;AACH,MAAM,OAAO,cAAc;IAGI;IAFZ,GAAG,GAAG,IAAI,GAAG,EAA8B,CAAC;IAE7D,YAA6B,QAAgB,sBAAsB;QAAtC,UAAK,GAAL,KAAK,CAAiC;IAAG,CAAC;IAEvE,GAAG,CAAC,MAAc,EAAE,OAAyB;QAC3C,IAAI,CAAC,GAAG,CAAC,GAAG,CAAC,MAAM,EAAE,EAAE,OAAO,EAAE,SAAS,EAAE,IAAI,CAAC,GAAG,EAAE,GAAG,IAAI,CAAC,KAAK,EAAE,CAAC,CAAC;IACxE,CAAC;IAED,iEAAiE;IACjE,KAAK,CAAC,MAAc,EAAE,IAAY;QAChC,MAAM,KAAK,GAAG,IAAI,CAAC,GAAG,CAAC,GAAG,CAAC,MAAM,CAAC,CAAC;QACnC,IAAI,CAAC,KAAK;YAAE,OAAO,SAAS,CAAC;QAC7B,IAAI,KAAK,CAAC,SAAS,GAAG,IAAI,CAAC,GAAG,EAAE,EAAE,CAAC;YACjC,IAAI,CAAC,GAAG,CAAC,MAAM,CAAC,MAAM,CAAC,CAAC;YACxB,OAAO,SAAS,CAAC;QACnB,CAAC;QACD,MAAM,CAAC,GAAG,MAAM,CAAC,IAAI,CAAC,IAAI,EAAE,CAAC,CAAC;QAC9B,IAAI,CAAC,MAAM,CAAC,SAAS,CAAC,CAAC,CAAC,IAAI,CAAC,GAAG,CAAC,IAAI,CAAC,GAAG,KAAK,CAAC,OAAO,CAAC,MAAM;YAAE,OAAO,SAAS,CAAC;QAChF,MAAM,MAAM,GAAG,KAAK,CAAC,OAAO,CAAC,CAAC,GAAG,CAAC,CAAC,CAAC;QACpC,IAAI,MAAM;YAAE,IAAI,CAAC,GAAG,CAAC,MAAM,CAAC,MAAM,CAAC,CAAC;QACpC,OAAO,MAAM,CAAC;IAChB,CAAC;IAED,sDAAsD;IACtD,OAAO,CAAC,MAAc;QACpB,IAAI,CAAC,GAAG,CAAC,MAAM,CAAC,MAAM,CAAC,CAAC;IAC1B,CAAC;CACF"}
package/dist/session.d.ts CHANGED
@@ -2,9 +2,13 @@ export declare function buildSessionKey(adapterId: string, msg: {
2
2
  chatId: string;
3
3
  userId: string;
4
4
  }): string;
5
- /** 空列表 = 开发模式放行所有;生产环境必须显式配置。 */
5
+ /**
6
+ * 白名单:默认 fail-closed —— 只有显式配置了条目才放行;
7
+ * 开发环境可用 ALLOW_ALL=1 显式放行所有(比空列表隐式放行安全得多)。
8
+ */
6
9
  export declare class Allowlist {
7
10
  private readonly entries;
8
- constructor(entries: string[]);
11
+ private readonly allowAll;
12
+ constructor(entries: string[], allowAll?: boolean);
9
13
  allows(key: string): boolean;
10
14
  }
package/dist/session.js CHANGED
@@ -2,14 +2,19 @@ import { sessionKey } from '@dsh-overdrive/sdk';
2
2
  export function buildSessionKey(adapterId, msg) {
3
3
  return sessionKey(adapterId, msg.chatId, msg.userId);
4
4
  }
5
- /** 空列表 = 开发模式放行所有;生产环境必须显式配置。 */
5
+ /**
6
+ * 白名单:默认 fail-closed —— 只有显式配置了条目才放行;
7
+ * 开发环境可用 ALLOW_ALL=1 显式放行所有(比空列表隐式放行安全得多)。
8
+ */
6
9
  export class Allowlist {
7
10
  entries;
8
- constructor(entries) {
11
+ allowAll;
12
+ constructor(entries, allowAll = false) {
9
13
  this.entries = entries;
14
+ this.allowAll = allowAll;
10
15
  }
11
16
  allows(key) {
12
- return this.entries.length === 0 || this.entries.includes(key);
17
+ return this.allowAll || (this.entries.length > 0 && this.entries.includes(key));
13
18
  }
14
19
  }
15
20
  //# sourceMappingURL=session.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"session.js","sourceRoot":"","sources":["../src/session.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,UAAU,EAAE,MAAM,oBAAoB,CAAC;AAEhD,MAAM,UAAU,eAAe,CAC7B,SAAiB,EACjB,GAAuC;IAEvC,OAAO,UAAU,CAAC,SAAS,EAAE,GAAG,CAAC,MAAM,EAAE,GAAG,CAAC,MAAM,CAAC,CAAC;AACvD,CAAC;AAED,iCAAiC;AACjC,MAAM,OAAO,SAAS;IACS;IAA7B,YAA6B,OAAiB;QAAjB,YAAO,GAAP,OAAO,CAAU;IAAG,CAAC;IAElD,MAAM,CAAC,GAAW;QAChB,OAAO,IAAI,CAAC,OAAO,CAAC,MAAM,KAAK,CAAC,IAAI,IAAI,CAAC,OAAO,CAAC,QAAQ,CAAC,GAAG,CAAC,CAAC;IACjE,CAAC;CACF"}
1
+ {"version":3,"file":"session.js","sourceRoot":"","sources":["../src/session.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,UAAU,EAAE,MAAM,oBAAoB,CAAC;AAEhD,MAAM,UAAU,eAAe,CAC7B,SAAiB,EACjB,GAAuC;IAEvC,OAAO,UAAU,CAAC,SAAS,EAAE,GAAG,CAAC,MAAM,EAAE,GAAG,CAAC,MAAM,CAAC,CAAC;AACvD,CAAC;AAED;;;GAGG;AACH,MAAM,OAAO,SAAS;IAED;IACA;IAFnB,YACmB,OAAiB,EACjB,WAAW,KAAK;QADhB,YAAO,GAAP,OAAO,CAAU;QACjB,aAAQ,GAAR,QAAQ,CAAQ;IAChC,CAAC;IAEJ,MAAM,CAAC,GAAW;QAChB,OAAO,IAAI,CAAC,QAAQ,IAAI,CAAC,IAAI,CAAC,OAAO,CAAC,MAAM,GAAG,CAAC,IAAI,IAAI,CAAC,OAAO,CAAC,QAAQ,CAAC,GAAG,CAAC,CAAC,CAAC;IAClF,CAAC;CACF"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
- {
1
+ {
2
2
  "name": "@dsh-overdrive/gateway",
3
- "version": "0.1.3",
3
+ "version": "0.1.5",
4
4
  "type": "module",
5
5
  "scripts": {
6
6
  "build": "tsc"
@@ -10,7 +10,7 @@
10
10
  "dsh-overdrive-setup": "dist/setup.js"
11
11
  },
12
12
  "dependencies": {
13
- "@dsh-overdrive/sdk": "0.1.1",
13
+ "@dsh-overdrive/sdk": "0.1.2",
14
14
  "@larksuiteoapi/node-sdk": "^1.50.0",
15
15
  "@slack/bolt": "^3.0.0",
16
16
  "@whiskeysockets/baileys": "^6.0.1",
package/src/adapter.ts CHANGED
@@ -12,6 +12,12 @@ export interface OutboundPayload {
12
12
  buttons?: OutboundButton[];
13
13
  }
14
14
 
15
+ /** 按钮回执的点击者身份(用于白名单校验)。chatId 在个别平台回调中可能缺失,缺失时按未授权处理(fail-closed)。 */
16
+ export interface ReplySender {
17
+ chatId: string;
18
+ userId: string;
19
+ }
20
+
15
21
  /** 平台适配器契约:M2/M3 的 WhatsApp/Telegram/… 都实现它。 */
16
22
  export interface Adapter {
17
23
  readonly id: string;
@@ -22,5 +28,6 @@ export interface Adapter {
22
28
  /** 可选:连接状态(供控制台)。 */
23
29
  status?(): { connected: boolean };
24
30
  onMessage(cb: (msg: NormalizedMessage) => void): void;
25
- onReply(cb: (buttonId: string) => void): void;
31
+ /** 按钮点击回执:buttonId + 点击者身份。身份缺失即传空字符串,由上层按未授权处理。 */
32
+ onReply(cb: (buttonId: string, sender: ReplySender) => void): void;
26
33
  }
@@ -5,7 +5,7 @@ import type { Adapter, NormalizedMessage, OutboundPayload } from '../adapter.js'
5
5
  export class CliAdapter implements Adapter {
6
6
  readonly id = 'cli';
7
7
  private messageCb?: (msg: NormalizedMessage) => void;
8
- private replyCb?: (buttonId: string) => void;
8
+ private replyCb?: (buttonId: string, sender: { chatId: string; userId: string }) => void;
9
9
  private rl?: ReturnType<typeof createInterface>;
10
10
 
11
11
  async connect(): Promise<void> {
@@ -15,7 +15,7 @@ export class CliAdapter implements Adapter {
15
15
  if (!trimmed) return;
16
16
  const btn = trimmed.match(/^\/btn\s+(\S+)$/i);
17
17
  if (btn) {
18
- this.replyCb?.(btn[1]);
18
+ this.replyCb?.(btn[1], { chatId: 'cli', userId: 'local' });
19
19
  return;
20
20
  }
21
21
  this.messageCb?.({ chatId: 'cli', userId: 'local', text: trimmed });
@@ -31,7 +31,7 @@ export class CliAdapter implements Adapter {
31
31
  }
32
32
 
33
33
  onMessage(cb: (msg: NormalizedMessage) => void): void { this.messageCb = cb; }
34
- onReply(cb: (buttonId: string) => void): void { this.replyCb = cb; }
34
+ onReply(cb: (buttonId: string, sender: { chatId: string; userId: string }) => void): void { this.replyCb = cb; }
35
35
  /** CLI 是本地进程内适配器:恒为已连接。 */
36
36
  status(): { connected: boolean } { return { connected: true }; }
37
37
  }
@@ -1,5 +1,6 @@
1
1
  import type { Adapter, NormalizedMessage, OutboundButton, OutboundPayload } from '../adapter.js';
2
- import { DWClient, TOPIC_ROBOT, type RobotMessage } from 'dingtalk-stream-sdk-nodejs';
2
+ import { PendingButtons } from '../pending-buttons.js';
3
+ import { DWClient, TOPIC_CARD, TOPIC_ROBOT, type RobotMessage } from 'dingtalk-stream-sdk-nodejs';
3
4
 
4
5
  // dingtalk-stream-sdk-nodejs@2.0.4 实测:exports 提供 DWClient + TOPIC_ROBOT
5
6
  // (/v1.0/im/bot/messages/get);回调 registerCallbackListener(TOPIC_ROBOT, (msg) => …),
@@ -41,6 +42,76 @@ export function matchNumberedButton(text: string, buttons: OutboundButton[]): Ou
41
42
  return buttons[n - 1];
42
43
  }
43
44
 
45
+ /** 按钮 id("approve:<reqId>" / "reject:<reqId>")→ 卡片回调载荷 JSON 字符串。 */
46
+ export function buttonCallbackData(button: OutboundButton): string {
47
+ const idx = button.id.indexOf(':');
48
+ return JSON.stringify({
49
+ action: idx >= 0 ? button.id.slice(0, idx) : button.id,
50
+ reqId: idx >= 0 ? button.id.slice(idx + 1) : '',
51
+ });
52
+ }
53
+
54
+ /** 钉钉 actionCard 消息体(Roadmap v0.2)。按钮回调经 TOPIC_CARD 走 Stream 返回。 */
55
+ export function buildActionCard(text: string, buttons: OutboundButton[]): {
56
+ msgtype: 'actionCard';
57
+ actionCard: { title: string; text: string; btnOrientation: string; btns: Array<{ title: string; actionURL: string }> };
58
+ } {
59
+ return {
60
+ msgtype: 'actionCard',
61
+ actionCard: {
62
+ title: '需要批准',
63
+ text,
64
+ btnOrientation: '1',
65
+ btns: buttons.map((b) => ({
66
+ title: b.label,
67
+ actionURL: `dingtalk://dingtalkclient/action/openapp?cardCallbackData=${encodeURIComponent(buttonCallbackData(b))}`,
68
+ })),
69
+ },
70
+ };
71
+ }
72
+
73
+ export interface CardCallbackResult {
74
+ buttonId: string;
75
+ chatId?: string;
76
+ userId?: string;
77
+ }
78
+
79
+ /**
80
+ * 钉钉卡片回调载荷 → { buttonId, chatId?, userId? }。
81
+ * Stream 模式下回调 JSON 的字段名(cardCallbackData / params / cardActionData,以及会话/用户字段)
82
+ * 在不同卡片版本有差异,这里做多路径深度兜底解析;真机验证后可按实际字段收敛。找不到返回 null。
83
+ */
84
+ export function parseCardCallback(raw: unknown): CardCallbackResult | null {
85
+ let buttonId: string | null = null;
86
+ let chatId: string | undefined;
87
+ let userId: string | undefined;
88
+
89
+ const visit = (obj: unknown, depth: number): void => {
90
+ if (depth > 5 || !obj || typeof obj !== 'object') return;
91
+ for (const [key, value] of Object.entries(obj as Record<string, unknown>)) {
92
+ if (!buttonId && typeof value === 'string' && (key === 'cardCallbackData' || key === 'params' || key === 'cardActionData')) {
93
+ try {
94
+ const parsed = JSON.parse(value) as { action?: string; reqId?: string };
95
+ if ((parsed.action === 'approve' || parsed.action === 'reject') && typeof parsed.reqId === 'string' && parsed.reqId) {
96
+ buttonId = `${parsed.action}:${parsed.reqId}`;
97
+ }
98
+ } catch {
99
+ /* 该字段不是 JSON 载荷,继续往下找 */
100
+ }
101
+ }
102
+ if (!chatId && typeof value === 'string' && (key === 'conversationId' || key === 'conversation_id') && value) {
103
+ chatId = value;
104
+ }
105
+ if (!userId && typeof value === 'string' && (key === 'senderStaffId' || key === 'senderId' || key === 'userid' || key === 'userId') && value) {
106
+ userId = value;
107
+ }
108
+ if (typeof value === 'object') visit(value, depth + 1);
109
+ }
110
+ };
111
+ visit(raw, 0);
112
+ return buttonId ? { buttonId, chatId, userId } : null;
113
+ }
114
+
44
115
  // ── 适配器 ────────────────────────────────────────────────────
45
116
 
46
117
  export interface DingTalkAdapterOptions {
@@ -53,8 +124,8 @@ export class DingTalkAdapter implements Adapter {
53
124
  private client?: DWClient;
54
125
  private connected = false;
55
126
  private messageCb?: (msg: NormalizedMessage) => void;
56
- private replyCb?: (buttonId: string) => void;
57
- private readonly pendingButtons = new Map<string, OutboundButton[]>();
127
+ private replyCb?: (buttonId: string, sender: { chatId: string; userId: string }) => void;
128
+ private readonly pendingButtons = new PendingButtons();
58
129
  /** conversationId → 最近的 sessionWebhook(回复通道,过期由钉钉侧管理) */
59
130
  private readonly webhooks = new Map<string, string>();
60
131
 
@@ -73,17 +144,29 @@ export class DingTalkAdapter implements Adapter {
73
144
  const parsed = parseBotMessage(data);
74
145
  if (!parsed) return;
75
146
  this.webhooks.set(parsed.chatId, parsed.sessionWebhook);
76
- const pending = this.pendingButtons.get(parsed.chatId);
77
- if (pending) {
78
- const button = matchNumberedButton(parsed.text, pending);
79
- if (button) {
80
- this.pendingButtons.delete(parsed.chatId);
81
- this.replyCb?.(button.id);
82
- return;
83
- }
147
+ const button = this.pendingButtons.match(parsed.chatId, parsed.text);
148
+ if (button) {
149
+ this.replyCb?.(button.id, { chatId: parsed.chatId, userId: parsed.userId });
150
+ return;
84
151
  }
85
152
  this.messageCb?.({ chatId: parsed.chatId, userId: parsed.userId, text: parsed.text });
86
153
  });
154
+ // 原生 actionCard 按钮回调(Stream 模式,Roadmap v0.2)
155
+ client.registerCallbackListener(TOPIC_CARD, (msg) => {
156
+ let data: unknown;
157
+ try {
158
+ data = JSON.parse(msg.data) as unknown;
159
+ } catch {
160
+ return;
161
+ }
162
+ const result = parseCardCallback(data);
163
+ if (result) {
164
+ this.replyCb?.(result.buttonId, {
165
+ chatId: result.chatId ?? result.userId ?? '',
166
+ userId: result.userId ?? '',
167
+ });
168
+ }
169
+ });
87
170
  await client.connect();
88
171
  this.connected = true;
89
172
  console.log('[dingtalk] 钉钉 Stream 已连接');
@@ -92,7 +175,19 @@ export class DingTalkAdapter implements Adapter {
92
175
  async send(chatId: string, payload: OutboundPayload): Promise<void> {
93
176
  const webhook = this.webhooks.get(chatId);
94
177
  if (!webhook) throw new Error(`钉钉会话 ${chatId} 无可用 sessionWebhook(先让用户发一条消息)`);
95
- if (payload.buttons?.length) this.pendingButtons.set(chatId, payload.buttons);
178
+ if (payload.buttons?.length) {
179
+ this.pendingButtons.set(chatId, payload.buttons); // 卡片之外仍支持编号回复兜底
180
+ const res = await fetch(webhook, {
181
+ method: 'POST',
182
+ headers: { 'content-type': 'application/json' },
183
+ body: JSON.stringify(buildActionCard(payload.text, payload.buttons)),
184
+ });
185
+ if (!res.ok) {
186
+ const body = await res.text();
187
+ throw new Error(`钉钉回发失败 ${res.status}: ${body.slice(0, 200)}`);
188
+ }
189
+ return;
190
+ }
96
191
  const text = buildNumberedText(payload.text, payload.buttons ?? []);
97
192
  const res = await fetch(webhook, {
98
193
  method: 'POST',
@@ -106,6 +201,6 @@ export class DingTalkAdapter implements Adapter {
106
201
  }
107
202
 
108
203
  onMessage(cb: (msg: NormalizedMessage) => void): void { this.messageCb = cb; }
109
- onReply(cb: (buttonId: string) => void): void { this.replyCb = cb; }
204
+ onReply(cb: (buttonId: string, sender: { chatId: string; userId: string }) => void): void { this.replyCb = cb; }
110
205
  status(): { connected: boolean } { return { connected: this.connected }; }
111
206
  }
@@ -64,7 +64,7 @@ export class DiscordAdapter implements Adapter {
64
64
  private readonly client: Client;
65
65
  private connected = false;
66
66
  private messageCb?: (msg: NormalizedMessage) => void;
67
- private replyCb?: (buttonId: string) => void;
67
+ private replyCb?: (buttonId: string, sender: { chatId: string; userId: string }) => void;
68
68
 
69
69
  constructor(opts: DiscordAdapterOptions) {
70
70
  this.client = new Client({
@@ -89,7 +89,10 @@ export class DiscordAdapter implements Adapter {
89
89
  if (!interaction.isButton()) return;
90
90
  const button = interaction as ButtonInteraction;
91
91
  await button.deferUpdate().catch(() => undefined);
92
- this.replyCb?.(button.customId);
92
+ this.replyCb?.(button.customId, {
93
+ chatId: button.channelId,
94
+ userId: button.user.id,
95
+ });
93
96
  });
94
97
  }
95
98
 
@@ -112,6 +115,6 @@ export class DiscordAdapter implements Adapter {
112
115
  }
113
116
 
114
117
  onMessage(cb: (msg: NormalizedMessage) => void): void { this.messageCb = cb; }
115
- onReply(cb: (buttonId: string) => void): void { this.replyCb = cb; }
118
+ onReply(cb: (buttonId: string, sender: { chatId: string; userId: string }) => void): void { this.replyCb = cb; }
116
119
  status(): { connected: boolean } { return { connected: this.connected }; }
117
120
  }
@@ -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 {
@@ -59,8 +96,8 @@ export class FeishuAdapter implements Adapter {
59
96
  private ws?: InstanceType<typeof WSClient>;
60
97
  private connected = false;
61
98
  private messageCb?: (msg: NormalizedMessage) => void;
62
- private replyCb?: (buttonId: string) => void;
63
- private readonly pendingButtons = new Map<string, OutboundButton[]>();
99
+ private replyCb?: (buttonId: string, sender: { chatId: string; userId: string }) => void;
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,29 @@ 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, { chatId, userId: normalized.userId });
121
+ return;
89
122
  }
90
123
  this.messageCb?.(normalized);
91
124
  },
125
+ // 原生交互卡片按钮回调 → 审批应答(Roadmap v0.2)
126
+ // 载荷字段(operator.open_id / context.open_chat_id)取自官方卡片回调事件;
127
+ // 个别版本字段名可能不同 —— 拿不到身份时上层按未授权处理(fail-closed),编号回复兜底不受影响。
128
+ 'card.action.trigger': async (data: {
129
+ action?: { value?: unknown };
130
+ operator?: { open_id?: string };
131
+ context?: { open_chat_id?: string };
132
+ }) => {
133
+ const buttonId = cardActionToButtonId(data?.action?.value);
134
+ if (buttonId) {
135
+ this.replyCb?.(buttonId, {
136
+ chatId: data?.context?.open_chat_id ?? '',
137
+ userId: data?.operator?.open_id ?? '',
138
+ });
139
+ }
140
+ },
92
141
  });
93
142
  this.ws = new WSClient({
94
143
  appId: this.opts.appId,
@@ -101,7 +150,23 @@ export class FeishuAdapter implements Adapter {
101
150
  }
102
151
 
103
152
  async send(chatId: string, payload: OutboundPayload): Promise<void> {
104
- if (payload.buttons?.length) this.pendingButtons.set(chatId, payload.buttons);
153
+ if (payload.buttons?.length) {
154
+ this.pendingButtons.set(chatId, payload.buttons); // 卡片之外仍支持编号回复兜底
155
+ const content = buildApprovalCard(payload.text, payload.buttons);
156
+ const messageId = this.lastMessageIds.get(chatId);
157
+ if (messageId) {
158
+ await this.client.im.message.reply({
159
+ path: { message_id: messageId },
160
+ data: { msg_type: 'interactive', content },
161
+ });
162
+ } else {
163
+ await this.client.im.message.create({
164
+ params: { receive_id_type: 'chat_id' },
165
+ data: { receive_id: chatId, msg_type: 'interactive', content },
166
+ });
167
+ }
168
+ return;
169
+ }
105
170
  const text = buildNumberedText(payload.text, payload.buttons ?? []);
106
171
  const content = JSON.stringify({ text });
107
172
  const messageId = this.lastMessageIds.get(chatId);
@@ -121,6 +186,6 @@ export class FeishuAdapter implements Adapter {
121
186
  }
122
187
 
123
188
  onMessage(cb: (msg: NormalizedMessage) => void): void { this.messageCb = cb; }
124
- onReply(cb: (buttonId: string) => void): void { this.replyCb = cb; }
189
+ onReply(cb: (buttonId: string, sender: { chatId: string; userId: string }) => void): void { this.replyCb = cb; }
125
190
  status(): { connected: boolean } { return { connected: this.connected }; }
126
191
  }
@@ -69,7 +69,7 @@ export class SlackAdapter implements Adapter {
69
69
  private readonly app: InstanceType<typeof App>;
70
70
  private connected = false;
71
71
  private messageCb?: (msg: NormalizedMessage) => void;
72
- private replyCb?: (buttonId: string) => void;
72
+ private replyCb?: (buttonId: string, sender: { chatId: string; userId: string }) => void;
73
73
 
74
74
  constructor(opts: SlackAdapterOptions) {
75
75
  this.app = new App({ token: opts.botToken, appToken: opts.appToken, socketMode: true });
@@ -82,8 +82,18 @@ export class SlackAdapter implements Adapter {
82
82
  });
83
83
  this.app.action(/^approve:|^reject:/, async ({ ack, body, respond }) => {
84
84
  await ack();
85
- const action = (body as { actions?: Array<{ value?: string }> }).actions?.[0];
86
- if (action?.value) this.replyCb?.(action.value);
85
+ const b = body as {
86
+ actions?: Array<{ value?: string }>;
87
+ user?: { id?: string };
88
+ channel?: { id?: string };
89
+ };
90
+ const action = b.actions?.[0];
91
+ if (action?.value) {
92
+ this.replyCb?.(action.value, {
93
+ chatId: b.channel?.id ?? '',
94
+ userId: b.user?.id ?? '',
95
+ });
96
+ }
87
97
  await respond({ text: '处理中…', replace_original: false }).catch(() => undefined);
88
98
  });
89
99
  await this.app.start(0); // Socket Mode 不需要端口;start(0) 仅建立连接
@@ -100,6 +110,6 @@ export class SlackAdapter implements Adapter {
100
110
  }
101
111
 
102
112
  onMessage(cb: (msg: NormalizedMessage) => void): void { this.messageCb = cb; }
103
- onReply(cb: (buttonId: string) => void): void { this.replyCb = cb; }
113
+ onReply(cb: (buttonId: string, sender: { chatId: string; userId: string }) => void): void { this.replyCb = cb; }
104
114
  status(): { connected: boolean } { return { connected: this.connected }; }
105
115
  }
@@ -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;
@@ -57,7 +69,7 @@ export class TelegramAdapter implements Adapter {
57
69
  private readonly token: string;
58
70
  private connected = false;
59
71
  private messageCb?: (msg: NormalizedMessage) => void;
60
- private replyCb?: (buttonId: string) => void;
72
+ private replyCb?: (buttonId: string, sender: { chatId: string; userId: string }) => void;
61
73
 
62
74
  constructor(opts: TelegramAdapterOptions) {
63
75
  this.token = opts.token;
@@ -74,17 +86,26 @@ export class TelegramAdapter implements Adapter {
74
86
  this.bot.on('callback_query:data', async (ctx) => {
75
87
  const data = ctx.callbackQuery.data;
76
88
  await ctx.answerCallbackQuery().catch(() => undefined);
77
- this.replyCb?.(data);
89
+ const chat = ctx.callbackQuery.message?.chat as { id?: number | string } | undefined;
90
+ this.replyCb?.(data, {
91
+ chatId: String(chat?.id ?? ''),
92
+ userId: String(ctx.callbackQuery.from.id),
93
+ });
78
94
  });
79
95
  this.bot.catch((err) => console.error('[telegram]', err));
80
96
  void this.bot.start(); // 长轮询(自托管无需 webhook)
81
97
  }
82
98
 
83
- /** 薄层:photo → getFile(file_id) 换 file_path → 下载 URL 填充 msg.media.url(纯函数只做模板)。 */
99
+ /** 薄层:photo/voice/audio → getFile(file_id) 换 file_path → 下载 URL 填充 msg.media.url(纯函数只做模板)。 */
84
100
  private async handleMessage(raw: RawTelegramMessage): Promise<void> {
85
101
  const msg = normalizeTelegramMessage(raw);
86
102
  if (!msg) return;
87
- const fileId = msg.media?.kind === 'image' ? telegramPhotoFileId(raw.message?.photo ?? []) : undefined;
103
+ const fileId =
104
+ msg.media?.kind === 'image'
105
+ ? telegramPhotoFileId(raw.message?.photo ?? [])
106
+ : msg.media?.kind === 'voice'
107
+ ? telegramVoiceFileId(raw)
108
+ : undefined;
88
109
  if (fileId) {
89
110
  try {
90
111
  const file = await this.bot.api.getFile(fileId);
@@ -107,6 +128,6 @@ export class TelegramAdapter implements Adapter {
107
128
  }
108
129
 
109
130
  onMessage(cb: (msg: NormalizedMessage) => void): void { this.messageCb = cb; }
110
- onReply(cb: (buttonId: string) => void): void { this.replyCb = cb; }
131
+ onReply(cb: (buttonId: string, sender: { chatId: string; userId: string }) => void): void { this.replyCb = cb; }
111
132
  status(): { connected: boolean } { return { connected: this.connected }; }
112
133
  }
@@ -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
 
@@ -97,8 +98,10 @@ export class WeComAdapter implements Adapter {
97
98
  private server?: ReturnType<typeof createServer>;
98
99
  private connected = false;
99
100
  private messageCb?: (msg: NormalizedMessage) => void;
100
- private replyCb?: (buttonId: string) => void;
101
- private readonly pendingButtons = new Map<string, OutboundButton[]>();
101
+ private replyCb?: (buttonId: string, sender: { chatId: string; userId: string }) => void;
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, { chatId: normalized.chatId, userId: normalized.userId });
147
+ return;
149
148
  }
150
149
  this.messageCb?.(normalized);
151
150
  }
@@ -169,14 +168,17 @@ 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
 
178
180
  onMessage(cb: (msg: NormalizedMessage) => void): void { this.messageCb = cb; }
179
- onReply(cb: (buttonId: string) => void): void { this.replyCb = cb; }
181
+ onReply(cb: (buttonId: string, sender: { chatId: string; userId: string }) => void): void { this.replyCb = cb; }
180
182
  status(): { connected: boolean } { return { connected: this.connected }; }
181
183
  }
182
184