@hopgoldy/agent-bridge 0.1.1 → 0.2.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.
package/dist/cli.js CHANGED
@@ -783,11 +783,6 @@ var PiRpcClient = class {
783
783
  const data = response.data;
784
784
  return data ?? {};
785
785
  }
786
- async getLastAssistantText() {
787
- const response = await this.#send({ type: "get_last_assistant_text" });
788
- const data = response.data;
789
- return data?.text ?? null;
790
- }
791
786
  async getState() {
792
787
  const response = await this.#send({ type: "get_state" });
793
788
  return response.data ?? {};
@@ -795,26 +790,6 @@ var PiRpcClient = class {
795
790
  async setSessionName(name) {
796
791
  await this.#send({ type: "set_session_name", name });
797
792
  }
798
- waitForSettled(timeoutMs = 10 * 60 * 1e3) {
799
- if (!this.#process) {
800
- return Promise.reject(new Error("pi RPC client is not running"));
801
- }
802
- return new Promise((resolve, reject) => {
803
- const timeout = setTimeout(() => {
804
- reject(new Error(`Timed out waiting for pi agent to settle after ${timeoutMs}ms`));
805
- }, timeoutMs);
806
- this.#settledWaiters.push({
807
- resolve: () => {
808
- clearTimeout(timeout);
809
- resolve();
810
- },
811
- reject: (error) => {
812
- clearTimeout(timeout);
813
- reject(error);
814
- }
815
- });
816
- });
817
- }
818
793
  async #send(command) {
819
794
  if (!this.#process?.stdin.writable) {
820
795
  throw this.#exitError ?? new Error("pi RPC process is not writable");
@@ -1018,20 +993,12 @@ var PiCodingAgentAdapter = class {
1018
993
  try {
1019
994
  if (event.type === "user.message") {
1020
995
  this.#logger.info(`sending prompt to agent (session=${this.#agentSessionId})`);
1021
- const startedAt = Date.now();
1022
996
  await this.#emitProgress({
1023
997
  type: "assistant.thinking",
1024
998
  agentSessionId: this.#agentSessionId,
1025
999
  text: "Processing request"
1026
1000
  });
1027
1001
  await this.#client.prompt(event.text);
1028
- await this.#client.waitForSettled();
1029
- const rawText = await this.#client.getLastAssistantText();
1030
- const { text: text2, attachments } = extractMediaMarkers(rawText ?? "(pi returned no assistant text)");
1031
- this.#logger.debug(
1032
- `prompt settled (session=${this.#agentSessionId} durationMs=${Date.now() - startedAt} replyLength=${text2.length} attachments=${attachments.length})`
1033
- );
1034
- await this.#emitAssistant(text2, attachments);
1035
1002
  return;
1036
1003
  }
1037
1004
  this.#logger.info(`compacting context (session=${this.#agentSessionId})`);
@@ -1077,6 +1044,28 @@ var PiCodingAgentAdapter = class {
1077
1044
  if (!this.#onOutput) {
1078
1045
  return;
1079
1046
  }
1047
+ if (rpcEvent.type === "message_end") {
1048
+ const message = rpcEvent.message;
1049
+ if (this.#isAssistantMessage(message)) {
1050
+ this.#logger.debug(`assistant message_end content shape (session=${this.#agentSessionId})`, {
1051
+ contentType: Array.isArray(message.content) ? "array" : typeof message.content,
1052
+ contentPreview: this.#summarizeContentShape(message.content)
1053
+ });
1054
+ const rawText = this.#extractMessageText(message.content);
1055
+ const { text: text2, attachments } = extractMediaMarkers(rawText);
1056
+ this.#logger.debug(
1057
+ `assistant message_end received (session=${this.#agentSessionId} textLength=${rawText.length} attachmentCount=${attachments.length})`
1058
+ );
1059
+ if (!text2.trim() && attachments.length === 0) {
1060
+ this.#logger.debug(
1061
+ `ignoring assistant message_end without visible content (session=${this.#agentSessionId})`
1062
+ );
1063
+ return;
1064
+ }
1065
+ await this.#emitAssistant(text2, attachments);
1066
+ }
1067
+ return;
1068
+ }
1080
1069
  if (rpcEvent.type === "tool_execution_start") {
1081
1070
  const toolName = typeof rpcEvent.toolName === "string" ? rpcEvent.toolName : "unknown";
1082
1071
  await this.#emitProgress({
@@ -1098,6 +1087,65 @@ var PiCodingAgentAdapter = class {
1098
1087
  });
1099
1088
  }
1100
1089
  }
1090
+ #isAssistantMessage(value) {
1091
+ if (!value || typeof value !== "object") {
1092
+ return false;
1093
+ }
1094
+ return value.role === "assistant";
1095
+ }
1096
+ #extractMessageText(content) {
1097
+ if (typeof content === "string") {
1098
+ return content;
1099
+ }
1100
+ if (this.#isTextBlock(content)) {
1101
+ return content.text;
1102
+ }
1103
+ if (!Array.isArray(content)) {
1104
+ return "";
1105
+ }
1106
+ const textParts = [];
1107
+ for (const block of content) {
1108
+ if (this.#isTextBlock(block)) {
1109
+ textParts.push(block.text);
1110
+ continue;
1111
+ }
1112
+ if (!block || typeof block !== "object") {
1113
+ continue;
1114
+ }
1115
+ const candidate = block;
1116
+ if (typeof candidate.content === "string") {
1117
+ textParts.push(candidate.content);
1118
+ }
1119
+ }
1120
+ return textParts.join("");
1121
+ }
1122
+ #isTextBlock(value) {
1123
+ if (!value || typeof value !== "object") {
1124
+ return false;
1125
+ }
1126
+ const candidate = value;
1127
+ return candidate.type === "text" && typeof candidate.text === "string";
1128
+ }
1129
+ #summarizeContentShape(content) {
1130
+ if (typeof content === "string") {
1131
+ return { kind: "string", length: content.length, preview: content.slice(0, 200) };
1132
+ }
1133
+ if (!Array.isArray(content)) {
1134
+ return { kind: typeof content };
1135
+ }
1136
+ return content.slice(0, 10).map((block) => {
1137
+ if (!block || typeof block !== "object") {
1138
+ return { kind: typeof block };
1139
+ }
1140
+ const candidate = block;
1141
+ return {
1142
+ type: candidate.type,
1143
+ textLength: typeof candidate.text === "string" ? candidate.text.length : void 0,
1144
+ contentType: Array.isArray(candidate.content) ? "array" : typeof candidate.content,
1145
+ mimeType: candidate.mimeType
1146
+ };
1147
+ });
1148
+ }
1101
1149
  };
1102
1150
 
1103
1151
  // src/modules/agent/pi-coding-agent/index.ts
@@ -1165,6 +1213,102 @@ function getTypedAgentModule(config) {
1165
1213
  return module;
1166
1214
  }
1167
1215
 
1216
+ // src/modules/client/utils/progress-renderer.ts
1217
+ var DEFAULT_COLLAPSE_THRESHOLD = 10;
1218
+ var NO_PROGRESS_MARKDOWN = "No progress yet.";
1219
+ var ProgressRenderer = class {
1220
+ #collapseThreshold;
1221
+ #lines = [];
1222
+ #status = "running";
1223
+ #collapsedCount = 0;
1224
+ constructor(options = {}) {
1225
+ this.#collapseThreshold = options.collapseThreshold ?? DEFAULT_COLLAPSE_THRESHOLD;
1226
+ }
1227
+ /** Whether `event` should be recorded as progress (excludes assistant messages and thinking). */
1228
+ isProgressEvent(event) {
1229
+ return event.type !== "assistant.message" && event.type !== "assistant.thinking";
1230
+ }
1231
+ /** Records a progress event, formatting it into a line and collapsing older lines if needed. */
1232
+ takeProgressEvent(event) {
1233
+ this.#lines.push(this.#formatProgressLine(event));
1234
+ if (this.#lines.length > this.#collapseThreshold) {
1235
+ const overflow = this.#lines.length - this.#collapseThreshold;
1236
+ this.#collapsedCount += overflow;
1237
+ this.#lines.splice(0, overflow);
1238
+ }
1239
+ this.#status = this.#progressStatus(event);
1240
+ }
1241
+ /** Returns the current rendered markdown, status, and collapsed-line count. */
1242
+ getCurrentProgress() {
1243
+ return {
1244
+ markdown: this.#renderMarkdown(),
1245
+ status: this.#status,
1246
+ collapsedCount: this.#collapsedCount
1247
+ };
1248
+ }
1249
+ #renderMarkdown() {
1250
+ const contentLines = [];
1251
+ if (this.#collapsedCount > 0) {
1252
+ contentLines.push(`- Collapsed ${this.#collapsedCount} earlier updates.`);
1253
+ }
1254
+ if (this.#lines.length > 0) {
1255
+ contentLines.push(...this.#lines);
1256
+ }
1257
+ return contentLines.length > 0 ? contentLines.join("\n") : NO_PROGRESS_MARKDOWN;
1258
+ }
1259
+ #formatProgressLine(event) {
1260
+ switch (event.type) {
1261
+ case "session.compacting":
1262
+ return `- Compacting session${event.text ? `: ${event.text}` : ""}`;
1263
+ case "assistant.tool.running":
1264
+ return `- Running ${event.toolName}`;
1265
+ case "assistant.tool.done":
1266
+ return `- Finished ${event.toolName}`;
1267
+ case "assistant.tool.error":
1268
+ return this.#formatToolErrorLine(event.toolName, event.text);
1269
+ }
1270
+ }
1271
+ #formatToolErrorLine(toolName, text2) {
1272
+ const normalizedText = text2?.trim();
1273
+ if (!normalizedText) {
1274
+ return `- ${this.#humanizeToolError(toolName)}`;
1275
+ }
1276
+ const lowerText = normalizedText.toLowerCase();
1277
+ const lowerToolName = toolName.toLowerCase();
1278
+ if (lowerText === lowerToolName || lowerText === `failed ${lowerToolName}`) {
1279
+ return `- ${this.#humanizeToolError(toolName)}`;
1280
+ }
1281
+ return `- ${this.#humanizeToolError(toolName)}: ${normalizedText}`;
1282
+ }
1283
+ #humanizeToolError(toolName) {
1284
+ return `Failed ${toolName}`;
1285
+ }
1286
+ #progressStatus(event) {
1287
+ switch (event.type) {
1288
+ case "assistant.tool.error":
1289
+ return "error";
1290
+ case "assistant.tool.done":
1291
+ return "done";
1292
+ default:
1293
+ return "running";
1294
+ }
1295
+ }
1296
+ };
1297
+
1298
+ // src/modules/client/utils/slash-commands.ts
1299
+ function parseSlashCommand(text2, clientSessionId) {
1300
+ switch (text2) {
1301
+ case "/new":
1302
+ return { type: "command.session.new", clientSessionId };
1303
+ case "/compact":
1304
+ return { type: "command.session.compact", clientSessionId };
1305
+ case "/stop":
1306
+ return { type: "command.session.stop", clientSessionId };
1307
+ default:
1308
+ return null;
1309
+ }
1310
+ }
1311
+
1168
1312
  // src/modules/client/feishu/adapter/feishu-client.ts
