omp-wechat 1.0.0 → 1.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +17 -5
- package/dist/index.js +879 -269
- package/package.json +1 -1
package/dist/index.js
CHANGED
|
@@ -1054,15 +1054,93 @@ import {
|
|
|
1054
1054
|
readFileSync,
|
|
1055
1055
|
writeFileSync,
|
|
1056
1056
|
mkdirSync as mkdirSync2,
|
|
1057
|
-
renameSync
|
|
1057
|
+
renameSync as renameSync2
|
|
1058
1058
|
} from "fs";
|
|
1059
1059
|
import { homedir as homedir2 } from "os";
|
|
1060
|
-
import { join as
|
|
1060
|
+
import { join as join3 } from "path";
|
|
1061
1061
|
|
|
1062
1062
|
// src/utils/logger.ts
|
|
1063
1063
|
import { homedir } from "os";
|
|
1064
|
-
import { join } from "path";
|
|
1065
|
-
import { mkdirSync
|
|
1064
|
+
import { join as join2 } from "path";
|
|
1065
|
+
import { mkdirSync } from "fs";
|
|
1066
|
+
|
|
1067
|
+
// src/utils/rotating-log.ts
|
|
1068
|
+
import { appendFileSync, statSync, renameSync, unlinkSync, readdirSync } from "fs";
|
|
1069
|
+
import { dirname, join, basename } from "path";
|
|
1070
|
+
var DEFAULT_MAX_BYTES = 5 * 1024 * 1024;
|
|
1071
|
+
var DEFAULT_MAX_FILES = 3;
|
|
1072
|
+
var DEFAULT_MAX_AGE_MS = 30 * 24 * 60 * 60 * 1000;
|
|
1073
|
+
|
|
1074
|
+
class RotatingLog {
|
|
1075
|
+
filePath;
|
|
1076
|
+
maxBytes;
|
|
1077
|
+
maxFiles;
|
|
1078
|
+
maxAgeMs;
|
|
1079
|
+
baseName;
|
|
1080
|
+
dir;
|
|
1081
|
+
activeFileName;
|
|
1082
|
+
constructor(opts) {
|
|
1083
|
+
this.filePath = opts.filePath;
|
|
1084
|
+
this.maxBytes = opts.maxBytes ?? DEFAULT_MAX_BYTES;
|
|
1085
|
+
this.maxFiles = opts.maxFiles ?? DEFAULT_MAX_FILES;
|
|
1086
|
+
this.maxAgeMs = opts.maxAgeMs ?? DEFAULT_MAX_AGE_MS;
|
|
1087
|
+
this.dir = dirname(this.filePath);
|
|
1088
|
+
this.activeFileName = basename(this.filePath);
|
|
1089
|
+
this.baseName = basename(this.filePath, ".log");
|
|
1090
|
+
}
|
|
1091
|
+
write(line) {
|
|
1092
|
+
try {
|
|
1093
|
+
this.maybeRotate();
|
|
1094
|
+
appendFileSync(this.filePath, line, "utf-8");
|
|
1095
|
+
} catch {}
|
|
1096
|
+
}
|
|
1097
|
+
cleanStale() {
|
|
1098
|
+
const cutoff = Date.now() - this.maxAgeMs;
|
|
1099
|
+
try {
|
|
1100
|
+
for (const name of readdirSync(this.dir)) {
|
|
1101
|
+
if (name === this.activeFileName)
|
|
1102
|
+
continue;
|
|
1103
|
+
if (!name.startsWith(this.baseName) || !name.endsWith(".log"))
|
|
1104
|
+
continue;
|
|
1105
|
+
const fullPath = join(this.dir, name);
|
|
1106
|
+
try {
|
|
1107
|
+
const stat = statSync(fullPath);
|
|
1108
|
+
if (stat.mtimeMs < cutoff)
|
|
1109
|
+
unlinkSync(fullPath);
|
|
1110
|
+
} catch {}
|
|
1111
|
+
}
|
|
1112
|
+
} catch {}
|
|
1113
|
+
}
|
|
1114
|
+
maybeRotate() {
|
|
1115
|
+
let size;
|
|
1116
|
+
try {
|
|
1117
|
+
size = statSync(this.filePath).size;
|
|
1118
|
+
} catch {
|
|
1119
|
+
return;
|
|
1120
|
+
}
|
|
1121
|
+
if (size < this.maxBytes)
|
|
1122
|
+
return;
|
|
1123
|
+
const oldest = this.rotatedPath(this.maxFiles);
|
|
1124
|
+
try {
|
|
1125
|
+
unlinkSync(oldest);
|
|
1126
|
+
} catch {}
|
|
1127
|
+
for (let i = this.maxFiles - 1;i >= 1; i--) {
|
|
1128
|
+
const src = this.rotatedPath(i);
|
|
1129
|
+
const dst = this.rotatedPath(i + 1);
|
|
1130
|
+
try {
|
|
1131
|
+
renameSync(src, dst);
|
|
1132
|
+
} catch {}
|
|
1133
|
+
}
|
|
1134
|
+
try {
|
|
1135
|
+
renameSync(this.filePath, this.rotatedPath(1));
|
|
1136
|
+
} catch {}
|
|
1137
|
+
}
|
|
1138
|
+
rotatedPath(index) {
|
|
1139
|
+
return join(this.dir, `${this.baseName}.${index}.log`);
|
|
1140
|
+
}
|
|
1141
|
+
}
|
|
1142
|
+
|
|
1143
|
+
// src/utils/logger.ts
|
|
1066
1144
|
var LEVEL_ORDER = {
|
|
1067
1145
|
debug: 0,
|
|
1068
1146
|
info: 1,
|
|
@@ -1070,11 +1148,18 @@ var LEVEL_ORDER = {
|
|
|
1070
1148
|
error: 3
|
|
1071
1149
|
};
|
|
1072
1150
|
var minLevel = process.env.OMP_WECHAT_LOG ?? "info";
|
|
1073
|
-
var LOG_DIR =
|
|
1074
|
-
var LOG_FILE =
|
|
1151
|
+
var LOG_DIR = join2(homedir(), ".omp", "logs");
|
|
1152
|
+
var LOG_FILE = join2(LOG_DIR, "wechat.log");
|
|
1075
1153
|
try {
|
|
1076
1154
|
mkdirSync(LOG_DIR, { recursive: true });
|
|
1077
1155
|
} catch {}
|
|
1156
|
+
var rotatingLog = new RotatingLog({
|
|
1157
|
+
filePath: LOG_FILE,
|
|
1158
|
+
maxBytes: 5 * 1024 * 1024,
|
|
1159
|
+
maxFiles: 3,
|
|
1160
|
+
maxAgeMs: 30 * 24 * 60 * 60 * 1000
|
|
1161
|
+
});
|
|
1162
|
+
rotatingLog.cleanStale();
|
|
1078
1163
|
function ts() {
|
|
1079
1164
|
return new Date().toISOString();
|
|
1080
1165
|
}
|
|
@@ -1085,10 +1170,7 @@ function log(level, msg, meta) {
|
|
|
1085
1170
|
const line = meta !== undefined ? `${prefix} ${msg} ${JSON.stringify(meta)}
|
|
1086
1171
|
` : `${prefix} ${msg}
|
|
1087
1172
|
`;
|
|
1088
|
-
|
|
1089
|
-
try {
|
|
1090
|
-
appendFileSync(LOG_FILE, line);
|
|
1091
|
-
} catch {}
|
|
1173
|
+
rotatingLog.write(line);
|
|
1092
1174
|
}
|
|
1093
1175
|
var logger = {
|
|
1094
1176
|
debug: (msg, meta) => log("debug", msg, meta),
|
|
@@ -1098,9 +1180,9 @@ var logger = {
|
|
|
1098
1180
|
};
|
|
1099
1181
|
|
|
1100
1182
|
// src/ilink/client.ts
|
|
1101
|
-
var STATE_DIR =
|
|
1102
|
-
var CREDENTIALS_FILE =
|
|
1103
|
-
var SYNC_BUF_FILE =
|
|
1183
|
+
var STATE_DIR = join3(homedir2(), ".omp-wechat");
|
|
1184
|
+
var CREDENTIALS_FILE = join3(STATE_DIR, "credentials.json");
|
|
1185
|
+
var SYNC_BUF_FILE = join3(STATE_DIR, "sync_buf.txt");
|
|
1104
1186
|
function loadCredentials() {
|
|
1105
1187
|
try {
|
|
1106
1188
|
return JSON.parse(readFileSync(CREDENTIALS_FILE, "utf8"));
|
|
@@ -1113,7 +1195,7 @@ function saveCredentials(creds) {
|
|
|
1113
1195
|
const tmp = CREDENTIALS_FILE + ".tmp";
|
|
1114
1196
|
writeFileSync(tmp, JSON.stringify(creds, null, 2) + `
|
|
1115
1197
|
`, { mode: 384 });
|
|
1116
|
-
|
|
1198
|
+
renameSync2(tmp, CREDENTIALS_FILE);
|
|
1117
1199
|
}
|
|
1118
1200
|
function getCredentials() {
|
|
1119
1201
|
const creds = loadCredentials();
|
|
@@ -1175,12 +1257,12 @@ async function getUpdates(creds, buf) {
|
|
|
1175
1257
|
throw err;
|
|
1176
1258
|
}
|
|
1177
1259
|
}
|
|
1178
|
-
async function sendMessage(creds, to, text, contextToken) {
|
|
1260
|
+
async function sendMessage(creds, to, text, contextToken, clientId) {
|
|
1179
1261
|
await apiFetch(creds, "ilink/bot/sendmessage", {
|
|
1180
1262
|
msg: {
|
|
1181
1263
|
from_user_id: "",
|
|
1182
1264
|
to_user_id: to,
|
|
1183
|
-
client_id: `omp-wechat-${Date.now()}-${randomBytes(4).toString("hex")}`,
|
|
1265
|
+
client_id: clientId ?? `omp-wechat-${Date.now()}-${randomBytes(4).toString("hex")}`,
|
|
1184
1266
|
message_type: 2,
|
|
1185
1267
|
message_state: 2,
|
|
1186
1268
|
item_list: [{ type: 1, text_item: { text } }],
|
|
@@ -1243,6 +1325,7 @@ function saveSyncBuf(buf) {
|
|
|
1243
1325
|
function extractInboundText(msg) {
|
|
1244
1326
|
const items = msg.item_list ?? [];
|
|
1245
1327
|
const parts = [];
|
|
1328
|
+
let imgCount = 0;
|
|
1246
1329
|
for (const item of items) {
|
|
1247
1330
|
switch (item.type) {
|
|
1248
1331
|
case 1:
|
|
@@ -1250,7 +1333,7 @@ function extractInboundText(msg) {
|
|
|
1250
1333
|
parts.push(item.text_item.text);
|
|
1251
1334
|
break;
|
|
1252
1335
|
case 2:
|
|
1253
|
-
|
|
1336
|
+
imgCount++;
|
|
1254
1337
|
break;
|
|
1255
1338
|
case 3:
|
|
1256
1339
|
parts.push(item.voice_item?.text ?? "(voice)");
|
|
@@ -1263,9 +1346,17 @@ function extractInboundText(msg) {
|
|
|
1263
1346
|
break;
|
|
1264
1347
|
}
|
|
1265
1348
|
}
|
|
1349
|
+
if (imgCount > 0 && parts.length === 0) {
|
|
1350
|
+
parts.push(`(user sent ${imgCount} image${imgCount > 1 ? "s" : ""})`);
|
|
1351
|
+
} else if (imgCount > 0) {
|
|
1352
|
+
parts.push(`(+${imgCount} image${imgCount > 1 ? "s" : ""})`);
|
|
1353
|
+
}
|
|
1266
1354
|
return parts.join(`
|
|
1267
1355
|
`) || "";
|
|
1268
1356
|
}
|
|
1357
|
+
function extractInboundImages(msg) {
|
|
1358
|
+
return (msg.item_list ?? []).filter((item) => item.type === 2);
|
|
1359
|
+
}
|
|
1269
1360
|
|
|
1270
1361
|
// src/access/control.ts
|
|
1271
1362
|
import { randomBytes as randomBytes2 } from "crypto";
|
|
@@ -1273,12 +1364,12 @@ import {
|
|
|
1273
1364
|
readFileSync as readFileSync2,
|
|
1274
1365
|
writeFileSync as writeFileSync2,
|
|
1275
1366
|
mkdirSync as mkdirSync3,
|
|
1276
|
-
renameSync as
|
|
1367
|
+
renameSync as renameSync3
|
|
1277
1368
|
} from "fs";
|
|
1278
1369
|
import { homedir as homedir3 } from "os";
|
|
1279
|
-
import { join as
|
|
1280
|
-
var STATE_DIR2 =
|
|
1281
|
-
var ACCESS_FILE =
|
|
1370
|
+
import { join as join4 } from "path";
|
|
1371
|
+
var STATE_DIR2 = join4(homedir3(), ".omp-wechat");
|
|
1372
|
+
var ACCESS_FILE = join4(STATE_DIR2, "access.json");
|
|
1282
1373
|
function defaultAccess() {
|
|
1283
1374
|
return { dmPolicy: "pairing", allowFrom: [], pending: {} };
|
|
1284
1375
|
}
|
|
@@ -1295,7 +1386,7 @@ function readAccessFile() {
|
|
|
1295
1386
|
if (err.code === "ENOENT")
|
|
1296
1387
|
return defaultAccess();
|
|
1297
1388
|
try {
|
|
1298
|
-
|
|
1389
|
+
renameSync3(ACCESS_FILE, `${ACCESS_FILE}.corrupt-${Date.now()}`);
|
|
1299
1390
|
} catch {}
|
|
1300
1391
|
logger.warn("access.json is corrupt, moved aside, using defaults");
|
|
1301
1392
|
return defaultAccess();
|
|
@@ -1306,7 +1397,7 @@ function saveAccess(a) {
|
|
|
1306
1397
|
const tmp = ACCESS_FILE + ".tmp";
|
|
1307
1398
|
writeFileSync2(tmp, JSON.stringify(a, null, 2) + `
|
|
1308
1399
|
`, { mode: 384 });
|
|
1309
|
-
|
|
1400
|
+
renameSync3(tmp, ACCESS_FILE);
|
|
1310
1401
|
}
|
|
1311
1402
|
function loadAccess() {
|
|
1312
1403
|
return readAccessFile();
|
|
@@ -1476,9 +1567,12 @@ async function pollQrStatus(qrcode, baseUrl) {
|
|
|
1476
1567
|
}
|
|
1477
1568
|
}
|
|
1478
1569
|
|
|
1570
|
+
// src/bridge.ts
|
|
1571
|
+
import { randomBytes as randomBytes3 } from "crypto";
|
|
1572
|
+
|
|
1479
1573
|
// src/config.ts
|
|
1480
1574
|
import { homedir as homedir4 } from "os";
|
|
1481
|
-
import { join as
|
|
1575
|
+
import { join as join5 } from "path";
|
|
1482
1576
|
import { existsSync, readFileSync as readFileSync3 } from "fs";
|
|
1483
1577
|
var DEFAULT_SYSTEM_PROMPT = `You are an AI assistant chatting with users via WeChat.
|
|
1484
1578
|
|
|
@@ -1487,8 +1581,8 @@ Constraints:
|
|
|
1487
1581
|
- Keep replies concise; WeChat has a ~2000 character limit per message
|
|
1488
1582
|
- If you need to write code or long documents, summarize the key points; the user will review on their computer
|
|
1489
1583
|
- Users may send short or incomplete messages from their phone; proactively understand their intent`;
|
|
1490
|
-
var CONFIG_DIR =
|
|
1491
|
-
var CONFIG_FILE =
|
|
1584
|
+
var CONFIG_DIR = join5(homedir4(), ".omp-wechat");
|
|
1585
|
+
var CONFIG_FILE = join5(CONFIG_DIR, "config.yml");
|
|
1492
1586
|
function loadConfig() {
|
|
1493
1587
|
const config = {
|
|
1494
1588
|
maxSessions: 50,
|
|
@@ -1503,6 +1597,10 @@ function loadConfig() {
|
|
|
1503
1597
|
config.maxSessions = parseInt(parsed.maxSessions, 10);
|
|
1504
1598
|
if (parsed.dmPolicy)
|
|
1505
1599
|
config.dmPolicy = parsed.dmPolicy;
|
|
1600
|
+
if (parsed.model)
|
|
1601
|
+
config.model = parsed.model;
|
|
1602
|
+
if (parsed.cwd)
|
|
1603
|
+
config.cwd = expandTilde(parsed.cwd);
|
|
1506
1604
|
if (parsed.systemPrompt)
|
|
1507
1605
|
config.systemPrompt = parsed.systemPrompt;
|
|
1508
1606
|
}
|
|
@@ -1511,6 +1609,13 @@ function loadConfig() {
|
|
|
1511
1609
|
}
|
|
1512
1610
|
return config;
|
|
1513
1611
|
}
|
|
1612
|
+
function expandTilde(p) {
|
|
1613
|
+
if (p.startsWith("~/"))
|
|
1614
|
+
return join5(homedir4(), p.slice(2));
|
|
1615
|
+
if (p === "~")
|
|
1616
|
+
return homedir4();
|
|
1617
|
+
return p;
|
|
1618
|
+
}
|
|
1514
1619
|
function parseSimpleYaml(yaml) {
|
|
1515
1620
|
const result = {};
|
|
1516
1621
|
let inMultiline = false;
|
|
@@ -1574,35 +1679,204 @@ function chunkText(text, limit = 2000) {
|
|
|
1574
1679
|
return out;
|
|
1575
1680
|
}
|
|
1576
1681
|
|
|
1682
|
+
// src/utils/dedup.ts
|
|
1683
|
+
import { readFileSync as readFileSync4, writeFileSync as writeFileSync3, mkdirSync as mkdirSync4, renameSync as renameSync4 } from "fs";
|
|
1684
|
+
import { homedir as homedir5 } from "os";
|
|
1685
|
+
import { join as join6 } from "path";
|
|
1686
|
+
var STATE_DIR3 = join6(homedir5(), ".omp-wechat");
|
|
1687
|
+
var DEDUP_FILE = join6(STATE_DIR3, "seen_msgs.json");
|
|
1688
|
+
var MAX_ENTRIES = 500;
|
|
1689
|
+
var DEDUP_TTL_MS = 5 * 60 * 1000;
|
|
1690
|
+
var entries = null;
|
|
1691
|
+
function load() {
|
|
1692
|
+
if (entries)
|
|
1693
|
+
return entries;
|
|
1694
|
+
try {
|
|
1695
|
+
const raw = readFileSync4(DEDUP_FILE, "utf8");
|
|
1696
|
+
const arr = JSON.parse(raw);
|
|
1697
|
+
entries = Array.isArray(arr) ? arr : [];
|
|
1698
|
+
} catch {
|
|
1699
|
+
entries = [];
|
|
1700
|
+
}
|
|
1701
|
+
return entries;
|
|
1702
|
+
}
|
|
1703
|
+
function gc(s) {
|
|
1704
|
+
const now = Date.now();
|
|
1705
|
+
const fresh = s.filter((e) => now - e.ts < DEDUP_TTL_MS);
|
|
1706
|
+
if (fresh.length < s.length) {
|
|
1707
|
+
s.length = 0;
|
|
1708
|
+
s.push(...fresh);
|
|
1709
|
+
}
|
|
1710
|
+
}
|
|
1711
|
+
function persist(s) {
|
|
1712
|
+
let arr = s.slice();
|
|
1713
|
+
if (arr.length > MAX_ENTRIES)
|
|
1714
|
+
arr = arr.slice(arr.length - MAX_ENTRIES);
|
|
1715
|
+
try {
|
|
1716
|
+
mkdirSync4(STATE_DIR3, { recursive: true, mode: 448 });
|
|
1717
|
+
const tmp = DEDUP_FILE + ".tmp";
|
|
1718
|
+
writeFileSync3(tmp, JSON.stringify(arr) + `
|
|
1719
|
+
`, { mode: 384 });
|
|
1720
|
+
renameSync4(tmp, DEDUP_FILE);
|
|
1721
|
+
} catch (err) {
|
|
1722
|
+
logger.debug(`dedup persist failed: ${err}`);
|
|
1723
|
+
}
|
|
1724
|
+
}
|
|
1725
|
+
function makeDedupKey(senderId, createTimeMs, text) {
|
|
1726
|
+
if (!createTimeMs)
|
|
1727
|
+
return null;
|
|
1728
|
+
return `${senderId}|${createTimeMs}|${text.slice(0, 200)}`;
|
|
1729
|
+
}
|
|
1730
|
+
function isDuplicate(key) {
|
|
1731
|
+
const s = load();
|
|
1732
|
+
gc(s);
|
|
1733
|
+
if (s.some((e) => e.key === key))
|
|
1734
|
+
return true;
|
|
1735
|
+
s.push({ key, ts: Date.now() });
|
|
1736
|
+
persist(s);
|
|
1737
|
+
return false;
|
|
1738
|
+
}
|
|
1739
|
+
|
|
1740
|
+
// src/ilink/cdn.ts
|
|
1741
|
+
import { createDecipheriv } from "crypto";
|
|
1742
|
+
var CDN_BASE_URL = "https://novac2c.cdn.weixin.qq.com/c2c";
|
|
1743
|
+
function parseAesKey(aeskeyHex, aesKeyBase64) {
|
|
1744
|
+
if (aeskeyHex && /^[0-9a-fA-F]{32}$/.test(aeskeyHex)) {
|
|
1745
|
+
return Buffer.from(aeskeyHex, "hex");
|
|
1746
|
+
}
|
|
1747
|
+
if (!aesKeyBase64)
|
|
1748
|
+
return null;
|
|
1749
|
+
const decoded = Buffer.from(aesKeyBase64, "base64");
|
|
1750
|
+
if (decoded.length === 16)
|
|
1751
|
+
return decoded;
|
|
1752
|
+
if (decoded.length === 32 && /^[0-9a-fA-F]{32}$/.test(decoded.toString("ascii"))) {
|
|
1753
|
+
return Buffer.from(decoded.toString("ascii"), "hex");
|
|
1754
|
+
}
|
|
1755
|
+
return null;
|
|
1756
|
+
}
|
|
1757
|
+
function decryptAesEcb(ciphertext, key) {
|
|
1758
|
+
const decipher = createDecipheriv("aes-128-ecb", key, null);
|
|
1759
|
+
return Buffer.concat([decipher.update(ciphertext), decipher.final()]);
|
|
1760
|
+
}
|
|
1761
|
+
function buildCdnUrl(fileUrl, fullUrl) {
|
|
1762
|
+
if (fullUrl)
|
|
1763
|
+
return fullUrl;
|
|
1764
|
+
if (!fileUrl)
|
|
1765
|
+
return null;
|
|
1766
|
+
if (fileUrl.startsWith("http"))
|
|
1767
|
+
return fileUrl;
|
|
1768
|
+
return `${CDN_BASE_URL}/download?encrypted_query_param=${encodeURIComponent(fileUrl)}`;
|
|
1769
|
+
}
|
|
1770
|
+
async function downloadAndDecrypt(fileUrl, fullUrl, aeskeyHex, aesKeyBase64, label = "media") {
|
|
1771
|
+
const url = buildCdnUrl(fileUrl, fullUrl);
|
|
1772
|
+
if (!url) {
|
|
1773
|
+
logger.warn(`[${label}] No CDN URL available`);
|
|
1774
|
+
return null;
|
|
1775
|
+
}
|
|
1776
|
+
const key = parseAesKey(aeskeyHex, aesKeyBase64);
|
|
1777
|
+
if (!key) {
|
|
1778
|
+
logger.warn(`[${label}] Could not parse AES key`);
|
|
1779
|
+
return null;
|
|
1780
|
+
}
|
|
1781
|
+
try {
|
|
1782
|
+
const resp = await fetch(url);
|
|
1783
|
+
if (!resp.ok) {
|
|
1784
|
+
logger.warn(`[${label}] CDN download failed: ${resp.status} ${resp.statusText}`);
|
|
1785
|
+
return null;
|
|
1786
|
+
}
|
|
1787
|
+
const encrypted = Buffer.from(await resp.arrayBuffer());
|
|
1788
|
+
const plaintext = decryptAesEcb(encrypted, key);
|
|
1789
|
+
logger.info(`[${label}] Downloaded + decrypted: ${encrypted.length} \u2192 ${plaintext.length} bytes`);
|
|
1790
|
+
return plaintext;
|
|
1791
|
+
} catch (err) {
|
|
1792
|
+
logger.error(`[${label}] CDN download/decrypt error:`, err);
|
|
1793
|
+
return null;
|
|
1794
|
+
}
|
|
1795
|
+
}
|
|
1796
|
+
|
|
1577
1797
|
// src/engine/session.ts
|
|
1578
1798
|
import { createAgentSession, SessionManager } from "@oh-my-pi/pi-coding-agent";
|
|
1579
|
-
|
|
1580
|
-
|
|
1581
|
-
|
|
1582
|
-
|
|
1583
|
-
|
|
1584
|
-
|
|
1585
|
-
|
|
1586
|
-
|
|
1587
|
-
|
|
1588
|
-
|
|
1799
|
+
|
|
1800
|
+
// src/engine/session-store.ts
|
|
1801
|
+
import { join as join7 } from "path";
|
|
1802
|
+
import { homedir as homedir6 } from "os";
|
|
1803
|
+
import { existsSync as existsSync2, mkdirSync as mkdirSync5, readdirSync as readdirSync2, statSync as statSync2, rmSync } from "fs";
|
|
1804
|
+
var STATE_DIR4 = join7(homedir6(), ".omp-wechat");
|
|
1805
|
+
var SESSIONS_DIR = join7(STATE_DIR4, "sessions");
|
|
1806
|
+
var STALE_THRESHOLD_MS = 30 * 24 * 60 * 60 * 1000;
|
|
1807
|
+
function sanitizeChatId(chatId) {
|
|
1808
|
+
return chatId.replace(/[^a-zA-Z0-9_@.-]/g, "_").slice(0, 200);
|
|
1809
|
+
}
|
|
1810
|
+
function sessionDirFor(chatId) {
|
|
1811
|
+
return join7(SESSIONS_DIR, sanitizeChatId(chatId));
|
|
1812
|
+
}
|
|
1813
|
+
function ensureSessionsDir() {
|
|
1814
|
+
mkdirSync5(SESSIONS_DIR, { recursive: true, mode: 448 });
|
|
1815
|
+
}
|
|
1816
|
+
function removeSessionDir(chatId) {
|
|
1817
|
+
const dir = sessionDirFor(chatId);
|
|
1818
|
+
if (!existsSync2(dir))
|
|
1819
|
+
return false;
|
|
1820
|
+
try {
|
|
1821
|
+
rmSync(dir, { recursive: true, force: true });
|
|
1822
|
+
return true;
|
|
1823
|
+
} catch (err) {
|
|
1824
|
+
logger.warn(`Failed to remove session dir ${dir}: ${err}`);
|
|
1825
|
+
return false;
|
|
1589
1826
|
}
|
|
1590
|
-
|
|
1591
|
-
|
|
1592
|
-
|
|
1593
|
-
|
|
1594
|
-
|
|
1595
|
-
|
|
1596
|
-
|
|
1597
|
-
|
|
1827
|
+
}
|
|
1828
|
+
function cleanupStaleSessions() {
|
|
1829
|
+
if (!existsSync2(SESSIONS_DIR))
|
|
1830
|
+
return 0;
|
|
1831
|
+
const now = Date.now();
|
|
1832
|
+
let removed = 0;
|
|
1833
|
+
for (const entry of readdirSync2(SESSIONS_DIR, { withFileTypes: true })) {
|
|
1834
|
+
if (!entry.isDirectory())
|
|
1835
|
+
continue;
|
|
1836
|
+
const dir = join7(SESSIONS_DIR, entry.name);
|
|
1837
|
+
let newestMtime = 0;
|
|
1838
|
+
try {
|
|
1839
|
+
for (const file of readdirSync2(dir)) {
|
|
1840
|
+
const mtime = statSync2(join7(dir, file)).mtimeMs;
|
|
1841
|
+
if (mtime > newestMtime)
|
|
1842
|
+
newestMtime = mtime;
|
|
1843
|
+
}
|
|
1844
|
+
} catch {
|
|
1845
|
+
continue;
|
|
1598
1846
|
}
|
|
1599
|
-
|
|
1600
|
-
|
|
1601
|
-
|
|
1602
|
-
|
|
1603
|
-
|
|
1604
|
-
|
|
1847
|
+
if (newestMtime === 0 || now - newestMtime > STALE_THRESHOLD_MS) {
|
|
1848
|
+
try {
|
|
1849
|
+
rmSync(dir, { recursive: true, force: true });
|
|
1850
|
+
removed++;
|
|
1851
|
+
logger.info(`Cleaned up stale session dir: ${entry.name}`);
|
|
1852
|
+
} catch (err) {
|
|
1853
|
+
logger.warn(`Failed to cleanup session dir ${entry.name}: ${err}`);
|
|
1854
|
+
}
|
|
1855
|
+
}
|
|
1856
|
+
}
|
|
1857
|
+
if (removed > 0) {
|
|
1858
|
+
logger.info(`Session cleanup: removed ${removed} stale session(s)`);
|
|
1859
|
+
}
|
|
1860
|
+
return removed;
|
|
1861
|
+
}
|
|
1862
|
+
function clearAllSessions() {
|
|
1863
|
+
if (!existsSync2(SESSIONS_DIR))
|
|
1864
|
+
return 0;
|
|
1865
|
+
let removed = 0;
|
|
1866
|
+
for (const entry of readdirSync2(SESSIONS_DIR, { withFileTypes: true })) {
|
|
1867
|
+
if (!entry.isDirectory())
|
|
1868
|
+
continue;
|
|
1869
|
+
try {
|
|
1870
|
+
rmSync(join7(SESSIONS_DIR, entry.name), { recursive: true, force: true });
|
|
1871
|
+
removed++;
|
|
1872
|
+
} catch (err) {
|
|
1873
|
+
logger.warn(`Failed to remove session dir ${entry.name}: ${err}`);
|
|
1874
|
+
}
|
|
1875
|
+
}
|
|
1876
|
+
return removed;
|
|
1605
1877
|
}
|
|
1878
|
+
|
|
1879
|
+
// src/engine/session.ts
|
|
1606
1880
|
function extractAssistantText(message) {
|
|
1607
1881
|
const content = message.content;
|
|
1608
1882
|
if (!Array.isArray(content))
|
|
@@ -1617,70 +1891,326 @@ function extractAssistantText(message) {
|
|
|
1617
1891
|
`).trim();
|
|
1618
1892
|
}
|
|
1619
1893
|
|
|
1894
|
+
class ChatSession {
|
|
1895
|
+
session;
|
|
1896
|
+
chatId;
|
|
1897
|
+
contextToken;
|
|
1898
|
+
lastActive;
|
|
1899
|
+
replyCount = 0;
|
|
1900
|
+
constructor(session, chatId, contextToken) {
|
|
1901
|
+
this.session = session;
|
|
1902
|
+
this.chatId = chatId;
|
|
1903
|
+
this.contextToken = contextToken;
|
|
1904
|
+
this.lastActive = Date.now();
|
|
1905
|
+
}
|
|
1906
|
+
static async create(chatId, contextToken, config, onReply) {
|
|
1907
|
+
logger.info(`Creating session: ${chatId}`);
|
|
1908
|
+
ensureSessionsDir();
|
|
1909
|
+
const sessionDir = sessionDirFor(chatId);
|
|
1910
|
+
const sessionManager = await SessionManager.continueRecent(config.cwd || process.cwd(), sessionDir);
|
|
1911
|
+
logger.info(`Session dir: ${sessionDir} (resumed=${sessionManager.getSessionFile() !== null})`);
|
|
1912
|
+
const { session, modelFallbackMessage } = await createAgentSession({
|
|
1913
|
+
sessionManager,
|
|
1914
|
+
enableMCP: false,
|
|
1915
|
+
enableLsp: false,
|
|
1916
|
+
systemPrompt: config.systemPrompt,
|
|
1917
|
+
modelPattern: config.model
|
|
1918
|
+
});
|
|
1919
|
+
if (modelFallbackMessage) {
|
|
1920
|
+
logger.warn(`Model fallback: ${modelFallbackMessage}`);
|
|
1921
|
+
}
|
|
1922
|
+
const wrapper = new ChatSession(session, chatId, contextToken);
|
|
1923
|
+
const visionRole = session.settings.getModelRole("vision");
|
|
1924
|
+
logger.info(`[${chatId}] Model: ${session.model?.id ?? "unknown"}, vision role: ${visionRole ?? "(not configured)"}`);
|
|
1925
|
+
session.subscribe((event) => {
|
|
1926
|
+
if (event.type !== "message_end")
|
|
1927
|
+
return;
|
|
1928
|
+
if (event.message.role !== "assistant")
|
|
1929
|
+
return;
|
|
1930
|
+
wrapper.replyCount++;
|
|
1931
|
+
const text = extractAssistantText(event.message);
|
|
1932
|
+
logger.info(`[${chatId}] message_end #${wrapper.replyCount}: ${text.slice(0, 100)}`);
|
|
1933
|
+
if (text) {
|
|
1934
|
+
onReply(chatId, text);
|
|
1935
|
+
}
|
|
1936
|
+
});
|
|
1937
|
+
return wrapper;
|
|
1938
|
+
}
|
|
1939
|
+
async prompt(text, images) {
|
|
1940
|
+
this.lastActive = Date.now();
|
|
1941
|
+
await this.session.prompt(text, images?.length ? { images } : undefined);
|
|
1942
|
+
}
|
|
1943
|
+
supportsVision() {
|
|
1944
|
+
return this.session.settings.getModelRole("vision") !== undefined;
|
|
1945
|
+
}
|
|
1946
|
+
setContextToken(token) {
|
|
1947
|
+
this.contextToken = token;
|
|
1948
|
+
}
|
|
1949
|
+
getContextToken() {
|
|
1950
|
+
return this.contextToken;
|
|
1951
|
+
}
|
|
1952
|
+
getLastActive() {
|
|
1953
|
+
return this.lastActive;
|
|
1954
|
+
}
|
|
1955
|
+
async dispose() {
|
|
1956
|
+
await this.session.dispose();
|
|
1957
|
+
}
|
|
1958
|
+
}
|
|
1959
|
+
|
|
1620
1960
|
// src/engine/pool.ts
|
|
1621
|
-
|
|
1622
|
-
|
|
1623
|
-
|
|
1624
|
-
|
|
1625
|
-
replyHandler
|
|
1626
|
-
|
|
1627
|
-
|
|
1628
|
-
|
|
1629
|
-
|
|
1630
|
-
|
|
1631
|
-
|
|
1632
|
-
|
|
1633
|
-
entry
|
|
1634
|
-
|
|
1961
|
+
class SessionPool {
|
|
1962
|
+
pool = new Map;
|
|
1963
|
+
maxSessions;
|
|
1964
|
+
replyHandler;
|
|
1965
|
+
constructor(maxSessions, replyHandler) {
|
|
1966
|
+
this.maxSessions = maxSessions;
|
|
1967
|
+
this.replyHandler = replyHandler;
|
|
1968
|
+
}
|
|
1969
|
+
setMaxSessions(n) {
|
|
1970
|
+
this.maxSessions = n;
|
|
1971
|
+
}
|
|
1972
|
+
async ensure(chatId, contextToken, config) {
|
|
1973
|
+
let entry = this.pool.get(chatId);
|
|
1974
|
+
if (entry) {
|
|
1975
|
+
entry.setContextToken(contextToken);
|
|
1976
|
+
return entry;
|
|
1977
|
+
}
|
|
1978
|
+
if (this.pool.size >= this.maxSessions) {
|
|
1979
|
+
this.evictOldest();
|
|
1980
|
+
}
|
|
1981
|
+
entry = await ChatSession.create(chatId, contextToken, config, this.replyHandler);
|
|
1982
|
+
this.pool.set(chatId, entry);
|
|
1635
1983
|
return entry;
|
|
1636
1984
|
}
|
|
1637
|
-
|
|
1638
|
-
|
|
1985
|
+
async prompt(chatId, contextToken, text, config, images) {
|
|
1986
|
+
const entry = await this.ensure(chatId, contextToken, config);
|
|
1987
|
+
await entry.prompt(text, images);
|
|
1988
|
+
}
|
|
1989
|
+
get(chatId) {
|
|
1990
|
+
return this.pool.get(chatId);
|
|
1991
|
+
}
|
|
1992
|
+
async resetSession(chatId) {
|
|
1993
|
+
const entry = this.pool.get(chatId);
|
|
1994
|
+
if (entry) {
|
|
1995
|
+
await entry.dispose();
|
|
1996
|
+
this.pool.delete(chatId);
|
|
1997
|
+
}
|
|
1998
|
+
removeSessionDir(chatId);
|
|
1999
|
+
}
|
|
2000
|
+
getContextToken(chatId) {
|
|
2001
|
+
return this.pool.get(chatId)?.getContextToken() ?? "";
|
|
2002
|
+
}
|
|
2003
|
+
supportsVision(chatId) {
|
|
2004
|
+
return this.pool.get(chatId)?.supportsVision() ?? false;
|
|
2005
|
+
}
|
|
2006
|
+
evictOldest() {
|
|
2007
|
+
let oldestId = null;
|
|
2008
|
+
let oldestTime = Infinity;
|
|
2009
|
+
for (const [id, entry] of this.pool) {
|
|
2010
|
+
const t = entry.getLastActive();
|
|
2011
|
+
if (t < oldestTime) {
|
|
2012
|
+
oldestTime = t;
|
|
2013
|
+
oldestId = id;
|
|
2014
|
+
}
|
|
2015
|
+
}
|
|
2016
|
+
if (oldestId) {
|
|
2017
|
+
const entry = this.pool.get(oldestId);
|
|
2018
|
+
if (entry) {
|
|
2019
|
+
entry.dispose().catch((err) => {
|
|
2020
|
+
logger.warn(`Session dispose error (${oldestId}): ${err}`);
|
|
2021
|
+
});
|
|
2022
|
+
}
|
|
2023
|
+
this.pool.delete(oldestId);
|
|
2024
|
+
logger.info(`LRU evicted session: ${oldestId}`);
|
|
2025
|
+
}
|
|
2026
|
+
}
|
|
2027
|
+
getPoolStatus() {
|
|
2028
|
+
const chats = Array.from(this.pool.entries()).map(([chatId, entry]) => ({
|
|
2029
|
+
chatId,
|
|
2030
|
+
lastActive: entry.getLastActive()
|
|
2031
|
+
}));
|
|
2032
|
+
return { count: this.pool.size, max: this.maxSessions, chats };
|
|
2033
|
+
}
|
|
2034
|
+
async disposeAll() {
|
|
2035
|
+
const disposals = Array.from(this.pool.values()).map((entry) => entry.dispose().catch((err) => {
|
|
2036
|
+
logger.warn(`Session dispose error: ${err}`);
|
|
2037
|
+
}));
|
|
2038
|
+
await Promise.all(disposals);
|
|
2039
|
+
this.pool.clear();
|
|
1639
2040
|
}
|
|
1640
|
-
entry = await createSession(chatId, contextToken, config, replyHandler);
|
|
1641
|
-
pool.set(chatId, entry);
|
|
1642
|
-
return entry;
|
|
1643
2041
|
}
|
|
1644
|
-
|
|
1645
|
-
|
|
1646
|
-
|
|
2042
|
+
|
|
2043
|
+
// src/command/registry.ts
|
|
2044
|
+
class CommandRegistry {
|
|
2045
|
+
commands = [];
|
|
2046
|
+
register(cmd) {
|
|
2047
|
+
this.commands.push(cmd);
|
|
2048
|
+
}
|
|
2049
|
+
tryParse(text) {
|
|
2050
|
+
const trimmed = text.trim();
|
|
2051
|
+
if (!trimmed.startsWith("/"))
|
|
2052
|
+
return null;
|
|
2053
|
+
for (const cmd of this.commands) {
|
|
2054
|
+
const inv = cmd.parse(trimmed);
|
|
2055
|
+
if (inv)
|
|
2056
|
+
return inv;
|
|
2057
|
+
}
|
|
2058
|
+
return null;
|
|
2059
|
+
}
|
|
1647
2060
|
}
|
|
1648
|
-
|
|
1649
|
-
|
|
2061
|
+
|
|
2062
|
+
// src/engine/model-controller.ts
|
|
2063
|
+
function currentModel(session) {
|
|
2064
|
+
const m = session.model;
|
|
2065
|
+
if (!m)
|
|
2066
|
+
return;
|
|
2067
|
+
return {
|
|
2068
|
+
provider: m.provider,
|
|
2069
|
+
id: m.id,
|
|
2070
|
+
name: m.name,
|
|
2071
|
+
contextWindow: m.contextWindow
|
|
2072
|
+
};
|
|
1650
2073
|
}
|
|
1651
|
-
function
|
|
1652
|
-
|
|
1653
|
-
|
|
1654
|
-
|
|
1655
|
-
|
|
1656
|
-
|
|
1657
|
-
|
|
2074
|
+
function listModels(session) {
|
|
2075
|
+
return session.modelRegistry.getAvailable().map((m) => ({
|
|
2076
|
+
provider: m.provider,
|
|
2077
|
+
id: m.id,
|
|
2078
|
+
name: m.name,
|
|
2079
|
+
contextWindow: m.contextWindow
|
|
2080
|
+
}));
|
|
2081
|
+
}
|
|
2082
|
+
function resolveModel(pattern, session) {
|
|
2083
|
+
const trimmed = pattern.trim();
|
|
2084
|
+
const slash = trimmed.indexOf("/");
|
|
2085
|
+
if (slash <= 0)
|
|
2086
|
+
return null;
|
|
2087
|
+
const provider = trimmed.slice(0, slash);
|
|
2088
|
+
const modelId = trimmed.slice(slash + 1);
|
|
2089
|
+
if (!provider || !modelId)
|
|
2090
|
+
return null;
|
|
2091
|
+
const model = session.modelRegistry.find(provider, modelId);
|
|
2092
|
+
if (!model)
|
|
2093
|
+
return null;
|
|
2094
|
+
return {
|
|
2095
|
+
provider: model.provider,
|
|
2096
|
+
id: model.id,
|
|
2097
|
+
name: model.name,
|
|
2098
|
+
contextWindow: model.contextWindow
|
|
2099
|
+
};
|
|
2100
|
+
}
|
|
2101
|
+
async function switchModel(pattern, session) {
|
|
2102
|
+
const info = resolveModel(pattern, session);
|
|
2103
|
+
if (!info) {
|
|
2104
|
+
return {
|
|
2105
|
+
ok: false,
|
|
2106
|
+
message: `Unknown model: ${pattern}
|
|
2107
|
+
Use /models to see available options (format: provider/model-id).`
|
|
2108
|
+
};
|
|
2109
|
+
}
|
|
2110
|
+
const model = session.modelRegistry.find(info.provider, info.id);
|
|
2111
|
+
if (!model) {
|
|
2112
|
+
return { ok: false, message: `Model not found: ${pattern}` };
|
|
2113
|
+
}
|
|
2114
|
+
try {
|
|
2115
|
+
await session.setModelTemporary(model);
|
|
2116
|
+
return {
|
|
2117
|
+
ok: true,
|
|
2118
|
+
message: `Switched to ${info.provider}/${info.id} (${info.name})`
|
|
2119
|
+
};
|
|
2120
|
+
} catch (err) {
|
|
2121
|
+
return {
|
|
2122
|
+
ok: false,
|
|
2123
|
+
message: `Switch failed: ${err instanceof Error ? err.message : String(err)}`
|
|
2124
|
+
};
|
|
2125
|
+
}
|
|
2126
|
+
}
|
|
2127
|
+
|
|
2128
|
+
// src/command/model-command.ts
|
|
2129
|
+
var MODEL_PREFIX = "/model";
|
|
2130
|
+
var MODELS_PREFIX = "/models";
|
|
2131
|
+
|
|
2132
|
+
class ModelQueryInvocation {
|
|
2133
|
+
async execute(ctx) {
|
|
2134
|
+
const entry = ctx.pool.get(ctx.chatId);
|
|
2135
|
+
if (!entry)
|
|
2136
|
+
return "No active session for this chat.";
|
|
2137
|
+
const info = currentModel(entry.session);
|
|
2138
|
+
if (!info)
|
|
2139
|
+
return "No model selected.";
|
|
2140
|
+
return `Current model: ${info.provider}/${info.id} (${info.name})`;
|
|
2141
|
+
}
|
|
2142
|
+
}
|
|
2143
|
+
|
|
2144
|
+
class ModelListInvocation {
|
|
2145
|
+
async execute(ctx) {
|
|
2146
|
+
const entry = ctx.pool.get(ctx.chatId);
|
|
2147
|
+
if (!entry)
|
|
2148
|
+
return "No active session for this chat.";
|
|
2149
|
+
const models = listModels(entry.session);
|
|
2150
|
+
if (models.length === 0)
|
|
2151
|
+
return "No models available.";
|
|
2152
|
+
const cur = currentModel(entry.session);
|
|
2153
|
+
const curKey = cur ? `${cur.provider}/${cur.id}` : "";
|
|
2154
|
+
const lines = [`Available models (${models.length}):`];
|
|
2155
|
+
for (const m of models) {
|
|
2156
|
+
const key = `${m.provider}/${m.id}`;
|
|
2157
|
+
const marker = key === curKey ? " \u2190 current" : "";
|
|
2158
|
+
lines.push(` ${key} (${m.name})${marker}`);
|
|
1658
2159
|
}
|
|
2160
|
+
lines.push("", "Switch with: /model provider/id");
|
|
2161
|
+
return lines.join(`
|
|
2162
|
+
`);
|
|
1659
2163
|
}
|
|
1660
|
-
|
|
1661
|
-
|
|
1662
|
-
|
|
1663
|
-
|
|
1664
|
-
|
|
1665
|
-
|
|
2164
|
+
}
|
|
2165
|
+
|
|
2166
|
+
class ModelSwitchInvocation {
|
|
2167
|
+
pattern;
|
|
2168
|
+
constructor(pattern) {
|
|
2169
|
+
this.pattern = pattern;
|
|
2170
|
+
}
|
|
2171
|
+
async execute(ctx) {
|
|
2172
|
+
const entry = ctx.pool.get(ctx.chatId);
|
|
2173
|
+
if (!entry)
|
|
2174
|
+
return "No active session for this chat.";
|
|
2175
|
+
const result = await switchModel(this.pattern, entry.session);
|
|
2176
|
+
return result.message;
|
|
2177
|
+
}
|
|
2178
|
+
}
|
|
2179
|
+
|
|
2180
|
+
class ModelCommand {
|
|
2181
|
+
name = "model";
|
|
2182
|
+
parse(text) {
|
|
2183
|
+
if (text === MODELS_PREFIX || text.startsWith(`${MODELS_PREFIX} `)) {
|
|
2184
|
+
return new ModelListInvocation;
|
|
2185
|
+
}
|
|
2186
|
+
if (text === MODEL_PREFIX) {
|
|
2187
|
+
return new ModelQueryInvocation;
|
|
1666
2188
|
}
|
|
1667
|
-
|
|
1668
|
-
|
|
2189
|
+
if (text.startsWith(`${MODEL_PREFIX} `)) {
|
|
2190
|
+
const pattern = text.slice(MODEL_PREFIX.length + 1).trim();
|
|
2191
|
+
if (pattern)
|
|
2192
|
+
return new ModelSwitchInvocation(pattern);
|
|
2193
|
+
}
|
|
2194
|
+
return null;
|
|
1669
2195
|
}
|
|
1670
2196
|
}
|
|
1671
|
-
|
|
1672
|
-
|
|
1673
|
-
|
|
1674
|
-
|
|
1675
|
-
|
|
1676
|
-
|
|
2197
|
+
|
|
2198
|
+
// src/command/new-session-command.ts
|
|
2199
|
+
class NewSessionInvocation {
|
|
2200
|
+
async execute(ctx) {
|
|
2201
|
+
await ctx.pool.resetSession(ctx.chatId);
|
|
2202
|
+
return "Session reset. Your next message starts a fresh conversation.";
|
|
2203
|
+
}
|
|
1677
2204
|
}
|
|
1678
|
-
|
|
1679
|
-
|
|
1680
|
-
|
|
1681
|
-
|
|
1682
|
-
|
|
1683
|
-
|
|
2205
|
+
|
|
2206
|
+
class NewSessionCommand {
|
|
2207
|
+
name = "new";
|
|
2208
|
+
parse(text) {
|
|
2209
|
+
if (text === "/new" || text === "/reset") {
|
|
2210
|
+
return new NewSessionInvocation;
|
|
2211
|
+
}
|
|
2212
|
+
return null;
|
|
2213
|
+
}
|
|
1684
2214
|
}
|
|
1685
2215
|
|
|
1686
2216
|
// src/bridge.ts
|
|
@@ -1690,171 +2220,236 @@ var RETRY_MS = 2000;
|
|
|
1690
2220
|
var MAX_SEND_RETRIES = 2;
|
|
1691
2221
|
var CHUNK_LIMIT = 2000;
|
|
1692
2222
|
var LOCK_PORT = 19821;
|
|
1693
|
-
|
|
1694
|
-
|
|
1695
|
-
|
|
1696
|
-
|
|
1697
|
-
|
|
1698
|
-
|
|
1699
|
-
|
|
1700
|
-
|
|
1701
|
-
|
|
1702
|
-
|
|
1703
|
-
|
|
1704
|
-
return true;
|
|
1705
|
-
} catch {
|
|
1706
|
-
return false;
|
|
2223
|
+
|
|
2224
|
+
class WeChatBridge {
|
|
2225
|
+
state = null;
|
|
2226
|
+
pollActive = false;
|
|
2227
|
+
lockServer = null;
|
|
2228
|
+
cleanupTimer = null;
|
|
2229
|
+
pool = null;
|
|
2230
|
+
commands = new CommandRegistry;
|
|
2231
|
+
constructor() {
|
|
2232
|
+
this.commands.register(new ModelCommand);
|
|
2233
|
+
this.commands.register(new NewSessionCommand);
|
|
1707
2234
|
}
|
|
1708
|
-
|
|
1709
|
-
|
|
1710
|
-
|
|
1711
|
-
|
|
1712
|
-
|
|
2235
|
+
start() {
|
|
2236
|
+
const config = loadConfig();
|
|
2237
|
+
const creds = getCredentials();
|
|
2238
|
+
if (this.pollActive) {
|
|
2239
|
+
return this.state;
|
|
2240
|
+
}
|
|
2241
|
+
if (!this.acquireLock()) {
|
|
2242
|
+
logger.debug("Another poll loop is running (port lock held), skipping");
|
|
2243
|
+
return { running: false, config, creds, lastError: "another instance running" };
|
|
2244
|
+
}
|
|
2245
|
+
this.pollActive = true;
|
|
2246
|
+
const replyHandler = (chatId, text) => {
|
|
2247
|
+
sendTyping(creds, chatId, 2).catch(() => {});
|
|
2248
|
+
this.sendReply(creds, chatId, text).catch((err) => {
|
|
2249
|
+
logger.error(`[${chatId}] Reply send failed:`, err);
|
|
2250
|
+
});
|
|
2251
|
+
};
|
|
2252
|
+
this.pool = new SessionPool(config.maxSessions, replyHandler);
|
|
2253
|
+
this.state = { running: true, config, creds, lastError: null };
|
|
2254
|
+
logger.info("OMP-Wechat poll loop starting", {
|
|
2255
|
+
maxSessions: config.maxSessions,
|
|
2256
|
+
dmPolicy: config.dmPolicy,
|
|
2257
|
+
model: config.model ?? "(omp default)"
|
|
2258
|
+
});
|
|
2259
|
+
this.pollLoop(creds, this.state).catch((err) => {
|
|
2260
|
+
logger.error("Poll loop crashed:", err);
|
|
2261
|
+
this.state.running = false;
|
|
2262
|
+
this.state.lastError = String(err);
|
|
2263
|
+
this.pollActive = false;
|
|
2264
|
+
this.releaseLock();
|
|
2265
|
+
});
|
|
2266
|
+
cleanupStaleSessions();
|
|
2267
|
+
this.cleanupTimer = setInterval(() => cleanupStaleSessions(), 6 * 60 * 60 * 1000);
|
|
2268
|
+
return this.state;
|
|
1713
2269
|
}
|
|
1714
|
-
|
|
1715
|
-
|
|
1716
|
-
|
|
1717
|
-
|
|
1718
|
-
|
|
1719
|
-
|
|
2270
|
+
async stop() {
|
|
2271
|
+
this.pollActive = false;
|
|
2272
|
+
if (this.cleanupTimer) {
|
|
2273
|
+
clearInterval(this.cleanupTimer);
|
|
2274
|
+
this.cleanupTimer = null;
|
|
2275
|
+
}
|
|
2276
|
+
this.releaseLock();
|
|
2277
|
+
if (this.pool) {
|
|
2278
|
+
await this.pool.disposeAll();
|
|
2279
|
+
}
|
|
2280
|
+
logger.info("Poll loop stopped");
|
|
1720
2281
|
}
|
|
1721
|
-
|
|
1722
|
-
|
|
1723
|
-
return { running: false, config, creds, lastError: "another instance running" };
|
|
2282
|
+
getPoolStatus() {
|
|
2283
|
+
return this.pool?.getPoolStatus() ?? { count: 0, max: 0, chats: [] };
|
|
1724
2284
|
}
|
|
1725
|
-
|
|
1726
|
-
setMaxSessions(config.maxSessions);
|
|
1727
|
-
const state = { running: true, config, creds, lastError: null };
|
|
1728
|
-
setReplyHandler((chatId, text) => {
|
|
1729
|
-
sendTyping(creds, chatId, 2).catch(() => {});
|
|
1730
|
-
sendReply(creds, chatId, text).catch((err) => {
|
|
1731
|
-
logger.error(`[${chatId}] Reply send failed:`, err);
|
|
1732
|
-
});
|
|
1733
|
-
});
|
|
1734
|
-
logger.info("OMP-Wechat poll loop starting", {
|
|
1735
|
-
maxSessions: config.maxSessions,
|
|
1736
|
-
dmPolicy: config.dmPolicy
|
|
1737
|
-
});
|
|
1738
|
-
pollLoop(creds, state).catch((err) => {
|
|
1739
|
-
logger.error("Poll loop crashed:", err);
|
|
1740
|
-
state.running = false;
|
|
1741
|
-
state.lastError = String(err);
|
|
1742
|
-
pollActive = false;
|
|
1743
|
-
releaseLock();
|
|
1744
|
-
});
|
|
1745
|
-
return state;
|
|
1746
|
-
}
|
|
1747
|
-
async function stopPollLoop() {
|
|
1748
|
-
pollActive = false;
|
|
1749
|
-
releaseLock();
|
|
1750
|
-
await disposeAll();
|
|
1751
|
-
logger.info("Poll loop stopped");
|
|
1752
|
-
}
|
|
1753
|
-
async function pollLoop(creds, state) {
|
|
1754
|
-
let failures = 0;
|
|
1755
|
-
logger.info("Long-poll started");
|
|
1756
|
-
while (pollActive) {
|
|
2285
|
+
acquireLock() {
|
|
1757
2286
|
try {
|
|
1758
|
-
|
|
1759
|
-
|
|
1760
|
-
|
|
2287
|
+
this.lockServer = Bun.serve({
|
|
2288
|
+
port: LOCK_PORT,
|
|
2289
|
+
hostname: "127.0.0.1",
|
|
2290
|
+
fetch() {
|
|
2291
|
+
return new Response("ok");
|
|
2292
|
+
}
|
|
2293
|
+
});
|
|
2294
|
+
return true;
|
|
2295
|
+
} catch {
|
|
2296
|
+
return false;
|
|
2297
|
+
}
|
|
2298
|
+
}
|
|
2299
|
+
releaseLock() {
|
|
2300
|
+
if (this.lockServer) {
|
|
2301
|
+
this.lockServer.stop(true);
|
|
2302
|
+
this.lockServer = null;
|
|
2303
|
+
}
|
|
2304
|
+
}
|
|
2305
|
+
async pollLoop(creds, state) {
|
|
2306
|
+
let failures = 0;
|
|
2307
|
+
logger.info("Long-poll started");
|
|
2308
|
+
while (this.pollActive) {
|
|
2309
|
+
try {
|
|
2310
|
+
const buf = loadSyncBuf();
|
|
2311
|
+
const resp = await getUpdates(creds, buf);
|
|
2312
|
+
if (resp.ret !== undefined && resp.ret !== 0) {
|
|
2313
|
+
failures++;
|
|
2314
|
+
logger.warn(`getupdates error ret=${resp.ret} errmsg=${resp.errmsg ?? ""} (${failures}/${MAX_FAILURES})`);
|
|
2315
|
+
if (failures >= MAX_FAILURES) {
|
|
2316
|
+
failures = 0;
|
|
2317
|
+
await Bun.sleep(BACKOFF_MS);
|
|
2318
|
+
} else {
|
|
2319
|
+
await Bun.sleep(RETRY_MS);
|
|
2320
|
+
}
|
|
2321
|
+
continue;
|
|
2322
|
+
}
|
|
2323
|
+
failures = 0;
|
|
2324
|
+
state.lastError = null;
|
|
2325
|
+
if (resp.get_updates_buf && resp.get_updates_buf !== buf) {
|
|
2326
|
+
saveSyncBuf(resp.get_updates_buf);
|
|
2327
|
+
}
|
|
2328
|
+
const msgs = resp.msgs ?? [];
|
|
2329
|
+
for (const msg of msgs) {
|
|
2330
|
+
await this.handleInbound(creds, msg).catch((err) => {
|
|
2331
|
+
logger.error("Message handler error:", err);
|
|
2332
|
+
});
|
|
2333
|
+
}
|
|
2334
|
+
} catch (err) {
|
|
1761
2335
|
failures++;
|
|
1762
|
-
|
|
2336
|
+
state.lastError = String(err);
|
|
2337
|
+
logger.error(`Poll error (${failures}/${MAX_FAILURES}):`, err);
|
|
1763
2338
|
if (failures >= MAX_FAILURES) {
|
|
1764
2339
|
failures = 0;
|
|
1765
2340
|
await Bun.sleep(BACKOFF_MS);
|
|
1766
2341
|
} else {
|
|
1767
2342
|
await Bun.sleep(RETRY_MS);
|
|
1768
2343
|
}
|
|
1769
|
-
continue;
|
|
1770
2344
|
}
|
|
1771
|
-
|
|
1772
|
-
|
|
1773
|
-
|
|
1774
|
-
|
|
1775
|
-
|
|
1776
|
-
|
|
1777
|
-
|
|
1778
|
-
|
|
1779
|
-
|
|
2345
|
+
}
|
|
2346
|
+
}
|
|
2347
|
+
async handleInbound(creds, msg) {
|
|
2348
|
+
if (msg.message_type !== 1)
|
|
2349
|
+
return;
|
|
2350
|
+
const senderId = msg.from_user_id;
|
|
2351
|
+
if (!senderId)
|
|
2352
|
+
return;
|
|
2353
|
+
const contextToken = msg.context_token ?? "";
|
|
2354
|
+
const result = gate(senderId);
|
|
2355
|
+
if (result.action === "drop")
|
|
2356
|
+
return;
|
|
2357
|
+
if (result.action === "pair") {
|
|
2358
|
+
if (contextToken) {
|
|
2359
|
+
const lead = result.isResend ? "Still waiting for pairing" : "Pairing required";
|
|
2360
|
+
const text2 = `${lead} \u2014 approve in OMP with: /wechat pair ${result.code}`;
|
|
2361
|
+
await sendMessage(creds, senderId, text2, contextToken).catch((err) => {
|
|
2362
|
+
logger.warn("Pairing reply send failed:", err);
|
|
1780
2363
|
});
|
|
1781
2364
|
}
|
|
1782
|
-
|
|
1783
|
-
failures++;
|
|
1784
|
-
state.lastError = String(err);
|
|
1785
|
-
logger.error(`Poll error (${failures}/${MAX_FAILURES}):`, err);
|
|
1786
|
-
if (failures >= MAX_FAILURES) {
|
|
1787
|
-
failures = 0;
|
|
1788
|
-
await Bun.sleep(BACKOFF_MS);
|
|
1789
|
-
} else {
|
|
1790
|
-
await Bun.sleep(RETRY_MS);
|
|
1791
|
-
}
|
|
2365
|
+
return;
|
|
1792
2366
|
}
|
|
1793
|
-
|
|
1794
|
-
|
|
1795
|
-
|
|
1796
|
-
|
|
1797
|
-
|
|
1798
|
-
|
|
1799
|
-
|
|
1800
|
-
|
|
1801
|
-
|
|
1802
|
-
|
|
1803
|
-
|
|
1804
|
-
|
|
1805
|
-
|
|
1806
|
-
|
|
1807
|
-
|
|
1808
|
-
const text2 = `${lead} \u2014 approve in OMP with: /wechat pair ${result.code}`;
|
|
1809
|
-
await sendMessage(creds, senderId, text2, contextToken).catch((err) => {
|
|
1810
|
-
logger.warn("Pairing reply send failed:", err);
|
|
2367
|
+
const text = extractInboundText(msg);
|
|
2368
|
+
if (!text)
|
|
2369
|
+
return;
|
|
2370
|
+
const dedupKey = makeDedupKey(senderId, msg.create_time_ms, text);
|
|
2371
|
+
if (dedupKey && isDuplicate(dedupKey)) {
|
|
2372
|
+
logger.info(`[${senderId}] Skipping duplicate message: ${text.slice(0, 80)}`);
|
|
2373
|
+
return;
|
|
2374
|
+
}
|
|
2375
|
+
const config = loadConfig();
|
|
2376
|
+
logger.info(`[${senderId}] Inbound (ts=${msg.create_time_ms ?? "n/a"}): ${text.slice(0, 80)}`);
|
|
2377
|
+
const invocation = this.commands.tryParse(text);
|
|
2378
|
+
if (invocation) {
|
|
2379
|
+
const reply = await invocation.execute({ pool: this.pool, config, chatId: senderId }).catch((err) => `Command failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
2380
|
+
await sendMessage(creds, senderId, reply, contextToken).catch((err) => {
|
|
2381
|
+
logger.warn(`[${senderId}] Command reply send failed:`, err);
|
|
1811
2382
|
});
|
|
2383
|
+
return;
|
|
2384
|
+
}
|
|
2385
|
+
await sendTyping(creds, senderId, 1).catch(() => {});
|
|
2386
|
+
try {
|
|
2387
|
+
const session = await this.pool.ensure(senderId, contextToken, config);
|
|
2388
|
+
const images = await this.downloadImages(creds, msg, senderId);
|
|
2389
|
+
await session.prompt(text, images);
|
|
2390
|
+
} catch (err) {
|
|
2391
|
+
logger.error(`[${senderId}] prompt failed:`, err);
|
|
2392
|
+
await this.sendReply(creds, senderId, "Processing failed, please try again.");
|
|
1812
2393
|
}
|
|
1813
|
-
return;
|
|
1814
|
-
}
|
|
1815
|
-
const text = extractInboundText(msg);
|
|
1816
|
-
if (!text)
|
|
1817
|
-
return;
|
|
1818
|
-
const config = loadConfig();
|
|
1819
|
-
logger.info(`[${senderId}] Inbound: ${text.slice(0, 80)}`);
|
|
1820
|
-
await sendTyping(creds, senderId, 1).catch(() => {});
|
|
1821
|
-
try {
|
|
1822
|
-
await promptSession(senderId, contextToken, text, config);
|
|
1823
|
-
} catch (err) {
|
|
1824
|
-
logger.error(`[${senderId}] prompt failed:`, err);
|
|
1825
|
-
await sendReply(creds, senderId, "Processing failed, please try again.");
|
|
1826
2394
|
}
|
|
1827
|
-
|
|
1828
|
-
|
|
1829
|
-
|
|
1830
|
-
|
|
1831
|
-
|
|
1832
|
-
|
|
2395
|
+
async downloadImages(_creds, msg, chatId) {
|
|
2396
|
+
const imageItems = extractInboundImages(msg);
|
|
2397
|
+
if (imageItems.length === 0)
|
|
2398
|
+
return [];
|
|
2399
|
+
if (!this.pool?.supportsVision(chatId)) {
|
|
2400
|
+
logger.info(`[${chatId}] Skipping image download \u2014 model does not support vision`);
|
|
2401
|
+
return [];
|
|
2402
|
+
}
|
|
2403
|
+
const results = [];
|
|
2404
|
+
for (const item of imageItems) {
|
|
2405
|
+
const img = item.image_item;
|
|
2406
|
+
const buf = await downloadAndDecrypt(img.file_url, img.full_url, img.aeskey, img.aes_key ?? img.media?.aes_key, `image[${chatId}]`);
|
|
2407
|
+
if (buf) {
|
|
2408
|
+
const mimeType = buf.length > 4 && buf[0] === 137 && buf[1] === 80 ? "image/png" : "image/jpeg";
|
|
2409
|
+
results.push({
|
|
2410
|
+
type: "image",
|
|
2411
|
+
data: buf.toString("base64"),
|
|
2412
|
+
mimeType
|
|
2413
|
+
});
|
|
2414
|
+
}
|
|
2415
|
+
}
|
|
2416
|
+
if (results.length > 0) {
|
|
2417
|
+
logger.info(`[${chatId}] Downloaded ${results.length}/${imageItems.length} images for AI`);
|
|
2418
|
+
}
|
|
2419
|
+
return results;
|
|
1833
2420
|
}
|
|
1834
|
-
|
|
1835
|
-
|
|
1836
|
-
|
|
1837
|
-
|
|
1838
|
-
|
|
1839
|
-
|
|
1840
|
-
|
|
1841
|
-
|
|
1842
|
-
|
|
1843
|
-
|
|
1844
|
-
|
|
1845
|
-
|
|
2421
|
+
async sendReply(creds, chatId, text) {
|
|
2422
|
+
const contextToken = this.pool?.getContextToken(chatId) ?? "";
|
|
2423
|
+
if (!contextToken) {
|
|
2424
|
+
logger.warn(`[${chatId}] No context_token, cannot reply`);
|
|
2425
|
+
return;
|
|
2426
|
+
}
|
|
2427
|
+
const chunks = chunkText(text, CHUNK_LIMIT);
|
|
2428
|
+
for (const chunk of chunks) {
|
|
2429
|
+
const clientId = `omp-wechat-${Date.now()}-${randomBytes3(4).toString("hex")}`;
|
|
2430
|
+
let retries = 0;
|
|
2431
|
+
while (retries <= MAX_SEND_RETRIES) {
|
|
2432
|
+
try {
|
|
2433
|
+
await sendMessage(creds, chatId, chunk, contextToken, clientId);
|
|
2434
|
+
break;
|
|
2435
|
+
} catch (err) {
|
|
2436
|
+
retries++;
|
|
2437
|
+
if (retries > MAX_SEND_RETRIES) {
|
|
2438
|
+
logger.error(`[${chatId}] Send failed, dropping:`, err);
|
|
2439
|
+
return;
|
|
2440
|
+
}
|
|
2441
|
+
logger.warn(`[${chatId}] Send retry ${retries}/${MAX_SEND_RETRIES}:`, err);
|
|
2442
|
+
await Bun.sleep(1000 * retries);
|
|
1846
2443
|
}
|
|
1847
|
-
logger.warn(`[${chatId}] Send retry ${retries}/${MAX_SEND_RETRIES}:`, err);
|
|
1848
|
-
await Bun.sleep(1000 * retries);
|
|
1849
2444
|
}
|
|
1850
2445
|
}
|
|
1851
2446
|
}
|
|
1852
2447
|
}
|
|
1853
2448
|
|
|
1854
2449
|
// src/service.ts
|
|
1855
|
-
import { platform, homedir as
|
|
1856
|
-
import { join as
|
|
1857
|
-
import { existsSync as
|
|
2450
|
+
import { platform, homedir as homedir7 } from "os";
|
|
2451
|
+
import { join as join8 } from "path";
|
|
2452
|
+
import { existsSync as existsSync3, mkdirSync as mkdirSync6, writeFileSync as writeFileSync4, rmSync as rmSync2 } from "fs";
|
|
1858
2453
|
var PLIST_LABEL = "com.omp-wechat";
|
|
1859
2454
|
var SERVICE_NAME = "omp-wechat";
|
|
1860
2455
|
function detectPlatform() {
|
|
@@ -1866,13 +2461,13 @@ function detectPlatform() {
|
|
|
1866
2461
|
return "other";
|
|
1867
2462
|
}
|
|
1868
2463
|
function getLogDir() {
|
|
1869
|
-
return
|
|
2464
|
+
return join8(homedir7(), ".omp", "logs");
|
|
1870
2465
|
}
|
|
1871
2466
|
function resolveHostBinary() {
|
|
1872
2467
|
return process.execPath || "omp";
|
|
1873
2468
|
}
|
|
1874
2469
|
function plistPath() {
|
|
1875
|
-
return
|
|
2470
|
+
return join8(homedir7(), "Library", "LaunchAgents", `${PLIST_LABEL}.plist`);
|
|
1876
2471
|
}
|
|
1877
2472
|
function generatePlist() {
|
|
1878
2473
|
const omp = resolveHostBinary();
|
|
@@ -1914,21 +2509,21 @@ function generatePlist() {
|
|
|
1914
2509
|
<key>PATH</key>
|
|
1915
2510
|
<string>/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin</string>
|
|
1916
2511
|
<key>HOME</key>
|
|
1917
|
-
<string>${
|
|
2512
|
+
<string>${homedir7()}</string>
|
|
1918
2513
|
</dict>
|
|
1919
2514
|
</dict>
|
|
1920
2515
|
</plist>
|
|
1921
2516
|
`;
|
|
1922
2517
|
}
|
|
1923
2518
|
function installLaunchd() {
|
|
1924
|
-
const dir =
|
|
1925
|
-
|
|
1926
|
-
|
|
2519
|
+
const dir = join8(homedir7(), "Library", "LaunchAgents");
|
|
2520
|
+
mkdirSync6(dir, { recursive: true });
|
|
2521
|
+
mkdirSync6(getLogDir(), { recursive: true });
|
|
1927
2522
|
const plist = plistPath();
|
|
1928
|
-
if (
|
|
2523
|
+
if (existsSync3(plist)) {
|
|
1929
2524
|
Bun.spawnSync(["launchctl", "unload", plist], { stderr: "ignore" });
|
|
1930
2525
|
}
|
|
1931
|
-
|
|
2526
|
+
writeFileSync4(plist, generatePlist());
|
|
1932
2527
|
const result = Bun.spawnSync(["launchctl", "load", plist], { stderr: "inherit" });
|
|
1933
2528
|
if (result.exitCode !== 0) {
|
|
1934
2529
|
throw new Error("launchctl load failed");
|
|
@@ -1937,11 +2532,11 @@ function installLaunchd() {
|
|
|
1937
2532
|
}
|
|
1938
2533
|
function uninstallLaunchd() {
|
|
1939
2534
|
const plist = plistPath();
|
|
1940
|
-
if (!
|
|
2535
|
+
if (!existsSync3(plist)) {
|
|
1941
2536
|
throw new Error("No launchd service found (may not be installed)");
|
|
1942
2537
|
}
|
|
1943
2538
|
Bun.spawnSync(["launchctl", "unload", plist], { stderr: "inherit" });
|
|
1944
|
-
|
|
2539
|
+
rmSync2(plist);
|
|
1945
2540
|
}
|
|
1946
2541
|
function servicePath() {
|
|
1947
2542
|
return `/etc/systemd/system/${SERVICE_NAME}.service`;
|
|
@@ -1964,7 +2559,7 @@ StandardInput=null
|
|
|
1964
2559
|
Restart=always
|
|
1965
2560
|
RestartSec=10
|
|
1966
2561
|
|
|
1967
|
-
Environment=HOME=${
|
|
2562
|
+
Environment=HOME=${homedir7()}
|
|
1968
2563
|
Environment=PATH=/usr/local/bin:/usr/bin:/bin
|
|
1969
2564
|
|
|
1970
2565
|
StandardOutput=append:${logDir}/rpc.log
|
|
@@ -1973,7 +2568,7 @@ StandardError=append:${logDir}/rpc.log
|
|
|
1973
2568
|
NoNewPrivileges=true
|
|
1974
2569
|
ProtectSystem=strict
|
|
1975
2570
|
ProtectHome=read-only
|
|
1976
|
-
ReadWritePaths=${logDir} ${
|
|
2571
|
+
ReadWritePaths=${logDir} ${join8(homedir7(), ".omp-wechat")} ${join8(homedir7(), ".omp")}
|
|
1977
2572
|
PrivateTmp=true
|
|
1978
2573
|
|
|
1979
2574
|
[Install]
|
|
@@ -1982,17 +2577,17 @@ WantedBy=multi-user.target
|
|
|
1982
2577
|
}
|
|
1983
2578
|
function installSystemd() {
|
|
1984
2579
|
const svc = servicePath();
|
|
1985
|
-
|
|
1986
|
-
if (
|
|
2580
|
+
mkdirSync6(getLogDir(), { recursive: true });
|
|
2581
|
+
if (existsSync3(svc)) {
|
|
1987
2582
|
Bun.spawnSync(["sudo", "systemctl", "stop", SERVICE_NAME], { stderr: "inherit" });
|
|
1988
2583
|
}
|
|
1989
2584
|
const tmp = `/tmp/${SERVICE_NAME}.service`;
|
|
1990
|
-
|
|
2585
|
+
writeFileSync4(tmp, generateService());
|
|
1991
2586
|
let result = Bun.spawnSync(["sudo", "te", tmp, svc], { stderr: "inherit" });
|
|
1992
2587
|
if (result.exitCode !== 0) {
|
|
1993
2588
|
result = Bun.spawnSync(["sudo", "cp", tmp, svc], { stderr: "inherit" });
|
|
1994
2589
|
}
|
|
1995
|
-
|
|
2590
|
+
rmSync2(tmp);
|
|
1996
2591
|
if (result.exitCode !== 0) {
|
|
1997
2592
|
throw new Error("Failed to write service file (need sudo)");
|
|
1998
2593
|
}
|
|
@@ -2004,7 +2599,7 @@ function installSystemd() {
|
|
|
2004
2599
|
}
|
|
2005
2600
|
function uninstallSystemd() {
|
|
2006
2601
|
const svc = servicePath();
|
|
2007
|
-
if (!
|
|
2602
|
+
if (!existsSync3(svc)) {
|
|
2008
2603
|
throw new Error("No systemd service found (may not be installed)");
|
|
2009
2604
|
}
|
|
2010
2605
|
Bun.spawnSync(["sudo", "systemctl", "stop", SERVICE_NAME], { stderr: "inherit" });
|
|
@@ -2042,30 +2637,32 @@ function isServiceInstalled() {
|
|
|
2042
2637
|
const p = detectPlatform();
|
|
2043
2638
|
switch (p) {
|
|
2044
2639
|
case "darwin":
|
|
2045
|
-
return
|
|
2640
|
+
return existsSync3(plistPath());
|
|
2046
2641
|
case "linux":
|
|
2047
|
-
return
|
|
2642
|
+
return existsSync3(servicePath());
|
|
2048
2643
|
default:
|
|
2049
2644
|
return false;
|
|
2050
2645
|
}
|
|
2051
2646
|
}
|
|
2052
2647
|
|
|
2053
2648
|
// src/index.ts
|
|
2649
|
+
var bridge = null;
|
|
2054
2650
|
var daemonState = null;
|
|
2055
2651
|
function wechatExtension(pi) {
|
|
2056
2652
|
if (pi.pi && typeof pi.setLabel === "function") {
|
|
2057
2653
|
pi.setLabel("OMP-Wechat Bridge");
|
|
2058
2654
|
}
|
|
2059
2655
|
pi.on("session_start", async (_event, ctx) => {
|
|
2060
|
-
|
|
2656
|
+
bridge = new WeChatBridge;
|
|
2657
|
+
daemonState = bridge.start();
|
|
2061
2658
|
if (daemonState.running) {
|
|
2062
2659
|
ctx.ui.notify("WeChat bridge started", "info");
|
|
2063
2660
|
} else {
|
|
2064
|
-
|
|
2661
|
+
logger.debug("WeChat bridge: another instance holds the lock, starting failover watch");
|
|
2065
2662
|
ctx.setInterval(() => {
|
|
2066
2663
|
if (daemonState?.running)
|
|
2067
2664
|
return;
|
|
2068
|
-
daemonState =
|
|
2665
|
+
daemonState = bridge.start();
|
|
2069
2666
|
if (daemonState.running) {
|
|
2070
2667
|
ctx.ui.notify("WeChat bridge: took over from failed instance", "info");
|
|
2071
2668
|
}
|
|
@@ -2073,7 +2670,7 @@ function wechatExtension(pi) {
|
|
|
2073
2670
|
}
|
|
2074
2671
|
});
|
|
2075
2672
|
pi.registerCommand("wechat", {
|
|
2076
|
-
description: "WeChat bridge: login, status, pair, allow, revoke, list, stop, install, uninstall",
|
|
2673
|
+
description: "WeChat bridge: login, status, pair, allow, revoke, list, stop, clear, install, uninstall",
|
|
2077
2674
|
handler: async (args, ctx) => {
|
|
2078
2675
|
const parts = args.trim().split(/\s+/);
|
|
2079
2676
|
const sub = parts[0] ?? "";
|
|
@@ -2083,18 +2680,18 @@ function wechatExtension(pi) {
|
|
|
2083
2680
|
case "status": {
|
|
2084
2681
|
const running = daemonState?.running ?? false;
|
|
2085
2682
|
const svc = isServiceInstalled();
|
|
2086
|
-
const
|
|
2683
|
+
const pool = bridge?.getPoolStatus() ?? { count: 0, max: 0, chats: [] };
|
|
2087
2684
|
const allowed = listAllowed();
|
|
2088
2685
|
const lines = [
|
|
2089
2686
|
`Poll loop: ${running ? "running" : "stopped"}`,
|
|
2090
2687
|
`Boot service: ${svc ? "installed" : "not installed"}`,
|
|
2091
2688
|
`Last error: ${daemonState?.lastError ?? "none"}`,
|
|
2092
|
-
`Session pool: ${
|
|
2689
|
+
`Session pool: ${pool.count}/${pool.max}`,
|
|
2093
2690
|
`Authorized users: ${allowed.length}`
|
|
2094
2691
|
];
|
|
2095
|
-
if (
|
|
2692
|
+
if (pool.chats.length > 0) {
|
|
2096
2693
|
lines.push("Active chats:");
|
|
2097
|
-
for (const chat of
|
|
2694
|
+
for (const chat of pool.chats) {
|
|
2098
2695
|
const ago = Math.round((Date.now() - chat.lastActive) / 1000);
|
|
2099
2696
|
lines.push(` ${chat.chatId} (${ago}s ago)`);
|
|
2100
2697
|
}
|
|
@@ -2146,7 +2743,10 @@ function wechatExtension(pi) {
|
|
|
2146
2743
|
break;
|
|
2147
2744
|
}
|
|
2148
2745
|
case "stop": {
|
|
2149
|
-
|
|
2746
|
+
if (bridge) {
|
|
2747
|
+
await bridge.stop();
|
|
2748
|
+
bridge = null;
|
|
2749
|
+
}
|
|
2150
2750
|
daemonState = null;
|
|
2151
2751
|
ctx.ui.notify("WeChat bridge stopped", "info");
|
|
2152
2752
|
break;
|
|
@@ -2155,7 +2755,7 @@ function wechatExtension(pi) {
|
|
|
2155
2755
|
try {
|
|
2156
2756
|
const r = installService();
|
|
2157
2757
|
ctx.ui.notify(`Boot service installed (${r.platform}): ${r.path}`, "info");
|
|
2158
|
-
|
|
2758
|
+
logger.info(`Service installed on ${r.platform} at ${r.path}
|
|
2159
2759
|
` + `OMP will run via launchd/systemd at boot. ` + `Manage: ${r.platform === "darwin" ? "launchctl start|stop com.omp-wechat" : "sudo systemctl start|stop omp-wechat"}`);
|
|
2160
2760
|
} catch (err) {
|
|
2161
2761
|
ctx.ui.notify(`Install failed: ${err}`, "error");
|
|
@@ -2171,8 +2771,18 @@ function wechatExtension(pi) {
|
|
|
2171
2771
|
}
|
|
2172
2772
|
break;
|
|
2173
2773
|
}
|
|
2774
|
+
case "clear": {
|
|
2775
|
+
if (bridge) {
|
|
2776
|
+
await bridge.stop();
|
|
2777
|
+
bridge = null;
|
|
2778
|
+
}
|
|
2779
|
+
daemonState = null;
|
|
2780
|
+
const removed = clearAllSessions();
|
|
2781
|
+
ctx.ui.notify(`Cleared ${removed} session(s)`, "info");
|
|
2782
|
+
break;
|
|
2783
|
+
}
|
|
2174
2784
|
default:
|
|
2175
|
-
ctx.ui.notify("Unknown subcommand. Available: login, status, pair, allow, revoke, list, stop, install, uninstall", "warn");
|
|
2785
|
+
ctx.ui.notify("Unknown subcommand. Available: login, status, pair, allow, revoke, list, stop, clear, install, uninstall", "warn");
|
|
2176
2786
|
}
|
|
2177
2787
|
}
|
|
2178
2788
|
});
|