@hopgoldy/agent-bridge 0.1.1 → 0.2.2

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
@@ -409,6 +409,8 @@ var GatewayCore = class {
409
409
  agentSessionId,
410
410
  clientSessionId,
411
411
  toolName: "toolName" in event ? event.toolName : void 0,
412
+ toolCallId: "toolCallId" in event ? event.toolCallId : void 0,
413
+ toolLabel: "toolLabel" in event ? event.toolLabel : void 0,
412
414
  text: event.text
413
415
  });
414
416
  }
@@ -427,7 +429,7 @@ var GatewayCore = class {
427
429
  });
428
430
  }
429
431
  #isToolRelatedEvent(event) {
430
- return event.type === "assistant.tool.running" || event.type === "assistant.tool.done" || event.type === "assistant.tool.error" || event.type === "session.compacting";
432
+ return event.type === "assistant.tool.running" || event.type === "assistant.tool.update" || event.type === "assistant.tool.done" || event.type === "assistant.tool.error" || event.type === "session.compacting";
431
433
  }
432
434
  #touchRuntime(runtime) {
433
435
  runtime.lastActiveAt = Date.now();
@@ -783,11 +785,6 @@ var PiRpcClient = class {
783
785
  const data = response.data;
784
786
  return data ?? {};
785
787
  }
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
788
  async getState() {
792
789
  const response = await this.#send({ type: "get_state" });
793
790
  return response.data ?? {};
@@ -795,26 +792,6 @@ var PiRpcClient = class {
795
792
  async setSessionName(name) {
796
793
  await this.#send({ type: "set_session_name", name });
797
794
  }
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
795
  async #send(command) {
819
796
  if (!this.#process?.stdin.writable) {
820
797
  throw this.#exitError ?? new Error("pi RPC process is not writable");
@@ -931,6 +908,8 @@ var PiCodingAgentAdapter = class {
931
908
  #onOutput = null;
932
909
  #inputQueue = [];
933
910
  #processing = false;
911
+ #toolLabelByCallId = /* @__PURE__ */ new Map();
912
+ #toolInputByCallId = /* @__PURE__ */ new Map();
934
913
  constructor({
935
914
  agentSessionId,
936
915
  cwd,
@@ -973,6 +952,8 @@ var PiCodingAgentAdapter = class {
973
952
  }
974
953
  async stop() {
975
954
  this.#inputQueue.length = 0;
955
+ this.#toolLabelByCallId.clear();
956
+ this.#toolInputByCallId.clear();
976
957
  await this.#client?.stop();
977
958
  this.#client = null;
978
959
  this.#processing = false;
@@ -1018,20 +999,12 @@ var PiCodingAgentAdapter = class {
1018
999
  try {
1019
1000
  if (event.type === "user.message") {
1020
1001
  this.#logger.info(`sending prompt to agent (session=${this.#agentSessionId})`);
1021
- const startedAt = Date.now();
1022
1002
  await this.#emitProgress({
1023
1003
  type: "assistant.thinking",
1024
1004
  agentSessionId: this.#agentSessionId,
1025
1005
  text: "Processing request"
1026
1006
  });
1027
1007
  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
1008
  return;
1036
1009
  }
1037
1010
  this.#logger.info(`compacting context (session=${this.#agentSessionId})`);
@@ -1077,26 +1050,210 @@ var PiCodingAgentAdapter = class {
1077
1050
  if (!this.#onOutput) {
1078
1051
  return;
1079
1052
  }
1053
+ if (rpcEvent.type === "message_end") {
1054
+ const message = rpcEvent.message;
1055
+ if (this.#isAssistantMessage(message)) {
1056
+ this.#logger.debug(`assistant message_end content shape (session=${this.#agentSessionId})`, {
1057
+ contentType: Array.isArray(message.content) ? "array" : typeof message.content,
1058
+ contentPreview: this.#summarizeContentShape(message.content)
1059
+ });
1060
+ const rawText = this.#extractMessageText(message.content);
1061
+ const { text: text2, attachments } = extractMediaMarkers(rawText);
1062
+ this.#logger.debug(
1063
+ `assistant message_end received (session=${this.#agentSessionId} textLength=${rawText.length} attachmentCount=${attachments.length})`
1064
+ );
1065
+ if (!text2.trim() && attachments.length === 0) {
1066
+ this.#logger.debug(
1067
+ `ignoring assistant message_end without visible content (session=${this.#agentSessionId})`
1068
+ );
1069
+ return;
1070
+ }
1071
+ await this.#emitAssistant(text2, attachments);
1072
+ }
1073
+ return;
1074
+ }
1080
1075
  if (rpcEvent.type === "tool_execution_start") {
1081
1076
  const toolName = typeof rpcEvent.toolName === "string" ? rpcEvent.toolName : "unknown";
1077
+ const toolCallId = typeof rpcEvent.toolCallId === "string" ? rpcEvent.toolCallId : void 0;
1078
+ const toolInput = "args" in rpcEvent ? rpcEvent.args : void 0;
1079
+ const toolLabel = this.#summarizeToolLabel(toolName, toolInput);
1080
+ if (toolCallId) {
1081
+ if (toolLabel) this.#toolLabelByCallId.set(toolCallId, toolLabel);
1082
+ if (toolInput !== void 0) this.#toolInputByCallId.set(toolCallId, toolInput);
1083
+ }
1082
1084
  await this.#emitProgress({
1083
1085
  type: "assistant.tool.running",
1084
1086
  agentSessionId: this.#agentSessionId,
1085
1087
  toolName,
1088
+ toolCallId,
1089
+ toolInput,
1090
+ toolLabel,
1091
+ text: `Running ${toolName}`
1092
+ });
1093
+ return;
1094
+ }
1095
+ if (rpcEvent.type === "tool_execution_update") {
1096
+ const toolName = typeof rpcEvent.toolName === "string" ? rpcEvent.toolName : "unknown";
1097
+ const toolCallId = typeof rpcEvent.toolCallId === "string" ? rpcEvent.toolCallId : void 0;
1098
+ const toolInput = "args" in rpcEvent ? rpcEvent.args : this.#toolInputForCall(toolCallId);
1099
+ const toolLabel = this.#toolLabelForCall(toolCallId, toolName, toolInput);
1100
+ if (toolCallId) {
1101
+ if (toolLabel) this.#toolLabelByCallId.set(toolCallId, toolLabel);
1102
+ if (toolInput !== void 0) this.#toolInputByCallId.set(toolCallId, toolInput);
1103
+ }
1104
+ await this.#emitProgress({
1105
+ type: "assistant.tool.update",
1106
+ agentSessionId: this.#agentSessionId,
1107
+ toolName,
1108
+ toolCallId,
1109
+ toolInput,
1110
+ toolLabel,
1111
+ partialResult: "partialResult" in rpcEvent ? rpcEvent.partialResult : void 0,
1086
1112
  text: `Running ${toolName}`
1087
1113
  });
1088
1114
  return;
1089
1115
  }
1090
1116
  if (rpcEvent.type === "tool_execution_end") {
1091
1117
  const toolName = typeof rpcEvent.toolName === "string" ? rpcEvent.toolName : "unknown";
1118
+ const toolCallId = typeof rpcEvent.toolCallId === "string" ? rpcEvent.toolCallId : void 0;
1092
1119
  const isError = Boolean(rpcEvent.isError);
1120
+ const toolInput = this.#toolInputForCall(toolCallId);
1121
+ const toolLabel = this.#toolLabelForCall(toolCallId, toolName, toolInput);
1093
1122
  await this.#emitProgress({
1094
1123
  type: isError ? "assistant.tool.error" : "assistant.tool.done",
1095
1124
  agentSessionId: this.#agentSessionId,
1096
1125
  toolName,
1126
+ toolCallId,
1127
+ toolInput,
1128
+ toolLabel,
1129
+ result: "result" in rpcEvent ? rpcEvent.result : void 0,
1097
1130
  text: isError ? `Failed ${toolName}` : `Finished ${toolName}`
1098
1131
  });
1132
+ if (toolCallId) {
1133
+ this.#toolLabelByCallId.delete(toolCallId);
1134
+ this.#toolInputByCallId.delete(toolCallId);
1135
+ }
1136
+ }
1137
+ }
1138
+ #toolInputForCall(toolCallId) {
1139
+ return toolCallId ? this.#toolInputByCallId.get(toolCallId) : void 0;
1140
+ }
1141
+ #toolLabelForCall(toolCallId, toolName, toolInput) {
1142
+ return (toolCallId ? this.#toolLabelByCallId.get(toolCallId) : void 0) ?? this.#summarizeToolLabel(toolName, toolInput);
1143
+ }
1144
+ #summarizeToolLabel(toolName, toolInput) {
1145
+ if (!toolInput || typeof toolInput !== "object" || Array.isArray(toolInput)) {
1146
+ return void 0;
1147
+ }
1148
+ const input = toolInput;
1149
+ const stringField = (key) => {
1150
+ const value = input[key];
1151
+ return typeof value === "string" && value.trim().length > 0 ? value.trim() : void 0;
1152
+ };
1153
+ const stringArrayField = (key) => {
1154
+ const value = input[key];
1155
+ if (!Array.isArray(value)) return void 0;
1156
+ const items = value.filter((item) => typeof item === "string" && item.trim().length > 0);
1157
+ return items.length > 0 ? items.map((item) => item.trim()) : void 0;
1158
+ };
1159
+ switch (toolName) {
1160
+ case "bash":
1161
+ return stringField("command");
1162
+ case "read":
1163
+ case "write":
1164
+ case "edit":
1165
+ case "find":
1166
+ case "ls":
1167
+ return stringField("path");
1168
+ case "grep": {
1169
+ const pattern = stringField("pattern");
1170
+ const path7 = stringField("path");
1171
+ if (pattern && path7) return `${pattern} in ${path7}`;
1172
+ return pattern ?? path7;
1173
+ }
1174
+ case "web_search": {
1175
+ const query = stringField("query");
1176
+ const queries = stringArrayField("queries");
1177
+ return query ?? (queries ? queries.join(" | ") : void 0);
1178
+ }
1179
+ case "fetch_content": {
1180
+ const url = stringField("url");
1181
+ const urls = stringArrayField("urls");
1182
+ return url ?? (urls ? urls.join(" | ") : void 0);
1183
+ }
1184
+ default:
1185
+ return this.#truncate(this.#safeJson(toolInput), 120);
1186
+ }
1187
+ }
1188
+ #safeJson(value) {
1189
+ try {
1190
+ return JSON.stringify(value);
1191
+ } catch {
1192
+ return void 0;
1193
+ }
1194
+ }
1195
+ #truncate(value, maxLength) {
1196
+ if (!value) return void 0;
1197
+ return value.length > maxLength ? `${value.slice(0, maxLength - 1)}\u2026` : value;
1198
+ }
1199
+ #isAssistantMessage(value) {
1200
+ if (!value || typeof value !== "object") {
1201
+ return false;
1099
1202
  }
