@dsh-overdrive/gateway 0.1.4 → 0.1.6

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 (56) 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 +13 -5
  6. package/dist/adapters/dingtalk.js +27 -15
  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 +4 -1
  12. package/dist/adapters/feishu.js +9 -3
  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 +4 -1
  18. package/dist/adapters/telegram.js +5 -1
  19. package/dist/adapters/telegram.js.map +1 -1
  20. package/dist/adapters/wechat.d.ts +63 -0
  21. package/dist/adapters/wechat.js +231 -0
  22. package/dist/adapters/wechat.js.map +1 -0
  23. package/dist/adapters/wecom.d.ts +4 -1
  24. package/dist/adapters/wecom.js +1 -1
  25. package/dist/adapters/wecom.js.map +1 -1
  26. package/dist/adapters/whatsapp.d.ts +4 -1
  27. package/dist/adapters/whatsapp.js +8 -2
  28. package/dist/adapters/whatsapp.js.map +1 -1
  29. package/dist/config.d.ts +2 -0
  30. package/dist/config.js +6 -0
  31. package/dist/config.js.map +1 -1
  32. package/dist/index.d.ts +2 -0
  33. package/dist/index.js +20 -5
  34. package/dist/index.js.map +1 -1
  35. package/dist/session.d.ts +6 -2
  36. package/dist/session.js +8 -3
  37. package/dist/session.js.map +1 -1
  38. package/package.json +1 -1
  39. package/src/adapter.ts +8 -1
  40. package/src/adapters/cli.ts +3 -3
  41. package/src/adapters/dingtalk.ts +37 -17
  42. package/src/adapters/discord.ts +6 -3
  43. package/src/adapters/feishu.ts +16 -5
  44. package/src/adapters/slack.ts +14 -4
  45. package/src/adapters/telegram.ts +7 -3
  46. package/src/adapters/wechat.ts +247 -0
  47. package/src/adapters/wecom.ts +3 -3
  48. package/src/adapters/whatsapp.ts +10 -4
  49. package/src/config.ts +8 -0
  50. package/src/index.ts +22 -5
  51. package/src/session.ts +9 -3
  52. package/test/adapters.dingtalk.test.ts +11 -5
  53. package/test/adapters.wechat.test.ts +78 -0
  54. package/test/multi.test.ts +29 -8
  55. package/test/session.test.ts +7 -2
  56. package/test/streaming.test.ts +162 -162
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
  }
@@ -70,31 +70,46 @@ export function buildActionCard(text: string, buttons: OutboundButton[]): {
70
70
  };
71
71
  }
72
72
 
73
+ export interface CardCallbackResult {
74
+ buttonId: string;
75
+ chatId?: string;
76
+ userId?: string;
77
+ }
78
+
73
79
  /**
74
- * 钉钉卡片回调载荷 → 按钮 id("approve:<reqId>")。
75
- * Stream 模式下回调 JSON 的字段名(cardCallbackData / params / cardActionData)在不同卡片版本有差异,
76
- * 这里做多路径深度兜底解析;真机验证后可按实际字段收敛。找不到返回 null。
80
+ * 钉钉卡片回调载荷 → { buttonId, chatId?, userId? }。
81
+ * Stream 模式下回调 JSON 的字段名(cardCallbackData / params / cardActionData,以及会话/用户字段)
82
+ * 在不同卡片版本有差异,这里做多路径深度兜底解析;真机验证后可按实际字段收敛。找不到返回 null。
77
83
  */
78
- export function parseCardCallback(raw: unknown): string | null {
79
- const visit = (obj: unknown, depth: number): string | null => {
80
- if (depth > 5 || !obj || typeof obj !== 'object') return null;
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;
81
91
  for (const [key, value] of Object.entries(obj as Record<string, unknown>)) {
82
- if (typeof value === 'string' && (key === 'cardCallbackData' || key === 'params' || key === 'cardActionData')) {
92
+ if (!buttonId && typeof value === 'string' && (key === 'cardCallbackData' || key === 'params' || key === 'cardActionData')) {
83
93
  try {
84
94
  const parsed = JSON.parse(value) as { action?: string; reqId?: string };
85
95
  if ((parsed.action === 'approve' || parsed.action === 'reject') && typeof parsed.reqId === 'string' && parsed.reqId) {
86
- return `${parsed.action}:${parsed.reqId}`;
96
+ buttonId = `${parsed.action}:${parsed.reqId}`;
87
97
  }
88
98
  } catch {
89
99
  /* 该字段不是 JSON 载荷,继续往下找 */
90
100
  }
91
101
  }
92
- const found = visit(value, depth + 1);
93
- if (found) return found;
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);
94
109
  }
95
- return null;
96
110
  };
97
- return visit(raw, 0);
111
+ visit(raw, 0);
112
+ return buttonId ? { buttonId, chatId, userId } : null;
98
113
  }
