@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/README.md +22 -5
- package/dist/agent-bridge.js +1634 -132
- package/dist/cli.js +1634 -132
- package/dist/index.d.ts +35 -1
- package/package.json +9 -4
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(
|
|
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:
|
|
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
|
-
|
|
1581
|
-
|
|
1582
|
-
|
|
1583
|
-
|
|
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
|
-
|
|
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
|
|
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);
|