pikiloop 0.4.0

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 (154) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +353 -0
  3. package/README.v2.md +287 -0
  4. package/README.zh-CN.md +352 -0
  5. package/dashboard/dist/assets/AgentTab-UZPIhlkr.js +1 -0
  6. package/dashboard/dist/assets/DirBrowser-Ckcmi-Pi.js +1 -0
  7. package/dashboard/dist/assets/ExtensionsTab-KZhEDrdu.js +1 -0
  8. package/dashboard/dist/assets/IMAccessTab-Bd_IY1GQ.js +1 -0
  9. package/dashboard/dist/assets/Modal-CTeL0y7P.js +1 -0
  10. package/dashboard/dist/assets/Modals-axftHasy.js +1 -0
  11. package/dashboard/dist/assets/Select-C8tOdPhe.js +1 -0
  12. package/dashboard/dist/assets/SessionPanel-C1geSRxw.js +1 -0
  13. package/dashboard/dist/assets/SystemTab-DBDkaPiO.js +1 -0
  14. package/dashboard/dist/assets/anthropic-BAdojD7P.ico +0 -0
  15. package/dashboard/dist/assets/codex-DYadqqp0.png +0 -0
  16. package/dashboard/dist/assets/deepseek-BeYNZEk0.ico +0 -0
  17. package/dashboard/dist/assets/doubao-DloFDuFR.png +0 -0
  18. package/dashboard/dist/assets/feishu-C4OMrjCW.ico +0 -0
  19. package/dashboard/dist/assets/gemini-BYkEpiWr.svg +1 -0
  20. package/dashboard/dist/assets/hermes-BAarh-tH.png +0 -0
  21. package/dashboard/dist/assets/index-CpM4CqZJ.js +23 -0
  22. package/dashboard/dist/assets/index-DXSohzrE.js +3 -0
  23. package/dashboard/dist/assets/index-reSbuley.css +1 -0
  24. package/dashboard/dist/assets/markdown-DxQYQFeH.js +29 -0
  25. package/dashboard/dist/assets/minimax-PuEGTfrF.ico +0 -0
  26. package/dashboard/dist/assets/mlx-DhWwjtMw.png +0 -0
  27. package/dashboard/dist/assets/ollama-Bt9O-2K_.png +0 -0
  28. package/dashboard/dist/assets/openrouter-CsJ_bD5Q.ico +0 -0
  29. package/dashboard/dist/assets/playwright-BldPFZgC.ico +0 -0
  30. package/dashboard/dist/assets/qwen-xykkX0_y.png +0 -0
  31. package/dashboard/dist/assets/react-vendor-C7Sl8SE7.js +9 -0
  32. package/dashboard/dist/assets/router-DHISdpPk.js +3 -0
  33. package/dashboard/dist/assets/shared-BIP_4k4I.js +1 -0
  34. package/dashboard/dist/favicon.svg +28 -0
  35. package/dashboard/dist/index.html +17 -0
  36. package/dist/agent/acp-client.js +261 -0
  37. package/dist/agent/auto-update.js +432 -0
  38. package/dist/agent/await-resume.js +50 -0
  39. package/dist/agent/cli/auth.js +325 -0
  40. package/dist/agent/cli/catalog.js +40 -0
  41. package/dist/agent/cli/detector.js +136 -0
  42. package/dist/agent/cli/index.js +7 -0
  43. package/dist/agent/cli/registry.js +33 -0
  44. package/dist/agent/driver.js +39 -0
  45. package/dist/agent/drivers/claude-tui.js +2297 -0
  46. package/dist/agent/drivers/claude.js +2689 -0
  47. package/dist/agent/drivers/codex.js +2210 -0
  48. package/dist/agent/drivers/gemini.js +1059 -0
  49. package/dist/agent/drivers/hermes.js +795 -0
  50. package/dist/agent/goal.js +274 -0
  51. package/dist/agent/handover.js +130 -0
  52. package/dist/agent/images.js +355 -0
  53. package/dist/agent/index.js +50 -0
  54. package/dist/agent/mcp/bridge.js +791 -0
  55. package/dist/agent/mcp/extensions.js +637 -0
  56. package/dist/agent/mcp/oauth.js +353 -0
  57. package/dist/agent/mcp/registry.js +119 -0
  58. package/dist/agent/mcp/session-server.js +229 -0
  59. package/dist/agent/mcp/tools/ask-user.js +113 -0
  60. package/dist/agent/mcp/tools/await-resume.js +77 -0
  61. package/dist/agent/mcp/tools/goal.js +144 -0
  62. package/dist/agent/mcp/tools/types.js +12 -0
  63. package/dist/agent/mcp/tools/workspace.js +212 -0
  64. package/dist/agent/npm.js +31 -0
  65. package/dist/agent/session.js +1206 -0
  66. package/dist/agent/skill-installer.js +160 -0
  67. package/dist/agent/skills.js +257 -0
  68. package/dist/agent/stream.js +743 -0
  69. package/dist/agent/types.js +13 -0
  70. package/dist/agent/utils.js +687 -0
  71. package/dist/bot/bot.js +2499 -0
  72. package/dist/bot/command-ui.js +633 -0
  73. package/dist/bot/commands.js +513 -0
  74. package/dist/bot/headless-bot.js +36 -0
  75. package/dist/bot/host.js +192 -0
  76. package/dist/bot/human-loop.js +168 -0
  77. package/dist/bot/menu.js +48 -0
  78. package/dist/bot/orchestration.js +79 -0
  79. package/dist/bot/render-shared.js +309 -0
  80. package/dist/bot/session-hub.js +361 -0
  81. package/dist/bot/session-status.js +55 -0
  82. package/dist/bot/streaming.js +309 -0
  83. package/dist/browser-profile.js +579 -0
  84. package/dist/browser-supervisor.js +249 -0
  85. package/dist/catalog/cli-tools.js +421 -0
  86. package/dist/catalog/index.js +21 -0
  87. package/dist/catalog/local-models.js +94 -0
  88. package/dist/catalog/mcp-servers.js +315 -0
  89. package/dist/catalog/skill-repos.js +173 -0
  90. package/dist/channels/base.js +55 -0
  91. package/dist/channels/dingtalk/bot.js +549 -0
  92. package/dist/channels/dingtalk/channel.js +268 -0
  93. package/dist/channels/discord/bot.js +552 -0
  94. package/dist/channels/discord/channel.js +245 -0
  95. package/dist/channels/feishu/bot.js +1275 -0
  96. package/dist/channels/feishu/channel.js +911 -0
  97. package/dist/channels/feishu/markdown.js +91 -0
  98. package/dist/channels/feishu/render.js +619 -0
  99. package/dist/channels/health.js +109 -0
  100. package/dist/channels/slack/bot.js +554 -0
  101. package/dist/channels/slack/channel.js +283 -0
  102. package/dist/channels/states.js +6 -0
  103. package/dist/channels/telegram/bot.js +1310 -0
  104. package/dist/channels/telegram/channel.js +820 -0
  105. package/dist/channels/telegram/directory.js +111 -0
  106. package/dist/channels/telegram/live-preview.js +220 -0
  107. package/dist/channels/telegram/render.js +384 -0
  108. package/dist/channels/wecom/bot.js +558 -0
  109. package/dist/channels/wecom/channel.js +479 -0
  110. package/dist/channels/weixin/api.js +520 -0
  111. package/dist/channels/weixin/bot.js +1000 -0
  112. package/dist/channels/weixin/channel.js +222 -0
  113. package/dist/cli/autostart.js +262 -0
  114. package/dist/cli/channel-supervisor.js +313 -0
  115. package/dist/cli/channels.js +54 -0
  116. package/dist/cli/main.js +726 -0
  117. package/dist/cli/onboarding.js +227 -0
  118. package/dist/cli/run.js +308 -0
  119. package/dist/cli/setup-wizard.js +235 -0
  120. package/dist/core/config/runtime-config.js +201 -0
  121. package/dist/core/config/user-config.js +510 -0
  122. package/dist/core/config/validation.js +521 -0
  123. package/dist/core/constants.js +400 -0
  124. package/dist/core/git.js +145 -0
  125. package/dist/core/legacy-compat.js +60 -0
  126. package/dist/core/logging.js +101 -0
  127. package/dist/core/platform.js +59 -0
  128. package/dist/core/process-control.js +315 -0
  129. package/dist/core/secrets/index.js +42 -0
  130. package/dist/core/secrets/inline-seal.js +60 -0
  131. package/dist/core/secrets/ref.js +33 -0
  132. package/dist/core/secrets/resolver.js +65 -0
  133. package/dist/core/secrets/store.js +63 -0
  134. package/dist/core/utils.js +233 -0
  135. package/dist/core/version.js +15 -0
  136. package/dist/dashboard/platform.js +219 -0
  137. package/dist/dashboard/routes/agents.js +450 -0
  138. package/dist/dashboard/routes/cli.js +174 -0
  139. package/dist/dashboard/routes/config.js +523 -0
  140. package/dist/dashboard/routes/extensions.js +745 -0
  141. package/dist/dashboard/routes/local-models.js +290 -0
  142. package/dist/dashboard/routes/models.js +324 -0
  143. package/dist/dashboard/routes/sessions.js +838 -0
  144. package/dist/dashboard/runtime.js +410 -0
  145. package/dist/dashboard/server.js +237 -0
  146. package/dist/dashboard/session-control.js +347 -0
  147. package/dist/model/catalog.js +104 -0
  148. package/dist/model/index.js +20 -0
  149. package/dist/model/injector.js +272 -0
  150. package/dist/model/provider-models.js +112 -0
  151. package/dist/model/store.js +212 -0
  152. package/dist/model/types.js +13 -0
  153. package/dist/model/validation.js +203 -0
  154. package/package.json +82 -0