1169
1313
  import * as Lark from "@larksuiteoapi/node-sdk";
1170
1314
  import { existsSync as existsSync3, mkdirSync, writeFileSync } from "fs";
@@ -1517,29 +1661,19 @@ var FeishuIMAdapter = class _FeishuIMAdapter {
1517
1661
  #processing = false;
1518
1662
  #lastInboundMessageIdBySession = /* @__PURE__ */ new Map();
1519
1663
  #progressStateBySession = /* @__PURE__ */ new Map();
1520
- static buildProgressCard(lines, _status, collapsedCount = 0) {
1664
+ static buildProgressCard(markdown) {
1521
1665
  return {
1522
1666
  schema: "2.0",
1523
1667
  body: {
1524
1668
  elements: [
1525
1669
  {
1526
1670
  tag: "markdown",
1527
- content: _FeishuIMAdapter.progressBody(lines, collapsedCount)
1671
+ content: markdown
1528
1672
  }
1529
1673
  ]
1530
1674
  }
1531
1675
  };
1532
1676
  }
1533
- static progressBody(lines, collapsedCount) {
1534
- const contentLines = [];
1535
- if (collapsedCount > 0) {
1536
- contentLines.push(`- Collapsed ${collapsedCount} earlier updates.`);
1537
- }
1538
- if (lines.length > 0) {
1539
- contentLines.push(...lines);
1540
- }
1541
- return contentLines.length > 0 ? contentLines.join("\n") : "No progress yet.";
1542
- }
1543
1677
  async #notifySendFailure(chatId, error) {
1544
1678
  if (!this.#client) {
1545
1679
  return;
@@ -1577,28 +1711,10 @@ ${message}`;
1577
1711
  this.#resetProgressState(clientSessionId);
1578
1712
  await this.#client?.startTyping(chatId, messageId);
1579
1713
  const normalizedText = text2.trim();
1580
- if (normalizedText === "/new") {
1581
- this.#logger.info(`received command /new (session=${clientSessionId})`);
1582
- await this.#onOutput({
1583
- type: "command.session.new",
1584
- clientSessionId
1585
- });
1586
- return;
1587
- }
1588
- if (normalizedText === "/compact") {
1589
- this.#logger.info(`received command /compact (session=${clientSessionId})`);
1590
- await this.#onOutput({
1591
- type: "command.session.compact",
1592
- clientSessionId
1593
- });
1594
- return;
1595
- }
1596
- if (normalizedText === "/stop") {
1597
- this.#logger.info(`received command /stop (session=${clientSessionId})`);
1598
- await this.#onOutput({
1599
- type: "command.session.stop",
1600
- clientSessionId
1601
- });
1714
+ const commandEvent = parseSlashCommand(normalizedText, clientSessionId);
1715
+ if (commandEvent) {
1716
+ this.#logger.info(`received command ${normalizedText} (session=${clientSessionId})`);
1717
+ await this.#onOutput(commandEvent);
1602
1718
  return;
1603
1719
  }
1604
1720
  this.#logger.info(`received user message (session=${clientSessionId}): ${normalizedText}`);
@@ -1686,25 +1802,17 @@ ${message}`;
1686
1802
  if (!this.#client) {
1687
1803
  return;
1688
1804
  }
1689
- if (!this.#shouldRenderProgressEvent(event)) {
1690
- return;
1691
- }
1692
1805
  const state = this.#progressStateBySession.get(event.clientSessionId) ?? {
1806
+ renderer: new ProgressRenderer(),
1693
1807
  messageId: null,
1694
- creating: false,
1695
- lines: [],
1696
- status: "running",
1697
- turnId: 0,
1698
- collapsedCount: 0
1808
+ creating: false
1699
1809
  };
1700
- state.lines.push(this.#formatProgressLine(event));
1701
- if (state.lines.length > 10) {
1702
- state.collapsedCount += state.lines.length - 10;
1703
- state.lines.splice(0, state.lines.length - 10);
1704
- }
1705
- state.status = this.#progressStatus(event);
1706
1810
  this.#progressStateBySession.set(event.clientSessionId, state);
1707
- const card = _FeishuIMAdapter.buildProgressCard(state.lines, state.status, state.collapsedCount);
1811
+ if (!state.renderer.isProgressEvent(event)) {
1812
+ return;
1813
+ }
1814
+ state.renderer.takeProgressEvent(event);
1815
+ const card = _FeishuIMAdapter.buildProgressCard(state.renderer.getCurrentProgress().markdown);
1708
1816
  if (state.messageId) {
1709
1817
  await this.#client.updateCard(state.messageId, card);
1710
1818
  return;
@@ -1723,59 +1831,13 @@ ${message}`;
1723
1831
  state.creating = false;
1724
1832
  }
1725
1833
  }
1726
- #shouldRenderProgressEvent(event) {
1727
- return event.type !== "assistant.thinking";
1728
- }
1729
1834
  #resetProgressState(clientSessionId) {
1730
- const previous = this.#progressStateBySession.get(clientSessionId);
1731
1835
  this.#progressStateBySession.set(clientSessionId, {
1836
+ renderer: new ProgressRenderer(),
1732
1837
  messageId: null,
1733
- creating: false,
1734
- lines: [],
1735
- status: "running",
1736
- turnId: (previous?.turnId ?? 0) + 1,
1737
- collapsedCount: 0
1838
+ creating: false
1738
1839
  });
1739
1840
  }
1740
- #formatProgressLine(event) {
1741
- switch (event.type) {
1742
- case "assistant.thinking":
1743
- return "";
1744
- case "session.compacting":
1745
- return `- Compacting session${event.text ? `: ${event.text}` : ""}`;
1746
- case "assistant.tool.running":
1747
- return `- Running ${event.toolName}`;
1748
- case "assistant.tool.done":
1749
- return `- Finished ${event.toolName}`;
1750
- case "assistant.tool.error":
1751
- return this.#formatToolErrorLine(event.toolName, event.text);
1752
- }
1753
- }
1754
- #formatToolErrorLine(toolName, text2) {
1755
- const normalizedText = text2?.trim();
1756
- if (!normalizedText) {
1757
- return `- ${this.#humanizeToolError(toolName)}`;
1758
- }
1759
- const lowerText = normalizedText.toLowerCase();
1760
- const lowerToolName = toolName.toLowerCase();
1761
- if (lowerText === lowerToolName || lowerText === `failed ${lowerToolName}`) {
1762
- return `- ${this.#humanizeToolError(toolName)}`;
1763
- }
1764
- return `- ${this.#humanizeToolError(toolName)}: ${normalizedText}`;
1765
- }
1766
- #humanizeToolError(toolName) {
1767
- return `Failed ${toolName}`;
1768
- }
1769
- #progressStatus(event) {
1770
- switch (event.type) {
1771
- case "assistant.tool.error":
1772
- return "error";
1773
- case "assistant.tool.done":
1774
- return "done";
1775
- default:
1776
- return "running";
1777
- }
1778
- }
1779
1841
  };
1780
1842
 
1781
1843
  // src/modules/client/feishu/index.ts
@@ -1828,9 +1890,1449 @@ var feishuClientModule = {
1828
1890
  }
1829
1891
  };
1830
1892
 
