@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.
- package/dist/adapter.d.ts +7 -1
- package/dist/adapters/cli.d.ts +4 -1
- package/dist/adapters/cli.js +1 -1
- package/dist/adapters/cli.js.map +1 -1
- package/dist/adapters/dingtalk.d.ts +30 -1
- package/dist/adapters/dingtalk.js +97 -12
- package/dist/adapters/dingtalk.js.map +1 -1
- package/dist/adapters/discord.d.ts +4 -1
- package/dist/adapters/discord.js +4 -1
- package/dist/adapters/discord.js.map +1 -1
- package/dist/adapters/feishu.d.ts +13 -1
- package/dist/adapters/feishu.js +71 -11
- package/dist/adapters/feishu.js.map +1 -1
- package/dist/adapters/slack.d.ts +4 -1
- package/dist/adapters/slack.js +8 -3
- package/dist/adapters/slack.js.map +1 -1
- package/dist/adapters/telegram.d.ts +11 -2
- package/dist/adapters/telegram.js +22 -5
- package/dist/adapters/telegram.js.map +1 -1
- package/dist/adapters/wecom.d.ts +6 -1
- package/dist/adapters/wecom.js +12 -9
- package/dist/adapters/wecom.js.map +1 -1
- package/dist/adapters/whatsapp.d.ts +5 -2
- package/dist/adapters/whatsapp.js +16 -13
- package/dist/adapters/whatsapp.js.map +1 -1
- package/dist/asr.d.ts +19 -0
- package/dist/asr.js +63 -0
- package/dist/asr.js.map +1 -0
- package/dist/commands.d.ts +5 -0
- package/dist/commands.js +7 -0
- package/dist/commands.js.map +1 -1
- package/dist/config.d.ts +3 -0
- package/dist/config.js +3 -0
- package/dist/config.js.map +1 -1
- package/dist/index.d.ts +5 -0
- package/dist/index.js +45 -3
- package/dist/index.js.map +1 -1
- package/dist/pending-buttons.d.ts +19 -0
- package/dist/pending-buttons.js +40 -0
- package/dist/pending-buttons.js.map +1 -0
- package/dist/session.d.ts +6 -2
- package/dist/session.js +8 -3
- package/dist/session.js.map +1 -1
- package/package.json +3 -3
- package/src/adapter.ts +8 -1
- package/src/adapters/cli.ts +3 -3
- package/src/adapters/dingtalk.ts +108 -13
- package/src/adapters/discord.ts +6 -3
- package/src/adapters/feishu.ts +77 -12
- package/src/adapters/slack.ts +14 -4
- package/src/adapters/telegram.ts +29 -8
- package/src/adapters/wecom.ts +13 -11
- package/src/adapters/whatsapp.ts +18 -15
- package/src/asr.ts +83 -0
- package/src/commands.ts +8 -1
- package/src/config.ts +6 -0
- package/src/index.ts +49 -3
- package/src/pending-buttons.ts +45 -0
- package/src/session.ts +9 -3
- package/test/adapters.dingtalk.test.ts +40 -1
- package/test/adapters.feishu.test.ts +37 -1
- package/test/asr.test.ts +77 -0
- package/test/commands.test.ts +6 -0
- package/test/multi.test.ts +29 -8
- package/test/pending-buttons.test.ts +61 -0
- package/test/session.test.ts +7 -2
- 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
|
-
|
|
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
|
-
|
|
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
|
|
17
|
+
return this.allowAll || (this.entries.length > 0 && this.entries.includes(key));
|
|
13
18
|
}
|
|
14
19
|
}
|
|
15
20
|
//# sourceMappingURL=session.js.map
|
package/dist/session.js.map
CHANGED
|
@@ -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
|
|
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
|
+
"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.
|
|
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
|
-
|
|
31
|
+
/** 按钮点击回执:buttonId + 点击者身份。身份缺失即传空字符串,由上层按未授权处理。 */
|
|
32
|
+
onReply(cb: (buttonId: string, sender: ReplySender) => void): void;
|
|
26
33
|
}
|
package/src/adapters/cli.ts
CHANGED
|
@@ -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
|
}
|
package/src/adapters/dingtalk.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { Adapter, NormalizedMessage, OutboundButton, OutboundPayload } from '../adapter.js';
|
|
2
|
-
import {
|
|
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
|
|
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
|
|
77
|
-
if (
|
|
78
|
-
|
|
79
|
-
|
|
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)
|
|
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
|
}
|
package/src/adapters/discord.ts
CHANGED
|
@@ -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
|
}
|
package/src/adapters/feishu.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import lark from '@larksuiteoapi/node-sdk';
|
|
2
2
|
import type { Adapter, NormalizedMessage, OutboundButton, OutboundPayload } from '../adapter.js';
|
|
3
|
+
import { PendingButtons } from '../pending-buttons.js';
|
|
3
4
|
|
|
4
5
|
// @larksuiteoapi/node-sdk 是 CommonJS 包(main=lib/index.js,无 "type":"module"):
|
|
5
6
|
// Node 原生 ESM 下必须 default 导入后解构(同 M2b 的 @slack/bolt 处理)。
|
|
@@ -46,6 +47,42 @@ export function matchNumberedButton(text: string, buttons: OutboundButton[]): Ou
|
|
|
46
47
|
return buttons[n - 1];
|
|
47
48
|
}
|
|
48
49
|
|
|
50
|
+
/** 按钮 id("approve:<reqId>" / "reject:<reqId>")→ 卡片按钮 value。 */
|
|
51
|
+
export function buttonValue(button: OutboundButton): { action: string; reqId: string } {
|
|
52
|
+
const idx = button.id.indexOf(':');
|
|
53
|
+
return {
|
|
54
|
+
action: idx >= 0 ? button.id.slice(0, idx) : button.id,
|
|
55
|
+
reqId: idx >= 0 ? button.id.slice(idx + 1) : '',
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** 交互卡片 JSON(msg_type: interactive)。原生按钮点击走 card.action.trigger 回调。 */
|
|
60
|
+
export function buildApprovalCard(text: string, buttons: OutboundButton[]): string {
|
|
61
|
+
const actions = buttons.map((b) => ({
|
|
62
|
+
tag: 'button',
|
|
63
|
+
text: { tag: 'plain_text', content: b.label },
|
|
64
|
+
type: b.id.startsWith('approve:') ? 'primary' : 'default',
|
|
65
|
+
value: buttonValue(b),
|
|
66
|
+
}));
|
|
67
|
+
const card = {
|
|
68
|
+
config: { wide_screen_mode: true },
|
|
69
|
+
header: { title: { tag: 'plain_text', content: text.slice(0, 60) }, template: 'blue' },
|
|
70
|
+
elements: [
|
|
71
|
+
{ tag: 'div', text: { tag: 'lark_md', content: text } },
|
|
72
|
+
{ tag: 'action', actions },
|
|
73
|
+
],
|
|
74
|
+
};
|
|
75
|
+
return JSON.stringify(card);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** 卡片回调 value → 按钮 id("approve:<reqId>");缺字段返回 null。 */
|
|
79
|
+
export function cardActionToButtonId(value: unknown): string | null {
|
|
80
|
+
if (!value || typeof value !== 'object') return null;
|
|
81
|
+
const { action, reqId } = value as { action?: unknown; reqId?: unknown };
|
|
82
|
+
if ((action !== 'approve' && action !== 'reject') || typeof reqId !== 'string' || !reqId) return null;
|
|
83
|
+
return `${action}:${reqId}`;
|
|
84
|
+
}
|
|
85
|
+
|
|
49
86
|
// ── 适配器 ────────────────────────────────────────────────────
|
|
50
87
|
|
|
51
88
|
export interface FeishuAdapterOptions {
|
|
@@ -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
|
|
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
|
|
82
|
-
if (
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
this.pendingButtons.delete(chatId);
|
|
86
|
-
this.replyCb?.(button.id);
|
|
87
|
-
return;
|
|
88
|
-
}
|
|
118
|
+
const button = this.pendingButtons.match(chatId, normalized.text);
|
|
119
|
+
if (button) {
|
|
120
|
+
this.replyCb?.(button.id, { 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)
|
|
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
|
}
|
package/src/adapters/slack.ts
CHANGED
|
@@ -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
|
|
86
|
-
|
|
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
|
}
|
package/src/adapters/telegram.ts
CHANGED
|
@@ -10,8 +10,8 @@ export interface RawTelegramMessage {
|
|
|
10
10
|
text?: string;
|
|
11
11
|
caption?: string;
|
|
12
12
|
photo?: Array<{ file_id?: string }>;
|
|
13
|
-
voice?: { file_id?: string };
|
|
14
|
-
audio?: { file_id?: string };
|
|
13
|
+
voice?: { file_id?: string; mime_type?: string };
|
|
14
|
+
audio?: { file_id?: string; mime_type?: string };
|
|
15
15
|
video?: { file_id?: string };
|
|
16
16
|
document?: { file_id?: string };
|
|
17
17
|
};
|
|
@@ -22,6 +22,16 @@ export function telegramPhotoFileId(photo: Array<{ file_id?: string }>): string
|
|
|
22
22
|
return photo[photo.length - 1]?.file_id;
|
|
23
23
|
}
|
|
24
24
|
|
|
25
|
+
/** 纯函数:语音消息的 file_id(voice 或 audio);无返回 undefined。 */
|
|
26
|
+
export function telegramVoiceFileId(raw: RawTelegramMessage): string | undefined {
|
|
27
|
+
return raw.message?.voice?.file_id ?? raw.message?.audio?.file_id;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** 纯函数:语音消息的 MIME(voice 优先,audio 兜底)。 */
|
|
31
|
+
export function telegramVoiceMime(raw: RawTelegramMessage): string | undefined {
|
|
32
|
+
return raw.message?.voice?.mime_type ?? raw.message?.audio?.mime_type;
|
|
33
|
+
}
|
|
34
|
+
|
|
25
35
|
/** 纯函数:Telegram 文件下载 URL 模板。file_path 需 getFile(file_id) 换取(真实调用在 adapter 薄层)。 */
|
|
26
36
|
export function telegramImageUrl(token: string, filePath: string): string {
|
|
27
37
|
return `https://api.telegram.org/file/bot${token}/${filePath}`;
|
|
@@ -34,7 +44,9 @@ export function normalizeTelegramMessage(raw: RawTelegramMessage): NormalizedMes
|
|
|
34
44
|
const text = msg.text ?? msg.caption ?? '';
|
|
35
45
|
let media: NormalizedMessage['media'];
|
|
36
46
|
if (msg.photo?.length) media = { kind: 'image' }; // url 由 adapter getFile 薄层填充
|
|
37
|
-
else if (msg.voice || msg.audio)
|
|
47
|
+
else if (msg.voice || msg.audio) {
|
|
48
|
+
media = { kind: 'voice', mime: telegramVoiceMime(raw) }; // url 由 adapter getFile 薄层填充(ASR 用)
|
|
49
|
+
}
|
|
38
50
|
else if (msg.video) media = { kind: 'video' };
|
|
39
51
|
else if (msg.document) media = { kind: 'file' };
|
|
40
52
|
if (!text && !media) return null;
|
|
@@ -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
|
-
|
|
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 =
|
|
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
|
}
|
package/src/adapters/wecom.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { createHash, createDecipheriv, createCipheriv, randomBytes } from 'node:crypto';
|
|
2
2
|
import { createServer, type IncomingMessage, type ServerResponse } from 'node:http';
|
|
3
3
|
import type { Adapter, NormalizedMessage, OutboundButton, OutboundPayload } from '../adapter.js';
|
|
4
|
+
import { PendingButtons } from '../pending-buttons.js';
|
|
4
5
|
|
|
5
6
|
// ── 纯函数:AES-256-CBC 加解密(企业微信协议)──────────────────
|
|
6
7
|
|
|
@@ -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
|
|
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
|
|
142
|
-
if (
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
this.pendingButtons.delete(normalized.chatId);
|
|
146
|
-
this.replyCb?.(button.id);
|
|
147
|
-
return;
|
|
148
|
-
}
|
|
144
|
+
const button = this.pendingButtons.match(normalized.chatId, normalized.text);
|
|
145
|
+
if (button) {
|
|
146
|
+
this.replyCb?.(button.id, { 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
|
|