1203
+ return value.role === "assistant";
1204
+ }
1205
+ #extractMessageText(content) {
1206
+ if (typeof content === "string") {
1207
+ return content;
1208
+ }
1209
+ if (this.#isTextBlock(content)) {
1210
+ return content.text;
1211
+ }
1212
+ if (!Array.isArray(content)) {
1213
+ return "";
1214
+ }
1215
+ const textParts = [];
1216
+ for (const block of content) {
1217
+ if (this.#isTextBlock(block)) {
1218
+ textParts.push(block.text);
1219
+ continue;
1220
+ }
1221
+ if (!block || typeof block !== "object") {
1222
+ continue;
1223
+ }
1224
+ const candidate = block;
1225
+ if (typeof candidate.content === "string") {
1226
+ textParts.push(candidate.content);
1227
+ }
1228
+ }
1229
+ return textParts.join("");
1230
+ }
1231
+ #isTextBlock(value) {
1232
+ if (!value || typeof value !== "object") {
1233
+ return false;
1234
+ }
1235
+ const candidate = value;
1236
+ return candidate.type === "text" && typeof candidate.text === "string";
1237
+ }
1238
+ #summarizeContentShape(content) {
1239
+ if (typeof content === "string") {
1240
+ return { kind: "string", length: content.length, preview: content.slice(0, 200) };
1241
+ }
1242
+ if (!Array.isArray(content)) {
1243
+ return { kind: typeof content };
1244
+ }
1245
+ return content.slice(0, 10).map((block) => {
1246
+ if (!block || typeof block !== "object") {
1247
+ return { kind: typeof block };
1248
+ }
1249
+ const candidate = block;
1250
+ return {
1251
+ type: candidate.type,
1252
+ textLength: typeof candidate.text === "string" ? candidate.text.length : void 0,
1253
+ contentType: Array.isArray(candidate.content) ? "array" : typeof candidate.content,
1254
+ mimeType: candidate.mimeType
1255
+ };
1256
+ });
1100
1257
  }
1101
1258
  };
1102
1259
 
@@ -1165,6 +1322,159 @@ function getTypedAgentModule(config) {
1165
1322
  return module;
1166
1323
  }
1167
1324
 
1325
+ // src/modules/client/utils/progress-renderer.ts
1326
+ var DEFAULT_COLLAPSE_THRESHOLD = 10;
1327
+ var MAX_TOOL_LABEL_DISPLAY_LENGTH = 15;
1328
+ var NO_PROGRESS_MARKDOWN = "No progress yet.";
1329
+ var ProgressRenderer = class {
1330
+ #collapseThreshold;
1331
+ #entries = /* @__PURE__ */ new Map();
1332
+ #order = [];
1333
+ #status = "running";
1334
+ constructor(options = {}) {
1335
+ this.#collapseThreshold = options.collapseThreshold ?? DEFAULT_COLLAPSE_THRESHOLD;
1336
+ }
1337
+ /** Whether `event` should be recorded as progress (excludes assistant messages and thinking). */
1338
+ isProgressEvent(event) {
1339
+ return event.type !== "assistant.message" && event.type !== "assistant.thinking";
1340
+ }
1341
+ /** Records a progress event, formatting it into a line and collapsing older lines if needed. */
1342
+ takeProgressEvent(event) {
1343
+ const toolEntryId = this.#toolEntryId(event);
1344
+ if (toolEntryId) {
1345
+ this.#upsertToolEntry(toolEntryId, event);
1346
+ } else {
1347
+ this.#appendLineEntry(this.#formatProgressLine(event));
1348
+ }
1349
+ this.#status = this.#progressStatus(event);
1350
+ }
1351
+ /** Returns the current rendered markdown, status, and collapsed-line count. */
1352
+ getCurrentProgress() {
1353
+ const collapsedCount = Math.max(0, this.#order.length - this.#collapseThreshold);
1354
+ return {
1355
+ markdown: this.#renderMarkdown(collapsedCount),
1356
+ status: this.#status,
1357
+ collapsedCount
1358
+ };
1359
+ }
1360
+ #renderMarkdown(collapsedCount) {
1361
+ const contentLines = [];
1362
+ if (collapsedCount > 0) {
1363
+ contentLines.push(`- Collapsed ${collapsedCount} earlier updates.`);
1364
+ }
1365
+ for (const id of this.#visibleOrder()) {
1366
+ const entry = this.#entries.get(id);
1367
+ if (!entry) continue;
1368
+ contentLines.push(entry.kind === "line" ? entry.line : this.#formatToolEntry(entry));
1369
+ }
1370
+ return contentLines.length > 0 ? contentLines.join("\n") : NO_PROGRESS_MARKDOWN;
1371
+ }
1372
+ #visibleOrder() {
1373
+ return this.#order.slice(-this.#collapseThreshold);
1374
+ }
1375
+ #appendLineEntry(line) {
1376
+ const id = `line:${this.#order.length}`;
1377
+ this.#entries.set(id, { kind: "line", line });
1378
+ this.#order.push(id);
1379
+ }
1380
+ #upsertToolEntry(id, event) {
1381
+ if (!this.#entries.has(id)) {
1382
+ this.#order.push(id);
1383
+ } else {
1384
+ this.#order = this.#order.filter((entryId) => entryId !== id);
1385
+ this.#order.push(id);
1386
+ }
1387
+ this.#entries.set(id, {
1388
+ kind: "tool",
1389
+ toolName: event.toolName,
1390
+ toolLabel: event.toolLabel,
1391
+ status: event.type === "assistant.tool.error" ? "error" : event.type === "assistant.tool.done" ? "done" : "running",
1392
+ text: event.text
1393
+ });
1394
+ }
1395
+ #toolEntryId(event) {
1396
+ if (event.type === "assistant.tool.running" || event.type === "assistant.tool.update" || event.type === "assistant.tool.done" || event.type === "assistant.tool.error") {
1397
+ return event.toolCallId ? `tool:${event.toolCallId}` : null;
1398
+ }
1399
+ return null;
1400
+ }
1401
+ #formatToolEntry(entry) {
1402
+ const subject = this.#formatToolSubject(entry.toolName, entry.toolLabel);
1403
+ switch (entry.status) {
1404
+ case "running":
1405
+ return `- Running ${subject}`;
1406
+ case "done":
1407
+ return `- Finished ${subject}`;
1408
+ case "error":
1409
+ return this.#formatToolErrorLine(subject, entry.text);
1410
+ }
1411
+ }
1412
+ #formatProgressLine(event) {
1413
+ switch (event.type) {
1414
+ case "session.compacting":
1415
+ return `- Compacting session${event.text ? `: ${event.text}` : ""}`;
1416
+ case "assistant.tool.running":
1417
+ case "assistant.tool.update":
1418
+ return `- Running ${this.#formatToolSubject(event.toolName, event.toolLabel)}`;
1419
+ case "assistant.tool.done":
1420
+ return `- Finished ${this.#formatToolSubject(event.toolName, event.toolLabel)}`;
1421
+ case "assistant.tool.error":
1422
+ return this.#formatToolErrorLine(this.#formatToolSubject(event.toolName, event.toolLabel), event.text);
1423
+ }
1424
+ }
1425
+ #formatToolSubject(toolName, toolLabel) {
1426
+ const normalizedLabel = toolLabel?.trim();
1427
+ if (!normalizedLabel) {
1428
+ return toolName;
1429
+ }
1430
+ return `${toolName}: ${this.#truncateToolLabel(normalizedLabel)}`;
1431
+ }
1432
+ #truncateToolLabel(toolLabel) {
1433
+ return toolLabel.length > MAX_TOOL_LABEL_DISPLAY_LENGTH ? `${toolLabel.slice(0, MAX_TOOL_LABEL_DISPLAY_LENGTH)}\u2026` : toolLabel;
1434
+ }
1435
+ #formatToolErrorLine(toolName, text2) {
1436
+ const normalizedText = text2?.trim();
1437
+ if (!normalizedText) {
1438
+ return `- ${this.#humanizeToolError(toolName)}`;
1439
+ }
1440
+ const lowerText = normalizedText.toLowerCase();
1441
+ const lowerToolName = toolName.toLowerCase();
1442
+ if (lowerText === lowerToolName || lowerText === `failed ${lowerToolName}` || lowerText === `running ${lowerToolName}` || lowerText === `finished ${lowerToolName}`) {
1443
+ return `- ${this.#humanizeToolError(toolName)}`;
1444
+ }
1445
+ return `- ${this.#humanizeToolError(toolName)}: ${normalizedText}`;
1446
+ }
1447
+ #humanizeToolError(toolName) {
1448
+ return `Failed ${toolName}`;
1449
+ }
1450
+ #progressStatus(event) {
1451
+ switch (event.type) {
1452
+ case "assistant.tool.error":
1453
+ return "error";
1454
+ case "assistant.tool.done":
1455
+ return "done";
1456
+ default:
1457
+ return "running";
1458
+ }
1459
+ }
1460
+ };
1461
+
1462
+ // src/modules/client/utils/slash-commands.ts
1463
+ function parseSlashCommand(text2, clientSessionId) {
1464
+ switch (text2.toLowerCase()) {
1465
+ case "/new":
1466
+ case "/n":
1467
+ return { type: "command.session.new", clientSessionId };
1468
+ case "/compact":
1469
+ case "/c":
1470
+ return { type: "command.session.compact", clientSessionId };
1471
+ case "/stop":
1472
+ return { type: "command.session.stop", clientSessionId };
1473
+ default:
1474
+ return null;
1475
+ }
1476
+ }
1477
+
1168
1478
  // src/modules/client/feishu/adapter/feishu-client.ts
1169
1479
  import * as Lark from "@larksuiteoapi/node-sdk";
1170
1480
  import { existsSync as existsSync3, mkdirSync, writeFileSync } from "fs";