99
114
 
100
115
  // ── 适配器 ────────────────────────────────────────────────────
@@ -109,7 +124,7 @@ export class DingTalkAdapter implements Adapter {
109
124
  private client?: DWClient;
110
125
  private connected = false;
111
126
  private messageCb?: (msg: NormalizedMessage) => void;
112
- private replyCb?: (buttonId: string) => void;
127
+ private replyCb?: (buttonId: string, sender: { chatId: string; userId: string }) => void;
113
128
  private readonly pendingButtons = new PendingButtons();
114
129
  /** conversationId → 最近的 sessionWebhook(回复通道,过期由钉钉侧管理) */
115
130
  private readonly webhooks = new Map<string, string>();
@@ -131,7 +146,7 @@ export class DingTalkAdapter implements Adapter {
131
146
  this.webhooks.set(parsed.chatId, parsed.sessionWebhook);
132
147
  const button = this.pendingButtons.match(parsed.chatId, parsed.text);
133
148
  if (button) {
134
- this.replyCb?.(button.id);
149
+ this.replyCb?.(button.id, { chatId: parsed.chatId, userId: parsed.userId });
135
150
  return;
136
151
  }
137
152
  this.messageCb?.({ chatId: parsed.chatId, userId: parsed.userId, text: parsed.text });
@@ -144,8 +159,13 @@ export class DingTalkAdapter implements Adapter {
144
159
  } catch {
145
160
  return;
146
161
  }
147
- const buttonId = parseCardCallback(data);
148
- if (buttonId) this.replyCb?.(buttonId);
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
+ }
149
169
  });
150
170
  await client.connect();
151
171
  this.connected = true;
@@ -181,6 +201,6 @@ export class DingTalkAdapter implements Adapter {
181
201
  }
182
202
 
183
203
  onMessage(cb: (msg: NormalizedMessage) => void): void { this.messageCb = cb; }
184
- onReply(cb: (buttonId: string) => void): void { this.replyCb = cb; }
204
+ onReply(cb: (buttonId: string, sender: { chatId: string; userId: string }) => void): void { this.replyCb = cb; }
185
205
  status(): { connected: boolean } { return { connected: this.connected }; }
186
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
  }
@@ -96,7 +96,7 @@ export class FeishuAdapter implements Adapter {
96
96
  private ws?: InstanceType<typeof WSClient>;
97
97
  private connected = false;
98
98
  private messageCb?: (msg: NormalizedMessage) => void;
99
- private replyCb?: (buttonId: string) => void;
99
+ private replyCb?: (buttonId: string, sender: { chatId: string; userId: string }) => void;
100
100
  private readonly pendingButtons = new PendingButtons();
101
101
  /** chatId → 最近一条入站消息的 message_id(send 优先 reply,缺失则 create 兜底) */
102
102
  private readonly lastMessageIds = new Map<string, string>();
@@ -117,15 +117,26 @@ export class FeishuAdapter implements Adapter {
117
117
  const chatId = normalized.chatId;
118
118
  const button = this.pendingButtons.match(chatId, normalized.text);
119
119
  if (button) {
120
- this.replyCb?.(button.id);
120
+ this.replyCb?.(button.id, { chatId, userId: normalized.userId });
121
121
  return;
122
122
  }
123
123
  this.messageCb?.(normalized);
124
124
  },
125
125
  // 原生交互卡片按钮回调 → 审批应答(Roadmap v0.2)
126
- 'card.action.trigger': async (data: { action?: { value?: unknown } }) => {
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
+ }) => {
127
133
  const buttonId = cardActionToButtonId(data?.action?.value);
128
- if (buttonId) this.replyCb?.(buttonId);
134
+ if (buttonId) {
135
+ this.replyCb?.(buttonId, {
136
+ chatId: data?.context?.open_chat_id ?? '',
137
+ userId: data?.operator?.open_id ?? '',
138
+ });
139
+ }
129
140
  },