@@ -0,0 +1,479 @@
1
+ /**
2
+ * WeChat Work / 企业微信 智能机器人 channel transport — Smart Bot WebSocket.
3
+ *
4
+ * Endpoint: wss://openws.work.weixin.qq.com
5
+ * Frame envelope:
6
+ * { cmd, headers: { req_id }, body }
7
+ * responses omit `cmd` and may carry { errcode, errmsg }
8
+ *
9
+ * Outbound:
10
+ * - aibot_subscribe { bot_id, secret } — auth + register
11
+ * - ping — heartbeat (30s)
12
+ * - aibot_respond_msg (stream, finish=true) — reply to a callback
13
+ * - aibot_send_msg (markdown) — proactive send
14
+ *
15
+ * Inbound:
16
+ * - aibot_msg_callback { msgid, chatid, chattype, from.userid, msgtype, text/voice/image/file/mixed/quote }
17
+ *
18
+ * Caveats:
19
+ * - text/voice messages are dispatched as text. Image/file/mixed payloads
20
+ * reference encrypted URLs that require a separate corp-secret + AES key
21
+ * to download — out of scope for this minimal transport.
22
+ * - The protocol has no edit primitive. editMessage is a no-op.
23
+ * - Plain text messages may be sent via aibot_respond_msg (in conversation,
24
+ * when we still hold the original req_id) or aibot_send_msg (proactive
25
+ * send to a known chatid). We use the original req_id for the first
26
+ * reply and switch to aibot_send_msg for follow-up messages in the same
27
+ * conversation (e.g. streaming result deliveries).
28
+ */
29
+ import { EventEmitter } from 'node:events';
30
+ import { WebSocket } from 'ws';
31
+ import { Channel, DEFAULT_CHANNEL_CAPABILITIES, splitText, sleep, } from '../base.js';
32
+ import { WECOM_LIMITS } from '../../core/constants.js';
33
+ import { writeScopedLog } from '../../core/logging.js';
34
+ import { ChannelHealth } from '../health.js';
35
+ const WC_MAX = WECOM_LIMITS.maxMessageLength;
36
+ const WC_HEARTBEAT_MS = WECOM_LIMITS.heartbeatInterval;
37
+ function describeError(err) {
38
+ return err instanceof Error ? err.message : String(err ?? 'unknown error');
39
+ }
40
+ export class WeComChannel extends Channel {
41
+ capabilities = {
42
+ ...DEFAULT_CHANNEL_CAPABILITIES,
43
+ // Smart Bot WS protocol has no edit primitive.
44
+ };
45
+ knownChats = new Set();
46
+ botId;
47
+ botSecret;
48
+ endpoint;
49
+ allowedUserIds;
50
+ ws = null;
51
+ running = false;
52
+ listenResolve = null;
53
+ heartbeatTimer = null;
54
+ missedPong = 0;
55
+ reqSeq = 0;
56
+ chatMeta = new Map();
57
+ /** req_id -> deferred ack handler. */
58
+ pendingAcks = new Map();
59
+ /** Dedup by msgid. */
60
+ seenMsgIds = new Set();
61
+ seenMsgQueue = [];
62
+ static SEEN_CAP = 256;
63
+ /** Subscribe ack waiter — only set during initial handshake. */
64
+ subscribeAck = null;
65
+ internalEmitter = new EventEmitter();
66
+ messageHandlers = new Set();
67
+ errorHandlers = new Set();
68
+ constructor(opts) {
69
+ super();
70
+ this.botId = opts.botId;
71
+ this.botSecret = opts.botSecret;
72
+ this.endpoint = (opts.endpoint || WECOM_LIMITS.defaultEndpoint).replace(/\/+$/, '/');
73
+ this.allowedUserIds = opts.allowedUserIds;
74
+ this.internalEmitter.setMaxListeners(0);
75
+ }
76
+ onMessage(handler) { this.messageHandlers.add(handler); return this; }
77
+ onError(handler) { this.errorHandlers.add(handler); return this; }
78
+ // ========================================================================
79
+ // Lifecycle
80
+ // ========================================================================
81
+ async connect() {
82
+ const shortId = this.botId.length > 12 ? `${this.botId.slice(0, 6)}...${this.botId.slice(-4)}` : this.botId;
83
+ this.bot = {
84
+ id: this.botId,
85
+ username: `wecom_${shortId}`,
86
+ displayName: `WeCom ${shortId}`,
87
+ };
88
+ return this.bot;
89
+ }
90
+ async listen() {
91
+ this.running = true;
92
+ const health = new ChannelHealth({
93
+ label: 'WeCom',
94
+ opAction: 'WS connect',
95
+ initialDelayMs: WECOM_LIMITS.initialRetryDelay,
96
+ maxDelayMs: WECOM_LIMITS.maxRetryDelay,
97
+ sustainedFailureHint: 'verify wecomBotId / wecomBotSecret / wecomEndpoint in setting.json',
98
+ log: (msg, level) => this.log(msg, level),
99
+ });
100
+ while (this.running) {
101
+ const connectedAt = Date.now();
102
+ let connectionErr = null;
103
+ try {
104
+ await this.runConnection();
105
+ }
106
+ catch (err) {
107
+ connectionErr = err;
108
+ this.emitError(err instanceof Error ? err : new Error(describeError(err)));
109
+ }
110
+ if (!this.running)
111
+ break;
112
+ // A connection that survived >2 heartbeats counts as "healthy session
113
+ // ended" → reset backoff and reconnect quickly. Anything shorter is
114
+ // treated as a failed attempt that advances exponential backoff.
115
+ const wasLongLived = Date.now() - connectedAt > 2 * WC_HEARTBEAT_MS;
116
+ const delayMs = wasLongLived
117
+ ? (health.recordSuccess(), WECOM_LIMITS.initialRetryDelay)
118
+ : health.recordFailure(connectionErr ?? new Error('connection dropped'));
119
+ await sleep(delayMs);
120
+ }
121
+ }
122
+ disconnect() {
123
+ this.running = false;
124
+ this.stopHeartbeat();
125
+ if (this.ws) {
126
+ try {
127
+ this.ws.close();
128
+ }
129
+ catch { }
130
+ this.ws = null;
131
+ }
132
+ this.listenResolve?.();
133
+ this.listenResolve = null;
134
+ }
135
+ // ========================================================================
136
+ // Single-connection lifecycle
137
+ // ========================================================================
138
+ runConnection() {
139
+ return new Promise((resolve, reject) => {
140
+ let settled = false;
141
+ const finish = (err) => {
142
+ if (settled)
143
+ return;
144
+ settled = true;
145
+ this.stopHeartbeat();
146
+ this.failPendingAcks();
147
+ try {
148
+ ws.close();
149
+ }
150
+ catch { }
151
+ if (err)
152
+ reject(err);
153
+ else
154
+ resolve();
155
+ };
156
+ this.debug(`[ws] dialing ${this.endpoint}`);
157
+ const ws = new WebSocket(this.endpoint);
158
+ this.ws = ws;
159
+ this.missedPong = 0;
160
+ ws.on('open', () => {
161
+ this.subscribe().catch(err => finish(err instanceof Error ? err : new Error(describeError(err))));
162
+ });
163
+ ws.on('message', raw => {
164
+ try {
165
+ const text = raw instanceof Buffer ? raw.toString('utf-8') : String(raw);
166
+ const frame = JSON.parse(text);
167
+ this.handleFrame(frame);
168
+ }
169
+ catch (err) {
170
+ this.debug(`[ws] invalid json: ${describeError(err)}`);
171
+ }
172
+ });
173
+ ws.on('error', err => {
174
+ const error = err instanceof Error ? err : new Error(describeError(err));
175
+ finish(error);
176
+ });
177
+ ws.on('close', (code, reasonBuf) => {
178
+ const reason = reasonBuf?.toString?.() || '';
179
+ const detail = reason ? `${code} ${reason}` : `${code}`;
180
+ finish(new Error(`websocket closed: ${detail}`));
181
+ });
182
+ });
183
+ }
184
+ async subscribe() {
185
+ const reqId = this.makeReqId('aibot_subscribe');
186
+ const frame = {
187
+ cmd: 'aibot_subscribe',
188
+ headers: { req_id: reqId },
189
+ body: { bot_id: this.botId, secret: this.botSecret },
190
+ };
191
+ await new Promise((resolve, reject) => {
192
+ this.subscribeAck = err => err ? reject(err) : resolve();
193
+ this.writeFrame(frame).catch(err => {
194
+ this.subscribeAck = null;
195
+ reject(err instanceof Error ? err : new Error(describeError(err)));
196
+ });
197
+ // Defensive timeout — server should ack within seconds.
198
+ const timer = setTimeout(() => {
199
+ if (!this.subscribeAck)
200
+ return;
201
+ const ack = this.subscribeAck;
202
+ this.subscribeAck = null;
203
+ ack(new Error('subscribe ack timeout'));
204
+ }, 10_000);
205
+ timer.unref?.();
206
+ });
207
+ this.debug('[ws] subscribed successfully');
208
+ this.startHeartbeat();
209
+ }
210
+ startHeartbeat() {
211
+ this.stopHeartbeat();
212
+ this.heartbeatTimer = setInterval(() => {
213
+ if (this.missedPong >= 2) {
214
+ this.log('[ws] no heartbeat ack for 2 consecutive pings — closing', 'warn');
215
+ try {
216
+ this.ws?.close();
217
+ }
218
+ catch { }
219
+ return;
220
+ }
221
+ this.missedPong++;
222
+ const reqId = this.makeReqId('ping');
223
+ void this.writeFrame({ cmd: 'ping', headers: { req_id: reqId } }).catch(() => { });
224
+ }, WC_HEARTBEAT_MS);
225
+ this.heartbeatTimer.unref?.();
226
+ }
227
+ stopHeartbeat() {
228
+ if (this.heartbeatTimer)
229
+ clearInterval(this.heartbeatTimer);
230
+ this.heartbeatTimer = null;
231
+ }
232
+ // ========================================================================
233
+ // Frame dispatch
234
+ // ========================================================================
235
+ handleFrame(frame) {
236
+ const cmd = frame.cmd || '';
237
+ const reqId = frame.headers?.req_id || '';
238
+ if (cmd === 'aibot_msg_callback') {
239
+ this.handleMsgCallback(frame);
240
+ return;
241
+ }
242
+ if (cmd === 'aibot_event_callback') {
243
+ this.debug(`[ws] event callback (ignored) req_id=${reqId}`);
244
+ return;
245
+ }
246
+ if (!cmd) {
247
+ // Response frame; route by req_id prefix.
248
+ if (reqId.startsWith('ping')) {
249
+ this.missedPong = 0;
250
+ return;
251
+ }
252
+ if (reqId.startsWith('aibot_subscribe')) {
253
+ const ack = this.subscribeAck;
254
+ this.subscribeAck = null;
255
+ if (typeof frame.errcode === 'number' && frame.errcode !== 0) {
256
+ ack?.(new Error(`subscribe failed: errcode=${frame.errcode} errmsg=${frame.errmsg || ''}`));
257
+ }
258
+ else {
259
+ ack?.(null);
260
+ }
261
+ return;
262
+ }
263
+ const pending = this.pendingAcks.get(reqId);
264
+ if (pending) {
265
+ clearTimeout(pending.timer);
266
+ this.pendingAcks.delete(reqId);
267
+ if (typeof frame.errcode === 'number' && frame.errcode !== 0) {
268
+ pending.reject(new Error(`ack errcode=${frame.errcode} errmsg=${frame.errmsg || ''}`));
269
+ }
270
+ else {
271
+ pending.resolve();
272
+ }
273
+ }
274
+ return;
275
+ }
276
+ this.debug(`[ws] unhandled cmd=${cmd}`);
277
+ }
278
+ handleMsgCallback(frame) {
279
+ const body = frame.body || {};
280
+ const msgId = String(body.msgid || '').trim();
281
+ const reqId = frame.headers?.req_id || '';
282
+ if (!msgId)
283
+ return;
284
+ if (this.seenMsgIds.has(msgId))
285
+ return;
286
+ this.seenMsgIds.add(msgId);
287
+ this.seenMsgQueue.push(msgId);
288
+ while (this.seenMsgQueue.length > WeComChannel.SEEN_CAP) {
289
+ this.seenMsgIds.delete(this.seenMsgQueue.shift());
290
+ }
291
+ const userId = String(body.from?.userid || '').trim();
292
+ if (!userId)
293
+ return;
294
+ if (!this.isAllowed(userId)) {
295
+ this.debug(`[recv] blocked: userid=${userId} not in allowlist`);
296
+ return;
297
+ }
298
+ const chatType = String(body.chattype || 'single');
299
+ const chatId = String(body.chatid || '').trim() || userId;
300
+ this.knownChats.add(chatId);
301
+ const meta = this.chatMeta.get(chatId) ?? { pendingReqId: null };
302
+ meta.pendingReqId = reqId || meta.pendingReqId;
303
+ this.chatMeta.set(chatId, meta);
304
+ const text = this.extractInboundText(body);
305
+ const ctx = {
306
+ chatId,
307
+ messageId: msgId,
308
+ chatType,
309
+ reqId: reqId || '',
310
+ from: { userId },
311
+ reply: (replyText, opts) => this.send(chatId, replyText, opts),
312
+ editReply: () => Promise.resolve(),
313
+ channel: this,
314
+ raw: body,
315
+ };
316
+ const payload = { text, files: [] };
317
+ void this.dispatchInbound(payload, ctx);
318
+ }
319
+ extractInboundText(body) {
320
+ if (!body)
321
+ return '';
322
+ const direct = String(body.text?.content || '').trim();
323
+ if (direct)
324
+ return this.stripBotMention(direct, body.aibotid);
325
+ if (body.msgtype === 'voice') {
326
+ const voice = body.voice || {};
327
+ const transcript = String(voice.content || voice.text || '').trim();
328
+ if (transcript)
329
+ return this.stripBotMention(transcript, body.aibotid);
330
+ }
331
+ if (body.msgtype === 'mixed' && Array.isArray(body.mixed?.msg_item)) {
332
+ const parts = [];
333
+ for (const item of body.mixed.msg_item) {
334
+ if (item?.msgtype === 'text' && item.text?.content)
335
+ parts.push(String(item.text.content));
336
+ }
337
+ const joined = parts.join('\n').trim();
338
+ if (joined)
339
+ return this.stripBotMention(joined, body.aibotid);
340
+ }
341
+ return '';
342
+ }
343
+ stripBotMention(text, aibotid) {
344
+ let out = text;
345
+ if (aibotid)
346
+ out = out.replace(new RegExp(`@${aibotid}`, 'g'), '');
347
+ if (this.botId)
348
+ out = out.replace(new RegExp(`@${this.botId}`, 'g'), '');
349
+ return out.trim();
350
+ }
351
+ async dispatchInbound(payload, ctx) {
352
+ for (const handler of this.messageHandlers) {
353
+ try {
354
+ await handler(payload, ctx);
355
+ }
356
+ catch (error) {
357
+ this.emitError(error instanceof Error ? error : new Error(describeError(error)));
358
+ }
359
+ }
360
+ }
361
+ // ========================================================================
362
+ // Outgoing primitives
363
+ // ========================================================================
364
+ async send(chatId, text, _opts) {
365
+ const chat = String(chatId);
366
+ const trimmed = (text || '').trim() || '(empty)';
367
+ const chunks = splitText(trimmed, WC_MAX);
368
+ let lastReqId = null;
369
+ const meta = this.chatMeta.get(chat);
370
+ const replyReqId = meta?.pendingReqId;
371
+ for (let i = 0; i < chunks.length; i++) {
372
+ const chunk = chunks[i];
373
+ // First chunk: prefer aibot_respond_msg if we hold a fresh req_id.
374
+ if (i === 0 && replyReqId) {
375
+ const streamId = this.makeReqId('stream');
376
+ const frame = {
377
+ cmd: 'aibot_respond_msg',
378
+ headers: { req_id: replyReqId },
379
+ body: {
380
+ msgtype: 'stream',
381
+ stream: { id: streamId, finish: true, content: chunk },
382
+ },
383
+ };
384
+ await this.writeFrame(frame);
385
+ // The server treats the req_id as consumed after stream finish=true.
386
+ if (meta)
387
+ meta.pendingReqId = null;
388
+ lastReqId = replyReqId;
389
+ continue;
390
+ }
391
+ const reqId = this.makeReqId('aibot_send_msg');
392
+ const frame = {
393
+ cmd: 'aibot_send_msg',
394
+ headers: { req_id: reqId },
395
+ body: {
396
+ chatid: chat,
397
+ msgtype: 'markdown',
398
+ markdown: { content: chunk },
399
+ },
400
+ };
401
+ await this.writeAndAwaitAck(reqId, frame, 5_000);
402
+ lastReqId = reqId;
403
+ }
404
+ return lastReqId;
405
+ }
406
+ async editMessage(_chatId, _msgId, _text, _opts) {
407
+ // No edit primitive in the Smart Bot WS protocol.
408
+ }
409
+ async deleteMessage(_chatId, _msgId) {
410
+ // No delete primitive.
411
+ }
412
+ async sendTyping(_chatId, _opts) {
413
+ // No typing indicator.
414
+ }
415
+ // ========================================================================
416
+ // Frame writing helpers
417
+ // ========================================================================
418
+ writeFrame(frame) {
419
+ return new Promise((resolve, reject) => {
420
+ const ws = this.ws;
421
+ if (!ws || ws.readyState !== ws.OPEN) {
422
+ reject(new Error('wecom-ws: not connected'));
423
+ return;
424
+ }
425
+ ws.send(JSON.stringify(frame), err => {
426
+ if (err)
427
+ reject(err instanceof Error ? err : new Error(describeError(err)));
428
+ else
429
+ resolve();
430
+ });
431
+ });
432
+ }
433
+ writeAndAwaitAck(reqId, frame, timeoutMs) {
434
+ return new Promise((resolve, reject) => {
435
+ const timer = setTimeout(() => {
436
+ this.pendingAcks.delete(reqId);
437
+ // Fall back to non-blocking on timeout to avoid deadlocks.
438
+ this.debug(`[ws] ack timeout req_id=${reqId} — proceeding`);
439
+ resolve();
440
+ }, timeoutMs);
441
+ timer.unref?.();
442
+ this.pendingAcks.set(reqId, { resolve, reject, timer });
443
+ this.writeFrame(frame).catch(err => {
444
+ const pending = this.pendingAcks.get(reqId);
445
+ if (pending) {
446
+ clearTimeout(pending.timer);
447
+ this.pendingAcks.delete(reqId);
448
+ }
449
+ reject(err);
450
+ });
451
+ });
452
+ }
453
+ failPendingAcks() {
454
+ for (const [reqId, pending] of this.pendingAcks) {
455
+ clearTimeout(pending.timer);
456
+ pending.reject(new Error('wecom-ws: connection closed'));
457
+ this.pendingAcks.delete(reqId);
458
+ }
459
+ }
460
+ makeReqId(prefix) {
461
+ this.reqSeq += 1;
462
+ return `${prefix}_${this.reqSeq}`;
463
+ }
464
+ isAllowed(userId) {
465
+ if (!this.allowedUserIds?.size)
466
+ return true;
467
+ return this.allowedUserIds.has(userId);
468
+ }
469
+ emitError(error) {
470
+ for (const handler of this.errorHandlers) {
471
+ try {
472
+ handler(error);
473
+ }
474
+ catch { }
475
+ }
476
+ }
477
+ debug(msg) { this.log(msg, 'debug'); }
478
+ log(msg, level = 'info') { writeScopedLog('wecom', msg, { level }); }
479
+ }