@paean-ai/wechat-mcp 0.1.0 → 0.2.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.
@@ -221,18 +221,22 @@ var DaemonStore = class {
221
221
  agents = /* @__PURE__ */ new Map();
222
222
  defaultAgentId = null;
223
223
  startedAt = Date.now();
224
- registerAgent(agentId, name) {
224
+ registerAgent(agentId, name, mode = "poll", gatewayUrl, gatewayType) {
225
225
  const existing = this.agents.get(agentId);
226
226
  if (existing) return existing.registration;
227
227
  const registration = {
228
228
  agentId,
229
229
  name: name || agentId,
230
+ mode,
231
+ gatewayUrl,
232
+ gatewayType,
230
233
  registeredAt: (/* @__PURE__ */ new Date()).toISOString()
231
234
  };
232
235
  this.agents.set(agentId, {
233
236
  registration,
234
237
  messages: [],
235
- waiters: []
238
+ waiters: [],
239
+ conversationIds: /* @__PURE__ */ new Map()
236
240
  });
237
241
  if (!this.defaultAgentId) {
238
242
  this.defaultAgentId = agentId;
@@ -256,6 +260,9 @@ var DaemonStore = class {
256
260
  getAgents() {
257
261
  return Array.from(this.agents.values()).map((s) => s.registration);
258
262
  }
263
+ getAgent(agentId) {
264
+ return this.agents.get(agentId)?.registration ?? null;
265
+ }
259
266
  getDefaultAgentId() {
260
267
  return this.defaultAgentId;
261
268
  }
@@ -265,6 +272,12 @@ var DaemonStore = class {
265
272
  getUptime() {
266
273
  return Date.now() - this.startedAt;
267
274
  }
275
+ getConversationId(agentId, senderId) {
276
+ return this.agents.get(agentId)?.conversationIds.get(senderId);
277
+ }
278
+ setConversationId(agentId, senderId, convId) {
279
+ this.agents.get(agentId)?.conversationIds.set(senderId, convId);
280
+ }
268
281
  /**
269
282
  * Push a message to a specific agent's queue.
270
283
  * If an agent has a waiting long-poll, resolve it immediately.
@@ -316,6 +329,18 @@ function parseMention(text) {
316
329
  }
317
330
  return { agent: match[1].toLowerCase(), rest: match[2] || "" };
318
331
  }
332
+ function buildMessage(senderId, text, rawText, contextToken, targetAgent) {
333
+ return {
334
+ id: crypto2.randomBytes(8).toString("hex"),
335
+ senderId,
336
+ senderName: senderId.split("@")[0] || senderId,
337
+ text,
338
+ rawText,
339
+ contextToken,
340
+ targetAgent,
341
+ timestamp: Date.now()
342
+ };
343
+ }
319
344
  function routeMessage(store2, senderId, text, contextToken) {
320
345
  const { agent, rest } = parseMention(text);
321
346
  let targetAgent = null;
@@ -328,20 +353,112 @@ function routeMessage(store2, senderId, text, contextToken) {
328
353
  strippedText = text;
329
354
  }
330
355
  if (!targetAgent) return null;
331
- const message = {
332
- id: crypto2.randomBytes(8).toString("hex"),
333
- senderId,
334
- senderName: senderId.split("@")[0] || senderId,
335
- text: strippedText,
336
- rawText: text,
337
- contextToken,
338
- targetAgent,
339
- timestamp: Date.now()
340
- };
356
+ const message = buildMessage(senderId, strippedText, text, contextToken, targetAgent);
357
+ const reg = store2.getAgent(targetAgent);
358
+ if (reg?.mode === "gateway") {
359
+ return message;
360
+ }
341
361
  store2.pushMessage(targetAgent, message);
342
362
  return message;
343
363
  }
344
364
 
365
+ // src/daemon/adapters.ts
366
+ async function readSSEStream(body, handler) {
367
+ const reader = body.getReader();
368
+ const decoder = new TextDecoder();
369
+ let buffer = "";
370
+ while (true) {
371
+ const { done, value } = await reader.read();
372
+ if (done) break;
373
+ buffer += decoder.decode(value, { stream: true });
374
+ const lines = buffer.split("\n");
375
+ buffer = lines.pop() || "";
376
+ for (const line of lines) {
377
+ handler(line);
378
+ }
379
+ }
380
+ }
381
+ var clawAdapter = {
382
+ name: "claw",
383
+ async send(gatewayUrl, message, signal, conversationId) {
384
+ const url = `${gatewayUrl.replace(/\/$/, "")}/api/chat`;
385
+ const body = { message };
386
+ if (conversationId) body.conversationId = conversationId;
387
+ const res = await fetch(url, {
388
+ method: "POST",
389
+ headers: { "Content-Type": "application/json" },
390
+ body: JSON.stringify(body),
391
+ signal
392
+ });
393
+ if (!res.ok || !res.body) {
394
+ throw new Error(`Gateway error: HTTP ${res.status}`);
395
+ }
396
+ let fullContent = "";
397
+ let returnedConversationId;
398
+ await readSSEStream(res.body, (line) => {
399
+ if (!line.startsWith("data: ")) return;
400
+ try {
401
+ const event = JSON.parse(line.slice(6));
402
+ switch (event.type) {
403
+ case "start":
404
+ if (event.conversationId) returnedConversationId = event.conversationId;
405
+ break;
406
+ case "content":
407
+ fullContent += event.text || "";
408
+ break;
409
+ case "done":
410
+ if (event.content && !fullContent) fullContent = event.content;
411
+ break;
412
+ }
413
+ } catch {
414
+ }
415
+ });
416
+ return { content: fullContent, conversationId: returnedConversationId };
417
+ }
418
+ };
419
+ var openaiAdapter = {
420
+ name: "openai",
421
+ async send(gatewayUrl, message, signal) {
422
+ const url = `${gatewayUrl.replace(/\/$/, "")}/v1/chat/completions`;
423
+ const res = await fetch(url, {
424
+ method: "POST",
425
+ headers: { "Content-Type": "application/json" },
426
+ body: JSON.stringify({
427
+ model: "default",
428
+ messages: [{ role: "user", content: message }],
429
+ stream: true
430
+ }),
431
+ signal
432
+ });
433
+ if (!res.ok || !res.body) {
434
+ throw new Error(`Gateway error: HTTP ${res.status}`);
435
+ }
436
+ let fullContent = "";
437
+ await readSSEStream(res.body, (line) => {
438
+ const trimmed = line.trim();
439
+ if (!trimmed.startsWith("data: ") || trimmed === "data: [DONE]") return;
440
+ try {
441
+ const chunk = JSON.parse(trimmed.slice(6));
442
+ const delta = chunk.choices?.[0]?.delta;
443
+ if (delta?.content) {
444
+ fullContent += delta.content;
445
+ }
446
+ } catch {
447
+ }
448
+ });
449
+ return { content: fullContent };
450
+ }
451
+ };
452
+ function getAdapter(type) {
453
+ switch (type) {
454
+ case "openai":
455
+ return openaiAdapter;
456
+ case "claw":
457
+ default:
458
+ return clawAdapter;
459
+ }
460
+ }
461
+
345
462
  // src/daemon/server.ts
346
463
  var store = new DaemonStore();
347
464
  var activeAccount = null;
@@ -391,8 +508,19 @@ async function handleRequest(req, res) {
391
508
  json(res, 400, { error: "agentId required" });
392
509
  return;
393
510
  }
394
- const reg = store.registerAgent(body.agentId, body.name);
395
- log(`Agent registered: ${reg.agentId} (${reg.name})`);
511
+ const mode = body.mode || "poll";
512
+ if (mode === "gateway" && !body.gatewayUrl) {
513
+ json(res, 400, { error: "gatewayUrl required for gateway mode" });
514
+ return;
515
+ }
516
+ const reg = store.registerAgent(
517
+ body.agentId,
518
+ body.name,
519
+ mode,
520
+ body.gatewayUrl,
521
+ body.gatewayType
522
+ );
523
+ log(`Agent registered: ${reg.agentId} (${reg.name}) mode=${reg.mode}${reg.gatewayUrl ? ` gateway=${reg.gatewayUrl}` : ""}`);
396
524
  json(res, 200, reg);
397
525
  return;
398
526
  }
@@ -461,6 +589,58 @@ async function handleRequest(req, res) {
461
589
  json(res, 500, { error: "internal error" });
462
590
  }
463
591
  }
592
+ async function bridgeToGateway(agentId, senderId, text, contextToken) {
593
+ const reg = store.getAgent(agentId);
594
+ if (!reg?.gatewayUrl) {
595
+ log(`Gateway bridge failed: no gatewayUrl for ${agentId}`);
596
+ return;
597
+ }
598
+ const adapter = getAdapter(reg.gatewayType || "claw");
599
+ const conversationId = store.getConversationId(agentId, senderId);
600
+ log(`Gateway bridge: ${agentId} \u2192 ${reg.gatewayUrl} (${adapter.name})`);
601
+ try {
602
+ const controller = new AbortController();
603
+ const timeout = setTimeout(() => controller.abort(), 12e4);
604
+ const result = await adapter.send(
605
+ reg.gatewayUrl,
606
+ text,
607
+ controller.signal,
608
+ conversationId
609
+ );
610
+ clearTimeout(timeout);
611
+ if (result.conversationId) {
612
+ store.setConversationId(agentId, senderId, result.conversationId);
613
+ }
614
+ if (result.content && activeAccount && contextToken) {
615
+ const preview = result.content.length > 80 ? result.content.slice(0, 80) + "..." : result.content;
616
+ log(`Gateway response: ${agentId} \u2192 ${senderId}: "${preview}"`);
617
+ for (let i = 0; i < result.content.length; i += MAX_WECHAT_MSG_LENGTH) {
618
+ await sendTextMessage(
619
+ activeAccount.baseUrl,
620
+ activeAccount.token,
621
+ senderId,
622
+ result.content.slice(i, i + MAX_WECHAT_MSG_LENGTH),
623
+ contextToken
624
+ );
625
+ }
626
+ }
627
+ } catch (err) {
628
+ const errMsg = err instanceof Error ? err.message : String(err);
629
+ log(`Gateway bridge error for ${agentId}: ${errMsg}`);
630
+ if (activeAccount && contextToken) {
631
+ try {
632
+ await sendTextMessage(
633
+ activeAccount.baseUrl,
634
+ activeAccount.token,
635
+ senderId,
636
+ `[${agentId}] Error: ${errMsg}`,
637
+ contextToken
638
+ );
639
+ } catch {
640
+ }
641
+ }
642
+ }
643
+ }
464
644
  async function startPolling(account) {
465
645
  let buf = loadSyncBuf();
466
646
  let failures = 0;
@@ -501,6 +681,12 @@ async function startPolling(account) {
501
681
  const routed = routeMessage(store, senderId, text, contextToken);
502
682
  if (routed) {
503
683
  log(`Message routed: from=${senderId} agent=${routed.targetAgent} text="${text.slice(0, 50)}..."`);
684
+ const reg = routed.targetAgent ? store.getAgent(routed.targetAgent) : null;
685
+ if (reg?.mode === "gateway") {
686
+ bridgeToGateway(routed.targetAgent, senderId, routed.text, contextToken).catch((err) => {
687
+ log(`Gateway bridge async error: ${err instanceof Error ? err.message : String(err)}`);
688
+ });
689
+ }
504
690
  } else {
505
691
  log(`Message dropped (no agents): from=${senderId} text="${text.slice(0, 50)}..."`);
506
692
  }
package/dist/index.d.ts CHANGED
@@ -50,9 +50,29 @@ interface GetUpdatesResp {
50
50
  get_updates_buf?: string;
51
51
  longpolling_timeout_ms?: number;
52
52
  }
53
+ /**
54
+ * How messages are delivered to and processed by an agent:
55
+ *
56
+ * channel — Push via MCP notification (Claude Code's proprietary channel protocol).
57
+ * Messages appear in the agent's active conversation.
58
+ *
59
+ * gateway — Daemon forwards message to the agent's HTTP endpoint (POST /api/chat)
60
+ * and relays the SSE response back through WeChat. Like AnyClaw bridge.
61
+ *
62
+ * poll — Agent actively reads messages via the wechat_read_messages MCP tool.
63
+ * Fallback for agents that support neither push nor HTTP gateway.
64
+ */
65
+ type AgentMode = "channel" | "gateway" | "poll";
66
+ /**
67
+ * For gateway-mode agents, which HTTP protocol the agent speaks.
68
+ */
69
+ type GatewayType = "claw" | "openai";
53
70
  interface AgentRegistration {
54
71
  agentId: string;
55
72
  name: string;
73
+ mode: AgentMode;
74
+ gatewayUrl?: string;
75
+ gatewayType?: GatewayType;
56
76
  registeredAt: string;
57
77
  }
58
78
  interface RoutedMessage {
@@ -95,6 +115,14 @@ interface SendMessageResponse {
95
115
  interface PollMessagesResponse {
96
116
  messages: RoutedMessage[];
97
117
  }
118
+ interface GatewaySendResult {
119
+ content: string;
120
+ conversationId?: string;
121
+ }
122
+ interface GatewayAdapter {
123
+ name: string;
124
+ send(gatewayUrl: string, message: string, signal?: AbortSignal, conversationId?: string): Promise<GatewaySendResult>;
125
+ }
98
126
 
99
127
  /**
100
128
  * Unified WeChat iLink API Client
@@ -157,25 +185,50 @@ declare class DaemonClient {
157
185
  private request;
158
186
  health(): Promise<boolean>;
159
187
  status(): Promise<DaemonStatus>;
160
- registerAgent(agentId: string, name?: string): Promise<AgentRegistration>;
188
+ registerAgent(agentId: string, name?: string, mode?: AgentMode, gatewayUrl?: string, gatewayType?: GatewayType): Promise<AgentRegistration>;
161
189
  unregisterAgent(agentId: string): Promise<{
162
190
  removed: boolean;
163
191
  }>;
164
192
  /**
165
193
  * Long-poll for messages intended for this agent.
166
- * Will block up to ~30s on the daemon side.
194
+ * Will block up to ~30s on the daemon side by default.
195
+ * Pass a custom timeoutMs for shorter waits (e.g. 500 for non-blocking read).
167
196
  */
168
- pollMessages(agentId: string): Promise<RoutedMessage[]>;
197
+ pollMessages(agentId: string, clientTimeoutMs?: number): Promise<RoutedMessage[]>;
169
198
  send(to: string, text: string, agentId: string): Promise<SendMessageResponse>;
170
199
  getContacts(): Promise<WechatContact[]>;
171
200
  }
172
201
 
202
+ /**
203
+ * Gateway Adapters
204
+ *
205
+ * Protocol translators for different agent gateway types.
206
+ * Ported from AnyClaw bridge — each adapter knows how to POST a message
207
+ * to a local AI agent's HTTP endpoint and collect the response.
208
+ */
209
+
210
+ /**
211
+ * Claw adapter (OpenClaw / 0claw / PaeanClaw / ZeroClaw / NanoClaw)
212
+ * All expose POST /api/chat with SSE response.
213
+ */
214
+ declare const clawAdapter: GatewayAdapter;
215
+ /**
216
+ * OpenAI-compatible adapter
217
+ * For any endpoint that speaks POST /v1/chat/completions with SSE.
218
+ */
219
+ declare const openaiAdapter: GatewayAdapter;
220
+ declare function getAdapter(type: GatewayType): GatewayAdapter;
221
+
173
222
  /**
174
223
  * Message Router
175
224
  *
176
225
  * Parses incoming WeChat messages for @agent_name mentions and routes
177
226
  * them to the appropriate agent. Messages without a mention go to the
178
227
  * default agent.
228
+ *
229
+ * For channel/poll-mode agents, messages are queued in the store.
230
+ * For gateway-mode agents, the message is returned without queuing —
231
+ * the daemon server handles the HTTP gateway bridge directly.
179
232
  */
180
233
 
181
234
  /**
@@ -188,8 +241,8 @@ declare function parseMention(text: string): {
188
241
  rest: string;
189
242
  };
190
243
 
191
- declare const PACKAGE_VERSION = "0.1.0";
244
+ declare const PACKAGE_VERSION = "0.2.0";
192
245
  declare const DEFAULT_BASE_URL = "https://ilinkai.weixin.qq.com";
193
246
  declare const CONFIG_DIR: string;
194
247
 
195
- export { type AccountData, type AgentRegistration, CONFIG_DIR, DEFAULT_BASE_URL, DaemonClient, type DaemonInfo, type DaemonStatus, type GetUpdatesResp, PACKAGE_VERSION, type PollMessagesResponse, type QRCodeResponse, type QRStatusResponse, type RoutedMessage, type SendMessageRequest, type SendMessageResponse, type WechatContact, type WeixinMessage, checkDaemon, ensureDaemon, extractTextFromMessage, fetchQRCode, getContactToken, getUpdates, loadContacts, loadCredentials, parseMention, pollQRStatus, removeCredentials, saveContact, saveCredentials, sendTextMessage, stopDaemon };
248
+ export { type AccountData, type AgentMode, type AgentRegistration, CONFIG_DIR, DEFAULT_BASE_URL, DaemonClient, type DaemonInfo, type DaemonStatus, type GatewayAdapter, type GatewaySendResult, type GatewayType, type GetUpdatesResp, PACKAGE_VERSION, type PollMessagesResponse, type QRCodeResponse, type QRStatusResponse, type RoutedMessage, type SendMessageRequest, type SendMessageResponse, type WechatContact, type WeixinMessage, checkDaemon, clawAdapter, ensureDaemon, extractTextFromMessage, fetchQRCode, getAdapter, getContactToken, getUpdates, loadContacts, loadCredentials, openaiAdapter, parseMention, pollQRStatus, removeCredentials, saveContact, saveCredentials, sendTextMessage, stopDaemon };
package/dist/index.js CHANGED
@@ -4,7 +4,7 @@ import crypto from "crypto";
4
4
  // src/constants.ts
5
5
  import path from "path";
6
6
  import os from "os";
7
- var PACKAGE_VERSION = "0.1.0";
7
+ var PACKAGE_VERSION = "0.2.0";
8
8
  var DEFAULT_BASE_URL = "https://ilinkai.weixin.qq.com";
9
9
  var BOT_TYPE = "3";
10
10
  var CHANNEL_VERSION = "0.1.0";
@@ -382,22 +382,30 @@ var DaemonClient = class {
382
382
  async status() {
383
383
  return this.request("GET", "/status");
384
384
  }
385
- async registerAgent(agentId, name) {
386
- return this.request("POST", "/agents", { agentId, name });
385
+ async registerAgent(agentId, name, mode, gatewayUrl, gatewayType) {
386
+ return this.request("POST", "/agents", {
387
+ agentId,
388
+ name,
389
+ mode,
390
+ gatewayUrl,
391
+ gatewayType
392
+ });
387
393
  }
388
394
  async unregisterAgent(agentId) {
389
395
  return this.request("DELETE", `/agents/${encodeURIComponent(agentId)}`);
390
396
  }
391
397
  /**
392
398
  * Long-poll for messages intended for this agent.
393
- * Will block up to ~30s on the daemon side.
399
+ * Will block up to ~30s on the daemon side by default.
400
+ * Pass a custom timeoutMs for shorter waits (e.g. 500 for non-blocking read).
394
401
  */
395
- async pollMessages(agentId) {
402
+ async pollMessages(agentId, clientTimeoutMs) {
403
+ const path3 = clientTimeoutMs !== void 0 ? `/messages/${encodeURIComponent(agentId)}?timeout=${clientTimeoutMs}` : `/messages/${encodeURIComponent(agentId)}`;
396
404
  const resp = await this.request(
397
405
  "GET",
398
- `/messages/${encodeURIComponent(agentId)}`,
406
+ path3,
399
407
  void 0,
400
- 35e3
408
+ Math.max(clientTimeoutMs ?? 35e3, 35e3)
401
409
  );
402
410
  return resp.messages;
403
411
  }
@@ -410,6 +418,103 @@ var DaemonClient = class {
410
418
  }
411
419
  };
412
420
 
421
+ // src/daemon/adapters.ts
422
+ async function readSSEStream(body, handler) {
423
+ const reader = body.getReader();
424
+ const decoder = new TextDecoder();
425
+ let buffer = "";
426
+ while (true) {
427
+ const { done, value } = await reader.read();
428
+ if (done) break;
429
+ buffer += decoder.decode(value, { stream: true });
430
+ const lines = buffer.split("\n");
431
+ buffer = lines.pop() || "";
432
+ for (const line of lines) {
433
+ handler(line);
434
+ }
435
+ }
436
+ }
437
+ var clawAdapter = {
438
+ name: "claw",
439
+ async send(gatewayUrl, message, signal, conversationId) {
440
+ const url = `${gatewayUrl.replace(/\/$/, "")}/api/chat`;
441
+ const body = { message };
442
+ if (conversationId) body.conversationId = conversationId;
443
+ const res = await fetch(url, {
444
+ method: "POST",
445
+ headers: { "Content-Type": "application/json" },
446
+ body: JSON.stringify(body),
447
+ signal
448
+ });
449
+ if (!res.ok || !res.body) {
450
+ throw new Error(`Gateway error: HTTP ${res.status}`);
451
+ }
452
+ let fullContent = "";
453
+ let returnedConversationId;
454
+ await readSSEStream(res.body, (line) => {
455
+ if (!line.startsWith("data: ")) return;
456
+ try {
457
+ const event = JSON.parse(line.slice(6));
458
+ switch (event.type) {
459
+ case "start":
460
+ if (event.conversationId) returnedConversationId = event.conversationId;
461
+ break;
462
+ case "content":
463
+ fullContent += event.text || "";
464
+ break;
465
+ case "done":
466
+ if (event.content && !fullContent) fullContent = event.content;
467
+ break;
468
+ }
469
+ } catch {
470
+ }
471
+ });
472
+ return { content: fullContent, conversationId: returnedConversationId };
473
+ }
474
+ };
475
+ var openaiAdapter = {
476
+ name: "openai",
477
+ async send(gatewayUrl, message, signal) {
478
+ const url = `${gatewayUrl.replace(/\/$/, "")}/v1/chat/completions`;
479
+ const res = await fetch(url, {
480
+ method: "POST",
481
+ headers: { "Content-Type": "application/json" },
482
+ body: JSON.stringify({
483
+ model: "default",
484
+ messages: [{ role: "user", content: message }],
485
+ stream: true
486
+ }),
487
+ signal
488
+ });
489
+ if (!res.ok || !res.body) {
490
+ throw new Error(`Gateway error: HTTP ${res.status}`);
491
+ }
492
+ let fullContent = "";
493
+ await readSSEStream(res.body, (line) => {
494
+ const trimmed = line.trim();
495
+ if (!trimmed.startsWith("data: ") || trimmed === "data: [DONE]") return;
496
+ try {
497
+ const chunk = JSON.parse(trimmed.slice(6));
498
+ const delta = chunk.choices?.[0]?.delta;
499
+ if (delta?.content) {
500
+ fullContent += delta.content;
501
+ }
502
+ } catch {
503
+ }
504
+ });
505
+ return { content: fullContent };
506
+ }
507
+ };
508
+ function getAdapter(type) {
509
+ switch (type) {
510
+ case "openai":
511
+ return openaiAdapter;
512
+ case "claw":
513
+ default:
514
+ return clawAdapter;
515
+ }
516
+ }
517
+
413
518
  // src/daemon/router.ts
414
519
  import crypto2 from "crypto";
415
520
  function parseMention(text) {
@@ -426,13 +531,16 @@ export {
426
531
  DaemonClient,
427
532
  PACKAGE_VERSION,
428
533
  checkDaemon,
534
+ clawAdapter,
429
535
  ensureDaemon,
430
536
  extractTextFromMessage,
431
537
  fetchQRCode,
538
+ getAdapter,
432
539
  getContactToken,
433
540
  getUpdates,
434
541
  loadContacts,
435
542
  loadCredentials,
543
+ openaiAdapter,
436
544
  parseMention,
437
545
  pollQRStatus,
438
546
  removeCredentials,
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@paean-ai/wechat-mcp",
3
- "version": "0.1.0",
4
- "description": "WeChat MCP middleware — shared WeChat connection for multiple AI agents with @mention routing",
3
+ "version": "0.2.0",
4
+ "description": "WeChat MCP middleware — shared WeChat connection for multiple AI agents with @mention routing and multi-mode message delivery",
5
5
  "type": "module",
6
6
  "bin": {
7
7
  "wechat-mcp": "dist/cli.js"