@@ -1517,29 +1827,19 @@ var FeishuIMAdapter = class _FeishuIMAdapter {
1517
1827
  #processing = false;
1518
1828
  #lastInboundMessageIdBySession = /* @__PURE__ */ new Map();
1519
1829
  #progressStateBySession = /* @__PURE__ */ new Map();
1520
- static buildProgressCard(lines, _status, collapsedCount = 0) {
1830
+ static buildProgressCard(markdown) {
1521
1831
  return {
1522
1832
  schema: "2.0",
1523
1833
  body: {
1524
1834
  elements: [
1525
1835
  {
1526
1836
  tag: "markdown",
1527
- content: _FeishuIMAdapter.progressBody(lines, collapsedCount)
1837
+ content: markdown
1528
1838
  }
1529
1839
  ]
1530
1840
  }
1531
1841
  };
1532
1842
  }
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
1843
  async #notifySendFailure(chatId, error) {
1544
1844
  if (!this.#client) {
1545
1845
  return;
@@ -1577,28 +1877,10 @@ ${message}`;
1577
1877
  this.#resetProgressState(clientSessionId);
1578
1878
  await this.#client?.startTyping(chatId, messageId);
1579
1879
  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
- });
1880
+ const commandEvent = parseSlashCommand(normalizedText, clientSessionId);
1881
+ if (commandEvent) {
1882
+ this.#logger.info(`received command ${normalizedText} (session=${clientSessionId})`);
1883
+ await this.#onOutput(commandEvent);
1602
1884
  return;
1603
1885
  }
1604
1886
  this.#logger.info(`received user message (session=${clientSessionId}): ${normalizedText}`);
@@ -1686,25 +1968,17 @@ ${message}`;
1686
1968
  if (!this.#client) {
1687
1969
  return;
1688
1970
  }
1689
- if (!this.#shouldRenderProgressEvent(event)) {
1690
- return;
1691
- }
1692
1971
  const state = this.#progressStateBySession.get(event.clientSessionId) ?? {
1972
+ renderer: new ProgressRenderer(),
1693
1973
  messageId: null,
1694
- creating: false,
1695
- lines: [],
1696
- status: "running",
1697
- turnId: 0,
1698
- collapsedCount: 0
1974
+ creating: false
1699
1975
  };
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
1976
  this.#progressStateBySession.set(event.clientSessionId, state);
1707
- const card = _FeishuIMAdapter.buildProgressCard(state.lines, state.status, state.collapsedCount);
1977
+ if (!state.renderer.isProgressEvent(event)) {
1978
+ return;
1979
+ }
1980
+ state.renderer.takeProgressEvent(event);
1981
+ const card = _FeishuIMAdapter.buildProgressCard(state.renderer.getCurrentProgress().markdown);
1708
1982
  if (state.messageId) {
1709
1983
  await this.#client.updateCard(state.messageId, card);
1710
1984
  return;
@@ -1723,59 +1997,13 @@ ${message}`;
1723
1997
  state.creating = false;
1724
1998
  }
1725
1999
  }
1726
- #shouldRenderProgressEvent(event) {
1727
- return event.type !== "assistant.thinking";
1728
- }
1729
2000
  #resetProgressState(clientSessionId) {
1730
- const previous = this.#progressStateBySession.get(clientSessionId);
1731
2001
  this.#progressStateBySession.set(clientSessionId, {
2002
+ renderer: new ProgressRenderer(),
1732
2003
  messageId: null,
1733
- creating: false,
1734
- lines: [],
1735
- status: "running",
1736
- turnId: (previous?.turnId ?? 0) + 1,
1737
- collapsedCount: 0
2004
+ creating: false
1738
2005
  });
1739
2006
  }
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
2007
  };
1780
2008
 
1781
2009
  // src/modules/client/feishu/index.ts
@@ -1828,9 +2056,1449 @@ var feishuClientModule = {
1828
2056
  }
1829
2057
  };
1830
2058
 
2059
+ // src/modules/client/wecom/adapter/wecom-client.ts
2060
+ import { existsSync as existsSync4 } from "fs";
2061
+ import { mkdir as mkdir4, readFile as readFile3, writeFile as writeFile3 } from "fs/promises";
2062
+ import { randomUUID as randomUUID2 } from "crypto";
2063
+ import { tmpdir as tmpdir2 } from "os";
2064
+ import { basename as basename2, extname, join as join2 } from "path";
2065
+ import { WSClient } from "@wecom/aibot-node-sdk";
2066
+ var DEFAULT_WEBSOCKET_URL = "wss://openws.work.weixin.qq.com";
2067
+ var MEDIA_TEMP_DIR = join2(tmpdir2(), "agent-bridge-wecom-media");
2068
+ var IMAGE_SIGNATURE_PNG = Buffer.from("89504e470d0a1a0a", "hex");
2069
+ var IMAGE_SIGNATURE_JPG = Buffer.from("ffd8ff", "hex");
2070
+ var PROCESSED_MESSAGE_ID_LIMIT = 500;
2071
+ 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";
2072
+ function sanitizeFileName(fileName) {
2073
+ return fileName.replace(/[^a-zA-Z0-9._-]/g, "_");
2074
+ }
2075
+ function detectImageExtension(data) {
2076
+ if (data.subarray(0, IMAGE_SIGNATURE_PNG.length).equals(IMAGE_SIGNATURE_PNG)) return ".png";
2077
+ if (data.subarray(0, IMAGE_SIGNATURE_JPG.length).equals(IMAGE_SIGNATURE_JPG)) return ".jpg";
2078
+ return ".jpg";
2079
+ }
2080
+ function decodeBase64(data) {
2081
+ const payload = data.split(",", 1).length > 1 ? data.split(",", 2)[1] : data;
2082
+ return Buffer.from(payload.trim(), "base64");
2083
+ }
2084
+ function buildMarkdownBody(text2) {
2085
+ return {
2086
+ msgtype: "markdown",
2087
+ markdown: {
2088
+ content: text2
2089
+ }
2090
+ };
2091
+ }
2092
+ async function ensureMediaDir() {
2093
+ if (!existsSync4(MEDIA_TEMP_DIR)) {
2094
+ await mkdir4(MEDIA_TEMP_DIR, { recursive: true });
2095
+ }
2096
+ }
2097
+ var WecomClient = class {
2098
+ #config;
2099
+ #logger;
2100
+ #onMessage = null;
2101
+ #onKicked = null;
2102
+ #client = null;
2103
+ #kicked = false;
2104
+ #processedMessageIds = /* @__PURE__ */ new Set();
2105
+ #replyReqIdByMessageId = /* @__PURE__ */ new Map();
2106
+ #lastChatReqId = /* @__PURE__ */ new Map();
2107
+ #replyContextByMessageId = /* @__PURE__ */ new Map();
2108
+ #lastReplyContextByChatId = /* @__PURE__ */ new Map();
2109
+ #streamContextByMessageId = /* @__PURE__ */ new Map();
2110
+ #lastStreamContextByChatId = /* @__PURE__ */ new Map();
2111
+ constructor(config, logger3 = createLogger("wecom")) {
2112
+ this.#config = config;
2113
+ this.#logger = logger3;
2114
+ }
2115
+ setOnMessage(onMessage) {
2116
+ this.#onMessage = onMessage;
2117
+ }
2118
+ setOnKicked(onKicked) {
2119
+ this.#onKicked = onKicked;
2120
+ }
2121
+ isKicked() {
2122
+ return this.#kicked;
2123
+ }
2124
+ async connect() {
2125
+ const websocketUrl = this.#config.websocketUrl ?? DEFAULT_WEBSOCKET_URL;
2126
+ this.#kicked = false;
2127
+ const client = new WSClient({
2128
+ botId: this.#config.botId,
2129
+ secret: this.#config.secret,
2130
+ wsUrl: websocketUrl,
2131
+ logger: this.#logger
2132
+ });
2133
+ this.#client = client;
2134
+ this.#registerInboundHandlers(client);
2135
+ await new Promise((resolve, reject) => {
2136
+ let settled = false;
2137
+ const resolveOnce = () => {
2138
+ if (settled) {
2139
+ return;
2140
+ }
2141
+ settled = true;
2142
+ resolve();
2143
+ };
2144
+ const rejectOnce = (error, phase) => {
2145
+ if (settled) {
2146
+ return;
2147
+ }
2148
+ settled = true;
2149
+ reject(this.#toConnectionError(error, websocketUrl, phase));
2150
+ };
2151
+ client.on("authenticated", resolveOnce);
2152
+ client.on("ready", resolveOnce);
2153
+ client.on("error", (error) => rejectOnce(error, "connect"));
2154
+ client.on("auth_error", (error) => rejectOnce(error, "subscribe"));
2155
+ client.on("close", () => {
2156
+ if (!settled) {
2157
+ rejectOnce(new Error("WeCom websocket closed before subscription completed"), "connect");
2158
+ }
2159
+ });
2160
+ try {
2161
+ client.connect();
2162
+ } catch (error) {
2163
+ rejectOnce(error, "connect");
2164
+ }
2165
+ });
2166
+ }
2167
+ #toConnectionError(error, websocketUrl, phase) {
2168
+ if (error instanceof Error) {
2169
+ return new Error(`WeCom websocket ${phase} failed (${websocketUrl}): ${error.message}`);
2170
+ }
2171
+ const event = error;
2172
+ const details = [event?.message, event?.error].filter((value) => typeof value === "string" && value.trim().length > 0).join("; ");
2173
+ const suffix = details ? `: ${details}` : "";
2174
+ return new Error(`WeCom websocket ${phase} failed (${websocketUrl})${suffix}`);
2175
+ }
2176
+ async disconnect() {
2177
+ this.#client?.disconnect();
2178
+ this.#client = null;
2179
+ }
2180
+ #requireClient() {
2181
+ if (!this.#client) {
2182
+ throw new Error("WeCom websocket is not connected");
2183
+ }
2184
+ if (this.#kicked) {
2185
+ throw new Error(KICKED_ERROR_MESSAGE);
2186
+ }
2187
+ return this.#client;
2188
+ }
2189
+ async sendText(chatId, text2, replyToMessageId) {
2190
+ const client = this.#requireClient();
2191
+ const replyContext = this.#replyContextForMessage(replyToMessageId) ?? this.#lastReplyContextByChatId.get(chatId);
2192
+ if (replyContext) {
2193
+ this.#logger.debug(`reply context available for ${chatId}: ${replyContext.reqId}`);
2194
+ await client.reply(replyContext.frame, buildMarkdownBody(text2));
2195
+ return;
2196
+ }
2197
+ await client.sendMessage(chatId, buildMarkdownBody(text2));
2198
+ }
2199
+ async sendStreamText(chatId, text2, options) {
2200
+ const client = this.#requireClient();
2201
+ const replyToMessageId = options?.replyToMessageId;
2202
+ const replyContext = this.#replyContextForMessage(replyToMessageId) ?? this.#lastReplyContextByChatId.get(chatId);
2203
+ if (!replyContext) {
2204
+ await client.sendMessage(chatId, buildMarkdownBody(text2));
2205
+ return;
2206
+ }
2207
+ const streamContext = this.#streamContextFor(chatId, replyToMessageId);
2208
+ const feedback = !streamContext.started && options?.feedbackId ? { id: options.feedbackId } : void 0;
2209
+ await client.replyStream(
2210
+ replyContext.frame,
2211
+ streamContext.streamId,
2212
+ text2,
2213
+ options?.finish ?? false,
2214
+ void 0,
2215
+ feedback
2216
+ );
2217
+ streamContext.started = true;
2218
+ }
2219
+ async sendAttachment(chatId, attachment, replyToMessageId) {
2220
+ const client = this.#requireClient();
2221
+ const upload = await this.#uploadMedia(attachment);
2222
+ const replyContext = this.#replyContextForMessage(replyToMessageId) ?? this.#lastReplyContextByChatId.get(chatId);
2223
+ if (replyContext) {
2224
+ this.#logger.debug(`reply media context available for ${chatId}: ${replyContext.reqId}`);
2225
+ await client.replyMedia(replyContext.frame, upload.type, upload.mediaId);
2226
+ return;
2227
+ }
2228
+ await client.sendMessage(chatId, {
2229
+ msgtype: upload.type,
2230
+ [upload.type]: { media_id: upload.mediaId }
2231
+ });
2232
+ }
2233
+ async #uploadMedia(attachment) {
2234
+ if (!this.#client) {
2235
+ throw new Error("WeCom websocket is not connected");
2236
+ }
2237
+ const data = await readFile3(attachment.filePath);
2238
+ const type = attachment.kind === "image" ? "image" : "file";
2239
+ const filename = sanitizeFileName(attachment.fileName ?? basename2(attachment.filePath));
2240
+ const response = await this.#client.uploadMedia(data, { type, filename });
2241
+ const mediaId = String(response.media_id ?? "").trim();
2242
+ if (!mediaId) {
2243
+ throw new Error("media upload failed: missing media_id");
2244
+ }
2245
+ return { type, mediaId };
2246
+ }
2247
+ #replyReqIdForMessage(messageId) {
2248
+ if (!messageId) return void 0;
2249
+ return this.#replyReqIdByMessageId.get(messageId);
2250
+ }
2251
+ #replyContextForMessage(messageId) {
2252
+ if (!messageId) return void 0;
2253
+ return this.#replyContextByMessageId.get(messageId);
2254
+ }
2255
+ #streamContextFor(chatId, messageId) {
2256
+ if (messageId) {
2257
+ const existing = this.#streamContextByMessageId.get(messageId);
2258
+ if (existing) {
2259
+ this.#lastStreamContextByChatId.set(chatId, existing);
2260
+ return existing;
2261
+ }
2262
+ }
2263
+ const fallback = this.#lastStreamContextByChatId.get(chatId);
2264
+ if (fallback) {
2265
+ return fallback;
2266
+ }
2267
+ const created = { streamId: randomUUID2(), started: false };
2268
+ if (messageId) {
2269
+ this.#streamContextByMessageId.set(messageId, created);
2270
+ }
2271
+ this.#lastStreamContextByChatId.set(chatId, created);
2272
+ return created;
2273
+ }
2274
+ #registerInboundHandlers(client) {
2275
+ client.on("event", (payload) => {
2276
+ this.#logger.info(
2277
+ `received SDK event event (reqId=${String(payload?.headers?.req_id ?? "") || "n/a"} eventType=${String(payload?.body?.event?.eventtype ?? "") || "n/a"})`
2278
+ );
2279
+ });
2280
+ client.on("event.enter_chat", (payload) => {
2281
+ this.#logger.info(
2282
+ `received enter_chat event (reqId=${String(payload?.headers?.req_id ?? "") || "n/a"} userId=${String(payload?.body?.from?.userid ?? "") || "n/a"})`
2283
+ );
2284
+ void this.#handleEnterChat(payload);
2285
+ });
2286
+ client.on("event.disconnected_event", (payload) => {
2287
+ this.#kicked = true;
2288
+ this.#logger.error(
2289
+ `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`
2290
+ );
2291
+ this.#onKicked?.();
2292
+ });
2293
+ const specificMsgTypes = /* @__PURE__ */ new Set(["text", "image", "file", "voice", "video", "mixed"]);
2294
+ client.on("message", (payload) => {
2295
+ const msgtype = String(payload?.body?.msgtype ?? "").toLowerCase();
2296
+ if (specificMsgTypes.has(msgtype)) {
2297
+ return;
2298
+ }
2299
+ this.#logger.info(
2300
+ `received SDK event message (reqId=${String(payload?.headers?.req_id ?? "") || "n/a"} msgtype=${msgtype || "n/a"})`
2301
+ );
2302
+ void this.#handleInboundCallback(
2303
+ payload,
2304
+ String(payload?.headers?.req_id ?? "")
2305
+ );
2306
+ });
2307
+ for (const eventName of [
2308
+ "message.text",
2309
+ "message.image",
2310
+ "message.file",
2311
+ "message.voice",
2312
+ "message.video",
2313
+ "message.mixed"
2314
+ ]) {
2315
+ client.on(eventName, (payload) => {
2316
+ this.#logger.info(
2317
+ `received SDK event ${eventName} (reqId=${String(payload?.headers?.req_id ?? "") || "n/a"} msgtype=${String(payload?.body?.msgtype ?? "") || "n/a"})`
2318
+ );
2319
+ void this.#handleInboundCallback(
2320
+ payload,
2321
+ String(payload?.headers?.req_id ?? "")
2322
+ );
2323
+ });
2324
+ }
2325
+ }
2326
+ /**
2327
+ * Records a message id as processed. Returns false when the id was already
2328
+ * seen, so callers can drop duplicate callbacks. Bounded to the most recent
2329
+ * PROCESSED_MESSAGE_ID_LIMIT ids (insertion-ordered Set acts as the LRU).
2330
+ */
2331
+ #markMessageProcessed(messageId) {
2332
+ if (this.#processedMessageIds.has(messageId)) {
2333
+ return false;
2334
+ }
2335
+ this.#processedMessageIds.add(messageId);
2336
+ if (this.#processedMessageIds.size > PROCESSED_MESSAGE_ID_LIMIT) {
2337
+ const oldest = this.#processedMessageIds.values().next().value;
2338
+ if (oldest !== void 0) {
2339
+ this.#processedMessageIds.delete(oldest);
2340
+ }
2341
+ }
2342
+ return true;
2343
+ }
2344
+ async #handleInboundCallback(payload, reqId) {
2345
+ const body = payload.body ?? {};
2346
+ const senderId = String(body.from?.userid ?? "").trim();
2347
+ const chatId = String(body.chatid ?? senderId).trim();
2348
+ if (!chatId) {
2349
+ this.#logger.warn(`dropping inbound callback without chatId (reqId=${reqId || "n/a"})`);
2350
+ return;
2351
+ }
2352
+ const messageId = String(body.msgid ?? reqId ?? randomUUID2()).trim();
2353
+ if (!this.#markMessageProcessed(messageId)) {
2354
+ this.#logger.info(
2355
+ `dropping duplicate inbound callback (messageId=${messageId} reqId=${reqId || "n/a"})`
2356
+ );
2357
+ return;
2358
+ }
2359
+ this.#replyReqIdByMessageId.set(messageId, reqId);
2360
+ this.#lastChatReqId.set(chatId, reqId);
2361
+ const replyContext = { reqId, frame: { headers: { req_id: reqId } } };
2362
+ this.#replyContextByMessageId.set(messageId, replyContext);
2363
+ this.#lastReplyContextByChatId.set(chatId, replyContext);
2364
+ const streamContext = { streamId: randomUUID2(), started: false };
2365
+ this.#streamContextByMessageId.set(messageId, streamContext);
2366
+ this.#lastStreamContextByChatId.set(chatId, streamContext);
2367
+ const textParts = [];
2368
+ const plainText = String(body.text?.content ?? "").trim();
2369
+ if (plainText) {
2370
+ textParts.push(plainText);
2371
+ }
2372
+ if (String(body.msgtype ?? "").toLowerCase() === "appmsg") {
2373
+ const title = String(body.appmsg?.title ?? "").trim();
2374
+ if (title) {
2375
+ textParts.push(title);
2376
+ }
2377
+ }
2378
+ if (String(body.msgtype ?? "").toLowerCase() === "mixed") {
2379
+ const items = Array.isArray(body.mixed?.msg_item) ? body.mixed.msg_item : [];
2380
+ for (const item of items) {
2381
+ if (String(item?.msgtype ?? "").toLowerCase() === "text") {
2382
+ const content = String(item?.text?.content ?? "").trim();
2383
+ if (content) textParts.push(content);
2384
+ }
2385
+ }
2386
+ }
2387
+ const refs = this.#extractMediaRefs(body);
2388
+ for (const ref of refs) {
2389
+ const localPath = await this.#downloadMediaRef(ref);
2390
+ if (localPath) {
2391
+ textParts.push(`[Received ${ref.kind}: ${localPath}]`);
2392
+ }
2393
+ }
2394
+ const chatType = String(body.chattype ?? "").toLowerCase() === "group" ? "group" : "dm";
2395
+ const rawText = textParts.join("\n").trim();
2396
+ const { text: text2, mentionedBot } = this.#normalizeMention(rawText, chatType);
2397
+ this.#logger.info(
2398
+ `normalized inbound message (chatType=${chatType} chatId=${chatId} messageId=${messageId} textLength=${text2.length})`
2399
+ );
2400
+ await this.#onMessage?.({
2401
+ chatId,
2402
+ chatType,
2403
+ messageId,
2404
+ text: text2,
2405
+ mentionedBot,
2406
+ raw: payload
2407
+ });
2408
+ }
2409
+ async #handleEnterChat(payload) {
2410
+ if (!this.#client) {
2411
+ return;
2412
+ }
2413
+ const reqId = String(payload?.headers?.req_id ?? "").trim();
2414
+ if (!reqId) {
2415
+ return;
2416
+ }
2417
+ try {
2418
+ await this.#client.replyWelcome(
2419
+ { headers: { req_id: reqId } },
2420
+ {
2421
+ msgtype: "text",
2422
+ text: {
2423
+ content: "\u60A8\u597D\uFF0C\u6211\u5DF2\u8FDE\u63A5\u6210\u529F\uFF0C\u53EF\u4EE5\u76F4\u63A5\u7ED9\u6211\u53D1\u6D88\u606F\u3002"
2424
+ }
2425
+ }
2426
+ );
2427
+ this.#logger.info(`sent enter_chat welcome reply (reqId=${reqId})`);
2428
+ } catch (error) {
2429
+ this.#logger.warn(`failed to send enter_chat welcome reply (reqId=${reqId}):`, error);
2430
+ }
2431
+ }
2432
+ #normalizeMention(text2, chatType) {
2433
+ if (chatType !== "group") {
2434
+ return { text: text2, mentionedBot: false };
2435
+ }
2436
+ const match = text2.match(/^@(\S+)\s*(.*)$/s);
2437
+ if (!match) {
2438
+ return { text: text2, mentionedBot: false };
2439
+ }
2440
+ return { text: match[2] ?? "", mentionedBot: true };
2441
+ }
2442
+ #extractMediaRefs(body) {
2443
+ const refs = [];
2444
+ const msgType = String(body.msgtype ?? "").toLowerCase();
2445
+ if (msgType === "image" && body.image) {
2446
+ refs.push({
2447
+ kind: "image",
2448
+ url: typeof body.image.url === "string" ? body.image.url : void 0,
2449
+ aeskey: typeof body.image.aeskey === "string" ? body.image.aeskey : void 0,
2450
+ base64: typeof body.image.base64 === "string" ? body.image.base64 : void 0,
2451
+ fileName: typeof body.image.filename === "string" ? body.image.filename : void 0
2452
+ });
2453
+ }
2454
+ if (msgType === "file" && body.file) {
2455
+ refs.push({
2456
+ kind: "file",
2457
+ url: typeof body.file.url === "string" ? body.file.url : void 0,
2458
+ aeskey: typeof body.file.aeskey === "string" ? body.file.aeskey : void 0,
2459
+ base64: typeof body.file.base64 === "string" ? body.file.base64 : void 0,
2460
+ fileName: typeof body.file.filename === "string" ? body.file.filename : typeof body.file.name === "string" ? body.file.name : void 0
2461
+ });
2462
+ }
2463
+ if (msgType === "appmsg" && body.appmsg?.file) {
2464
+ refs.push({
2465
+ kind: "file",
2466
+ url: typeof body.appmsg.file.url === "string" ? body.appmsg.file.url : void 0,
2467
+ aeskey: typeof body.appmsg.file.aeskey === "string" ? body.appmsg.file.aeskey : void 0,
2468
+ base64: typeof body.appmsg.file.base64 === "string" ? body.appmsg.file.base64 : void 0,
2469
+ fileName: typeof body.appmsg.file.filename === "string" ? body.appmsg.file.filename : typeof body.appmsg.title === "string" ? body.appmsg.title : void 0
2470
+ });
2471
+ }
2472
+ return refs;
2473
+ }
2474
+ async #downloadMediaRef(ref) {
2475
+ try {
2476
+ await ensureMediaDir();
2477
+ let bytes;
2478
+ let fileName = ref.fileName;
2479
+ if (ref.base64) {
2480
+ bytes = decodeBase64(ref.base64);
2481
+ } else if (ref.url) {
2482
+ if (!this.#client) {
2483
+ throw new Error("WeCom websocket is not connected");
2484
+ }
2485
+ const downloaded = await this.#client.downloadFile(ref.url, ref.aeskey);
2486
+ bytes = downloaded.buffer;
2487
+ fileName = fileName ?? downloaded.filename;
2488
+ } else {
2489
+ return null;
2490
+ }
2491
+ const safeName = fileName ? sanitizeFileName(fileName) : ref.kind === "image" ? `${randomUUID2()}${detectImageExtension(bytes)}` : `${randomUUID2()}.bin`;
2492
+ const resolvedName = extname(safeName) ? safeName : ref.kind === "image" ? `${safeName}${detectImageExtension(bytes)}` : `${safeName}.bin`;
2493
+ const outputPath = join2(MEDIA_TEMP_DIR, `${Date.now()}-${resolvedName}`);
2494
+ await writeFile3(outputPath, bytes);
2495
+ return outputPath;
2496
+ } catch (error) {
2497
+ this.#logger.warn(`failed to download ${ref.kind} resource:`, error);
2498
+ return null;
2499
+ }
2500
+ }
2501
+ };
2502
+
2503
+ // src/modules/client/wecom/adapter/wecom-session.ts
2504
+ function buildWecomSessionId(chatType, chatId) {
2505
+ if (chatType === "dm") {
2506
+ return `wecom:dm:${chatId}`;
2507
+ }
2508
+ if (chatType === "group") {
2509
+ return `wecom:group:${chatId}`;
2510
+ }
2511
+ throw new Error(`Unsupported WeCom chat type: ${chatType}`);
2512
+ }
2513
+ function parseWecomSessionId(clientSessionId) {
2514
+ if (clientSessionId.startsWith("wecom:dm:")) {
2515
+ return { platform: "wecom", chatType: "dm", chatId: clientSessionId.slice("wecom:dm:".length) };
2516
+ }
2517
+ if (clientSessionId.startsWith("wecom:group:")) {
2518
+ return { platform: "wecom", chatType: "group", chatId: clientSessionId.slice("wecom:group:".length) };
2519
+ }
2520
+ throw new Error(`Unsupported clientSessionId: ${clientSessionId}`);
2521
+ }
2522
+
2523
+ // src/modules/client/wecom/adapter/wecom-im-adapter.ts
2524
+ var MAX_TEXT_CHUNK2 = 4e3;
2525
+ var STARTING_MESSAGE = "Processing...";
2526
+ function chunkText2(text2, maxLen) {
2527
+ if (text2.length <= maxLen) return [text2];
2528
+ const chunks = [];
2529
+ let remaining = text2;
2530
+ while (remaining.length > 0) {
2531
+ if (remaining.length <= maxLen) {
2532
+ chunks.push(remaining);
2533
+ break;
2534
+ }
2535
+ let splitPos = remaining.lastIndexOf("\n", maxLen);
2536
+ if (splitPos <= 0) {
2537
+ splitPos = remaining.lastIndexOf(" ", maxLen);
2538
+ }
2539
+ if (splitPos <= 0) {
2540
+ chunks.push(remaining.slice(0, maxLen));
2541
+ remaining = remaining.slice(maxLen);
2542
+ continue;
2543
+ }
2544
+ chunks.push(remaining.slice(0, splitPos + 1));
2545
+ remaining = remaining.slice(splitPos + 1);
2546
+ }
2547
+ return chunks.filter((chunk) => chunk.length > 0);
2548
+ }
2549
+ var WecomIMAdapter = class {
2550
+ #config;
2551
+ #logger;
2552
+ #onOutput = null;
2553
+ #client = null;
2554
+ #egressQueue = [];
2555
+ #processing = false;
2556
+ #lastInboundMessageIdBySession = /* @__PURE__ */ new Map();
2557
+ #progressStateBySession = /* @__PURE__ */ new Map();
2558
+ async #notifySendFailure(chatId, error) {
2559
+ if (!this.#client) {
2560
+ return;
2561
+ }
2562
+ if (this.#client.isKicked()) {
2563
+ this.#logger.debug("skipping send-failure notification, connection was replaced");
2564
+ return;
2565
+ }
2566
+ const message = error instanceof Error ? error.message : String(error);
2567
+ const text2 = `[agent-bridge error] Message delivery failed
2568
+
2569
+ ${message}`;
2570
+ try {
2571
+ await this.#client.sendText(chatId, text2);
2572
+ } catch (notifyError) {
2573
+ this.#logger.error("failed to notify send failure:", notifyError);
2574
+ }
2575
+ }
2576
+ constructor(config, logger3 = createLogger("wecom")) {
2577
+ this.#config = config;
2578
+ this.#logger = logger3;
2579
+ }
2580
+ async start(onOutput) {
2581
+ this.#onOutput = onOutput;
2582
+ this.#client = new WecomClient(this.#config, this.#logger);
2583
+ this.#client.setOnKicked(() => {
2584
+ this.#logger.error(
2585
+ "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"
2586
+ );
2587
+ });
2588
+ this.#client.setOnMessage(async ({ chatId, chatType, text: text2, messageId, mentionedBot }) => {
2589
+ if (!this.#onOutput) {
2590
+ this.#logger.warn(`dropping inbound message, adapter not ready (chatId=${chatId})`);
2591
+ return;
2592
+ }
2593
+ this.#logger.info(
2594
+ `adapter received inbound message (chatType=${chatType} chatId=${chatId} messageId=${messageId} mentionedBot=${mentionedBot} textLength=${text2.trim().length})`
2595
+ );
2596
+ const clientSessionId = buildWecomSessionId(chatType, chatId);
2597
+ if (chatType === "group" && (this.#config.requireMentionInGroup ?? true) && !mentionedBot) {
2598
+ this.#logger.debug(
2599
+ `ignoring group message without bot mention (session=${clientSessionId} messageId=${messageId})`
2600
+ );
2601
+ return;
2602
+ }
2603
+ const normalizedText = text2.trim();
2604
+ const commandEvent = parseSlashCommand(normalizedText, clientSessionId);
2605
+ if (commandEvent) {
2606
+ await this.#onOutput(commandEvent);
2607
+ return;
2608
+ }
2609
+ this.#lastInboundMessageIdBySession.set(clientSessionId, messageId);
2610
+ this.#resetProgressState(clientSessionId);
2611
+ await this.#announceStart(chatId, messageId, clientSessionId);
2612
+ await this.#onOutput({
2613
+ type: "user.message",
2614
+ clientSessionId,
2615
+ text: text2
2616
+ });
2617
+ });
2618
+ await this.#client.connect();
2619
+ this.#logger.info(
2620
+ `adapter started (websocketUrl=${this.#config.websocketUrl ?? "wss://openws.work.weixin.qq.com"})`
2621
+ );
2622
+ }
2623
+ async stop() {
2624
+ this.#egressQueue.length = 0;
2625
+ this.#progressStateBySession.clear();
2626
+ if (this.#client) {
2627
+ await this.#client.disconnect();
2628
+ this.#client = null;
2629
+ }
2630
+ this.#processing = false;
2631
+ this.#onOutput = null;
2632
+ this.#logger.info("adapter stopped");
2633
+ }
2634
+ async input(event) {
2635
+ if (!this.#client) {
2636
+ throw new Error("WecomIMAdapter is not started");
2637
+ }
2638
+ this.#egressQueue.push(event);
2639
+ void this.#drainEgressQueue();
2640
+ }
2641
+ async isBusy() {
2642
+ return this.#processing || this.#egressQueue.length > 0;
2643
+ }
2644
+ async #drainEgressQueue() {
2645
+ if (this.#processing) {
2646
+ return;
2647
+ }
2648
+ this.#processing = true;
2649
+ try {
2650
+ while (this.#client && this.#egressQueue.length > 0) {
2651
+ const event = this.#egressQueue.shift();
2652
+ if (!event) continue;
2653
+ try {
2654
+ const target = parseWecomSessionId(event.clientSessionId);
2655
+ if (event.type !== "assistant.message") {
2656
+ await this.#handleProgressEvent(target.chatId, event);
2657
+ continue;
2658
+ }
2659
+ const replyToMessageId = this.#lastInboundMessageIdBySession.get(event.clientSessionId);
2660
+ await this.#finishProgressMessage(target.chatId, event.clientSessionId, replyToMessageId);
2661
+ if (event.text.trim().length > 0) {
2662
+ const chunks = chunkText2(event.text, MAX_TEXT_CHUNK2);
2663
+ for (const [index, chunk] of chunks.entries()) {
2664
+ await this.#client.sendText(target.chatId, chunk, index === 0 ? replyToMessageId : void 0);
2665
+ }
2666
+ }
2667
+ for (const attachment of event.attachments ?? []) {
2668
+ try {
2669
+ await this.#client.sendAttachment(target.chatId, attachment, replyToMessageId);
2670
+ } catch (attachmentError) {
2671
+ this.#logger.error("failed to send attachment:", attachmentError);
2672
+ await this.#notifySendFailure(target.chatId, attachmentError);
2673
+ }
2674
+ }
2675
+ } catch (error) {
2676
+ this.#logger.error("failed to send egress event:", error);
2677
+ try {
2678
+ const target = parseWecomSessionId(event.clientSessionId);
2679
+ await this.#notifySendFailure(target.chatId, error);
2680
+ } catch (notifyError) {
2681
+ this.#logger.error("failed to handle egress send failure:", notifyError);
2682
+ }
2683
+ }
2684
+ }
2685
+ } finally {
2686
+ this.#processing = false;
2687
+ }
2688
+ }
2689
+ async #announceStart(chatId, messageId, clientSessionId) {
2690
+ const state = this.#progressStateBySession.get(clientSessionId);
2691
+ if (!state || state.announced || !this.#client) {
2692
+ return;
2693
+ }
2694
+ await this.#client.sendStreamText(chatId, STARTING_MESSAGE, {
2695
+ replyToMessageId: messageId,
2696
+ finish: false
2697
+ });
2698
+ state.announced = true;
2699
+ }
2700
+ async #handleProgressEvent(chatId, event) {
2701
+ if (!this.#client) {
2702
+ return;
2703
+ }
2704
+ const state = this.#progressStateBySession.get(event.clientSessionId) ?? {
2705
+ renderer: new ProgressRenderer(),
2706
+ announced: false
2707
+ };
2708
+ this.#progressStateBySession.set(event.clientSessionId, state);
2709
+ if (!state.renderer.isProgressEvent(event)) {
2710
+ return;
2711
+ }
2712
+ state.renderer.takeProgressEvent(event);
2713
+ const body = state.renderer.getCurrentProgress().markdown;
2714
+ const replyToMessageId = this.#lastInboundMessageIdBySession.get(event.clientSessionId);
2715
+ await this.#client.sendStreamText(chatId, body, {
2716
+ replyToMessageId,
2717
+ finish: false
2718
+ });
2719
+ state.announced = true;
2720
+ }
2721
+ async #finishProgressMessage(chatId, clientSessionId, replyToMessageId) {
2722
+ const state = this.#progressStateBySession.get(clientSessionId);
2723
+ if (!state || !state.announced || !this.#client) {
2724
+ return;
2725
+ }
2726
+ this.#progressStateBySession.delete(clientSessionId);
2727
+ const progress = state.renderer.getCurrentProgress();
2728
+ const body = progress.markdown === NO_PROGRESS_MARKDOWN ? STARTING_MESSAGE : progress.markdown;
2729
+ try {
2730
+ await this.#client.sendStreamText(chatId, body, { replyToMessageId, finish: true });
2731
+ } catch (error) {
2732
+ this.#logger.warn("failed to finish progress message:", error);
2733
+ }
2734
+ }
2735
+ #resetProgressState(clientSessionId) {
2736
+ this.#progressStateBySession.set(clientSessionId, {
2737
+ renderer: new ProgressRenderer(),
2738
+ announced: false
2739
+ });
2740
+ }
2741
+ };
2742
+
2743
+ // src/modules/client/wecom/index.ts
2744
+ var DEFAULT_WEBSOCKET_URL2 = "wss://openws.work.weixin.qq.com";
2745
+ function createWecomConfigCollector() {
2746
+ return {
2747
+ async collect(ctx) {
2748
+ const botId = await ctx.input("WeCom Bot ID", {
2749
+ required: true,
2750
+ validate: (value) => value ? null : "Bot ID is required"
2751
+ });
2752
+ const secret = await ctx.input("WeCom Secret", {
2753
+ required: true,
2754
+ secret: true,
2755
+ validate: (value) => value ? null : "Secret is required"
2756
+ });
2757
+ const websocketUrl = await ctx.input("WeCom WebSocket URL", {
2758
+ defaultValue: DEFAULT_WEBSOCKET_URL2,
2759
+ validate: (value) => !value || /^wss?:\/\//.test(value) ? null : "WebSocket URL must start with ws:// or wss://"
2760
+ });
2761
+ const requireMentionInGroup = await ctx.confirm("Require @mention in group chats", true);
2762
+ return { botId, secret, websocketUrl, requireMentionInGroup };
2763
+ },
2764
+ validate(config) {
2765
+ if (!config.botId.trim()) {
2766
+ throw new Error("WeCom botId is required");
2767
+ }
2768
+ if (!config.secret.trim()) {
2769
+ throw new Error("WeCom secret is required");
2770
+ }
2771
+ if (config.websocketUrl && !/^wss?:\/\//.test(config.websocketUrl)) {
2772
+ throw new Error("WeCom websocketUrl must start with ws:// or wss://");
2773
+ }
2774
+ },
2775
+ summarize(config) {
2776
+ const masked = config.botId.length > 8 ? `${config.botId.slice(0, 4)}****${config.botId.slice(-4)}` : "****";
2777
+ return `type=wecom botId=${masked} websocketUrl=${config.websocketUrl ?? DEFAULT_WEBSOCKET_URL2} requireMentionInGroup=${config.requireMentionInGroup ?? true}`;
2778
+ }
2779
+ };
2780
+ }
2781
+ var wecomClientModule = {
2782
+ type: "wecom",
2783
+ createConfigCollector: createWecomConfigCollector,
2784
+ createClientAdapter(config) {
2785
+ return new WecomIMAdapter(config);
2786
+ }
2787
+ };
2788
+
2789
+ // src/modules/client/weixin/adapter/weixin-client.ts
2790
+ import * as fs from "fs/promises";
2791
+ import { randomUUID as randomUUID3 } from "crypto";
2792
+ import { basename as basename3, extname as extname2, join as join3 } from "path";
2793
+ import { tmpdir as tmpdir3 } from "os";
2794
+ import { Client as OpenILinkClient, extractText, TYPING, CANCEL_TYPING } from "@openilink/openilink-sdk-node";
2795
+ var MEDIA_TEMP_DIR2 = join3(tmpdir3(), "agent-bridge-weixin-media");
2796
+ var STALE_SESSION_ERRCODE = -2;
2797
+ var STALE_SESSION_ERRMSG = "unknown error";
2798
+ var WeixinStaleSessionError = class extends Error {
2799
+ constructor(message = "Weixin conversation context became stale; wait for the user to send a fresh message.") {
2800
+ super(message);
2801
+ this.name = "WeixinStaleSessionError";
2802
+ }
2803
+ };
2804
+ var WeixinClient = class {
2805
+ #config;
2806
+ #logger;
2807
+ #onMessage = null;
2808
+ #client = null;
2809
+ #monitorTask = null;
2810
+ #running = false;
2811
+ #syncBuf = "";
2812
+ constructor(config, logger3 = createLogger("weixin")) {
2813
+ this.#config = config;
2814
+ this.#logger = logger3;
2815
+ }
2816
+ setOnMessage(onMessage) {
2817
+ this.#onMessage = onMessage;
2818
+ }
2819
+ async connect() {
2820
+ if (this.#client) {
2821
+ return;
2822
+ }
2823
+ this.#client = new OpenILinkClient(this.#config.token, {
2824
+ base_url: this.#config.baseUrl,
2825
+ cdn_base_url: this.#config.cdnBaseUrl
2826
+ });
2827
+ this.#running = true;
2828
+ this.#monitorTask = this.#client.monitor(
2829
+ async (message) => {
2830
+ await this.#handleMessage(message);
2831
+ },
2832
+ {
2833
+ initial_buf: this.#syncBuf,
2834
+ on_buf_update: (buf) => {
2835
+ this.#syncBuf = buf;
2836
+ },
2837
+ on_error: (error) => {
2838
+ this.#logger.error("weixin monitor error:", error);
2839
+ },
2840
+ should_continue: () => this.#running
2841
+ }
2842
+ ).catch((error) => {
2843
+ if (this.#running) {
2844
+ this.#logger.error("weixin monitor stopped unexpectedly:", error);
2845
+ }
2846
+ });
2847
+ }
2848
+ async disconnect() {
2849
+ this.#running = false;
2850
+ try {
2851
+ await this.#monitorTask;
2852
+ } catch {
2853
+ }
2854
+ this.#monitorTask = null;
2855
+ this.#client = null;
2856
+ }
2857
+ async sendText(chatId, text2) {
2858
+ if (!this.#client) {
2859
+ throw new Error("Weixin client is not connected");
2860
+ }
2861
+ try {
2862
+ await this.#client.push(chatId, text2);
2863
+ } catch (error) {
2864
+ if (isStaleSessionError(error)) {
2865
+ throw new WeixinStaleSessionError();
2866
+ }
2867
+ throw error;
2868
+ }
2869
+ }
2870
+ async sendAttachment(chatId, attachment) {
2871
+ if (!this.#client) {
2872
+ throw new Error("Weixin client is not connected");
2873
+ }
2874
+ const contextToken = this.#client.getContextToken(chatId);
2875
+ if (!contextToken) {
2876
+ throw new Error(`No context token for Weixin chat ${chatId}`);
2877
+ }
2878
+ const data = await fs.readFile(attachment.filePath);
2879
+ await this.#client.sendMediaFile(
2880
+ chatId,
2881
+ contextToken,
2882
+ data,
2883
+ attachment.fileName ?? basename3(attachment.filePath),
2884
+ attachment.caption ?? ""
2885
+ );
2886
+ }
2887
+ async sendTyping(chatId) {
2888
+ if (!this.#client) {
2889
+ return;
2890
+ }
2891
+ const contextToken = this.#client.getContextToken(chatId);
2892
+ if (!contextToken) {
2893
+ return;
2894
+ }
2895
+ const config = await this.#client.getConfig(chatId, contextToken);
2896
+ if (!config.typing_ticket) {
2897
+ return;
2898
+ }
2899
+ await this.#client.sendTyping(chatId, config.typing_ticket, TYPING);
2900
+ }
2901
+ async stopTyping(chatId) {
2902
+ if (!this.#client) {
2903
+ return;
2904
+ }
2905
+ const contextToken = this.#client.getContextToken(chatId);
2906
+ if (!contextToken) {
2907
+ return;
2908
+ }
2909
+ const config = await this.#client.getConfig(chatId, contextToken);
2910
+ if (!config.typing_ticket) {
2911
+ return;
2912
+ }
2913
+ await this.#client.sendTyping(chatId, config.typing_ticket, CANCEL_TYPING);
2914
+ }
2915
+ async #handleMessage(message) {
2916
+ const chatType = this.#inferChatType(message);
2917
+ const chatId = this.#resolveChatId(message, chatType);
2918
+ const extractedText = extractText(message);
2919
+ const { text: text2, mentionedBot } = this.#normalizeMention(extractedText, chatType);
2920
+ const attachmentHints = await this.#downloadAttachmentHints(message);
2921
+ const combinedText = [text2, ...attachmentHints].filter((part) => part.trim().length > 0).join("\n").trim();
2922
+ if (!combinedText) {
2923
+ return;
2924
+ }
2925
+ await this.#onMessage?.({
2926
+ chatId,
2927
+ chatType,
2928
+ messageId: String(message.message_id ?? randomUUID3()),
2929
+ text: combinedText,
2930
+ mentionedBot,
2931
+ raw: message
2932
+ });
2933
+ }
2934
+ #inferChatType(message) {
2935
+ const roomId = String(message.room_id ?? message.chat_room_id ?? "").trim();
2936
+ if (roomId) {
2937
+ return "group";
2938
+ }
2939
+ const fromUserId = String(message.from_user_id ?? "").trim();
2940
+ return fromUserId.endsWith("@chatroom") ? "group" : "dm";
2941
+ }
2942
+ #resolveChatId(message, chatType) {
2943
+ if (chatType === "group") {
2944
+ return String(message.room_id ?? message.chat_room_id ?? message.from_user_id ?? "").trim();
2945
+ }
2946
+ return String(message.from_user_id ?? "").trim();
2947
+ }
2948
+ #normalizeMention(text2, chatType) {
2949
+ if (chatType !== "group") {
2950
+ return { text: text2, mentionedBot: false };
2951
+ }
2952
+ const match = text2.match(/^@(\S+)\s*(.*)$/s);
2953
+ if (!match) {
2954
+ return { text: text2, mentionedBot: false };
2955
+ }
2956
+ return { text: match[2] ?? "", mentionedBot: true };
2957
+ }
2958
+ async #downloadAttachmentHints(message) {
2959
+ if (!this.#client) {
2960
+ return [];
2961
+ }
2962
+ const hints = [];
2963
+ for (const item of Array.isArray(message.item_list) ? message.item_list : []) {
2964
+ const downloaded = await this.#downloadItem(item);
2965
+ if (downloaded) {
2966
+ hints.push(downloaded);
2967
+ }
2968
+ }
2969
+ return hints;
2970
+ }
2971
+ async #downloadItem(item) {
2972
+ if (!this.#client || typeof item?.type !== "number") {
2973
+ return null;
2974
+ }
2975
+ try {
2976
+ switch (item.type) {
2977
+ case 2:
2978
+ return await this.#downloadMediaHint(item.image_item?.media, "image", ".jpg");
2979
+ case 3:
2980
+ return await this.#downloadMediaHint(item.voice_item?.media, "voice", ".silk");
2981
+ case 4:
2982
+ return await this.#downloadMediaHint(
2983
+ item.file_item?.media,
2984
+ "file",
2985
+ extname2(String(item.file_item?.file_name ?? "")) || ".bin",
2986
+ String(item.file_item?.file_name ?? "").trim() || void 0
2987
+ );
2988
+ case 5:
2989
+ return await this.#downloadMediaHint(item.video_item?.media, "video", ".mp4");
2990
+ default:
2991
+ return null;
2992
+ }
2993
+ } catch (error) {
2994
+ this.#logger.warn("failed to download Weixin attachment:", error);
2995
+ return null;
2996
+ }
2997
+ }
2998
+ async #downloadMediaHint(media, label, fallbackExt, preferredName) {
2999
+ if (!this.#client || !media) {
3000
+ throw new Error("missing media payload");
3001
+ }
3002
+ const bytes = await this.#client.downloadMedia(media);
3003
+ await fs.mkdir(MEDIA_TEMP_DIR2, { recursive: true });
3004
+ const safeName = (preferredName || `${Date.now()}-${randomUUID3()}${fallbackExt}`).replace(/[^a-zA-Z0-9._@-]/g, "_");
3005
+ const resolvedName = extname2(safeName) ? safeName : `${safeName}${fallbackExt}`;
3006
+ const outputPath = join3(MEDIA_TEMP_DIR2, resolvedName);
3007
+ await fs.writeFile(outputPath, Buffer.from(bytes));
3008
+ return `[Received ${label}: ${outputPath}]`;
3009
+ }
3010
+ };
3011
+ function isStaleSessionError(error) {
3012
+ const details = extractErrorDetails(error);
3013
+ if (details.errMsg.toLowerCase() === STALE_SESSION_ERRMSG) {
3014
+ return details.ret === STALE_SESSION_ERRCODE || details.errCode === STALE_SESSION_ERRCODE;
3015
+ }
3016
+ return false;
3017
+ }
3018
+ function extractErrorDetails(error) {
3019
+ const candidate = error;
3020
+ const message = String(candidate?.message ?? "");
3021
+ const ret = typeof candidate?.ret === "number" ? candidate.ret : parseCodeFromText(message, /ret=(-?\d+)/i);
3022
+ const errCode = typeof candidate?.errCode === "number" ? candidate.errCode : parseCodeFromText(message, /errcode=(-?\d+)/i);
3023
+ const errMsg = typeof candidate?.errMsg === "string" ? candidate.errMsg : parseErrMsgFromText(message);
3024
+ return { ret, errCode, errMsg };
3025
+ }
3026
+ function parseCodeFromText(text2, pattern) {
3027
+ const match = text2.match(pattern);
3028
+ if (!match) {
3029
+ return void 0;
3030
+ }
3031
+ const parsed = Number(match[1]);
3032
+ return Number.isFinite(parsed) ? parsed : void 0;
3033
+ }
3034
+ function parseErrMsgFromText(text2) {
3035
+ const match = text2.match(/errmsg=([^\n]+)$/i);
3036
+ return (match?.[1] ?? text2).trim();
3037
+ }
3038
+
3039
+ // src/modules/client/weixin/adapter/weixin-session.ts
3040
+ function buildWeixinSessionId(chatType, chatId) {
3041
+ if (chatType === "dm") {
3042
+ return `weixin:dm:${chatId}`;
3043
+ }
3044
+ if (chatType === "group") {
3045
+ return `weixin:group:${chatId}`;
3046
+ }
3047
+ throw new Error(`Unsupported Weixin chat type: ${chatType}`);
3048
+ }
3049
+ function parseWeixinSessionId(clientSessionId) {
3050
+ if (clientSessionId.startsWith("weixin:dm:")) {
3051
+ return { platform: "weixin", chatType: "dm", chatId: clientSessionId.slice("weixin:dm:".length) };
3052
+ }
3053
+ if (clientSessionId.startsWith("weixin:group:")) {
3054
+ return { platform: "weixin", chatType: "group", chatId: clientSessionId.slice("weixin:group:".length) };
3055
+ }
3056
+ throw new Error(`Unsupported clientSessionId: ${clientSessionId}`);
3057
+ }
3058
+
3059
+ // src/modules/client/weixin/adapter/weixin-im-adapter.ts
3060
+ var MAX_TEXT_CHUNK3 = 2e3;
3061
+ var PROGRESS_INTERVAL_MS = 6e4;
3062
+ var MESSAGE_DEDUP_TTL_MS = 5 * 6e4;
3063
+ var TYPING_REFRESH_INTERVAL_MS = 1e4;
3064
+ var RATE_LIMIT_WINDOW_MS = 6e4;
3065
+ var RATE_LIMIT_THRESHOLD = 2;
3066
+ var RATE_LIMIT_COOLDOWN_MS = 6e4;
3067
+ function chunkText3(text2, maxLen) {
3068
+ if (text2.length <= maxLen) return [text2];
3069
+ const chunks = [];
3070
+ let remaining = text2;
3071
+ while (remaining.length > 0) {
3072
+ if (remaining.length <= maxLen) {
3073
+ chunks.push(remaining);
3074
+ break;
3075
+ }
3076
+ let splitPos = remaining.lastIndexOf("\n", maxLen);
3077
+ if (splitPos <= 0) {
3078
+ splitPos = remaining.lastIndexOf(" ", maxLen);
3079
+ }
3080
+ if (splitPos <= 0) {
3081
+ chunks.push(remaining.slice(0, maxLen));
3082
+ remaining = remaining.slice(maxLen);
3083
+ continue;
3084
+ }
3085
+ chunks.push(remaining.slice(0, splitPos + 1));
3086
+ remaining = remaining.slice(splitPos + 1);
3087
+ }
3088
+ return chunks.filter((chunk) => chunk.length > 0);
3089
+ }
3090
+ var WeixinIMAdapter = class {
3091
+ #config;
3092
+ #logger;
3093
+ #onOutput = null;
3094
+ #client = null;
3095
+ #egressQueue = [];
3096
+ #processing = false;
3097
+ #progressStateBySession = /* @__PURE__ */ new Map();
3098
+ #typingHeartbeatBySession = /* @__PURE__ */ new Map();
3099
+ #recentInboundMessageIds = /* @__PURE__ */ new Map();
3100
+ #recentInboundContentKeys = /* @__PURE__ */ new Map();
3101
+ #rateLimitEvents = [];
3102
+ #rateLimitCircuitUntil = 0;
3103
+ constructor(config, logger3 = createLogger("weixin")) {
3104
+ this.#config = config;
3105
+ this.#logger = logger3;
3106
+ }
3107
+ async start(onOutput) {
3108
+ this.#onOutput = onOutput;
3109
+ this.#client = new WeixinClient(this.#config, this.#logger);
3110
+ this.#client.setOnMessage(async ({ chatId, chatType, text: text2, messageId }) => {
3111
+ if (!this.#onOutput) {
3112
+ this.#logger.warn(`dropping inbound message, adapter not ready (chatId=${chatId})`);
3113
+ return;
3114
+ }
3115
+ const clientSessionId = buildWeixinSessionId(chatType, chatId);
3116
+ if (this.#isDuplicateInbound(chatId, messageId, text2)) {
3117
+ this.#logger.debug(
3118
+ `dropping duplicate inbound message (session=${clientSessionId} messageId=${messageId})`
3119
+ );
3120
+ return;
3121
+ }
3122
+ if (chatType === "group") {
3123
+ this.#logger.debug(`ignoring unsupported Weixin group message (session=${clientSessionId})`);
3124
+ return;
3125
+ }
3126
+ this.#resetProgressState(clientSessionId);
3127
+ const normalizedText = text2.trim();
3128
+ const commandEvent = parseSlashCommand(normalizedText, clientSessionId);
3129
+ if (commandEvent) {
3130
+ await this.#onOutput(commandEvent);
3131
+ return;
3132
+ }
3133
+ await this.#client?.sendTyping(chatId);
3134
+ this.#startTypingHeartbeat(clientSessionId, chatId);
3135
+ await this.#onOutput({
3136
+ type: "user.message",
3137
+ clientSessionId,
3138
+ text: text2
3139
+ });
3140
+ });
3141
+ await this.#client.connect();
3142
+ this.#logger.info(`adapter started (baseUrl=${this.#config.baseUrl ?? "https://ilinkai.weixin.qq.com"})`);
3143
+ }
3144
+ async stop() {
3145
+ this.#egressQueue.length = 0;
3146
+ for (const state of this.#progressStateBySession.values()) {
3147
+ if (state.interval) {
3148
+ clearInterval(state.interval);
3149
+ }
3150
+ }
3151
+ this.#progressStateBySession.clear();
3152
+ for (const timer of this.#typingHeartbeatBySession.values()) {
3153
+ clearInterval(timer);
3154
+ }
3155
+ this.#typingHeartbeatBySession.clear();
3156
+ if (this.#client) {
3157
+ await this.#client.disconnect();
3158
+ this.#client = null;
3159
+ }
3160
+ this.#processing = false;
3161
+ this.#onOutput = null;
3162
+ this.#logger.info("adapter stopped");
3163
+ }
3164
+ async input(event) {
3165
+ if (!this.#client) {
3166
+ throw new Error("WeixinIMAdapter is not started");
3167
+ }
3168
+ this.#egressQueue.push(event);
3169
+ await this.#drainEgressQueue();
3170
+ }
3171
+ async isBusy() {
3172
+ return this.#processing || this.#egressQueue.length > 0;
3173
+ }
3174
+ async #drainEgressQueue() {
3175
+ if (this.#processing) {
3176
+ return;
3177
+ }
3178
+ this.#processing = true;
3179
+ try {
3180
+ while (this.#client && this.#egressQueue.length > 0) {
3181
+ const event = this.#egressQueue.shift();
3182
+ if (!event) continue;
3183
+ try {
3184
+ const target = parseWeixinSessionId(event.clientSessionId);
3185
+ if (event.type === "$progress.flush") {
3186
+ await this.#flushProgressSummary(target.chatId, event.clientSessionId);
3187
+ continue;
3188
+ }
3189
+ if (event.type !== "assistant.message") {
3190
+ this.#recordProgressEvent(event);
3191
+ continue;
3192
+ }
3193
+ this.#stopProgressTimer(event.clientSessionId);
3194
+ if (event.text.trim().length > 0) {
3195
+ const chunks = chunkText3(event.text, MAX_TEXT_CHUNK3);
3196
+ for (const chunk of chunks) {
3197
+ await this.#sendTextWithProtection(target.chatId, chunk);
3198
+ }
3199
+ }
3200
+ for (const attachment of event.attachments ?? []) {
3201
+ try {
3202
+ await this.#client.sendAttachment(target.chatId, attachment);
3203
+ } catch (attachmentError) {
3204
+ this.#logger.error("failed to send attachment:", attachmentError);
3205
+ await this.#notifySendFailure(target.chatId, attachmentError);
3206
+ }
3207
+ }
3208
+ this.#stopTypingHeartbeat(event.clientSessionId);
3209
+ await this.#client.stopTyping(target.chatId);
3210
+ } catch (error) {
3211
+ this.#logger.error("failed to send egress event:", error);
3212
+ try {
3213
+ const target = parseWeixinSessionId(event.clientSessionId);
3214
+ this.#stopTypingHeartbeat(event.clientSessionId);
3215
+ await this.#client.stopTyping(target.chatId);
3216
+ await this.#notifySendFailure(target.chatId, error);
3217
+ } catch (notifyError) {
3218
+ this.#logger.error("failed to handle egress send failure:", notifyError);
3219
+ }
3220
+ }
3221
+ }
3222
+ } finally {
3223
+ this.#processing = false;
3224
+ }
3225
+ }
3226
+ #startTypingHeartbeat(clientSessionId, chatId) {
3227
+ this.#stopTypingHeartbeat(clientSessionId);
3228
+ const timer = setInterval(() => {
3229
+ void this.#client?.sendTyping(chatId);
3230
+ }, TYPING_REFRESH_INTERVAL_MS);
3231
+ timer.unref?.();
3232
+ this.#typingHeartbeatBySession.set(clientSessionId, timer);
3233
+ }
3234
+ #stopTypingHeartbeat(clientSessionId) {
3235
+ const timer = this.#typingHeartbeatBySession.get(clientSessionId);
3236
+ if (!timer) {
3237
+ return;
3238
+ }
3239
+ clearInterval(timer);
3240
+ this.#typingHeartbeatBySession.delete(clientSessionId);
3241
+ }
3242
+ async #notifySendFailure(chatId, error) {
3243
+ if (!this.#client) {
3244
+ return;
3245
+ }
3246
+ const message = error instanceof Error ? error.message : String(error);
3247
+ const text2 = `[agent-bridge error] Message delivery failed
3248
+
3249
+ ${message}`;
3250
+ try {
3251
+ await this.#client.sendText(chatId, text2);
3252
+ } catch (notifyError) {
3253
+ this.#logger.error("failed to notify send failure:", notifyError);
3254
+ }
3255
+ }
3256
+ async #sendTextWithProtection(chatId, text2) {
3257
+ if (!this.#client) {
3258
+ throw new Error("WeixinIMAdapter is not started");
3259
+ }
3260
+ const now = Date.now();
3261
+ if (this.#rateLimitCircuitUntil > now) {
3262
+ throw new Error("Weixin send is cooling down after rate limiting. Please try again shortly.");
3263
+ }
3264
+ if (this.#rateLimitCircuitUntil !== 0 && this.#rateLimitCircuitUntil <= now) {
3265
+ this.#rateLimitCircuitUntil = 0;
3266
+ this.#rateLimitEvents = [];
3267
+ }
3268
+ try {
3269
+ await this.#client.sendText(chatId, text2);
3270
+ this.#resetRateLimitState();
3271
+ } catch (error) {
3272
+ if (this.#isStaleSessionError(error)) {
3273
+ throw error;
3274
+ }
3275
+ if (this.#isRateLimitError(error)) {
3276
+ this.#recordRateLimitEvent(now);
3277
+ if (this.#rateLimitCircuitUntil > now) {
3278
+ throw new Error("Weixin send is cooling down after rate limiting. Please try again shortly.");
3279
+ }
3280
+ }
3281
+ throw error;
3282
+ }
3283
+ }
3284
+ #recordProgressEvent(event) {
3285
+ const state = this.#progressStateBySession.get(event.clientSessionId) ?? this.#createProgressState();
3286
+ this.#progressStateBySession.set(event.clientSessionId, state);
3287
+ if (!state.renderer.isProgressEvent(event)) {
3288
+ return;
3289
+ }
3290
+ state.renderer.takeProgressEvent(event);
3291
+ state.dirty = true;
3292
+ }
3293
+ async #flushProgressSummary(chatId, clientSessionId) {
3294
+ const state = this.#progressStateBySession.get(clientSessionId);
3295
+ if (!state || !state.dirty || !this.#client) {
3296
+ return;
3297
+ }
3298
+ await this.#sendTextWithProtection(chatId, state.renderer.getCurrentProgress().markdown);
3299
+ state.dirty = false;
3300
+ }
3301
+ #queueProgressFlush(clientSessionId) {
3302
+ this.#egressQueue.push({ type: "$progress.flush", clientSessionId });
3303
+ void this.#drainEgressQueue();
3304
+ }
3305
+ #createProgressState() {
3306
+ return {
3307
+ renderer: new ProgressRenderer(),
3308
+ dirty: false,
3309
+ interval: null
3310
+ };
3311
+ }
3312
+ #resetProgressState(clientSessionId) {
3313
+ const previous = this.#progressStateBySession.get(clientSessionId);
3314
+ if (previous?.interval) {
3315
+ clearInterval(previous.interval);
3316
+ }
3317
+ const state = this.#createProgressState();
3318
+ state.interval = setInterval(() => {
3319
+ this.#queueProgressFlush(clientSessionId);
3320
+ }, PROGRESS_INTERVAL_MS);
3321
+ state.interval.unref?.();
3322
+ this.#progressStateBySession.set(clientSessionId, state);
3323
+ }
3324
+ #stopProgressTimer(clientSessionId) {
3325
+ const state = this.#progressStateBySession.get(clientSessionId);
3326
+ if (!state) {
3327
+ return;
3328
+ }
3329
+ if (state.interval) {
3330
+ clearInterval(state.interval);
3331
+ }
3332
+ this.#progressStateBySession.delete(clientSessionId);
3333
+ }
3334
+ #isDuplicateInbound(chatId, messageId, text2) {
3335
+ const now = Date.now();
3336
+ this.#pruneDedupState(now);
3337
+ if (messageId) {
3338
+ const existing = this.#recentInboundMessageIds.get(messageId);
3339
+ if (existing && now - existing < MESSAGE_DEDUP_TTL_MS) {
3340
+ return true;
3341
+ }
3342
+ this.#recentInboundMessageIds.set(messageId, now);
3343
+ }
3344
+ const normalizedText = text2.trim();
3345
+ if (!normalizedText) {
3346
+ return false;
3347
+ }
3348
+ const contentKey = `${chatId}:${normalizedText}`;
3349
+ const existingContent = this.#recentInboundContentKeys.get(contentKey);
3350
+ if (existingContent && now - existingContent < MESSAGE_DEDUP_TTL_MS) {
3351
+ return true;
3352
+ }
3353
+ this.#recentInboundContentKeys.set(contentKey, now);
3354
+ return false;
3355
+ }
3356
+ #pruneDedupState(now) {
3357
+ for (const [messageId, seenAt] of this.#recentInboundMessageIds) {
3358
+ if (now - seenAt >= MESSAGE_DEDUP_TTL_MS) {
3359
+ this.#recentInboundMessageIds.delete(messageId);
3360
+ }
3361
+ }
3362
+ for (const [contentKey, seenAt] of this.#recentInboundContentKeys) {
3363
+ if (now - seenAt >= MESSAGE_DEDUP_TTL_MS) {
3364
+ this.#recentInboundContentKeys.delete(contentKey);
3365
+ }
3366
+ }
3367
+ }
3368
+ #recordRateLimitEvent(now) {
3369
+ this.#rateLimitEvents = this.#rateLimitEvents.filter(
3370
+ (timestamp2) => now - timestamp2 < RATE_LIMIT_WINDOW_MS
3371
+ );
3372
+ this.#rateLimitEvents.push(now);
3373
+ if (this.#rateLimitEvents.length >= RATE_LIMIT_THRESHOLD) {
3374
+ this.#rateLimitCircuitUntil = now + RATE_LIMIT_COOLDOWN_MS;
3375
+ }
3376
+ }
3377
+ #resetRateLimitState() {
3378
+ this.#rateLimitEvents = [];
3379
+ this.#rateLimitCircuitUntil = 0;
3380
+ }
3381
+ #isRateLimitError(error) {
3382
+ if (!(error instanceof Error)) {
3383
+ return false;
3384
+ }
3385
+ const message = error.message.toLowerCase();
3386
+ if (this.#isStaleSessionError(error)) {
3387
+ return false;
3388
+ }
3389
+ return message.includes("frequency limit") || message.includes("rate limit") || message.includes("freq limit");
3390
+ }
3391
+ #isStaleSessionError(error) {
3392
+ return error instanceof Error && error.name === "WeixinStaleSessionError";
3393
+ }
3394
+ };
3395
+
3396
+ // src/modules/client/weixin/adapter/weixin-qr-login.ts
3397
+ import { Client as OpenILinkClient2 } from "@openilink/openilink-sdk-node";
3398
+ async function loginWithWeixinQr(options = {}) {
3399
+ const { baseUrl = "https://ilinkai.weixin.qq.com", timeoutMs } = options;
3400
+ const renderQr = await loadQrRenderer();
3401
+ const client = new OpenILinkClient2("", {
3402
+ base_url: baseUrl
3403
+ });
3404
+ let lastQrUrl = "";
3405
+ const result = await client.loginWithQr(
3406
+ {
3407
+ on_qrcode(url) {
3408
+ lastQrUrl = url;
3409
+ console.log("\n\u8BF7\u4F7F\u7528\u5FAE\u4FE1\u626B\u63CF\u4EE5\u4E0B\u4E8C\u7EF4\u7801\uFF1A");
3410
+ try {
3411
+ renderQr(url);
3412
+ } catch (error) {
3413
+ console.log(`\uFF08\u7EC8\u7AEF\u4E8C\u7EF4\u7801\u6E32\u67D3\u5931\u8D25: ${String(error)}\uFF09`);
3414
+ console.log(url);
3415
+ }
3416
+ },
3417
+ on_scanned() {
3418
+ console.log("\n\u5DF2\u626B\u7801\uFF0C\u8BF7\u5728\u5FAE\u4FE1\u91CC\u786E\u8BA4...");
3419
+ },
3420
+ on_expired(attempt, maxAttempts) {
3421
+ console.log(`
3422
+ \u4E8C\u7EF4\u7801\u5DF2\u8FC7\u671F\uFF0C\u6B63\u5728\u5237\u65B0... (${attempt}/${maxAttempts})`);
3423
+ }
3424
+ },
3425
+ timeoutMs
3426
+ );
3427
+ if (!result.connected || !result.bot_id || !result.bot_token) {
3428
+ throw new Error(`Weixin QR login failed: ${result.message || "unknown error"}`);
3429
+ }
3430
+ const resolvedBaseUrl = result.base_url || baseUrl;
3431
+ console.log(`
3432
+ \u5FAE\u4FE1\u8FDE\u63A5\u6210\u529F\uFF0CaccountId=${result.bot_id}`);
3433
+ if (!lastQrUrl) {
3434
+ 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");
3435
+ }
3436
+ return {
3437
+ accountId: result.bot_id,
3438
+ token: result.bot_token,
3439
+ baseUrl: resolvedBaseUrl
3440
+ };
3441
+ }
3442
+ async function loadQrRenderer() {
3443
+ const mod = await import("qrcode-terminal");
3444
+ const api = mod.default ?? mod;
3445
+ if (typeof api.generate !== "function") {
3446
+ throw new Error("qrcode-terminal generate() is unavailable");
3447
+ }
3448
+ return (data) => {
3449
+ api.generate?.(data, { small: true });
3450
+ console.log("\n\u4E8C\u7EF4\u7801\u94FE\u63A5\uFF1A");
3451
+ console.log(data);
3452
+ };
3453
+ }
3454
+
3455
+ // src/modules/client/weixin/index.ts
3456
+ var DEFAULT_BASE_URL = "https://ilinkai.weixin.qq.com";
3457
+ var DEFAULT_CDN_BASE_URL = "https://novac2c.cdn.weixin.qq.com/c2c";
3458
+ function createWeixinConfigCollector() {
3459
+ return {
3460
+ async collect(_ctx) {
3461
+ const qrCreds = await loginWithWeixinQr({ baseUrl: DEFAULT_BASE_URL });
3462
+ return {
3463
+ accountId: qrCreds.accountId,
3464
+ token: qrCreds.token,
3465
+ baseUrl: qrCreds.baseUrl,
3466
+ cdnBaseUrl: DEFAULT_CDN_BASE_URL
3467
+ };
3468
+ },
3469
+ validate(config) {
3470
+ if (!config.accountId.trim()) {
3471
+ throw new Error("Weixin accountId is required");
3472
+ }
3473
+ if (!config.token.trim()) {
3474
+ throw new Error("Weixin token is required");
3475
+ }
3476
+ if (config.baseUrl && !/^https?:\/\//.test(config.baseUrl)) {
3477
+ throw new Error("Weixin baseUrl must start with http:// or https://");
3478
+ }
3479
+ if (config.cdnBaseUrl && !/^https?:\/\//.test(config.cdnBaseUrl)) {
3480
+ throw new Error("Weixin cdnBaseUrl must start with http:// or https://");
3481
+ }
3482
+ },
3483
+ summarize(config) {
3484
+ const masked = config.accountId.length > 8 ? `${config.accountId.slice(0, 4)}****${config.accountId.slice(-4)}` : "****";
3485
+ return `type=weixin accountId=${masked} baseUrl=${config.baseUrl ?? DEFAULT_BASE_URL}`;
3486
+ }
3487
+ };
3488
+ }
3489
+ var weixinClientModule = {
3490
+ type: "weixin",
3491
+ createConfigCollector: createWeixinConfigCollector,
3492
+ createClientAdapter(config) {
3493
+ return new WeixinIMAdapter(config);
3494
+ }
3495
+ };
3496
+
1831
3497
  // src/modules/client/index.ts
1832
3498
  var registry2 = /* @__PURE__ */ new Map([
1833
- [feishuClientModule.type, feishuClientModule]
3499
+ [feishuClientModule.type, feishuClientModule],
3500
+ [wecomClientModule.type, wecomClientModule],
3501
+ [weixinClientModule.type, weixinClientModule]
1834
3502
  ]);
1835
3503
  function listClientModules() {
1836
3504
  return [...registry2.values()];
@@ -1995,7 +3663,7 @@ async function startChannel(channelName) {
1995
3663
  }
1996
3664
  async function runCli(argv = process2.argv) {
1997
3665
  const program = new Command();
1998
- program.name("agent-bridge").description("IM to Pi bridge CLI").version("0.1.0");
3666
+ program.name("agent-bridge").description("IM to Agent bridge CLI").version("0.1.0");
1999
3667
  program.command("add").description("Interactively add a channel").action(async () => {
2000
3668
  const config = await loadConfig();
2001
3669
  await addChannel(config);