@dsh-overdrive/gateway 0.2.0 → 0.3.1

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 (61) hide show
  1. package/dist/commands.d.ts +5 -0
  2. package/dist/commands.js +14 -4
  3. package/dist/commands.js.map +1 -1
  4. package/dist/index.d.ts +7 -1
  5. package/dist/index.js +57 -8
  6. package/dist/index.js.map +1 -1
  7. package/dist/memory.d.ts +10 -0
  8. package/dist/memory.js +43 -0
  9. package/dist/memory.js.map +1 -1
  10. package/dist/mention.d.ts +13 -0
  11. package/dist/mention.js +43 -0
  12. package/dist/mention.js.map +1 -0
  13. package/dist/pending-buttons.d.ts +5 -1
  14. package/dist/pending-buttons.js +26 -8
  15. package/dist/pending-buttons.js.map +1 -1
  16. package/dist/text.d.ts +2 -0
  17. package/dist/text.js +24 -0
  18. package/dist/text.js.map +1 -0
  19. package/package.json +9 -4
  20. package/src/adapter.ts +0 -42
  21. package/src/adapters/cli.ts +0 -37
  22. package/src/adapters/dingtalk.ts +0 -206
  23. package/src/adapters/discord.ts +0 -127
  24. package/src/adapters/feishu.ts +0 -224
  25. package/src/adapters/slack.ts +0 -123
  26. package/src/adapters/telegram.ts +0 -142
  27. package/src/adapters/wechat.ts +0 -247
  28. package/src/adapters/wecom.ts +0 -218
  29. package/src/adapters/whatsapp.ts +0 -249
  30. package/src/asr.ts +0 -83
  31. package/src/commands.ts +0 -89
  32. package/src/config.ts +0 -104
  33. package/src/feed.ts +0 -190
  34. package/src/index.ts +0 -456
  35. package/src/memory.ts +0 -133
  36. package/src/pending-buttons.ts +0 -45
  37. package/src/session.ts +0 -23
  38. package/src/setup.ts +0 -252
  39. package/src/status.ts +0 -63
  40. package/src/trajectory.ts +0 -45
  41. package/test/adapters.dingtalk.test.ts +0 -64
  42. package/test/adapters.discord.test.ts +0 -41
  43. package/test/adapters.feishu.test.ts +0 -66
  44. package/test/adapters.slack.test.ts +0 -45
  45. package/test/adapters.telegram.test.ts +0 -37
  46. package/test/adapters.wechat.test.ts +0 -78
  47. package/test/adapters.wecom.test.ts +0 -62
  48. package/test/adapters.whatsapp.test.ts +0 -138
  49. package/test/asr.test.ts +0 -77
  50. package/test/commands.test.ts +0 -43
  51. package/test/config.test.ts +0 -30
  52. package/test/feed.test.ts +0 -111
  53. package/test/memory.test.ts +0 -79
  54. package/test/multi.test.ts +0 -258
  55. package/test/outbound.test.ts +0 -29
  56. package/test/pending-buttons.test.ts +0 -61
  57. package/test/session.test.ts +0 -26
  58. package/test/status.test.ts +0 -41
  59. package/test/streaming.test.ts +0 -162
  60. package/test/trajectory.test.ts +0 -58
  61. package/tsconfig.json +0 -5