130
141
  });
131
142
  this.ws = new WSClient({
@@ -175,6 +186,6 @@ export class FeishuAdapter implements Adapter {
175
186
  }
176
187
 
177
188
  onMessage(cb: (msg: NormalizedMessage) => void): void { this.messageCb = cb; }
178
- onReply(cb: (buttonId: string) => void): void { this.replyCb = cb; }
189
+ onReply(cb: (buttonId: string, sender: { chatId: string; userId: string }) => void): void { this.replyCb = cb; }
179
190
  status(): { connected: boolean } { return { connected: this.connected }; }
180
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
  }
@@ -69,7 +69,7 @@ export class TelegramAdapter implements Adapter {
69
69
  private readonly token: string;
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: TelegramAdapterOptions) {
75
75
  this.token = opts.token;
@@ -86,7 +86,11 @@ export class TelegramAdapter implements Adapter {
86
86
  this.bot.on('callback_query:data', async (ctx) => {
87
87
  const data = ctx.callbackQuery.data;
88
88
  await ctx.answerCallbackQuery().catch(() => undefined);
89
- 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
+ });
90
94
  });
91
95
  this.bot.catch((err) => console.error('[telegram]', err));
92
96
  void this.bot.start(); // 长轮询(自托管无需 webhook)
@@ -124,6 +128,6 @@ export class TelegramAdapter implements Adapter {
124
128
  }
125
129
 
126
130
  onMessage(cb: (msg: NormalizedMessage) => void): void { this.messageCb = cb; }
127
- onReply(cb: (buttonId: string) => void): void { this.replyCb = cb; }
131
+ onReply(cb: (buttonId: string, sender: { chatId: string; userId: string }) => void): void { this.replyCb = cb; }
128
132
  status(): { connected: boolean } { return { connected: this.connected }; }
129
133
  }