1893
+ // src/modules/client/wecom/adapter/wecom-client.ts
1894
+ import { existsSync as existsSync4 } from "fs";
1895
+ import { mkdir as mkdir4, readFile as readFile3, writeFile as writeFile3 } from "fs/promises";
1896
+ import { randomUUID as randomUUID2 } from "crypto";
1897
+ import { tmpdir as tmpdir2 } from "os";
1898
+ import { basename as basename2, extname, join as join2 } from "path";
1899
+ import { WSClient } from "@wecom/aibot-node-sdk";
1900
+ var DEFAULT_WEBSOCKET_URL = "wss://openws.work.weixin.qq.com";
1901
+ var MEDIA_TEMP_DIR = join2(tmpdir2(), "agent-bridge-wecom-media");
1902
+ var IMAGE_SIGNATURE_PNG = Buffer.from("89504e470d0a1a0a", "hex");
1903
+ var IMAGE_SIGNATURE_JPG = Buffer.from("ffd8ff", "hex");
1904
+ var PROCESSED_MESSAGE_ID_LIMIT = 500;
1905
+ var KICKED_ERROR_MESSAGE = "WeCom connection was closed by the server because a newer connection was established for the same bot; this instance will not reconnect";
1906
+ function sanitizeFileName(fileName) {
1907
+ return fileName.replace(/[^a-zA-Z0-9._-]/g, "_");
1908
+ }
1909
+ function detectImageExtension(data) {
1910
+ if (data.subarray(0, IMAGE_SIGNATURE_PNG.length).equals(IMAGE_SIGNATURE_PNG)) return ".png";
1911
+ if (data.subarray(0, IMAGE_SIGNATURE_JPG.length).equals(IMAGE_SIGNATURE_JPG)) return ".jpg";
1912
+ return ".jpg";
1913
+ }
1914
+ function decodeBase64(data) {
1915
+ const payload = data.split(",", 1).length > 1 ? data.split(",", 2)[1] : data;
1916
+ return Buffer.from(payload.trim(), "base64");
1917
+ }
1918
+ function buildMarkdownBody(text2) {
1919
+ return {
1920
+ msgtype: "markdown",
1921
+ markdown: {
1922
+ content: text2
1923
+ }
1924
+ };
1925
+ }
1926
+ async function ensureMediaDir() {
1927
+ if (!existsSync4(MEDIA_TEMP_DIR)) {
1928
+ await mkdir4(MEDIA_TEMP_DIR, { recursive: true });
1929
+ }
1930
+ }
1931
+ var WecomClient = class {
1932
+ #config;
1933
+ #logger;
1934
+ #onMessage = null;
1935
+ #onKicked = null;
1936
+ #client = null;
1937
+ #kicked = false;
1938
+ #processedMessageIds = /* @__PURE__ */ new Set();
1939
+ #replyReqIdByMessageId = /* @__PURE__ */ new Map();
1940
+ #lastChatReqId = /* @__PURE__ */ new Map();
1941
+ #replyContextByMessageId = /* @__PURE__ */ new Map();
1942
+ #lastReplyContextByChatId = /* @__PURE__ */ new Map();
1943
+ #streamContextByMessageId = /* @__PURE__ */ new Map();
1944
+ #lastStreamContextByChatId = /* @__PURE__ */ new Map();
1945
+ constructor(config, logger3 = createLogger("wecom")) {
1946
+ this.#config = config;
1947
+ this.#logger = logger3;
1948
+ }
1949
+ setOnMessage(onMessage) {
1950
+ this.#onMessage = onMessage;
1951
+ }
1952
+ setOnKicked(onKicked) {
1953
+ this.#onKicked = onKicked;
1954
+ }
1955
+ isKicked() {
1956
+ return this.#kicked;
1957
+ }
1958
+ async connect() {
1959
+ const websocketUrl = this.#config.websocketUrl ?? DEFAULT_WEBSOCKET_URL;
1960
+ this.#kicked = false;
1961
+ const client = new WSClient({
1962
+ botId: this.#config.botId,
1963
+ secret: this.#config.secret,
1964
+ wsUrl: websocketUrl,
1965
+ logger: this.#logger
1966
+ });
1967
+ this.#client = client;
1968
+ this.#registerInboundHandlers(client);
1969
+ await new Promise((resolve, reject) => {
1970
+ let settled = false;
1971
+ const resolveOnce = () => {
1972
+ if (settled) {
1973
+ return;
1974
+ }
1975
+ settled = true;
1976
+ resolve();
1977
+ };
1978
+ const rejectOnce = (error, phase) => {
1979
+ if (settled) {
1980
+ return;
1981
+ }
1982
+ settled = true;
1983
+ reject(this.#toConnectionError(error, websocketUrl, phase));
1984
+ };
1985
+ client.on("authenticated", resolveOnce);
1986
+ client.on("ready", resolveOnce);
1987
+ client.on("error", (error) => rejectOnce(error, "connect"));
1988
+ client.on("auth_error", (error) => rejectOnce(error, "subscribe"));
1989
+ client.on("close", () => {
1990
+ if (!settled) {
1991
+ rejectOnce(new Error("WeCom websocket closed before subscription completed"), "connect");
1992
+ }
1993
+ });
1994
+ try {
1995
+ client.connect();
1996
+ } catch (error) {
1997
+ rejectOnce(error, "connect");
1998
+ }
1999
+ });
2000
+ }
2001
+ #toConnectionError(error, websocketUrl, phase) {
2002
+ if (error instanceof Error) {
2003
+ return new Error(`WeCom websocket ${phase} failed (${websocketUrl}): ${error.message}`);
2004
+ }
2005
+ const event = error;
2006
+ const details = [event?.message, event?.error].filter((value) => typeof value === "string" && value.trim().length > 0).join("; ");
2007
+ const suffix = details ? `: ${details}` : "";
2008
+ return new Error(`WeCom websocket ${phase} failed (${websocketUrl})${suffix}`);
2009
+ }
2010
+ async disconnect() {
2011
+ this.#client?.disconnect();
2012
+ this.#client = null;
2013
+ }
2014
+ #requireClient() {
2015
+ if (!this.#client) {
2016
+ throw new Error("WeCom websocket is not connected");
2017
+ }
2018
+ if (this.#kicked) {
2019
+ throw new Error(KICKED_ERROR_MESSAGE);
2020
+ }
2021
+ return this.#client;
2022
+ }
2023
+ async sendText(chatId, text2, replyToMessageId) {
2024
+ const client = this.#requireClient();
2025
+ const replyContext = this.#replyContextForMessage(replyToMessageId) ?? this.#lastReplyContextByChatId.get(chatId);
2026
+ if (replyContext) {
2027
+ this.#logger.debug(`reply context available for ${chatId}: ${replyContext.reqId}`);
2028
+ await client.reply(replyContext.frame, buildMarkdownBody(text2));
2029
+ return;
2030
+ }
2031
+ await client.sendMessage(chatId, buildMarkdownBody(text2));
2032
+ }
2033
+ async sendStreamText(chatId, text2, options) {
2034
+ const client = this.#requireClient();
2035
+ const replyToMessageId = options?.replyToMessageId;
2036
+ const replyContext = this.#replyContextForMessage(replyToMessageId) ?? this.#lastReplyContextByChatId.get(chatId);
2037
+ if (!replyContext) {
2038
+ await client.sendMessage(chatId, buildMarkdownBody(text2));
2039
+ return;
2040
+ }
2041
+ const streamContext = this.#streamContextFor(chatId, replyToMessageId);
2042
+ const feedback = !streamContext.started && options?.feedbackId ? { id: options.feedbackId } : void 0;
2043
+ await client.replyStream(
2044
+ replyContext.frame,
2045
+ streamContext.streamId,
2046
+ text2,
2047
+ options?.finish ?? false,
2048
+ void 0,
2049
+ feedback
2050
+ );
2051
+ streamContext.started = true;
2052
+ }
2053
+ async sendAttachment(chatId, attachment, replyToMessageId) {
2054
+ const client = this.#requireClient();
2055
+ const upload = await this.#uploadMedia(attachment);
2056
+ const replyContext = this.#replyContextForMessage(replyToMessageId) ?? this.#lastReplyContextByChatId.get(chatId);
2057
+ if (replyContext) {
2058
+ this.#logger.debug(`reply media context available for ${chatId}: ${replyContext.reqId}`);
2059
+ await client.replyMedia(replyContext.frame, upload.type, upload.mediaId);
2060
+ return;
2061
+ }
2062
+ await client.sendMessage(chatId, {
2063
+ msgtype: upload.type,
2064
+ [upload.type]: { media_id: upload.mediaId }
2065
+ });
2066
+ }
2067
+ async #uploadMedia(attachment) {
2068
+ if (!this.#client) {
2069
+ throw new Error("WeCom websocket is not connected");
2070
+ }
2071
+ const data = await readFile3(attachment.filePath);
2072
+ const type = attachment.kind === "image" ? "image" : "file";
2073
+ const filename = sanitizeFileName(attachment.fileName ?? basename2(attachment.filePath));
2074
+ const response = await this.#client.uploadMedia(data, { type, filename });
2075
+ const mediaId = String(response.media_id ?? "").trim();
2076
+ if (!mediaId) {
2077
+ throw new Error("media upload failed: missing media_id");
2078
+ }
2079
+ return { type, mediaId };
2080
+ }
2081
+ #replyReqIdForMessage(messageId) {
2082
+ if (!messageId) return void 0;
2083
+ return this.#replyReqIdByMessageId.get(messageId);
2084
+ }
2085
+ #replyContextForMessage(messageId) {
2086
+ if (!messageId) return void 0;
2087
+ return this.#replyContextByMessageId.get(messageId);
2088
+ }
2089
+ #streamContextFor(chatId, messageId) {
2090
+ if (messageId) {
2091
+ const existing = this.#streamContextByMessageId.get(messageId);
2092
+ if (existing) {
2093
+ this.#lastStreamContextByChatId.set(chatId, existing);
2094
+ return existing;
2095
+ }
2096
+ }
2097
+ const fallback = this.#lastStreamContextByChatId.get(chatId);
2098
+ if (fallback) {
2099
+ return fallback;
2100
+ }
2101
+ const created = { streamId: randomUUID2(), started: false };
2102
+ if (messageId) {
2103
+ this.#streamContextByMessageId.set(messageId, created);
2104
+ }
2105
+ this.#lastStreamContextByChatId.set(chatId, created);
2106
+ return created;
2107
+ }
2108
+ #registerInboundHandlers(client) {
2109
+ client.on("event", (payload) => {
2110
+ this.#logger.info(
2111
+ `received SDK event event (reqId=${String(payload?.headers?.req_id ?? "") || "n/a"} eventType=${String(payload?.body?.event?.eventtype ?? "") || "n/a"})`
2112
+ );
2113
+ });
2114
+ client.on("event.enter_chat", (payload) => {
2115
+ this.#logger.info(
2116
+ `received enter_chat event (reqId=${String(payload?.headers?.req_id ?? "") || "n/a"} userId=${String(payload?.body?.from?.userid ?? "") || "n/a"})`
2117
+ );
2118
+ void this.#handleEnterChat(payload);
2119
+ });
2120
+ client.on("event.disconnected_event", (payload) => {
2121
+ this.#kicked = true;
2122
+ this.#logger.error(
2123
+ `connection replaced by a newer connection for the same bot (reqId=${String(payload?.headers?.req_id ?? "") || "n/a"}); this instance will no longer receive or send messages`
2124
+ );
2125
+ this.#onKicked?.();
2126
+ });
2127
+ const specificMsgTypes = /* @__PURE__ */ new Set(["text", "image", "file", "voice", "video", "mixed"]);
2128
+ client.on("message", (payload) => {
2129
+ const msgtype = String(payload?.body?.msgtype ?? "").toLowerCase();
2130
+ if (specificMsgTypes.has(msgtype)) {
2131
+ return;
2132
+ }
2133
+ this.#logger.info(
2134
+ `received SDK event message (reqId=${String(payload?.headers?.req_id ?? "") || "n/a"} msgtype=${msgtype || "n/a"})`
2135
+ );
2136
+ void this.#handleInboundCallback(
2137
+ payload,
2138
+ String(payload?.headers?.req_id ?? "")
2139
+ );
2140
+ });
2141
+ for (const eventName of [
2142
+ "message.text",
2143
+ "message.image",
2144
+ "message.file",
2145
+ "message.voice",
2146
+ "message.video",
2147
+ "message.mixed"
2148
+ ]) {
2149
+ client.on(eventName, (payload) => {
2150
+ this.#logger.info(
2151
+ `received SDK event ${eventName} (reqId=${String(payload?.headers?.req_id ?? "") || "n/a"} msgtype=${String(payload?.body?.msgtype ?? "") || "n/a"})`
2152
+ );
2153
+ void this.#handleInboundCallback(
2154
+ payload,
2155
+ String(payload?.headers?.req_id ?? "")
2156
+ );
2157
+ });
2158
+ }
2159
+ }
2160
+ /**
2161
+ * Records a message id as processed. Returns false when the id was already
2162
+ * seen, so callers can drop duplicate callbacks. Bounded to the most recent
2163
+ * PROCESSED_MESSAGE_ID_LIMIT ids (insertion-ordered Set acts as the LRU).
2164
+ */
2165
+ #markMessageProcessed(messageId) {
2166
+ if (this.#processedMessageIds.has(messageId)) {
2167
+ return false;
2168
+ }
2169
+ this.#processedMessageIds.add(messageId);
2170
+ if (this.#processedMessageIds.size > PROCESSED_MESSAGE_ID_LIMIT) {
2171
+ const oldest = this.#processedMessageIds.values().next().value;
2172
+ if (oldest !== void 0) {
2173
+ this.#processedMessageIds.delete(oldest);
2174
+ }
2175
+ }
2176
+ return true;
2177
+ }
2178
+ async #handleInboundCallback(payload, reqId) {
2179
+ const body = payload.body ?? {};
2180
+ const senderId = String(body.from?.userid ?? "").trim();
2181
+ const chatId = String(body.chatid ?? senderId).trim();
2182
+ if (!chatId) {
2183
+ this.#logger.warn(`dropping inbound callback without chatId (reqId=${reqId || "n/a"})`);
2184
+ return;
2185
+ }
2186
+ const messageId = String(body.msgid ?? reqId ?? randomUUID2()).trim();
2187
+ if (!this.#markMessageProcessed(messageId)) {
2188
+ this.#logger.info(
2189
+ `dropping duplicate inbound callback (messageId=${messageId} reqId=${reqId || "n/a"})`
2190
+ );
2191
+ return;
2192
+ }
2193
+ this.#replyReqIdByMessageId.set(messageId, reqId);
2194
+ this.#lastChatReqId.set(chatId, reqId);
2195
+ const replyContext = { reqId, frame: { headers: { req_id: reqId } } };
2196
+ this.#replyContextByMessageId.set(messageId, replyContext);
2197
+ this.#lastReplyContextByChatId.set(chatId, replyContext);
2198
+ const streamContext = { streamId: randomUUID2(), started: false };
2199
+ this.#streamContextByMessageId.set(messageId, streamContext);
2200
+ this.#lastStreamContextByChatId.set(chatId, streamContext);
2201
+ const textParts = [];
2202
+ const plainText = String(body.text?.content ?? "").trim();
2203
+ if (plainText) {
2204
+ textParts.push(plainText);
2205
+ }
2206
+ if (String(body.msgtype ?? "").toLowerCase() === "appmsg") {
2207
+ const title = String(body.appmsg?.title ?? "").trim();
2208
+ if (title) {
2209
+ textParts.push(title);
2210
+ }
2211
+ }
2212
+ if (String(body.msgtype ?? "").toLowerCase() === "mixed") {
2213
+ const items = Array.isArray(body.mixed?.msg_item) ? body.mixed.msg_item : [];
2214
+ for (const item of items) {
2215
+ if (String(item?.msgtype ?? "").toLowerCase() === "text") {
2216
+ const content = String(item?.text?.content ?? "").trim();
2217
+ if (content) textParts.push(content);
2218
+ }
2219
+ }
2220
+ }
2221
+ const refs = this.#extractMediaRefs(body);
2222
+ for (const ref of refs) {
2223
+ const localPath = await this.#downloadMediaRef(ref);
2224
+ if (localPath) {
2225
+ textParts.push(`[Received ${ref.kind}: ${localPath}]`);
2226
+ }
2227
+ }
2228
+ const chatType = String(body.chattype ?? "").toLowerCase() === "group" ? "group" : "dm";
2229
+ const rawText = textParts.join("\n").trim();
2230
+ const { text: text2, mentionedBot } = this.#normalizeMention(rawText, chatType);
2231
+ this.#logger.info(
2232
+ `normalized inbound message (chatType=${chatType} chatId=${chatId} messageId=${messageId} textLength=${text2.length})`
2233
+ );
2234
+ await this.#onMessage?.({
2235
+ chatId,
2236
+ chatType,
2237
+ messageId,
2238
+ text: text2,
2239
+ mentionedBot,
2240
+ raw: payload
2241
+ });
2242
+ }
2243
+ async #handleEnterChat(payload) {
2244
+ if (!this.#client) {
2245
+ return;
2246
+ }
2247
+ const reqId = String(payload?.headers?.req_id ?? "").trim();
2248
+ if (!reqId) {
2249
+ return;
2250
+ }
2251
+ try {
2252
+ await this.#client.replyWelcome(
2253
+ { headers: { req_id: reqId } },
2254
+ {
2255
+ msgtype: "text",
2256
+ text: {
2257
+ content: "\u60A8\u597D\uFF0C\u6211\u5DF2\u8FDE\u63A5\u6210\u529F\uFF0C\u53EF\u4EE5\u76F4\u63A5\u7ED9\u6211\u53D1\u6D88\u606F\u3002"
2258
+ }
2259
+ }
2260
+ );
2261
+ this.#logger.info(`sent enter_chat welcome reply (reqId=${reqId})`);
2262
+ } catch (error) {
2263
+ this.#logger.warn(`failed to send enter_chat welcome reply (reqId=${reqId}):`, error);
2264
+ }
2265
+ }
2266
+ #normalizeMention(text2, chatType) {
2267
+ if (chatType !== "group") {
2268
+ return { text: text2, mentionedBot: false };
2269
+ }
2270
+ const match = text2.match(/^@(\S+)\s*(.*)$/s);
2271
+ if (!match) {
2272
+ return { text: text2, mentionedBot: false };
2273
+ }
2274
+ return { text: match[2] ?? "", mentionedBot: true };
2275
+ }
2276
+ #extractMediaRefs(body) {
2277
+ const refs = [];
2278
+ const msgType = String(body.msgtype ?? "").toLowerCase();
2279
+ if (msgType === "image" && body.image) {
2280
+ refs.push({
2281
+ kind: "image",
2282
+ url: typeof body.image.url === "string" ? body.image.url : void 0,
2283
+ aeskey: typeof body.image.aeskey === "string" ? body.image.aeskey : void 0,
2284
+ base64: typeof body.image.base64 === "string" ? body.image.base64 : void 0,
2285
+ fileName: typeof body.image.filename === "string" ? body.image.filename : void 0
2286
+ });
2287
+ }
2288
+ if (msgType === "file" && body.file) {
2289
+ refs.push({
2290
+ kind: "file",
2291
+ url: typeof body.file.url === "string" ? body.file.url : void 0,
2292
+ aeskey: typeof body.file.aeskey === "string" ? body.file.aeskey : void 0,
2293
+ base64: typeof body.file.base64 === "string" ? body.file.base64 : void 0,
2294
+ fileName: typeof body.file.filename === "string" ? body.file.filename : typeof body.file.name === "string" ? body.file.name : void 0
2295
+ });
2296
+ }
2297
+ if (msgType === "appmsg" && body.appmsg?.file) {
2298
+ refs.push({
2299
+ kind: "file",
2300
+ url: typeof body.appmsg.file.url === "string" ? body.appmsg.file.url : void 0,
2301
+ aeskey: typeof body.appmsg.file.aeskey === "string" ? body.appmsg.file.aeskey : void 0,
2302
+ base64: typeof body.appmsg.file.base64 === "string" ? body.appmsg.file.base64 : void 0,
2303
+ fileName: typeof body.appmsg.file.filename === "string" ? body.appmsg.file.filename : typeof body.appmsg.title === "string" ? body.appmsg.title : void 0
2304
+ });
2305
+ }
2306
+ return refs;
2307
+ }
2308
+ async #downloadMediaRef(ref) {
2309
+ try {
2310
+ await ensureMediaDir();
2311
+ let bytes;
2312
+ let fileName = ref.fileName;
2313
+ if (ref.base64) {
2314
+ bytes = decodeBase64(ref.base64);
2315
+ } else if (ref.url) {
2316
+ if (!this.#client) {
2317
+ throw new Error("WeCom websocket is not connected");
2318
+ }
2319
+ const downloaded = await this.#client.downloadFile(ref.url, ref.aeskey);
2320
+ bytes = downloaded.buffer;
2321
+ fileName = fileName ?? downloaded.filename;
2322
+ } else {
2323
+ return null;
2324
+ }
2325
+ const safeName = fileName ? sanitizeFileName(fileName) : ref.kind === "image" ? `${randomUUID2()}${detectImageExtension(bytes)}` : `${randomUUID2()}.bin`;
2326
+ const resolvedName = extname(safeName) ? safeName : ref.kind === "image" ? `${safeName}${detectImageExtension(bytes)}` : `${safeName}.bin`;
2327
+ const outputPath = join2(MEDIA_TEMP_DIR, `${Date.now()}-${resolvedName}`);
2328
+ await writeFile3(outputPath, bytes);
2329
+ return outputPath;
2330
+ } catch (error) {
2331
+ this.#logger.warn(`failed to download ${ref.kind} resource:`, error);
2332
+ return null;
2333
+ }
2334
+ }
2335
+ };
2336
+
2337
+ // src/modules/client/wecom/adapter/wecom-session.ts
2338
+ function buildWecomSessionId(chatType, chatId) {
2339
+ if (chatType === "dm") {
2340
+ return `wecom:dm:${chatId}`;
2341
+ }
2342
+ if (chatType === "group") {
2343
+ return `wecom:group:${chatId}`;
2344
+ }
2345
+ throw new Error(`Unsupported WeCom chat type: ${chatType}`);
2346
+ }
2347
+ function parseWecomSessionId(clientSessionId) {
2348
+ if (clientSessionId.startsWith("wecom:dm:")) {
2349
+ return { platform: "wecom", chatType: "dm", chatId: clientSessionId.slice("wecom:dm:".length) };
2350
+ }
2351
+ if (clientSessionId.startsWith("wecom:group:")) {
2352
+ return { platform: "wecom", chatType: "group", chatId: clientSessionId.slice("wecom:group:".length) };
2353
+ }
2354
+ throw new Error(`Unsupported clientSessionId: ${clientSessionId}`);
2355
+ }
2356
+
2357
+ // src/modules/client/wecom/adapter/wecom-im-adapter.ts
2358
+ var MAX_TEXT_CHUNK2 = 4e3;
2359
+ var STARTING_MESSAGE = "Processing...";
2360
+ function chunkText2(text2, maxLen) {
2361
+ if (text2.length <= maxLen) return [text2];
2362
+ const chunks = [];
2363
+ let remaining = text2;
2364
+ while (remaining.length > 0) {
2365
+ if (remaining.length <= maxLen) {
2366
+ chunks.push(remaining);
2367
+ break;
2368
+ }
2369
+ let splitPos = remaining.lastIndexOf("\n", maxLen);
2370
+ if (splitPos <= 0) {
2371
+ splitPos = remaining.lastIndexOf(" ", maxLen);
2372
+ }
2373
+ if (splitPos <= 0) {
2374
+ chunks.push(remaining.slice(0, maxLen));
2375
+ remaining = remaining.slice(maxLen);
2376
+ continue;
2377
+ }
2378
+ chunks.push(remaining.slice(0, splitPos + 1));
2379
+ remaining = remaining.slice(splitPos + 1);
2380
+ }
2381
+ return chunks.filter((chunk) => chunk.length > 0);
2382
+ }
2383
+ var WecomIMAdapter = class {
2384
+ #config;
2385
+ #logger;
2386
+ #onOutput = null;
2387
+ #client = null;
2388
+ #egressQueue = [];
2389
+ #processing = false;
2390
+ #lastInboundMessageIdBySession = /* @__PURE__ */ new Map();
2391
+ #progressStateBySession = /* @__PURE__ */ new Map();
2392
+ async #notifySendFailure(chatId, error) {
2393
+ if (!this.#client) {
2394
+ return;
2395
+ }
2396
+ if (this.#client.isKicked()) {
2397
+ this.#logger.debug("skipping send-failure notification, connection was replaced");
2398
+ return;
2399
+ }
2400
+ const message = error instanceof Error ? error.message : String(error);
2401
+ const text2 = `[agent-bridge error] Message delivery failed
2402
+
2403
+ ${message}`;
2404
+ try {
2405
+ await this.#client.sendText(chatId, text2);
2406
+ } catch (notifyError) {
2407
+ this.#logger.error("failed to notify send failure:", notifyError);
2408
+ }
2409
+ }
2410
+ constructor(config, logger3 = createLogger("wecom")) {
2411
+ this.#config = config;
2412
+ this.#logger = logger3;
2413
+ }
2414
+ async start(onOutput) {
2415
+ this.#onOutput = onOutput;
2416
+ this.#client = new WecomClient(this.#config, this.#logger);
2417
+ this.#client.setOnKicked(() => {
2418
+ this.#logger.error(
2419
+ "this bot connection was replaced by a newer connection (another instance is running with the same bot credentials); this instance will keep running but can no longer receive or send messages \u2014 stop the other instance and restart this process to recover"
2420
+ );
2421
+ });
2422
+ this.#client.setOnMessage(async ({ chatId, chatType, text: text2, messageId, mentionedBot }) => {
2423
+ if (!this.#onOutput) {
2424
+ this.#logger.warn(`dropping inbound message, adapter not ready (chatId=${chatId})`);
2425
+ return;
2426
+ }
2427
+ this.#logger.info(
2428
+ `adapter received inbound message (chatType=${chatType} chatId=${chatId} messageId=${messageId} mentionedBot=${mentionedBot} textLength=${text2.trim().length})`
2429
+ );
2430
+ const clientSessionId = buildWecomSessionId(chatType, chatId);
2431
+ if (chatType === "group" && (this.#config.requireMentionInGroup ?? true) && !mentionedBot) {
2432
+ this.#logger.debug(
2433
+ `ignoring group message without bot mention (session=${clientSessionId} messageId=${messageId})`
2434
+ );
2435
+ return;
2436
+ }
2437
+ const normalizedText = text2.trim();
2438
+ const commandEvent = parseSlashCommand(normalizedText, clientSessionId);
2439
+ if (commandEvent) {
2440
+ await this.#onOutput(commandEvent);
2441
+ return;
2442
+ }
2443
+ this.#lastInboundMessageIdBySession.set(clientSessionId, messageId);
2444
+ this.#resetProgressState(clientSessionId);
2445
+ await this.#announceStart(chatId, messageId, clientSessionId);
2446
+ await this.#onOutput({
2447
+ type: "user.message",
2448
+ clientSessionId,
2449
+ text: text2
2450
+ });
2451
+ });
2452
+ await this.#client.connect();
2453
+ this.#logger.info(
2454
+ `adapter started (websocketUrl=${this.#config.websocketUrl ?? "wss://openws.work.weixin.qq.com"})`
2455
+ );
2456
+ }
2457
+ async stop() {
2458
+ this.#egressQueue.length = 0;
2459
+ this.#progressStateBySession.clear();
2460
+ if (this.#client) {
2461
+ await this.#client.disconnect();
2462
+ this.#client = null;
2463
+ }
2464
+ this.#processing = false;
2465
+ this.#onOutput = null;
2466
+ this.#logger.info("adapter stopped");
2467
+ }
2468
+ async input(event) {
2469
+ if (!this.#client) {
2470
+ throw new Error("WecomIMAdapter is not started");
2471
+ }
2472
+ this.#egressQueue.push(event);
2473
+ void this.#drainEgressQueue();
2474
+ }
2475
+ async isBusy() {
2476
+ return this.#processing || this.#egressQueue.length > 0;
2477
+ }
2478
+ async #drainEgressQueue() {
2479
+ if (this.#processing) {
2480
+ return;
2481
+ }
2482
+ this.#processing = true;
2483
+ try {
2484
+ while (this.#client && this.#egressQueue.length > 0) {
2485
+ const event = this.#egressQueue.shift();
2486
+ if (!event) continue;
2487
+ try {
2488
+ const target = parseWecomSessionId(event.clientSessionId);
2489
+ if (event.type !== "assistant.message") {
2490
+ await this.#handleProgressEvent(target.chatId, event);
2491
+ continue;
2492
+ }
2493
+ const replyToMessageId = this.#lastInboundMessageIdBySession.get(event.clientSessionId);
2494
+ await this.#finishProgressMessage(target.chatId, event.clientSessionId, replyToMessageId);
2495
+ if (event.text.trim().length > 0) {
2496
+ const chunks = chunkText2(event.text, MAX_TEXT_CHUNK2);
2497
+ for (const [index, chunk] of chunks.entries()) {
2498
+ await this.#client.sendText(target.chatId, chunk, index === 0 ? replyToMessageId : void 0);
2499
+ }
2500
+ }
2501
+ for (const attachment of event.attachments ?? []) {
2502
+ try {
2503
+ await this.#client.sendAttachment(target.chatId, attachment, replyToMessageId);
2504
+ } catch (attachmentError) {
2505
+ this.#logger.error("failed to send attachment:", attachmentError);
2506
+ await this.#notifySendFailure(target.chatId, attachmentError);
2507
+ }
2508
+ }
2509
+ } catch (error) {
2510
+ this.#logger.error("failed to send egress event:", error);
2511
+ try {
2512
+ const target = parseWecomSessionId(event.clientSessionId);
2513
+ await this.#notifySendFailure(target.chatId, error);
2514
+ } catch (notifyError) {
2515
+ this.#logger.error("failed to handle egress send failure:", notifyError);
2516
+ }
2517
+ }
2518
+ }
2519
+ } finally {
2520
+ this.#processing = false;
2521
+ }
2522
+ }
2523
+ async #announceStart(chatId, messageId, clientSessionId) {
2524
+ const state = this.#progressStateBySession.get(clientSessionId);
2525
+ if (!state || state.announced || !this.#client) {
2526
+ return;
2527
+ }
2528
+ await this.#client.sendStreamText(chatId, STARTING_MESSAGE, {
2529
+ replyToMessageId: messageId,
2530
+ finish: false
2531
+ });
2532
+ state.announced = true;
2533
+ }
2534
+ async #handleProgressEvent(chatId, event) {
2535
+ if (!this.#client) {
2536
+ return;
2537
+ }
2538
+ const state = this.#progressStateBySession.get(event.clientSessionId) ?? {
2539
+ renderer: new ProgressRenderer(),
2540
+ announced: false
2541
+ };
2542
+ this.#progressStateBySession.set(event.clientSessionId, state);
2543
+ if (!state.renderer.isProgressEvent(event)) {
2544
+ return;
2545
+ }
2546
+ state.renderer.takeProgressEvent(event);
2547
+ const body = state.renderer.getCurrentProgress().markdown;
2548
+ const replyToMessageId = this.#lastInboundMessageIdBySession.get(event.clientSessionId);
2549
+ await this.#client.sendStreamText(chatId, body, {
2550
+ replyToMessageId,
2551
+ finish: false
2552
+ });
2553
+ state.announced = true;
2554
+ }
2555
+ async #finishProgressMessage(chatId, clientSessionId, replyToMessageId) {
2556
+ const state = this.#progressStateBySession.get(clientSessionId);
2557
+ if (!state || !state.announced || !this.#client) {
2558
+ return;
2559
+ }
2560
+ this.#progressStateBySession.delete(clientSessionId);
2561
+ const progress = state.renderer.getCurrentProgress();
2562
+ const body = progress.markdown === NO_PROGRESS_MARKDOWN ? STARTING_MESSAGE : progress.markdown;
2563
+ try {
2564
+ await this.#client.sendStreamText(chatId, body, { replyToMessageId, finish: true });
2565
+ } catch (error) {
2566
+ this.#logger.warn("failed to finish progress message:", error);
2567
+ }
2568
+ }
2569
+ #resetProgressState(clientSessionId) {
2570
+ this.#progressStateBySession.set(clientSessionId, {
2571
+ renderer: new ProgressRenderer(),
2572
+ announced: false
2573
+ });
2574
+ }
2575
+ };
2576
+
2577
+ // src/modules/client/wecom/index.ts
2578
+ var DEFAULT_WEBSOCKET_URL2 = "wss://openws.work.weixin.qq.com";
2579
+ function createWecomConfigCollector() {
2580
+ return {
2581
+ async collect(ctx) {
2582
+ const botId = await ctx.input("WeCom Bot ID", {
2583
+ required: true,
2584
+ validate: (value) => value ? null : "Bot ID is required"
2585
+ });
2586
+ const secret = await ctx.input("WeCom Secret", {
2587
+ required: true,
2588
+ secret: true,
2589
+ validate: (value) => value ? null : "Secret is required"
2590
+ });
2591
+ const websocketUrl = await ctx.input("WeCom WebSocket URL", {
2592
+ defaultValue: DEFAULT_WEBSOCKET_URL2,
2593
+ validate: (value) => !value || /^wss?:\/\//.test(value) ? null : "WebSocket URL must start with ws:// or wss://"
2594
+ });
2595
+ const requireMentionInGroup = await ctx.confirm("Require @mention in group chats", true);
2596
+ return { botId, secret, websocketUrl, requireMentionInGroup };
2597
+ },
2598
+ validate(config) {
2599
+ if (!config.botId.trim()) {
2600
+ throw new Error("WeCom botId is required");
2601
+ }
2602
+ if (!config.secret.trim()) {
2603
+ throw new Error("WeCom secret is required");
2604
+ }
2605
+ if (config.websocketUrl && !/^wss?:\/\//.test(config.websocketUrl)) {
2606
+ throw new Error("WeCom websocketUrl must start with ws:// or wss://");
2607
+ }
2608
+ },
2609
+ summarize(config) {
2610
+ const masked = config.botId.length > 8 ? `${config.botId.slice(0, 4)}****${config.botId.slice(-4)}` : "****";
2611
+ return `type=wecom botId=${masked} websocketUrl=${config.websocketUrl ?? DEFAULT_WEBSOCKET_URL2} requireMentionInGroup=${config.requireMentionInGroup ?? true}`;
2612
+ }
2613
+ };
2614
+ }
2615
+ var wecomClientModule = {
2616
+ type: "wecom",
2617
+ createConfigCollector: createWecomConfigCollector,
2618
+ createClientAdapter(config) {
2619
+ return new WecomIMAdapter(config);
2620
+ }
2621
+ };
2622
+
2623
+ // src/modules/client/weixin/adapter/weixin-client.ts
2624
+ import * as fs from "fs/promises";
2625
+ import { randomUUID as randomUUID3 } from "crypto";
2626
+ import { basename as basename3, extname as extname2, join as join3 } from "path";
2627
+ import { tmpdir as tmpdir3 } from "os";
2628
+ import { Client as OpenILinkClient, extractText, TYPING, CANCEL_TYPING } from "@openilink/openilink-sdk-node";
2629
+ var MEDIA_TEMP_DIR2 = join3(tmpdir3(), "agent-bridge-weixin-media");
2630
+ var STALE_SESSION_ERRCODE = -2;
2631
+ var STALE_SESSION_ERRMSG = "unknown error";
2632
+ var WeixinStaleSessionError = class extends Error {
2633
+ constructor(message = "Weixin conversation context became stale; wait for the user to send a fresh message.") {
2634
+ super(message);
2635
+ this.name = "WeixinStaleSessionError";
2636
+ }
2637
+ };
2638
+ var WeixinClient = class {
2639
+ #config;
2640
+ #logger;
2641
+ #onMessage = null;
2642
+ #client = null;
2643
+ #monitorTask = null;
2644
+ #running = false;
2645
+ #syncBuf = "";
2646
+ constructor(config, logger3 = createLogger("weixin")) {
2647
+ this.#config = config;
2648
+ this.#logger = logger3;
2649
+ }
2650
+ setOnMessage(onMessage) {
2651
+ this.#onMessage = onMessage;
2652
+ }
2653
+ async connect() {
2654
+ if (this.#client) {
2655
+ return;
2656
+ }
2657
+ this.#client = new OpenILinkClient(this.#config.token, {
2658
+ base_url: this.#config.baseUrl,
2659
+ cdn_base_url: this.#config.cdnBaseUrl
2660
+ });
2661
+ this.#running = true;
2662
+ this.#monitorTask = this.#client.monitor(
2663
+ async (message) => {
2664
+ await this.#handleMessage(message);
2665
+ },
2666
+ {
2667
+ initial_buf: this.#syncBuf,
2668
+ on_buf_update: (buf) => {
2669
+ this.#syncBuf = buf;
2670
+ },
2671
+ on_error: (error) => {
2672
+ this.#logger.error("weixin monitor error:", error);
2673
+ },
2674
+ should_continue: () => this.#running
2675
+ }
2676
+ ).catch((error) => {
2677
+ if (this.#running) {
2678
+ this.#logger.error("weixin monitor stopped unexpectedly:", error);
2679
+ }
2680
+ });
2681
+ }
2682
+ async disconnect() {
2683
+ this.#running = false;
2684
+ try {
2685
+ await this.#monitorTask;
2686
+ } catch {
2687
+ }
2688
+ this.#monitorTask = null;
2689
+ this.#client = null;
2690
+ }
2691
+ async sendText(chatId, text2) {
2692
+ if (!this.#client) {
2693
+ throw new Error("Weixin client is not connected");
2694
+ }
2695
+ try {
2696
+ await this.#client.push(chatId, text2);
2697
+ } catch (error) {
2698
+ if (isStaleSessionError(error)) {
2699
+ throw new WeixinStaleSessionError();
2700
+ }
2701
+ throw error;
2702
+ }
2703
+ }
2704
+ async sendAttachment(chatId, attachment) {
2705
+ if (!this.#client) {
2706
+ throw new Error("Weixin client is not connected");
2707
+ }
2708
+ const contextToken = this.#client.getContextToken(chatId);
2709
+ if (!contextToken) {
2710
+ throw new Error(`No context token for Weixin chat ${chatId}`);
2711
+ }
2712
+ const data = await fs.readFile(attachment.filePath);
2713
+ await this.#client.sendMediaFile(
2714
+ chatId,
2715
+ contextToken,
2716
+ data,
2717
+ attachment.fileName ?? basename3(attachment.filePath),
2718
+ attachment.caption ?? ""
2719
+ );
2720
+ }
2721
+ async sendTyping(chatId) {
2722
+ if (!this.#client) {
2723
+ return;
2724
+ }
2725
+ const contextToken = this.#client.getContextToken(chatId);
2726
+ if (!contextToken) {
2727
+ return;
2728
+ }
2729
+ const config = await this.#client.getConfig(chatId, contextToken);
2730
+ if (!config.typing_ticket) {
2731
+ return;
2732
+ }
2733
+ await this.#client.sendTyping(chatId, config.typing_ticket, TYPING);
2734
+ }
2735
+ async stopTyping(chatId) {
2736
+ if (!this.#client) {
2737
+ return;
2738
+ }
2739
+ const contextToken = this.#client.getContextToken(chatId);
2740
+ if (!contextToken) {
2741
+ return;
2742
+ }
2743
+ const config = await this.#client.getConfig(chatId, contextToken);
2744
+ if (!config.typing_ticket) {
2745
+ return;
2746
+ }
2747
+ await this.#client.sendTyping(chatId, config.typing_ticket, CANCEL_TYPING);
2748
+ }
2749
+ async #handleMessage(message) {
2750
+ const chatType = this.#inferChatType(message);
2751
+ const chatId = this.#resolveChatId(message, chatType);
2752
+ const extractedText = extractText(message);
2753
+ const { text: text2, mentionedBot } = this.#normalizeMention(extractedText, chatType);
2754
+ const attachmentHints = await this.#downloadAttachmentHints(message);
2755
+ const combinedText = [text2, ...attachmentHints].filter((part) => part.trim().length > 0).join("\n").trim();
2756
+ if (!combinedText) {
2757
+ return;
2758
+ }
2759
+ await this.#onMessage?.({
2760
+ chatId,
2761
+ chatType,
2762
+ messageId: String(message.message_id ?? randomUUID3()),
2763
+ text: combinedText,
2764
+ mentionedBot,
2765
+ raw: message
2766
+ });
2767
+ }
2768
+ #inferChatType(message) {
2769
+ const roomId = String(message.room_id ?? message.chat_room_id ?? "").trim();
2770
+ if (roomId) {
2771
+ return "group";
2772
+ }
2773
+ const fromUserId = String(message.from_user_id ?? "").trim();
2774
+ return fromUserId.endsWith("@chatroom") ? "group" : "dm";
2775
+ }
2776
+ #resolveChatId(message, chatType) {
2777
+ if (chatType === "group") {
2778
+ return String(message.room_id ?? message.chat_room_id ?? message.from_user_id ?? "").trim();
2779
+ }
2780
+ return String(message.from_user_id ?? "").trim();
2781
+ }
2782
+ #normalizeMention(text2, chatType) {
2783
+ if (chatType !== "group") {
2784
+ return { text: text2, mentionedBot: false };
2785
+ }
2786
+ const match = text2.match(/^@(\S+)\s*(.*)$/s);
2787
+ if (!match) {
2788
+ return { text: text2, mentionedBot: false };
2789
+ }
2790
+ return { text: match[2] ?? "", mentionedBot: true };
2791
+ }
2792
+ async #downloadAttachmentHints(message) {
2793
+ if (!this.#client) {
2794
+ return [];
2795
+ }
2796
+ const hints = [];
2797
+ for (const item of Array.isArray(message.item_list) ? message.item_list : []) {
2798
+ const downloaded = await this.#downloadItem(item);
2799
+ if (downloaded) {
2800
+ hints.push(downloaded);
2801
+ }
2802
+ }
2803
+ return hints;
2804
+ }
2805
+ async #downloadItem(item) {
2806
+ if (!this.#client || typeof item?.type !== "number") {
2807
+ return null;
2808
+ }
2809
+ try {
2810
+ switch (item.type) {
2811
+ case 2:
2812
+ return await this.#downloadMediaHint(item.image_item?.media, "image", ".jpg");
2813
+ case 3:
2814
+ return await this.#downloadMediaHint(item.voice_item?.media, "voice", ".silk");
2815
+ case 4:
2816
+ return await this.#downloadMediaHint(
2817
+ item.file_item?.media,
2818
+ "file",
2819
+ extname2(String(item.file_item?.file_name ?? "")) || ".bin",
2820
+ String(item.file_item?.file_name ?? "").trim() || void 0
2821
+ );
2822
+ case 5:
2823
+ return await this.#downloadMediaHint(item.video_item?.media, "video", ".mp4");
2824
+ default:
2825
+ return null;
2826
+ }
2827
+ } catch (error) {
2828
+ this.#logger.warn("failed to download Weixin attachment:", error);
2829
+ return null;
2830
+ }
2831
+ }
2832
+ async #downloadMediaHint(media, label, fallbackExt, preferredName) {
2833
+ if (!this.#client || !media) {
2834
+ throw new Error("missing media payload");
2835
+ }
2836
+ const bytes = await this.#client.downloadMedia(media);
2837
+ await fs.mkdir(MEDIA_TEMP_DIR2, { recursive: true });
2838
+ const safeName = (preferredName || `${Date.now()}-${randomUUID3()}${fallbackExt}`).replace(/[^a-zA-Z0-9._@-]/g, "_");
2839
+ const resolvedName = extname2(safeName) ? safeName : `${safeName}${fallbackExt}`;
2840
+ const outputPath = join3(MEDIA_TEMP_DIR2, resolvedName);
2841
+ await fs.writeFile(outputPath, Buffer.from(bytes));
2842
+ return `[Received ${label}: ${outputPath}]`;
2843
+ }
2844
+ };
2845
+ function isStaleSessionError(error) {
2846
+ const details = extractErrorDetails(error);
2847
+ if (details.errMsg.toLowerCase() === STALE_SESSION_ERRMSG) {
2848
+ return details.ret === STALE_SESSION_ERRCODE || details.errCode === STALE_SESSION_ERRCODE;
2849
+ }
2850
+ return false;
2851
+ }
2852
+ function extractErrorDetails(error) {
2853
+ const candidate = error;
2854
+ const message = String(candidate?.message ?? "");
2855
+ const ret = typeof candidate?.ret === "number" ? candidate.ret : parseCodeFromText(message, /ret=(-?\d+)/i);
2856
+ const errCode = typeof candidate?.errCode === "number" ? candidate.errCode : parseCodeFromText(message, /errcode=(-?\d+)/i);
2857
+ const errMsg = typeof candidate?.errMsg === "string" ? candidate.errMsg : parseErrMsgFromText(message);
2858
+ return { ret, errCode, errMsg };
2859
+ }
2860
+ function parseCodeFromText(text2, pattern) {
2861
+ const match = text2.match(pattern);
2862
+ if (!match) {
2863
+ return void 0;
2864
+ }
2865
+ const parsed = Number(match[1]);
2866
+ return Number.isFinite(parsed) ? parsed : void 0;
2867
+ }
2868
+ function parseErrMsgFromText(text2) {
2869
+ const match = text2.match(/errmsg=([^\n]+)$/i);
2870
+ return (match?.[1] ?? text2).trim();
2871
+ }
2872
+
2873
+ // src/modules/client/weixin/adapter/weixin-session.ts
2874
+ function buildWeixinSessionId(chatType, chatId) {
2875
+ if (chatType === "dm") {
2876
+ return `weixin:dm:${chatId}`;
2877
+ }
2878
+ if (chatType === "group") {
2879
+ return `weixin:group:${chatId}`;
2880
+ }
2881
+ throw new Error(`Unsupported Weixin chat type: ${chatType}`);
2882
+ }
2883
+ function parseWeixinSessionId(clientSessionId) {
2884
+ if (clientSessionId.startsWith("weixin:dm:")) {
2885
+ return { platform: "weixin", chatType: "dm", chatId: clientSessionId.slice("weixin:dm:".length) };
2886
+ }
2887
+ if (clientSessionId.startsWith("weixin:group:")) {
2888
+ return { platform: "weixin", chatType: "group", chatId: clientSessionId.slice("weixin:group:".length) };
2889
+ }
2890
+ throw new Error(`Unsupported clientSessionId: ${clientSessionId}`);
2891
+ }
2892
+
2893
+ // src/modules/client/weixin/adapter/weixin-im-adapter.ts
2894
+ var MAX_TEXT_CHUNK3 = 2e3;
2895
+ var PROGRESS_INTERVAL_MS = 6e4;
2896
+ var MESSAGE_DEDUP_TTL_MS = 5 * 6e4;
2897
+ var TYPING_REFRESH_INTERVAL_MS = 1e4;
2898
+ var RATE_LIMIT_WINDOW_MS = 6e4;
2899
+ var RATE_LIMIT_THRESHOLD = 2;
2900
+ var RATE_LIMIT_COOLDOWN_MS = 6e4;
2901
+ function chunkText3(text2, maxLen) {
2902
+ if (text2.length <= maxLen) return [text2];
2903
+ const chunks = [];
2904
+ let remaining = text2;
2905
+ while (remaining.length > 0) {
2906
+ if (remaining.length <= maxLen) {
2907
+ chunks.push(remaining);
2908
+ break;
2909
+ }
2910
+ let splitPos = remaining.lastIndexOf("\n", maxLen);
2911
+ if (splitPos <= 0) {
2912
+ splitPos = remaining.lastIndexOf(" ", maxLen);
2913
+ }
2914
+ if (splitPos <= 0) {
2915
+ chunks.push(remaining.slice(0, maxLen));
2916
+ remaining = remaining.slice(maxLen);
2917
+ continue;
2918
+ }
2919
+ chunks.push(remaining.slice(0, splitPos + 1));
2920
+ remaining = remaining.slice(splitPos + 1);
2921
+ }
2922
+ return chunks.filter((chunk) => chunk.length > 0);
2923
+ }
2924
+ var WeixinIMAdapter = class {
2925
+ #config;
2926
+ #logger;
2927
+ #onOutput = null;
2928
+ #client = null;
2929
+ #egressQueue = [];
2930
+ #processing = false;
2931
+ #progressStateBySession = /* @__PURE__ */ new Map();
2932
+ #typingHeartbeatBySession = /* @__PURE__ */ new Map();
2933
+ #recentInboundMessageIds = /* @__PURE__ */ new Map();
2934
+ #recentInboundContentKeys = /* @__PURE__ */ new Map();
2935
+ #rateLimitEvents = [];
2936
+ #rateLimitCircuitUntil = 0;
2937
+ constructor(config, logger3 = createLogger("weixin")) {
2938
+ this.#config = config;
2939
+ this.#logger = logger3;
2940
+ }
2941
+ async start(onOutput) {
2942
+ this.#onOutput = onOutput;
2943
+ this.#client = new WeixinClient(this.#config, this.#logger);
2944
+ this.#client.setOnMessage(async ({ chatId, chatType, text: text2, messageId }) => {
2945
+ if (!this.#onOutput) {
2946
+ this.#logger.warn(`dropping inbound message, adapter not ready (chatId=${chatId})`);
2947
+ return;
2948
+ }
2949
+ const clientSessionId = buildWeixinSessionId(chatType, chatId);
2950
+ if (this.#isDuplicateInbound(chatId, messageId, text2)) {
2951
+ this.#logger.debug(
2952
+ `dropping duplicate inbound message (session=${clientSessionId} messageId=${messageId})`
2953
+ );
2954
+ return;
2955
+ }
2956
+ if (chatType === "group") {
2957
+ this.#logger.debug(`ignoring unsupported Weixin group message (session=${clientSessionId})`);
2958
+ return;
2959
+ }
2960
+ this.#resetProgressState(clientSessionId);
2961
+ const normalizedText = text2.trim();
2962
+ const commandEvent = parseSlashCommand(normalizedText, clientSessionId);
2963
+ if (commandEvent) {
2964
+ await this.#onOutput(commandEvent);
2965
+ return;
2966
+ }
2967
+ await this.#client?.sendTyping(chatId);
2968
+ this.#startTypingHeartbeat(clientSessionId, chatId);
2969
+ await this.#onOutput({
2970
+ type: "user.message",
2971
+ clientSessionId,
2972
+ text: text2
2973
+ });
2974
+ });
2975
+ await this.#client.connect();
2976
+ this.#logger.info(`adapter started (baseUrl=${this.#config.baseUrl ?? "https://ilinkai.weixin.qq.com"})`);
2977
+ }
2978
+ async stop() {
2979
+ this.#egressQueue.length = 0;
2980
+ for (const state of this.#progressStateBySession.values()) {
2981
+ if (state.interval) {
2982
+ clearInterval(state.interval);
2983
+ }
2984
+ }
2985
+ this.#progressStateBySession.clear();
2986
+ for (const timer of this.#typingHeartbeatBySession.values()) {
2987
+ clearInterval(timer);
2988
+ }
2989
+ this.#typingHeartbeatBySession.clear();
2990
+ if (this.#client) {
2991
+ await this.#client.disconnect();
2992
+ this.#client = null;
2993
+ }
2994
+ this.#processing = false;
2995
+ this.#onOutput = null;
2996
+ this.#logger.info("adapter stopped");
2997
+ }
2998
+ async input(event) {
2999
+ if (!this.#client) {
3000
+ throw new Error("WeixinIMAdapter is not started");
3001
+ }
3002
+ this.#egressQueue.push(event);
3003
+ await this.#drainEgressQueue();
3004
+ }
3005
+ async isBusy() {
3006
+ return this.#processing || this.#egressQueue.length > 0;
3007
+ }
3008
+ async #drainEgressQueue() {
3009
+ if (this.#processing) {
3010
+ return;
3011
+ }
3012
+ this.#processing = true;
3013
+ try {
3014
+ while (this.#client && this.#egressQueue.length > 0) {
3015
+ const event = this.#egressQueue.shift();
3016
+ if (!event) continue;
3017
+ try {
3018
+ const target = parseWeixinSessionId(event.clientSessionId);
3019
+ if (event.type === "$progress.flush") {
3020
+ await this.#flushProgressSummary(target.chatId, event.clientSessionId);
3021
+ continue;
3022
+ }
3023
+ if (event.type !== "assistant.message") {
3024
+ this.#recordProgressEvent(event);
3025
+ continue;
3026
+ }
3027
+ this.#stopProgressTimer(event.clientSessionId);
3028
+ if (event.text.trim().length > 0) {
3029
+ const chunks = chunkText3(event.text, MAX_TEXT_CHUNK3);
3030
+ for (const chunk of chunks) {
3031
+ await this.#sendTextWithProtection(target.chatId, chunk);
3032
+ }
3033
+ }
3034
+ for (const attachment of event.attachments ?? []) {
3035
+ try {
3036
+ await this.#client.sendAttachment(target.chatId, attachment);
3037
+ } catch (attachmentError) {
3038
+ this.#logger.error("failed to send attachment:", attachmentError);
3039
+ await this.#notifySendFailure(target.chatId, attachmentError);
3040
+ }
3041
+ }
3042
+ this.#stopTypingHeartbeat(event.clientSessionId);
3043
+ await this.#client.stopTyping(target.chatId);
3044
+ } catch (error) {
3045
+ this.#logger.error("failed to send egress event:", error);
3046
+ try {
3047
+ const target = parseWeixinSessionId(event.clientSessionId);
3048
+ this.#stopTypingHeartbeat(event.clientSessionId);
3049
+ await this.#client.stopTyping(target.chatId);
3050
+ await this.#notifySendFailure(target.chatId, error);
3051
+ } catch (notifyError) {
3052
+ this.#logger.error("failed to handle egress send failure:", notifyError);
3053
+ }
3054
+ }
3055
+ }
3056
+ } finally {
3057
+ this.#processing = false;
3058
+ }
3059
+ }
3060
+ #startTypingHeartbeat(clientSessionId, chatId) {
3061
+ this.#stopTypingHeartbeat(clientSessionId);
3062
+ const timer = setInterval(() => {
3063
+ void this.#client?.sendTyping(chatId);
3064
+ }, TYPING_REFRESH_INTERVAL_MS);
3065
+ timer.unref?.();
3066
+ this.#typingHeartbeatBySession.set(clientSessionId, timer);
3067
+ }
3068
+ #stopTypingHeartbeat(clientSessionId) {
3069
+ const timer = this.#typingHeartbeatBySession.get(clientSessionId);
3070
+ if (!timer) {
3071
+ return;
3072
+ }
3073
+ clearInterval(timer);
3074
+ this.#typingHeartbeatBySession.delete(clientSessionId);
3075
+ }
3076
+ async #notifySendFailure(chatId, error) {
3077
+ if (!this.#client) {
3078
+ return;
3079
+ }
3080
+ const message = error instanceof Error ? error.message : String(error);
3081
+ const text2 = `[agent-bridge error] Message delivery failed
3082
+
3083
+ ${message}`;
3084
+ try {
3085
+ await this.#client.sendText(chatId, text2);
3086
+ } catch (notifyError) {
3087
+ this.#logger.error("failed to notify send failure:", notifyError);
3088
+ }
3089
+ }
3090
+ async #sendTextWithProtection(chatId, text2) {
3091
+ if (!this.#client) {
3092
+ throw new Error("WeixinIMAdapter is not started");
3093
+ }
3094
+ const now = Date.now();
3095
+ if (this.#rateLimitCircuitUntil > now) {
3096
+ throw new Error("Weixin send is cooling down after rate limiting. Please try again shortly.");
3097
+ }
3098
+ if (this.#rateLimitCircuitUntil !== 0 && this.#rateLimitCircuitUntil <= now) {
3099
+ this.#rateLimitCircuitUntil = 0;
3100
+ this.#rateLimitEvents = [];
3101
+ }
3102
+ try {
3103
+ await this.#client.sendText(chatId, text2);
3104
+ this.#resetRateLimitState();
3105
+ } catch (error) {
3106
+ if (this.#isStaleSessionError(error)) {
3107
+ throw error;
3108
+ }
3109
+ if (this.#isRateLimitError(error)) {
3110
+ this.#recordRateLimitEvent(now);
3111
+ if (this.#rateLimitCircuitUntil > now) {
3112
+ throw new Error("Weixin send is cooling down after rate limiting. Please try again shortly.");
3113
+ }
3114
+ }
3115
+ throw error;
3116
+ }
3117
+ }
3118
+ #recordProgressEvent(event) {
3119
+ const state = this.#progressStateBySession.get(event.clientSessionId) ?? this.#createProgressState();
3120
+ this.#progressStateBySession.set(event.clientSessionId, state);
3121
+ if (!state.renderer.isProgressEvent(event)) {
3122
+ return;
3123
+ }
3124
+ state.renderer.takeProgressEvent(event);
3125
+ state.dirty = true;
3126
+ }
3127
+ async #flushProgressSummary(chatId, clientSessionId) {
3128
+ const state = this.#progressStateBySession.get(clientSessionId);
3129
+ if (!state || !state.dirty || !this.#client) {
3130
+ return;
3131
+ }
3132
+ await this.#sendTextWithProtection(chatId, state.renderer.getCurrentProgress().markdown);
3133
+ state.dirty = false;
3134
+ }
3135
+ #queueProgressFlush(clientSessionId) {
3136
+ this.#egressQueue.push({ type: "$progress.flush", clientSessionId });
3137
+ void this.#drainEgressQueue();
3138
+ }
3139
+ #createProgressState() {
3140
+ return {
3141
+ renderer: new ProgressRenderer(),
3142
+ dirty: false,
3143
+ interval: null
3144
+ };
3145
+ }
3146
+ #resetProgressState(clientSessionId) {
3147
+ const previous = this.#progressStateBySession.get(clientSessionId);
3148
+ if (previous?.interval) {
3149
+ clearInterval(previous.interval);
3150
+ }
3151
+ const state = this.#createProgressState();
3152
+ state.interval = setInterval(() => {
3153
+ this.#queueProgressFlush(clientSessionId);
3154
+ }, PROGRESS_INTERVAL_MS);
3155
+ state.interval.unref?.();
3156
+ this.#progressStateBySession.set(clientSessionId, state);
3157
+ }
3158
+ #stopProgressTimer(clientSessionId) {
3159
+ const state = this.#progressStateBySession.get(clientSessionId);
3160
+ if (!state) {
3161
+ return;
3162
+ }
3163
+ if (state.interval) {
3164
+ clearInterval(state.interval);
3165
+ }
3166
+ this.#progressStateBySession.delete(clientSessionId);
3167
+ }
3168
+ #isDuplicateInbound(chatId, messageId, text2) {
3169
+ const now = Date.now();
3170
+ this.#pruneDedupState(now);
3171
+ if (messageId) {
3172
+ const existing = this.#recentInboundMessageIds.get(messageId);
3173
+ if (existing && now - existing < MESSAGE_DEDUP_TTL_MS) {
3174
+ return true;
3175
+ }
3176
+ this.#recentInboundMessageIds.set(messageId, now);
3177
+ }
3178
+ const normalizedText = text2.trim();
3179
+ if (!normalizedText) {
3180
+ return false;
3181
+ }
3182
+ const contentKey = `${chatId}:${normalizedText}`;
3183
+ const existingContent = this.#recentInboundContentKeys.get(contentKey);
3184
+ if (existingContent && now - existingContent < MESSAGE_DEDUP_TTL_MS) {
3185
+ return true;
3186
+ }
3187
+ this.#recentInboundContentKeys.set(contentKey, now);
3188
+ return false;
3189
+ }
3190
+ #pruneDedupState(now) {
3191
+ for (const [messageId, seenAt] of this.#recentInboundMessageIds) {
3192
+ if (now - seenAt >= MESSAGE_DEDUP_TTL_MS) {
3193
+ this.#recentInboundMessageIds.delete(messageId);
3194
+ }
3195
+ }
3196
+ for (const [contentKey, seenAt] of this.#recentInboundContentKeys) {
3197
+ if (now - seenAt >= MESSAGE_DEDUP_TTL_MS) {
3198
+ this.#recentInboundContentKeys.delete(contentKey);
3199
+ }
3200
+ }
3201
+ }
3202
+ #recordRateLimitEvent(now) {
3203
+ this.#rateLimitEvents = this.#rateLimitEvents.filter(
3204
+ (timestamp2) => now - timestamp2 < RATE_LIMIT_WINDOW_MS
3205
+ );
3206
+ this.#rateLimitEvents.push(now);
3207
+ if (this.#rateLimitEvents.length >= RATE_LIMIT_THRESHOLD) {
3208
+ this.#rateLimitCircuitUntil = now + RATE_LIMIT_COOLDOWN_MS;
3209
+ }
3210
+ }
3211
+ #resetRateLimitState() {
3212
+ this.#rateLimitEvents = [];
3213
+ this.#rateLimitCircuitUntil = 0;
3214
+ }
3215
+ #isRateLimitError(error) {
3216
+ if (!(error instanceof Error)) {
3217
+ return false;
3218
+ }
3219
+ const message = error.message.toLowerCase();
3220
+ if (this.#isStaleSessionError(error)) {
3221
+ return false;
3222
+ }
3223
+ return message.includes("frequency limit") || message.includes("rate limit") || message.includes("freq limit");
3224
+ }
3225
+ #isStaleSessionError(error) {
3226
+ return error instanceof Error && error.name === "WeixinStaleSessionError";
3227
+ }
3228
+ };
3229
+
3230
+ // src/modules/client/weixin/adapter/weixin-qr-login.ts
3231
+ import { Client as OpenILinkClient2 } from "@openilink/openilink-sdk-node";
3232
+ async function loginWithWeixinQr(options = {}) {
3233
+ const { baseUrl = "https://ilinkai.weixin.qq.com", timeoutMs } = options;
3234
+ const renderQr = await loadQrRenderer();
3235
+ const client = new OpenILinkClient2("", {
3236
+ base_url: baseUrl
3237
+ });
3238
+ let lastQrUrl = "";
3239
+ const result = await client.loginWithQr(
3240
+ {
3241
+ on_qrcode(url) {
3242
+ lastQrUrl = url;
3243
+ console.log("\n\u8BF7\u4F7F\u7528\u5FAE\u4FE1\u626B\u63CF\u4EE5\u4E0B\u4E8C\u7EF4\u7801\uFF1A");
3244
+ try {
3245
+ renderQr(url);
3246
+ } catch (error) {
3247
+ console.log(`\uFF08\u7EC8\u7AEF\u4E8C\u7EF4\u7801\u6E32\u67D3\u5931\u8D25: ${String(error)}\uFF09`);
3248
+ console.log(url);
3249
+ }
3250
+ },
3251
+ on_scanned() {
3252
+ console.log("\n\u5DF2\u626B\u7801\uFF0C\u8BF7\u5728\u5FAE\u4FE1\u91CC\u786E\u8BA4...");
3253
+ },
3254
+ on_expired(attempt, maxAttempts) {
3255
+ console.log(`
3256
+ \u4E8C\u7EF4\u7801\u5DF2\u8FC7\u671F\uFF0C\u6B63\u5728\u5237\u65B0... (${attempt}/${maxAttempts})`);
3257
+ }
3258
+ },
3259
+ timeoutMs
3260
+ );
3261
+ if (!result.connected || !result.bot_id || !result.bot_token) {
3262
+ throw new Error(`Weixin QR login failed: ${result.message || "unknown error"}`);
3263
+ }
3264
+ const resolvedBaseUrl = result.base_url || baseUrl;
3265
+ console.log(`
3266
+ \u5FAE\u4FE1\u8FDE\u63A5\u6210\u529F\uFF0CaccountId=${result.bot_id}`);
3267
+ if (!lastQrUrl) {
3268
+ console.log("\u63D0\u793A\uFF1A\u5982\u9700\u91CD\u65B0\u767B\u5F55\uFF0C\u53EF\u518D\u6B21\u89E6\u53D1\u4E8C\u7EF4\u7801\u767B\u5F55\u6D41\u7A0B\u3002");
3269
+ }
3270
+ return {
3271
+ accountId: result.bot_id,
3272
+ token: result.bot_token,
3273
+ baseUrl: resolvedBaseUrl
3274
+ };
3275
+ }
3276
+ async function loadQrRenderer() {
3277
+ const mod = await import("qrcode-terminal");
3278
+ const api = mod.default ?? mod;
3279
+ if (typeof api.generate !== "function") {
3280
+ throw new Error("qrcode-terminal generate() is unavailable");
3281
+ }
3282
+ return (data) => {
3283
+ api.generate?.(data, { small: true });
3284
+ console.log("\n\u4E8C\u7EF4\u7801\u94FE\u63A5\uFF1A");
3285
+ console.log(data);
3286
+ };
3287
+ }
3288
+
3289
+ // src/modules/client/weixin/index.ts
3290
+ var DEFAULT_BASE_URL = "https://ilinkai.weixin.qq.com";
3291
+ var DEFAULT_CDN_BASE_URL = "https://novac2c.cdn.weixin.qq.com/c2c";
3292
+ function createWeixinConfigCollector() {
3293
+ return {
3294
+ async collect(_ctx) {
3295
+ const qrCreds = await loginWithWeixinQr({ baseUrl: DEFAULT_BASE_URL });
3296
+ return {
3297
+ accountId: qrCreds.accountId,
3298
+ token: qrCreds.token,
3299
+ baseUrl: qrCreds.baseUrl,
3300
+ cdnBaseUrl: DEFAULT_CDN_BASE_URL
3301
+ };
3302
+ },
3303
+ validate(config) {
3304
+ if (!config.accountId.trim()) {
3305
+ throw new Error("Weixin accountId is required");
3306
+ }
3307
+ if (!config.token.trim()) {
3308
+ throw new Error("Weixin token is required");
3309
+ }
3310
+ if (config.baseUrl && !/^https?:\/\//.test(config.baseUrl)) {
3311
+ throw new Error("Weixin baseUrl must start with http:// or https://");
3312
+ }
3313
+ if (config.cdnBaseUrl && !/^https?:\/\//.test(config.cdnBaseUrl)) {
3314
+ throw new Error("Weixin cdnBaseUrl must start with http:// or https://");
3315
+ }
3316
+ },
3317
+ summarize(config) {
3318
+ const masked = config.accountId.length > 8 ? `${config.accountId.slice(0, 4)}****${config.accountId.slice(-4)}` : "****";
3319
+ return `type=weixin accountId=${masked} baseUrl=${config.baseUrl ?? DEFAULT_BASE_URL}`;
3320
+ }
3321
+ };
3322
+ }
3323
+ var weixinClientModule = {
3324
+ type: "weixin",
3325
+ createConfigCollector: createWeixinConfigCollector,
3326
+ createClientAdapter(config) {
3327
+ return new WeixinIMAdapter(config);
3328
+ }
3329
+ };
3330
+
1831
3331
  // src/modules/client/index.ts
1832
3332
  var registry2 = /* @__PURE__ */ new Map([
1833
- [feishuClientModule.type, feishuClientModule]
3333
+ [feishuClientModule.type, feishuClientModule],
3334
+ [wecomClientModule.type, wecomClientModule],
3335
+ [weixinClientModule.type, weixinClientModule]
1834
3336
  ]);
1835
3337
  function listClientModules() {
1836
3338
  return [...registry2.values()];
@@ -1995,7 +3497,7 @@ async function startChannel(channelName) {
1995
3497
  }
1996
3498
  async function runCli(argv = process2.argv) {
1997
3499
  const program = new Command();
1998
- program.name("agent-bridge").description("IM to Pi bridge CLI").version("0.1.0");
3500
+ program.name("agent-bridge").description("IM to Agent bridge CLI").version("0.1.0");
1999
3501
  program.command("add").description("Interactively add a channel").action(async () => {
2000
3502
  const config = await loadConfig();
2001
3503
  await addChannel(config);