@@ -1,123 +0,0 @@
1
- import Bolt from '@slack/bolt';
2
- import type { Adapter, NormalizedMessage, OutboundButton, OutboundPayload } from '../adapter.js';
3
-
4
- // @slack/bolt 是 CommonJS 包:Node 原生 ESM 下 `import { App }`(命名导入)会因
5
- // cjs-module-lexer 无法识别其导出而失败(App 为 undefined / 静态导入抛 SyntaxError),
6
- // 必须 default 导入后解构。
7
- const { App } = Bolt;
8
-
9
- // ── 纯函数 ────────────────────────────────────────────────────
10
-
11
- export interface RawSlackMessage {
12
- channel?: string;
13
- user?: string;
14
- text?: string;
15
- subtype?: string;
16
- bot_id?: string;
17
- files?: Array<{ url_private?: string; mimetype?: string }>;
18
- }
19
-
20
- /** 纯函数:取第一条文件的私有 URL(files[0].url_private);无文件返回 undefined。 */
21
- export function slackFileUrl(raw: RawSlackMessage): string | undefined {
22
- return raw.files?.[0]?.url_private;
23
- }
24
-
25
- type MediaKind = 'voice' | 'image' | 'video' | 'file';
26
-
27
- function mediaKindFromMime(mime?: string): MediaKind {
28
- if (mime?.startsWith('image/')) return 'image';
29
- if (mime?.startsWith('audio/')) return 'voice';
30
- if (mime?.startsWith('video/')) return 'video';
31
- return 'file';
32
- }
33
-
34
- export function normalizeSlackMessage(raw: RawSlackMessage): NormalizedMessage | null {
35
- if (!raw.channel || !raw.user || raw.subtype === 'bot_message' || raw.bot_id) return null;
36
- const text = raw.text ?? '';
37
- const url = slackFileUrl(raw);
38
- if (!text && !url) return null;
39
- const out: NormalizedMessage = { chatId: raw.channel, userId: raw.user, text };
40
- if (url) out.media = { kind: mediaKindFromMime(raw.files?.[0]?.mimetype), url };
41
- return out;
42
- }
43
-
44
- export function slackBlocks(text: string, buttons: OutboundButton[]): unknown[] {
45
- const blocks: unknown[] = [{ type: 'section', text: { type: 'mrkdwn', text } }];
46
- if (buttons.length > 0) {
47
- blocks.push({
48
- type: 'actions',
49
- elements: buttons.map((b) => ({
50
- type: 'button',
51
- value: b.id,
52
- text: { type: 'plain_text', text: b.label },
53
- })),
54
- });
55
- }
56
- return blocks;
57
- }
58
-
59
- // ── 适配器 ────────────────────────────────────────────────────
60
-
61
- export interface SlackAdapterOptions {
62
- botToken: string;
63
- appToken: string;
64
- }
65
-
66
- export class SlackAdapter implements Adapter {
67
- readonly id = 'slack';
68
- // 解构出的 App 只有值绑定(无类型绑定),实例类型需用 InstanceType<typeof App>
69
- private readonly app: InstanceType<typeof App>;
70
- private connected = false;
71
- private messageCb?: (msg: NormalizedMessage) => void;
72
- private replyCb?: (buttonId: string, sender: { chatId: string; userId: string }) => void;
73
-
74
- constructor(opts: SlackAdapterOptions) {
75
- this.app = new App({ token: opts.botToken, appToken: opts.appToken, socketMode: true });
76
- }
77
-
78
- async connect(): Promise<void> {
79
- this.app.message(async ({ message }) => {
80
- const msg = normalizeSlackMessage(message as RawSlackMessage);
81
- if (msg) this.messageCb?.(msg);
82
- });
83
- this.app.action(/^approve:|^reject:/, async ({ ack, body, respond }) => {
84
- await ack();
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
- }
97
- await respond({ text: '处理中…', replace_original: false }).catch(() => undefined);
98
- });
99
- await this.app.start(0); // Socket Mode 不需要端口;start(0) 仅建立连接
100
- this.connected = true;
101
- console.log('[slack] 已连接 Slack(Socket Mode)');
102
- }
103
-
104
- async send(chatId: string, payload: OutboundPayload): Promise<void> {
105
- if (payload.media) {
106
- await this.app.client.files.uploadV2({
107
- channel_id: chatId,
108
- file: payload.media.path,
109
- filename: payload.media.caption ?? payload.media.path.split('/').pop(),
110
- });
111
- return;
112
- }
113
- await this.app.client.chat.postMessage({
114
- channel: chatId,
115
- text: payload.text,
116
- blocks: slackBlocks(payload.text, payload.buttons ?? []) as never,
117
- });
118
- }
119
-
120
- onMessage(cb: (msg: NormalizedMessage) => void): void { this.messageCb = cb; }
121
- onReply(cb: (buttonId: string, sender: { chatId: string; userId: string }) => void): void { this.replyCb = cb; }
122
- status(): { connected: boolean } { return { connected: this.connected }; }
123
- }
@@ -1,142 +0,0 @@
1
- import { Bot, InlineKeyboard, InputFile } from 'grammy';
2
- import type { Adapter, NormalizedMessage, OutboundButton, OutboundPayload } from '../adapter.js';
3
-
4
- // ── 纯函数 ────────────────────────────────────────────────────
5
-
6
- export interface RawTelegramMessage {
7
- chat?: { id?: number | string };
8
- from?: { id?: number | string };
9
- message?: {
10
- text?: string;
11
- caption?: string;
12
- photo?: Array<{ file_id?: string }>;
13
- voice?: { file_id?: string; mime_type?: string };
14
- audio?: { file_id?: string; mime_type?: string };
15
- video?: { file_id?: string };
16
- document?: { file_id?: string };
17
- };
18
- }
19
-
20
- /** 纯函数:取 photo 数组最后一张(最大尺寸)的 file_id;无图返回 undefined。 */
21
- export function telegramPhotoFileId(photo: Array<{ file_id?: string }>): string | undefined {
22
- return photo[photo.length - 1]?.file_id;
23
- }
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
-
35
- /** 纯函数:Telegram 文件下载 URL 模板。file_path 需 getFile(file_id) 换取(真实调用在 adapter 薄层)。 */
36
- export function telegramImageUrl(token: string, filePath: string): string {
37
- return `https://api.telegram.org/file/bot${token}/${filePath}`;
38
- }
39
-
40
- export function normalizeTelegramMessage(raw: RawTelegramMessage): NormalizedMessage | null {
41
- if (raw.chat?.id === undefined || raw.from?.id === undefined) return null;
42
- const msg = raw.message;
43
- if (!msg) return null;
44
- const text = msg.text ?? msg.caption ?? '';
45
- let media: NormalizedMessage['media'];
46
- if (msg.photo?.length) media = { kind: 'image' }; // url 由 adapter getFile 薄层填充
47
- else if (msg.voice || msg.audio) {
48
- media = { kind: 'voice', mime: telegramVoiceMime(raw) }; // url 由 adapter getFile 薄层填充(ASR 用)
49
- }
50
- else if (msg.video) media = { kind: 'video' };
51
- else if (msg.document) media = { kind: 'file' };
52
- if (!text && !media) return null;
53
- const out: NormalizedMessage = { chatId: String(raw.chat.id), userId: String(raw.from.id), text };
54
- if (media) out.media = media;
55
- return out;
56
- }
57
-
58
- export function buttonRows(buttons: OutboundButton[]): Array<[string, string]> {
59
- return buttons.map((b) => [b.label, b.id]);
60
- }
61
-
62
- // ── 适配器 ────────────────────────────────────────────────────
63
-
64
- export interface TelegramAdapterOptions { token: string; }
65
-
66
- export class TelegramAdapter implements Adapter {
67
- readonly id = 'telegram';
68
- private readonly bot: Bot;
69
- private readonly token: string;
70
- private connected = false;
71
- private messageCb?: (msg: NormalizedMessage) => void;
72
- private replyCb?: (buttonId: string, sender: { chatId: string; userId: string }) => void;
73
-
74
- constructor(opts: TelegramAdapterOptions) {
75
- this.token = opts.token;
76
- this.bot = new Bot(opts.token);
77
- }
78
-
79
- async connect(): Promise<void> {
80
- await this.bot.api.getMe(); // 校验 token
81
- this.connected = true;
82
- this.bot.on('message', (ctx) => {
83
- console.log(`[telegram] 收到更新: chat=${ctx.chat?.id} from=${ctx.from?.id} text="${ctx.message?.text?.slice(0, 40) ?? ''}"`);
84
- void this.handleMessage(ctx as never);
85
- });
86
- this.bot.on('callback_query:data', async (ctx) => {
87
- const data = ctx.callbackQuery.data;
88
- await ctx.answerCallbackQuery().catch(() => undefined);
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
- });
94
- });
95
- this.bot.catch((err) => console.error('[telegram]', err));
96
- void this.bot.start(); // 长轮询(自托管无需 webhook)
97
- }
98
-
99
- /** 薄层:photo/voice/audio → getFile(file_id) 换 file_path → 下载 URL 填充 msg.media.url(纯函数只做模板)。 */
100
- private async handleMessage(raw: RawTelegramMessage): Promise<void> {
101
- const msg = normalizeTelegramMessage(raw);
102
- if (!msg) return;
103
- const fileId =
104
- msg.media?.kind === 'image'
105
- ? telegramPhotoFileId(raw.message?.photo ?? [])
106
- : msg.media?.kind === 'voice'
107
- ? telegramVoiceFileId(raw)
108
- : undefined;
109
- if (fileId) {
110
- try {
111
- const file = await this.bot.api.getFile(fileId);
112
- if (file.file_path) msg.media!.url = telegramImageUrl(this.token, file.file_path);
113
- } catch (error) {
114
- console.error('[telegram] getFile 失败:', error);
115
- }
116
- }
117
- this.messageCb?.(msg);
118
- }
119
-
120
- async send(chatId: string, payload: OutboundPayload): Promise<void> {
121
- if (payload.media) {
122
- const file = new InputFile(payload.media.path);
123
- if (payload.media.kind === 'image') {
124
- await this.bot.api.sendPhoto(chatId, file, { caption: payload.media.caption ?? '' });
125
- } else {
126
- await this.bot.api.sendDocument(chatId, file, { caption: payload.media.caption ?? '' });
127
- }
128
- return;
129
- }
130
- if (payload.buttons?.length) {
131
- const kb = new InlineKeyboard();
132
- for (const [label, id] of buttonRows(payload.buttons)) kb.text(label, id);
133
- await this.bot.api.sendMessage(chatId, payload.text, { reply_markup: kb });
134
- return;
135
- }
136
- await this.bot.api.sendMessage(chatId, payload.text);
137
- }
138
-
139
- onMessage(cb: (msg: NormalizedMessage) => void): void { this.messageCb = cb; }
140
- onReply(cb: (buttonId: string, sender: { chatId: string; userId: string }) => void): void { this.replyCb = cb; }
141
- status(): { connected: boolean } { return { connected: this.connected }; }
142
- }
@@ -1,247 +0,0 @@
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
- }