@@ -0,0 +1,247 @@
1
+ // v0.2b 实验性:个人微信适配器(腾讯官方 iLink / ClawBot 协议,HTTP/JSON)。
2
+ // 协议形状与 DSH 生态已验证实现(super-wechat-bridge)一致:
3
+ // - 登录:GET /ilink/bot/get_bot_qrcode?bot_type=3 → GET /ilink/bot/get_qrcode_status?qrcode=
4
+ // - 收:POST /ilink/bot/getupdates(长轮询,响应 data.msgs + get_updates_buf 同步游标)
5
+ // - 发:POST /ilink/bot/sendmessage(msg 包裹 + item_list[].text_item,必须带 base_info.channel_version)
6
+ // 使用 iLink 需遵守《微信 ClawBot 功能使用条款》;本适配器标记为实验性,需真实设备验证。
7
+ import { randomUUID } from 'node:crypto';
8
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
9
+ import { join } from 'node:path';
10
+ import * as qrcode from 'qrcode-terminal';
11
+ import type { Adapter, NormalizedMessage, OutboundButton, OutboundPayload } from '../adapter.js';
12
+ import { PendingButtons } from '../pending-buttons.js';
13
+
14
+ const DEFAULT_BASE = 'https://ilinkai.weixin.qq.com';
15
+ const CHANNEL_VERSION = '1.0.2';
16
+ const TEXT_CHUNK_LIMIT = 800;
17
+
18
+ // ── 纯函数(可单测)───────────────────────────────────────────
19
+
20
+ export interface WeChatIncomingMsg {
21
+ from_user_id?: string;
22
+ context_token?: string;
23
+ text_item?: { text?: string };
24
+ item_list?: Array<{ type?: number; text_item?: { text?: string } }>;
25
+ }
26
+
27
+ /** 纯函数:从 iLink 消息提取文本(text_item 或 item_list[].text_item,type===1 为文本)。 */
28
+ export function extractWeChatText(msg: WeChatIncomingMsg): string | null {
29
+ if (msg.text_item?.text) return msg.text_item.text;
30
+ for (const item of msg.item_list ?? []) {
31
+ if (item.type === 1 && item.text_item?.text) return item.text_item.text;
32
+ }
33
+ return null;
34
+ }
35
+
36
+ /** 纯函数:iLink 入站消息 → NormalizedMessage;非文本/缺发送者返回 null。 */
37
+ export function parseWeChatUpdate(msg: WeChatIncomingMsg): NormalizedMessage | null {
38
+ const userId = msg.from_user_id;
39
+ const text = extractWeChatText(msg);
40
+ if (!userId || !text) return null;
41
+ return { chatId: userId, userId, text };
42
+ }
43
+
44
+ /** 纯函数:getupdates 长轮询请求体。base_info.channel_version 缺失时服务器不投递(生态实测)。 */
45
+ export function buildGetUpdatesBody(syncBuf: string): Record<string, unknown> {
46
+ return { get_updates_buf: syncBuf, longpolling_timeout: 35000, base_info: { channel_version: CHANNEL_VERSION } };
47
+ }
48
+
49
+ /** 纯函数:sendmessage 请求体(msg 包裹 + text_item)。clientId 可注入以便测试。 */
50
+ export function buildSendMessageBody(
51
+ toUserId: string,
52
+ text: string,
53
+ contextToken: string,
54
+ clientId = `dsh-${randomUUID()}`,
55
+ ): Record<string, unknown> {
56
+ return {
57
+ msg: {
58
+ from_user_id: '',
59
+ to_user_id: toUserId,
60
+ client_id: clientId,
61
+ message_type: 2,
62
+ message_state: 2,
63
+ context_token: contextToken,
64
+ item_list: [{ type: 1, text_item: { text } }],
65
+ },
66
+ base_info: { channel_version: CHANNEL_VERSION },
67
+ };
68
+ }
69
+
70
+ /** 纯函数:长文本按上限分段(iLink 单条消息长度限制)。 */
71
+ export function chunkText(text: string, limit = TEXT_CHUNK_LIMIT): string[] {
72
+ const chunks: string[] = [];
73
+ for (let i = 0; i < text.length; i += limit) chunks.push(text.slice(i, i + limit));
74
+ return chunks;
75
+ }
76
+
77
+ /** 纯函数:审批按钮 → 编号回复文本(原生按钮不支持时的兜底)。 */
78
+ export function buildNumberedReplyText(text: string, buttons: OutboundButton[]): string {
79
+ if (buttons.length === 0) return text;
80
+ const options = buttons.map((b, i) => `${i + 1}) ${b.label}`).join('\n');
81
+ return `${text}\n\n${options}\n\n回复数字选择。`;
82
+ }
83
+
84
+ // ── 适配器(实验性)────────────────────────────────────────────
85
+
86
+ export interface WeChatAdapterOptions {
87
+ /** iLink api-token(扫码登录后自动保存;也可预先配置 WECHAT_TOKEN) */
88
+ token?: string;
89
+ baseUrl?: string;
90
+ stateDir?: string;
91
+ }
92
+
93
+ export class WeChatAdapter implements Adapter {
94
+ readonly id = 'wechat';
95
+ private messageCb?: (msg: NormalizedMessage) => void;
96
+ private replyCb?: (buttonId: string, sender: { chatId: string; userId: string }) => void;
97
+ private readonly pendingButtons = new PendingButtons();
98
+ private readonly stateDir: string;
99
+ private token: string;
100
+ private syncBuf = '';
101
+ private readonly contextTokens = new Map<string, string>();
102
+ private polling = false;
103
+ private stopped = false;
104
+
105
+ constructor(private readonly opts: WeChatAdapterOptions) {
106
+ this.stateDir = opts.stateDir ?? 'data/wechat';
107
+ this.token = opts.token ?? '';
108
+ // 恢复持久化状态:token / sync 游标 / 上下文 token(回话关联必需)
109
+ try {
110
+ if (existsSync(join(this.stateDir, 'token.txt'))) this.token = this.token || readFileSync(join(this.stateDir, 'token.txt'), 'utf8').trim();
111
+ if (existsSync(join(this.stateDir, 'sync_buf.txt'))) this.syncBuf = readFileSync(join(this.stateDir, 'sync_buf.txt'), 'utf8').trim();
112
+ if (existsSync(join(this.stateDir, 'context_tokens.json'))) {
113
+ const tokens = JSON.parse(readFileSync(join(this.stateDir, 'context_tokens.json'), 'utf8')) as Record<string, unknown>;
114
+ for (const [k, v] of Object.entries(tokens)) if (typeof v === 'string') this.contextTokens.set(k, v);
115
+ }
116
+ } catch {
117
+ /* 状态文件损坏则忽略,走全新登录 */
118
+ }
119
+ }
120
+
121
+ private persistState(): void {
122
+ try {
123
+ mkdirSync(this.stateDir, { recursive: true });
124
+ writeFileSync(join(this.stateDir, 'token.txt'), this.token, 'utf8');
125
+ writeFileSync(join(this.stateDir, 'sync_buf.txt'), this.syncBuf, 'utf8');
126
+ writeFileSync(join(this.stateDir, 'context_tokens.json'), JSON.stringify(Object.fromEntries(this.contextTokens)), 'utf8');
127
+ } catch {
128
+ /* 持久化失败不阻断 */
129
+ }
130
+ }
131
+
132
+ async connect(): Promise<void> {
133
+ if (!this.token) {
134
+ console.log('[wechat] 未配置 WECHAT_TOKEN:尝试实验性扫码登录(v0.2b)…');
135
+ void this.tryLogin().then((ok) => { if (ok) this.startPolling(); });
136
+ return;
137
+ }
138
+ this.startPolling();
139
+ }
140
+
141
+ private base(): string {
142
+ return (this.opts.baseUrl ?? DEFAULT_BASE).replace(/\/$/, '');
143
+ }
144
+
145
+ private async apiFetch(endpoint: string, body: Record<string, unknown>, timeoutMs = 45000): Promise<Record<string, unknown>> {
146
+ const controller = new AbortController();
147
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
148
+ try {
149
+ const res = await fetch(`${this.base()}/${endpoint}`, {
150
+ method: 'POST',
151
+ headers: {
152
+ 'content-type': 'application/json',
153
+ AuthorizationType: 'ilink_bot_token',
154
+ Authorization: `Bearer ${this.token}`,
155
+ 'X-WECHAT-UIN': String(Math.floor(Math.random() * 0x7fffffff)),
156
+ },
157
+ body: JSON.stringify(body),
158
+ signal: controller.signal,
159
+ });
160
+ if (!res.ok) throw new Error(`iLink ${endpoint} HTTP ${res.status}`);
161
+ return (await res.json()) as Record<string, unknown>;
162
+ } finally {
163
+ clearTimeout(timer);
164
+ }
165
+ }
166
+
167
+ /** 实验性扫码登录:打印二维码(liteapp URL 渲染为 QR),轮询扫码状态,成功后持久化 token。 */
168
+ private async tryLogin(): Promise<boolean> {
169
+ try {
170
+ const res = await fetch(`${this.base()}/ilink/bot/get_bot_qrcode?bot_type=3`);
171
+ const d = (await res.json().catch(() => null)) as { qrcode?: string; qrcode_img_content?: string } | null;
172
+ if (!d?.qrcode || !d.qrcode_img_content) {
173
+ console.warn(`[wechat] 获取登录二维码失败: ${JSON.stringify(d).slice(0, 200)}`);
174
+ return false;
175
+ }
176
+ console.log('[wechat] 请用微信扫描下方二维码完成 iLink ClawBot 登录:');
177
+ qrcode.generate(d.qrcode_img_content, { small: true });
178
+ for (let i = 0; i < 60 && !this.stopped; i++) {
179
+ await new Promise((r) => setTimeout(r, 3000));
180
+ try {
181
+ const st = (await fetch(`${this.base()}/ilink/bot/get_qrcode_status?qrcode=${encodeURIComponent(d.qrcode)}`).then((r) => r.json())) as { bot_token?: string };
182
+ if (st?.bot_token) {
183
+ this.token = st.bot_token;
184
+ this.persistState();
185
+ console.log('[wechat] 扫码成功,已保存 token,开始收消息');
186
+ return true;
187
+ }
188
+ } catch {
189
+ /* 状态轮询瞬时失败继续重试 */
190
+ }
191
+ }
192
+ console.log('[wechat] 扫码超时(3 分钟未确认);重启适配器可重新生成二维码');
193
+ return false;
194
+ } catch (error) {
195
+ console.warn(`[wechat] 登录流程异常: ${error instanceof Error ? error.message : String(error)}`);
196
+ return false;
197
+ }
198
+ }
199
+
200
+ private startPolling(): void {
201
+ if (this.polling) return;
202
+ this.polling = true;
203
+ void this.pollLoop();
204
+ }
205
+
206
+ private async pollLoop(): Promise<void> {
207
+ while (!this.stopped && this.token) {
208
+ try {
209
+ const data = await this.apiFetch('ilink/bot/getupdates', buildGetUpdatesBody(this.syncBuf));
210
+ if (typeof data.get_updates_buf === 'string') {
211
+ this.syncBuf = data.get_updates_buf;
212
+ this.persistState();
213
+ }
214
+ for (const msg of (data.msgs as WeChatIncomingMsg[] | undefined) ?? []) {
215
+ const normalized = parseWeChatUpdate(msg);
216
+ if (!normalized) continue;
217
+ if (msg.context_token) this.contextTokens.set(normalized.userId, msg.context_token);
218
+ const button = this.pendingButtons.match(normalized.chatId, normalized.text);
219
+ if (button) {
220
+ this.replyCb?.(button.id, { chatId: normalized.chatId, userId: normalized.userId });
221
+ continue;
222
+ }
223
+ this.messageCb?.(normalized);
224
+ }
225
+ } catch (error) {
226
+ if (this.stopped) break;
227
+ console.warn(`[wechat] 轮询失败: ${error instanceof Error ? error.message : String(error)}`);
228
+ await new Promise((r) => setTimeout(r, 5000));
229
+ }
230
+ }
231
+ this.polling = false;
232
+ }
233
+
234
+ async send(chatId: string, payload: OutboundPayload): Promise<void> {
235
+ if (!this.token) throw new Error('wechat 未登录(无 token),无法发送');
236
+ if (payload.buttons?.length) this.pendingButtons.set(chatId, payload.buttons);
237
+ const text = buildNumberedReplyText(payload.text, payload.buttons ?? []);
238
+ const contextToken = this.contextTokens.get(chatId) ?? '';
239
+ for (const chunk of chunkText(text)) {
240
+ await this.apiFetch('ilink/bot/sendmessage', buildSendMessageBody(chatId, chunk, contextToken));
241
+ }
242
+ }
243
+
244
+ onMessage(cb: (msg: NormalizedMessage) => void): void { this.messageCb = cb; }
245
+ onReply(cb: (buttonId: string, sender: { chatId: string; userId: string }) => void): void { this.replyCb = cb; }
246
+ status(): { connected: boolean } { return { connected: Boolean(this.token) }; }
247
+ }
@@ -98,7 +98,7 @@ export class WeComAdapter implements Adapter {
98
98
  private server?: ReturnType<typeof createServer>;
99
99
  private connected = false;
100
100
  private messageCb?: (msg: NormalizedMessage) => void;
101
- private replyCb?: (buttonId: string) => void;
101
+ private replyCb?: (buttonId: string, sender: { chatId: string; userId: string }) => void;
102
102
  private readonly pendingButtons = new PendingButtons();
103
103
  /** access_token 缓存:企业微信 token 有效期 7200s,且有获取频率限制,必须复用。 */
104
104
  private tokenCache?: { token: string; expiresAt: number };
@@ -143,7 +143,7 @@ export class WeComAdapter implements Adapter {
143
143
  if (!normalized) return;
144
144
  const button = this.pendingButtons.match(normalized.chatId, normalized.text);
145
145
  if (button) {
146
- this.replyCb?.(button.id);
146
+ this.replyCb?.(button.id, { chatId: normalized.chatId, userId: normalized.userId });
147
147
  return;
148
148
  }
149
149
  this.messageCb?.(normalized);
@@ -178,7 +178,7 @@ export class WeComAdapter implements Adapter {
178
178
  }
179
179
 
180
180
  onMessage(cb: (msg: NormalizedMessage) => void): void { this.messageCb = cb; }
181
- onReply(cb: (buttonId: string) => void): void { this.replyCb = cb; }
181
+ onReply(cb: (buttonId: string, sender: { chatId: string; userId: string }) => void): void { this.replyCb = cb; }
182
182
  status(): { connected: boolean } { return { connected: this.connected }; }
183
183
  }
184
184
 
@@ -129,7 +129,7 @@ export class WhatsAppAdapter implements Adapter {
129
129
  private sock?: WASocket;
130
130
  private connected = false;
131
131
  private messageCb?: (msg: NormalizedMessage) => void;
132
- private replyCb?: (buttonId: string) => void;
132
+ private replyCb?: (buttonId: string, sender: { chatId: string; userId: string }) => void;
133
133
  /** chatId → 当前 pending 按钮(编号回复 → 按钮 id,带 TTL 防过期误吞) */
134
134
  private readonly pendingButtons = new PendingButtons();
135
135
 
@@ -175,7 +175,10 @@ export class WhatsAppAdapter implements Adapter {
175
175
  if (buttonId) {
176
176
  const chatId = waRaw.key?.remoteJid;
177
177
  if (chatId) this.pendingButtons.consume(chatId);
178
- this.replyCb?.(buttonId);
178
+ this.replyCb?.(buttonId, {
179
+ chatId: chatId ?? '',
180
+ userId: waRaw.key?.participant ?? chatId ?? '',
181
+ });
179
182
  continue;
180
183
  }
181
184
  const normalized = normalizeWhatsAppMessage(waRaw);
@@ -183,7 +186,10 @@ export class WhatsAppAdapter implements Adapter {
183
186
  // 编号回复兜底:若该 chat 有 pending 按钮且消息是数字(TTL 内),转成按钮点击
184
187
  const button = this.pendingButtons.match(normalized.msg.chatId, normalized.msg.text);
185
188
  if (button) {
186
- this.replyCb?.(button.id);
189
+ this.replyCb?.(button.id, {
190
+ chatId: normalized.msg.chatId,
191
+ userId: normalized.msg.userId,
192
+ });
187
193
  continue;
188
194
  }
189
195
  this.messageCb?.(normalized.msg);
@@ -223,6 +229,6 @@ export class WhatsAppAdapter implements Adapter {
223
229
  }
224
230
 
225
231
  onMessage(cb: (msg: NormalizedMessage) => void): void { this.messageCb = cb; }
226
- onReply(cb: (buttonId: string) => void): void { this.replyCb = cb; }
232
+ onReply(cb: (buttonId: string, sender: { chatId: string; userId: string }) => void): void { this.replyCb = cb; }
227
233
  status(): { connected: boolean } { return { connected: this.connected }; }
228
234
  }
package/src/config.ts CHANGED
@@ -7,6 +7,7 @@ import { SlackAdapter } from './adapters/slack.js';
7
7
  import { FeishuAdapter } from './adapters/feishu.js';
8
8
  import { DingTalkAdapter } from './adapters/dingtalk.js';
9
9
  import { WeComAdapter } from './adapters/wecom.js';
10
+ import { WeChatAdapter } from './adapters/wechat.js';
10
11
 
11
12
  /** 平台适配器需要的全部环境变量(缺省为 undefined = 不启用该平台)。 */
12
13
  export interface AdapterEnv {
@@ -25,6 +26,8 @@ export interface AdapterEnv {
25
26
  wecomToken?: string;
26
27
  wecomEncodingAESKey?: string;
27
28
  wecomCallbackPort?: string;
29
+ wechatToken?: string;
30
+ wechatStateDir?: string;
28
31
  asrApiKey?: string;
29
32
  asrBaseUrl?: string;
30
33
  asrModel?: string;
@@ -66,6 +69,9 @@ export function createAdapter(id: string, env: AdapterEnv): Adapter {
66
69
  token: env.wecomToken, encodingAESKey: env.wecomEncodingAESKey,
67
70
  callbackPort: Number(env.wecomCallbackPort ?? 3193),
68
71
  });
72
+ case 'wechat':
73
+ // 实验性(v0.2b):token 可缺省——未配置时启动扫码登录(iLink/ClawBot)
74
+ return new WeChatAdapter({ token: env.wechatToken, stateDir: env.wechatStateDir ?? 'data/wechat' });
69
75
  default:
70
76
  throw new Error(`unknown adapter: ${id}`);
71
77
  }
@@ -89,6 +95,8 @@ export function adapterEnvFromProcess(env: NodeJS.ProcessEnv = process.env): Ada
89
95
  wecomToken: env.WECOM_TOKEN,
90
96
  wecomEncodingAESKey: env.WECOM_ENCODING_AES_KEY,
91
97
  wecomCallbackPort: env.WECOM_CALLBACK_PORT,
98
+ wechatToken: env.WECHAT_TOKEN,
99
+ wechatStateDir: env.WECHAT_STATE_DIR,
92
100
  asrApiKey: env.ASR_API_KEY,
93
101
  asrBaseUrl: env.ASR_BASE_URL,
94
102
  asrModel: env.ASR_MODEL,