omp-wechat 1.0.0 → 1.1.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 +12 -4
- package/dist/index.js +627 -249
- package/package.json +1 -1
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -92,6 +92,7 @@ Configuration is loaded from `~/.omp-wechat/config.yml`, falling back to built-i
|
|
|
92
92
|
# ~/.omp-wechat/config.yml
|
|
93
93
|
maxSessions: 50
|
|
94
94
|
dmPolicy: pairing
|
|
95
|
+
model: "@smol" # default model (role alias or provider/id)
|
|
95
96
|
systemPrompt: |
|
|
96
97
|
You are an AI assistant chatting via WeChat.
|
|
97
98
|
Keep replies concise and in plain text.
|
|
@@ -101,8 +102,8 @@ systemPrompt: |
|
|
|
101
102
|
|---|---|---|
|
|
102
103
|
| `maxSessions` | `50` | Session pool cap (LRU eviction) |
|
|
103
104
|
| `dmPolicy` | `pairing` | Access policy: `pairing` / `allowlist` / `disabled` |
|
|
105
|
+
| `model` | OMP default | Default model: role alias (`@smol`, `@slow`) or `provider/id` |
|
|
104
106
|
| `systemPrompt` | Built-in | System prompt for WeChat chat sessions |
|
|
105
|
-
|
|
106
107
|
> **Model, working directory, and tools are managed by OMP/Pi.** `createAgentSession()` automatically calls `discoverAuthStorage()`, reusing your existing `omp login` / `pi login` OAuth, `~/.omp/agent/agent.db` API keys, or `models.yml` config. This project never touches API keys.
|
|
107
108
|
|
|
108
109
|
## Slash Commands
|
|
@@ -119,6 +120,14 @@ systemPrompt: |
|
|
|
119
120
|
| `/wechat install` | Install boot-time launchd/systemd service |
|
|
120
121
|
| `/wechat uninstall` | Remove boot-time service |
|
|
121
122
|
|
|
123
|
+
### Chat Commands (via WeChat message)
|
|
124
|
+
|
|
125
|
+
| Command | Description |
|
|
126
|
+
|---|---|
|
|
127
|
+
| `/model` | Show current AI model |
|
|
128
|
+
| `/models` | List all available models |
|
|
129
|
+
| `/model provider/id` | Switch model for this chat (e.g. `/model anthropic/claude-haiku-4-5`) |
|
|
130
|
+
|
|
122
131
|
## Access Control
|
|
123
132
|
|
|
124
133
|
| Mode | Behavior |
|
|
@@ -174,15 +183,14 @@ OMP-Wechat/
|
|
|
174
183
|
|
|
175
184
|
- **Reply-only**: iLink requires `context_token` from an inbound message; you cannot initiate conversations
|
|
176
185
|
- **1:1 only**: iLink Bot API does not support group chats
|
|
177
|
-
- **No message history**: WeChat provides no history API; session context is lost on restart (in-memory sessions)
|
|
178
186
|
- **Single instance**: iLink allows only one bot connection per account
|
|
179
187
|
- **Text only (Phase 1)**: images/voice/video are represented as `(image)` / `(voice)` placeholders; media support is planned
|
|
180
188
|
|
|
181
189
|
## Roadmap
|
|
182
190
|
|
|
183
191
|
- [ ] **Phase 2**: Media support (inbound images as base64, voice transcription)
|
|
184
|
-
- [
|
|
185
|
-
- [
|
|
192
|
+
- [x] **Phase 3**: Persistent sessions — `SessionManager.continueRecent()` per chat, context survives restarts
|
|
193
|
+
- [x] **Phase 4**: Per-chat model selection — `/model` `/models` chat commands for manual switching
|
|
186
194
|
- [ ] **Phase 5**: Fine-grained permissions (per-user tool restrictions, bash approval via WeChat)
|
|
187
195
|
|
|
188
196
|
## License
|
package/dist/index.js
CHANGED
|
@@ -1070,8 +1070,8 @@ var LEVEL_ORDER = {
|
|
|
1070
1070
|
error: 3
|
|
1071
1071
|
};
|
|
1072
1072
|
var minLevel = process.env.OMP_WECHAT_LOG ?? "info";
|
|
1073
|
-
var LOG_DIR = join(homedir(), ".omp
|
|
1074
|
-
var LOG_FILE = join(LOG_DIR, "
|
|
1073
|
+
var LOG_DIR = join(homedir(), ".omp", "logs");
|
|
1074
|
+
var LOG_FILE = join(LOG_DIR, "wechat.log");
|
|
1075
1075
|
try {
|
|
1076
1076
|
mkdirSync(LOG_DIR, { recursive: true });
|
|
1077
1077
|
} catch {}
|
|
@@ -1175,12 +1175,12 @@ async function getUpdates(creds, buf) {
|
|
|
1175
1175
|
throw err;
|
|
1176
1176
|
}
|
|
1177
1177
|
}
|
|
1178
|
-
async function sendMessage(creds, to, text, contextToken) {
|
|
1178
|
+
async function sendMessage(creds, to, text, contextToken, clientId) {
|
|
1179
1179
|
await apiFetch(creds, "ilink/bot/sendmessage", {
|
|
1180
1180
|
msg: {
|
|
1181
1181
|
from_user_id: "",
|
|
1182
1182
|
to_user_id: to,
|
|
1183
|
-
client_id: `omp-wechat-${Date.now()}-${randomBytes(4).toString("hex")}`,
|
|
1183
|
+
client_id: clientId ?? `omp-wechat-${Date.now()}-${randomBytes(4).toString("hex")}`,
|
|
1184
1184
|
message_type: 2,
|
|
1185
1185
|
message_state: 2,
|
|
1186
1186
|
item_list: [{ type: 1, text_item: { text } }],
|
|
@@ -1476,6 +1476,9 @@ async function pollQrStatus(qrcode, baseUrl) {
|
|
|
1476
1476
|
}
|
|
1477
1477
|
}
|
|
1478
1478
|
|
|
1479
|
+
// src/bridge.ts
|
|
1480
|
+
import { randomBytes as randomBytes3 } from "crypto";
|
|
1481
|
+
|
|
1479
1482
|
// src/config.ts
|
|
1480
1483
|
import { homedir as homedir4 } from "os";
|
|
1481
1484
|
import { join as join4 } from "path";
|
|
@@ -1505,6 +1508,8 @@ function loadConfig() {
|
|
|
1505
1508
|
config.dmPolicy = parsed.dmPolicy;
|
|
1506
1509
|
if (parsed.systemPrompt)
|
|
1507
1510
|
config.systemPrompt = parsed.systemPrompt;
|
|
1511
|
+
if (parsed.model)
|
|
1512
|
+
config.model = parsed.model;
|
|
1508
1513
|
}
|
|
1509
1514
|
} catch (err) {
|
|
1510
1515
|
logger.warn("Failed to load config.yml, using defaults", err);
|
|
@@ -1574,35 +1579,135 @@ function chunkText(text, limit = 2000) {
|
|
|
1574
1579
|
return out;
|
|
1575
1580
|
}
|
|
1576
1581
|
|
|
1582
|
+
// src/utils/dedup.ts
|
|
1583
|
+
import { readFileSync as readFileSync4, writeFileSync as writeFileSync3, mkdirSync as mkdirSync4, renameSync as renameSync3 } from "fs";
|
|
1584
|
+
import { homedir as homedir5 } from "os";
|
|
1585
|
+
import { join as join5 } from "path";
|
|
1586
|
+
var STATE_DIR3 = join5(homedir5(), ".omp-wechat");
|
|
1587
|
+
var DEDUP_FILE = join5(STATE_DIR3, "seen_msgs.json");
|
|
1588
|
+
var MAX_ENTRIES = 500;
|
|
1589
|
+
var DEDUP_TTL_MS = 5 * 60 * 1000;
|
|
1590
|
+
var entries = null;
|
|
1591
|
+
function load() {
|
|
1592
|
+
if (entries)
|
|
1593
|
+
return entries;
|
|
1594
|
+
try {
|
|
1595
|
+
const raw = readFileSync4(DEDUP_FILE, "utf8");
|
|
1596
|
+
const arr = JSON.parse(raw);
|
|
1597
|
+
entries = Array.isArray(arr) ? arr : [];
|
|
1598
|
+
} catch {
|
|
1599
|
+
entries = [];
|
|
1600
|
+
}
|
|
1601
|
+
return entries;
|
|
1602
|
+
}
|
|
1603
|
+
function gc(s) {
|
|
1604
|
+
const now = Date.now();
|
|
1605
|
+
const fresh = s.filter((e) => now - e.ts < DEDUP_TTL_MS);
|
|
1606
|
+
if (fresh.length < s.length) {
|
|
1607
|
+
s.length = 0;
|
|
1608
|
+
s.push(...fresh);
|
|
1609
|
+
}
|
|
1610
|
+
}
|
|
1611
|
+
function persist(s) {
|
|
1612
|
+
let arr = s.slice();
|
|
1613
|
+
if (arr.length > MAX_ENTRIES)
|
|
1614
|
+
arr = arr.slice(arr.length - MAX_ENTRIES);
|
|
1615
|
+
try {
|
|
1616
|
+
mkdirSync4(STATE_DIR3, { recursive: true, mode: 448 });
|
|
1617
|
+
const tmp = DEDUP_FILE + ".tmp";
|
|
1618
|
+
writeFileSync3(tmp, JSON.stringify(arr) + `
|
|
1619
|
+
`, { mode: 384 });
|
|
1620
|
+
renameSync3(tmp, DEDUP_FILE);
|
|
1621
|
+
} catch (err) {
|
|
1622
|
+
logger.debug(`dedup persist failed: ${err}`);
|
|
1623
|
+
}
|
|
1624
|
+
}
|
|
1625
|
+
function makeDedupKey(senderId, createTimeMs, text) {
|
|
1626
|
+
if (!createTimeMs)
|
|
1627
|
+
return null;
|
|
1628
|
+
return `${senderId}|${createTimeMs}|${text.slice(0, 200)}`;
|
|
1629
|
+
}
|
|
1630
|
+
function isDuplicate(key) {
|
|
1631
|
+
const s = load();
|
|
1632
|
+
gc(s);
|
|
1633
|
+
if (s.some((e) => e.key === key))
|
|
1634
|
+
return true;
|
|
1635
|
+
s.push({ key, ts: Date.now() });
|
|
1636
|
+
persist(s);
|
|
1637
|
+
return false;
|
|
1638
|
+
}
|
|
1639
|
+
|
|
1577
1640
|
// src/engine/session.ts
|
|
1578
1641
|
import { createAgentSession, SessionManager } from "@oh-my-pi/pi-coding-agent";
|
|
1579
|
-
|
|
1580
|
-
|
|
1581
|
-
|
|
1582
|
-
|
|
1583
|
-
|
|
1584
|
-
|
|
1585
|
-
|
|
1586
|
-
|
|
1587
|
-
|
|
1588
|
-
|
|
1642
|
+
|
|
1643
|
+
// src/engine/session-store.ts
|
|
1644
|
+
import { join as join6 } from "path";
|
|
1645
|
+
import { homedir as homedir6 } from "os";
|
|
1646
|
+
import { existsSync as existsSync2, mkdirSync as mkdirSync5, readdirSync, statSync, rmSync } from "fs";
|
|
1647
|
+
var STATE_DIR4 = join6(homedir6(), ".omp-wechat");
|
|
1648
|
+
var SESSIONS_DIR = join6(STATE_DIR4, "sessions");
|
|
1649
|
+
var STALE_THRESHOLD_MS = 30 * 24 * 60 * 60 * 1000;
|
|
1650
|
+
function sanitizeChatId(chatId) {
|
|
1651
|
+
return chatId.replace(/[^a-zA-Z0-9_@.-]/g, "_").slice(0, 200);
|
|
1652
|
+
}
|
|
1653
|
+
function sessionDirFor(chatId) {
|
|
1654
|
+
return join6(SESSIONS_DIR, sanitizeChatId(chatId));
|
|
1655
|
+
}
|
|
1656
|
+
function ensureSessionsDir() {
|
|
1657
|
+
mkdirSync5(SESSIONS_DIR, { recursive: true, mode: 448 });
|
|
1658
|
+
}
|
|
1659
|
+
function cleanupStaleSessions() {
|
|
1660
|
+
if (!existsSync2(SESSIONS_DIR))
|
|
1661
|
+
return 0;
|
|
1662
|
+
const now = Date.now();
|
|
1663
|
+
let removed = 0;
|
|
1664
|
+
for (const entry of readdirSync(SESSIONS_DIR, { withFileTypes: true })) {
|
|
1665
|
+
if (!entry.isDirectory())
|
|
1666
|
+
continue;
|
|
1667
|
+
const dir = join6(SESSIONS_DIR, entry.name);
|
|
1668
|
+
let newestMtime = 0;
|
|
1669
|
+
try {
|
|
1670
|
+
for (const file of readdirSync(dir)) {
|
|
1671
|
+
const mtime = statSync(join6(dir, file)).mtimeMs;
|
|
1672
|
+
if (mtime > newestMtime)
|
|
1673
|
+
newestMtime = mtime;
|
|
1674
|
+
}
|
|
1675
|
+
} catch {
|
|
1676
|
+
continue;
|
|
1677
|
+
}
|
|
1678
|
+
if (newestMtime === 0 || now - newestMtime > STALE_THRESHOLD_MS) {
|
|
1679
|
+
try {
|
|
1680
|
+
rmSync(dir, { recursive: true, force: true });
|
|
1681
|
+
removed++;
|
|
1682
|
+
logger.info(`Cleaned up stale session dir: ${entry.name}`);
|
|
1683
|
+
} catch (err) {
|
|
1684
|
+
logger.warn(`Failed to cleanup session dir ${entry.name}: ${err}`);
|
|
1685
|
+
}
|
|
1686
|
+
}
|
|
1589
1687
|
}
|
|
1590
|
-
|
|
1591
|
-
|
|
1592
|
-
|
|
1593
|
-
|
|
1594
|
-
|
|
1595
|
-
|
|
1596
|
-
|
|
1597
|
-
|
|
1688
|
+
if (removed > 0) {
|
|
1689
|
+
logger.info(`Session cleanup: removed ${removed} stale session(s)`);
|
|
1690
|
+
}
|
|
1691
|
+
return removed;
|
|
1692
|
+
}
|
|
1693
|
+
function clearAllSessions() {
|
|
1694
|
+
if (!existsSync2(SESSIONS_DIR))
|
|
1695
|
+
return 0;
|
|
1696
|
+
let removed = 0;
|
|
1697
|
+
for (const entry of readdirSync(SESSIONS_DIR, { withFileTypes: true })) {
|
|
1698
|
+
if (!entry.isDirectory())
|
|
1699
|
+
continue;
|
|
1700
|
+
try {
|
|
1701
|
+
rmSync(join6(SESSIONS_DIR, entry.name), { recursive: true, force: true });
|
|
1702
|
+
removed++;
|
|
1703
|
+
} catch (err) {
|
|
1704
|
+
logger.warn(`Failed to remove session dir ${entry.name}: ${err}`);
|
|
1598
1705
|
}
|
|
1599
|
-
}
|
|
1600
|
-
return
|
|
1601
|
-
session,
|
|
1602
|
-
lastActive: Date.now(),
|
|
1603
|
-
contextToken
|
|
1604
|
-
};
|
|
1706
|
+
}
|
|
1707
|
+
return removed;
|
|
1605
1708
|
}
|
|
1709
|
+
|
|
1710
|
+
// src/engine/session.ts
|
|
1606
1711
|
function extractAssistantText(message) {
|
|
1607
1712
|
const content = message.content;
|
|
1608
1713
|
if (!Array.isArray(content))
|
|
@@ -1617,70 +1722,292 @@ function extractAssistantText(message) {
|
|
|
1617
1722
|
`).trim();
|
|
1618
1723
|
}
|
|
1619
1724
|
|
|
1725
|
+
class ChatSession {
|
|
1726
|
+
session;
|
|
1727
|
+
chatId;
|
|
1728
|
+
contextToken;
|
|
1729
|
+
lastActive;
|
|
1730
|
+
replyCount = 0;
|
|
1731
|
+
constructor(session, chatId, contextToken) {
|
|
1732
|
+
this.session = session;
|
|
1733
|
+
this.chatId = chatId;
|
|
1734
|
+
this.contextToken = contextToken;
|
|
1735
|
+
this.lastActive = Date.now();
|
|
1736
|
+
}
|
|
1737
|
+
static async create(chatId, contextToken, config, onReply) {
|
|
1738
|
+
logger.info(`Creating session: ${chatId}`);
|
|
1739
|
+
ensureSessionsDir();
|
|
1740
|
+
const sessionDir = sessionDirFor(chatId);
|
|
1741
|
+
const sessionManager = await SessionManager.continueRecent(process.cwd(), sessionDir);
|
|
1742
|
+
logger.info(`Session dir: ${sessionDir} (resumed=${sessionManager.getSessionFile() !== null})`);
|
|
1743
|
+
const { session, modelFallbackMessage } = await createAgentSession({
|
|
1744
|
+
sessionManager,
|
|
1745
|
+
enableMCP: false,
|
|
1746
|
+
enableLsp: false,
|
|
1747
|
+
systemPrompt: config.systemPrompt,
|
|
1748
|
+
modelPattern: config.model
|
|
1749
|
+
});
|
|
1750
|
+
if (modelFallbackMessage) {
|
|
1751
|
+
logger.warn(`Model fallback: ${modelFallbackMessage}`);
|
|
1752
|
+
}
|
|
1753
|
+
const wrapper = new ChatSession(session, chatId, contextToken);
|
|
1754
|
+
session.subscribe((event) => {
|
|
1755
|
+
if (event.type !== "message_end")
|
|
1756
|
+
return;
|
|
1757
|
+
if (event.message.role !== "assistant")
|
|
1758
|
+
return;
|
|
1759
|
+
wrapper.replyCount++;
|
|
1760
|
+
const text = extractAssistantText(event.message);
|
|
1761
|
+
logger.info(`[${chatId}] message_end #${wrapper.replyCount}: ${text.slice(0, 100)}`);
|
|
1762
|
+
if (text) {
|
|
1763
|
+
onReply(chatId, text);
|
|
1764
|
+
}
|
|
1765
|
+
});
|
|
1766
|
+
return wrapper;
|
|
1767
|
+
}
|
|
1768
|
+
async prompt(text) {
|
|
1769
|
+
this.lastActive = Date.now();
|
|
1770
|
+
await this.session.prompt(text);
|
|
1771
|
+
}
|
|
1772
|
+
setContextToken(token) {
|
|
1773
|
+
this.contextToken = token;
|
|
1774
|
+
}
|
|
1775
|
+
getContextToken() {
|
|
1776
|
+
return this.contextToken;
|
|
1777
|
+
}
|
|
1778
|
+
getLastActive() {
|
|
1779
|
+
return this.lastActive;
|
|
1780
|
+
}
|
|
1781
|
+
async dispose() {
|
|
1782
|
+
await this.session.dispose();
|
|
1783
|
+
}
|
|
1784
|
+
}
|
|
1785
|
+
|
|
1620
1786
|
// src/engine/pool.ts
|
|
1621
|
-
|
|
1622
|
-
|
|
1623
|
-
|
|
1624
|
-
|
|
1625
|
-
replyHandler
|
|
1626
|
-
|
|
1627
|
-
|
|
1628
|
-
|
|
1629
|
-
|
|
1630
|
-
|
|
1631
|
-
|
|
1632
|
-
|
|
1633
|
-
entry
|
|
1634
|
-
|
|
1787
|
+
class SessionPool {
|
|
1788
|
+
pool = new Map;
|
|
1789
|
+
maxSessions;
|
|
1790
|
+
replyHandler;
|
|
1791
|
+
constructor(maxSessions, replyHandler) {
|
|
1792
|
+
this.maxSessions = maxSessions;
|
|
1793
|
+
this.replyHandler = replyHandler;
|
|
1794
|
+
}
|
|
1795
|
+
setMaxSessions(n) {
|
|
1796
|
+
this.maxSessions = n;
|
|
1797
|
+
}
|
|
1798
|
+
async ensure(chatId, contextToken, config) {
|
|
1799
|
+
let entry = this.pool.get(chatId);
|
|
1800
|
+
if (entry) {
|
|
1801
|
+
entry.setContextToken(contextToken);
|
|
1802
|
+
return entry;
|
|
1803
|
+
}
|
|
1804
|
+
if (this.pool.size >= this.maxSessions) {
|
|
1805
|
+
this.evictOldest();
|
|
1806
|
+
}
|
|
1807
|
+
entry = await ChatSession.create(chatId, contextToken, config, this.replyHandler);
|
|
1808
|
+
this.pool.set(chatId, entry);
|
|
1635
1809
|
return entry;
|
|
1636
1810
|
}
|
|
1637
|
-
|
|
1638
|
-
|
|
1811
|
+
async prompt(chatId, contextToken, text, config) {
|
|
1812
|
+
const entry = await this.ensure(chatId, contextToken, config);
|
|
1813
|
+
await entry.prompt(text);
|
|
1814
|
+
}
|
|
1815
|
+
get(chatId) {
|
|
1816
|
+
return this.pool.get(chatId);
|
|
1817
|
+
}
|
|
1818
|
+
getContextToken(chatId) {
|
|
1819
|
+
return this.pool.get(chatId)?.getContextToken() ?? "";
|
|
1820
|
+
}
|
|
1821
|
+
evictOldest() {
|
|
1822
|
+
let oldestId = null;
|
|
1823
|
+
let oldestTime = Infinity;
|
|
1824
|
+
for (const [id, entry] of this.pool) {
|
|
1825
|
+
const t = entry.getLastActive();
|
|
1826
|
+
if (t < oldestTime) {
|
|
1827
|
+
oldestTime = t;
|
|
1828
|
+
oldestId = id;
|
|
1829
|
+
}
|
|
1830
|
+
}
|
|
1831
|
+
if (oldestId) {
|
|
1832
|
+
const entry = this.pool.get(oldestId);
|
|
1833
|
+
if (entry) {
|
|
1834
|
+
entry.dispose().catch((err) => {
|
|
1835
|
+
logger.warn(`Session dispose error (${oldestId}): ${err}`);
|
|
1836
|
+
});
|
|
1837
|
+
}
|
|
1838
|
+
this.pool.delete(oldestId);
|
|
1839
|
+
logger.info(`LRU evicted session: ${oldestId}`);
|
|
1840
|
+
}
|
|
1841
|
+
}
|
|
1842
|
+
getPoolStatus() {
|
|
1843
|
+
const chats = Array.from(this.pool.entries()).map(([chatId, entry]) => ({
|
|
1844
|
+
chatId,
|
|
1845
|
+
lastActive: entry.getLastActive()
|
|
1846
|
+
}));
|
|
1847
|
+
return { count: this.pool.size, max: this.maxSessions, chats };
|
|
1848
|
+
}
|
|
1849
|
+
async disposeAll() {
|
|
1850
|
+
const disposals = Array.from(this.pool.values()).map((entry) => entry.dispose().catch((err) => {
|
|
1851
|
+
logger.warn(`Session dispose error: ${err}`);
|
|
1852
|
+
}));
|
|
1853
|
+
await Promise.all(disposals);
|
|
1854
|
+
this.pool.clear();
|
|
1639
1855
|
}
|
|
1640
|
-
entry = await createSession(chatId, contextToken, config, replyHandler);
|
|
1641
|
-
pool.set(chatId, entry);
|
|
1642
|
-
return entry;
|
|
1643
1856
|
}
|
|
1644
|
-
|
|
1645
|
-
|
|
1646
|
-
|
|
1857
|
+
|
|
1858
|
+
// src/command/registry.ts
|
|
1859
|
+
class CommandRegistry {
|
|
1860
|
+
commands = [];
|
|
1861
|
+
register(cmd) {
|
|
1862
|
+
this.commands.push(cmd);
|
|
1863
|
+
}
|
|
1864
|
+
tryParse(text) {
|
|
1865
|
+
const trimmed = text.trim();
|
|
1866
|
+
if (!trimmed.startsWith("/"))
|
|
1867
|
+
return null;
|
|
1868
|
+
for (const cmd of this.commands) {
|
|
1869
|
+
const inv = cmd.parse(trimmed);
|
|
1870
|
+
if (inv)
|
|
1871
|
+
return inv;
|
|
1872
|
+
}
|
|
1873
|
+
return null;
|
|
1874
|
+
}
|
|
1647
1875
|
}
|
|
1648
|
-
|
|
1649
|
-
|
|
1876
|
+
|
|
1877
|
+
// src/engine/model-controller.ts
|
|
1878
|
+
function currentModel(session) {
|
|
1879
|
+
const m = session.model;
|
|
1880
|
+
if (!m)
|
|
1881
|
+
return;
|
|
1882
|
+
return {
|
|
1883
|
+
provider: m.provider,
|
|
1884
|
+
id: m.id,
|
|
1885
|
+
name: m.name,
|
|
1886
|
+
contextWindow: m.contextWindow
|
|
1887
|
+
};
|
|
1650
1888
|
}
|
|
1651
|
-
function
|
|
1652
|
-
|
|
1653
|
-
|
|
1654
|
-
|
|
1655
|
-
|
|
1656
|
-
|
|
1657
|
-
|
|
1658
|
-
|
|
1889
|
+
function listModels(session) {
|
|
1890
|
+
return session.modelRegistry.getAvailable().map((m) => ({
|
|
1891
|
+
provider: m.provider,
|
|
1892
|
+
id: m.id,
|
|
1893
|
+
name: m.name,
|
|
1894
|
+
contextWindow: m.contextWindow
|
|
1895
|
+
}));
|
|
1896
|
+
}
|
|
1897
|
+
function resolveModel(pattern, session) {
|
|
1898
|
+
const trimmed = pattern.trim();
|
|
1899
|
+
const slash = trimmed.indexOf("/");
|
|
1900
|
+
if (slash <= 0)
|
|
1901
|
+
return null;
|
|
1902
|
+
const provider = trimmed.slice(0, slash);
|
|
1903
|
+
const modelId = trimmed.slice(slash + 1);
|
|
1904
|
+
if (!provider || !modelId)
|
|
1905
|
+
return null;
|
|
1906
|
+
const model = session.modelRegistry.find(provider, modelId);
|
|
1907
|
+
if (!model)
|
|
1908
|
+
return null;
|
|
1909
|
+
return {
|
|
1910
|
+
provider: model.provider,
|
|
1911
|
+
id: model.id,
|
|
1912
|
+
name: model.name,
|
|
1913
|
+
contextWindow: model.contextWindow
|
|
1914
|
+
};
|
|
1915
|
+
}
|
|
1916
|
+
async function switchModel(pattern, session) {
|
|
1917
|
+
const info = resolveModel(pattern, session);
|
|
1918
|
+
if (!info) {
|
|
1919
|
+
return {
|
|
1920
|
+
ok: false,
|
|
1921
|
+
message: `Unknown model: ${pattern}
|
|
1922
|
+
Use /models to see available options (format: provider/model-id).`
|
|
1923
|
+
};
|
|
1659
1924
|
}
|
|
1660
|
-
|
|
1661
|
-
|
|
1662
|
-
|
|
1663
|
-
|
|
1664
|
-
|
|
1665
|
-
|
|
1925
|
+
const model = session.modelRegistry.find(info.provider, info.id);
|
|
1926
|
+
if (!model) {
|
|
1927
|
+
return { ok: false, message: `Model not found: ${pattern}` };
|
|
1928
|
+
}
|
|
1929
|
+
try {
|
|
1930
|
+
await session.setModelTemporary(model);
|
|
1931
|
+
return {
|
|
1932
|
+
ok: true,
|
|
1933
|
+
message: `Switched to ${info.provider}/${info.id} (${info.name})`
|
|
1934
|
+
};
|
|
1935
|
+
} catch (err) {
|
|
1936
|
+
return {
|
|
1937
|
+
ok: false,
|
|
1938
|
+
message: `Switch failed: ${err instanceof Error ? err.message : String(err)}`
|
|
1939
|
+
};
|
|
1940
|
+
}
|
|
1941
|
+
}
|
|
1942
|
+
|
|
1943
|
+
// src/command/model-command.ts
|
|
1944
|
+
var MODEL_PREFIX = "/model";
|
|
1945
|
+
var MODELS_PREFIX = "/models";
|
|
1946
|
+
|
|
1947
|
+
class ModelQueryInvocation {
|
|
1948
|
+
async execute(ctx) {
|
|
1949
|
+
const entry = ctx.pool.get(ctx.chatId);
|
|
1950
|
+
if (!entry)
|
|
1951
|
+
return "No active session for this chat.";
|
|
1952
|
+
const info = currentModel(entry.session);
|
|
1953
|
+
if (!info)
|
|
1954
|
+
return "No model selected.";
|
|
1955
|
+
return `Current model: ${info.provider}/${info.id} (${info.name})`;
|
|
1956
|
+
}
|
|
1957
|
+
}
|
|
1958
|
+
|
|
1959
|
+
class ModelListInvocation {
|
|
1960
|
+
async execute(ctx) {
|
|
1961
|
+
const entry = ctx.pool.get(ctx.chatId);
|
|
1962
|
+
if (!entry)
|
|
1963
|
+
return "No active session for this chat.";
|
|
1964
|
+
const models = listModels(entry.session);
|
|
1965
|
+
if (models.length === 0)
|
|
1966
|
+
return "No models available.";
|
|
1967
|
+
const cur = currentModel(entry.session);
|
|
1968
|
+
const curKey = cur ? `${cur.provider}/${cur.id}` : "";
|
|
1969
|
+
const lines = [`Available models (${models.length}):`];
|
|
1970
|
+
for (const m of models) {
|
|
1971
|
+
const key = `${m.provider}/${m.id}`;
|
|
1972
|
+
const marker = key === curKey ? " \u2190 current" : "";
|
|
1973
|
+
lines.push(` ${key} (${m.name})${marker}`);
|
|
1666
1974
|
}
|
|
1667
|
-
|
|
1668
|
-
|
|
1975
|
+
lines.push("", "Switch with: /model provider/id");
|
|
1976
|
+
return lines.join(`
|
|
1977
|
+
`);
|
|
1669
1978
|
}
|
|
1670
1979
|
}
|
|
1671
|
-
|
|
1672
|
-
|
|
1673
|
-
|
|
1674
|
-
|
|
1675
|
-
|
|
1676
|
-
|
|
1980
|
+
|
|
1981
|
+
class ModelSwitchInvocation {
|
|
1982
|
+
pattern;
|
|
1983
|
+
constructor(pattern) {
|
|
1984
|
+
this.pattern = pattern;
|
|
1985
|
+
}
|
|
1986
|
+
async execute(ctx) {
|
|
1987
|
+
const entry = ctx.pool.get(ctx.chatId);
|
|
1988
|
+
if (!entry)
|
|
1989
|
+
return "No active session for this chat.";
|
|
1990
|
+
const result = await switchModel(this.pattern, entry.session);
|
|
1991
|
+
return result.message;
|
|
1992
|
+
}
|
|
1677
1993
|
}
|
|
1678
|
-
|
|
1679
|
-
|
|
1680
|
-
|
|
1681
|
-
|
|
1682
|
-
|
|
1683
|
-
|
|
1994
|
+
|
|
1995
|
+
class ModelCommand {
|
|
1996
|
+
name = "model";
|
|
1997
|
+
parse(text) {
|
|
1998
|
+
if (text === MODELS_PREFIX || text.startsWith(`${MODELS_PREFIX} `)) {
|
|
1999
|
+
return new ModelListInvocation;
|
|
2000
|
+
}
|
|
2001
|
+
if (text === MODEL_PREFIX) {
|
|
2002
|
+
return new ModelQueryInvocation;
|
|
2003
|
+
}
|
|
2004
|
+
if (text.startsWith(`${MODEL_PREFIX} `)) {
|
|
2005
|
+
const pattern = text.slice(MODEL_PREFIX.length + 1).trim();
|
|
2006
|
+
if (pattern)
|
|
2007
|
+
return new ModelSwitchInvocation(pattern);
|
|
2008
|
+
}
|
|
2009
|
+
return null;
|
|
2010
|
+
}
|
|
1684
2011
|
}
|
|
1685
2012
|
|
|
1686
2013
|
// src/bridge.ts
|
|
@@ -1690,171 +2017,207 @@ var RETRY_MS = 2000;
|
|
|
1690
2017
|
var MAX_SEND_RETRIES = 2;
|
|
1691
2018
|
var CHUNK_LIMIT = 2000;
|
|
1692
2019
|
var LOCK_PORT = 19821;
|
|
1693
|
-
|
|
1694
|
-
|
|
1695
|
-
|
|
1696
|
-
|
|
1697
|
-
|
|
1698
|
-
|
|
1699
|
-
|
|
1700
|
-
|
|
1701
|
-
|
|
1702
|
-
|
|
1703
|
-
});
|
|
1704
|
-
return true;
|
|
1705
|
-
} catch {
|
|
1706
|
-
return false;
|
|
2020
|
+
|
|
2021
|
+
class WeChatBridge {
|
|
2022
|
+
state = null;
|
|
2023
|
+
pollActive = false;
|
|
2024
|
+
lockServer = null;
|
|
2025
|
+
cleanupTimer = null;
|
|
2026
|
+
pool = null;
|
|
2027
|
+
commands = new CommandRegistry;
|
|
2028
|
+
constructor() {
|
|
2029
|
+
this.commands.register(new ModelCommand);
|
|
1707
2030
|
}
|
|
1708
|
-
|
|
1709
|
-
|
|
1710
|
-
|
|
1711
|
-
|
|
1712
|
-
|
|
2031
|
+
start() {
|
|
2032
|
+
const config = loadConfig();
|
|
2033
|
+
const creds = getCredentials();
|
|
2034
|
+
if (this.pollActive) {
|
|
2035
|
+
return this.state;
|
|
2036
|
+
}
|
|
2037
|
+
if (!this.acquireLock()) {
|
|
2038
|
+
logger.debug("Another poll loop is running (port lock held), skipping");
|
|
2039
|
+
return { running: false, config, creds, lastError: "another instance running" };
|
|
2040
|
+
}
|
|
2041
|
+
this.pollActive = true;
|
|
2042
|
+
const replyHandler = (chatId, text) => {
|
|
2043
|
+
sendTyping(creds, chatId, 2).catch(() => {});
|
|
2044
|
+
this.sendReply(creds, chatId, text).catch((err) => {
|
|
2045
|
+
logger.error(`[${chatId}] Reply send failed:`, err);
|
|
2046
|
+
});
|
|
2047
|
+
};
|
|
2048
|
+
this.pool = new SessionPool(config.maxSessions, replyHandler);
|
|
2049
|
+
this.state = { running: true, config, creds, lastError: null };
|
|
2050
|
+
logger.info("OMP-Wechat poll loop starting", {
|
|
2051
|
+
maxSessions: config.maxSessions,
|
|
2052
|
+
dmPolicy: config.dmPolicy,
|
|
2053
|
+
model: config.model ?? "(omp default)"
|
|
2054
|
+
});
|
|
2055
|
+
this.pollLoop(creds, this.state).catch((err) => {
|
|
2056
|
+
logger.error("Poll loop crashed:", err);
|
|
2057
|
+
this.state.running = false;
|
|
2058
|
+
this.state.lastError = String(err);
|
|
2059
|
+
this.pollActive = false;
|
|
2060
|
+
this.releaseLock();
|
|
2061
|
+
});
|
|
2062
|
+
cleanupStaleSessions();
|
|
2063
|
+
this.cleanupTimer = setInterval(() => cleanupStaleSessions(), 6 * 60 * 60 * 1000);
|
|
2064
|
+
return this.state;
|
|
1713
2065
|
}
|
|
1714
|
-
|
|
1715
|
-
|
|
1716
|
-
|
|
1717
|
-
|
|
1718
|
-
|
|
1719
|
-
|
|
2066
|
+
async stop() {
|
|
2067
|
+
this.pollActive = false;
|
|
2068
|
+
if (this.cleanupTimer) {
|
|
2069
|
+
clearInterval(this.cleanupTimer);
|
|
2070
|
+
this.cleanupTimer = null;
|
|
2071
|
+
}
|
|
2072
|
+
this.releaseLock();
|
|
2073
|
+
if (this.pool) {
|
|
2074
|
+
await this.pool.disposeAll();
|
|
2075
|
+
}
|
|
2076
|
+
logger.info("Poll loop stopped");
|
|
1720
2077
|
}
|
|
1721
|
-
|
|
1722
|
-
|
|
1723
|
-
return { running: false, config, creds, lastError: "another instance running" };
|
|
2078
|
+
getPoolStatus() {
|
|
2079
|
+
return this.pool?.getPoolStatus() ?? { count: 0, max: 0, chats: [] };
|
|
1724
2080
|
}
|
|
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) {
|
|
2081
|
+
acquireLock() {
|
|
1757
2082
|
try {
|
|
1758
|
-
|
|
1759
|
-
|
|
1760
|
-
|
|
2083
|
+
this.lockServer = Bun.serve({
|
|
2084
|
+
port: LOCK_PORT,
|
|
2085
|
+
hostname: "127.0.0.1",
|
|
2086
|
+
fetch() {
|
|
2087
|
+
return new Response("ok");
|
|
2088
|
+
}
|
|
2089
|
+
});
|
|
2090
|
+
return true;
|
|
2091
|
+
} catch {
|
|
2092
|
+
return false;
|
|
2093
|
+
}
|
|
2094
|
+
}
|
|
2095
|
+
releaseLock() {
|
|
2096
|
+
if (this.lockServer) {
|
|
2097
|
+
this.lockServer.stop(true);
|
|
2098
|
+
this.lockServer = null;
|
|
2099
|
+
}
|
|
2100
|
+
}
|
|
2101
|
+
async pollLoop(creds, state) {
|
|
2102
|
+
let failures = 0;
|
|
2103
|
+
logger.info("Long-poll started");
|
|
2104
|
+
while (this.pollActive) {
|
|
2105
|
+
try {
|
|
2106
|
+
const buf = loadSyncBuf();
|
|
2107
|
+
const resp = await getUpdates(creds, buf);
|
|
2108
|
+
if (resp.ret !== undefined && resp.ret !== 0) {
|
|
2109
|
+
failures++;
|
|
2110
|
+
logger.warn(`getupdates error ret=${resp.ret} errmsg=${resp.errmsg ?? ""} (${failures}/${MAX_FAILURES})`);
|
|
2111
|
+
if (failures >= MAX_FAILURES) {
|
|
2112
|
+
failures = 0;
|
|
2113
|
+
await Bun.sleep(BACKOFF_MS);
|
|
2114
|
+
} else {
|
|
2115
|
+
await Bun.sleep(RETRY_MS);
|
|
2116
|
+
}
|
|
2117
|
+
continue;
|
|
2118
|
+
}
|
|
2119
|
+
failures = 0;
|
|
2120
|
+
state.lastError = null;
|
|
2121
|
+
if (resp.get_updates_buf && resp.get_updates_buf !== buf) {
|
|
2122
|
+
saveSyncBuf(resp.get_updates_buf);
|
|
2123
|
+
}
|
|
2124
|
+
const msgs = resp.msgs ?? [];
|
|
2125
|
+
for (const msg of msgs) {
|
|
2126
|
+
await this.handleInbound(creds, msg).catch((err) => {
|
|
2127
|
+
logger.error("Message handler error:", err);
|
|
2128
|
+
});
|
|
2129
|
+
}
|
|
2130
|
+
} catch (err) {
|
|
1761
2131
|
failures++;
|
|
1762
|
-
|
|
2132
|
+
state.lastError = String(err);
|
|
2133
|
+
logger.error(`Poll error (${failures}/${MAX_FAILURES}):`, err);
|
|
1763
2134
|
if (failures >= MAX_FAILURES) {
|
|
1764
2135
|
failures = 0;
|
|
1765
2136
|
await Bun.sleep(BACKOFF_MS);
|
|
1766
2137
|
} else {
|
|
1767
2138
|
await Bun.sleep(RETRY_MS);
|
|
1768
2139
|
}
|
|
1769
|
-
continue;
|
|
1770
|
-
}
|
|
1771
|
-
failures = 0;
|
|
1772
|
-
state.lastError = null;
|
|
1773
|
-
if (resp.get_updates_buf && resp.get_updates_buf !== buf) {
|
|
1774
|
-
saveSyncBuf(resp.get_updates_buf);
|
|
1775
2140
|
}
|
|
1776
|
-
|
|
1777
|
-
|
|
1778
|
-
|
|
1779
|
-
|
|
2141
|
+
}
|
|
2142
|
+
}
|
|
2143
|
+
async handleInbound(creds, msg) {
|
|
2144
|
+
if (msg.message_type !== 1)
|
|
2145
|
+
return;
|
|
2146
|
+
const senderId = msg.from_user_id;
|
|
2147
|
+
if (!senderId)
|
|
2148
|
+
return;
|
|
2149
|
+
const contextToken = msg.context_token ?? "";
|
|
2150
|
+
const result = gate(senderId);
|
|
2151
|
+
if (result.action === "drop")
|
|
2152
|
+
return;
|
|
2153
|
+
if (result.action === "pair") {
|
|
2154
|
+
if (contextToken) {
|
|
2155
|
+
const lead = result.isResend ? "Still waiting for pairing" : "Pairing required";
|
|
2156
|
+
const text2 = `${lead} \u2014 approve in OMP with: /wechat pair ${result.code}`;
|
|
2157
|
+
await sendMessage(creds, senderId, text2, contextToken).catch((err) => {
|
|
2158
|
+
logger.warn("Pairing reply send failed:", err);
|
|
1780
2159
|
});
|
|
1781
2160
|
}
|
|
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
|
-
}
|
|
2161
|
+
return;
|
|
1792
2162
|
}
|
|
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);
|
|
2163
|
+
const text = extractInboundText(msg);
|
|
2164
|
+
if (!text)
|
|
2165
|
+
return;
|
|
2166
|
+
const dedupKey = makeDedupKey(senderId, msg.create_time_ms, text);
|
|
2167
|
+
if (dedupKey && isDuplicate(dedupKey)) {
|
|
2168
|
+
logger.info(`[${senderId}] Skipping duplicate message: ${text.slice(0, 80)}`);
|
|
2169
|
+
return;
|
|
2170
|
+
}
|
|
2171
|
+
const config = loadConfig();
|
|
2172
|
+
logger.info(`[${senderId}] Inbound (ts=${msg.create_time_ms ?? "n/a"}): ${text.slice(0, 80)}`);
|
|
2173
|
+
const invocation = this.commands.tryParse(text);
|
|
2174
|
+
if (invocation) {
|
|
2175
|
+
const reply = await invocation.execute({ pool: this.pool, config, chatId: senderId }).catch((err) => `Command failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
2176
|
+
await sendMessage(creds, senderId, reply, contextToken).catch((err) => {
|
|
2177
|
+
logger.warn(`[${senderId}] Command reply send failed:`, err);
|
|
1811
2178
|
});
|
|
2179
|
+
return;
|
|
2180
|
+
}
|
|
2181
|
+
await sendTyping(creds, senderId, 1).catch(() => {});
|
|
2182
|
+
try {
|
|
2183
|
+
await this.pool.prompt(senderId, contextToken, text, config);
|
|
2184
|
+
} catch (err) {
|
|
2185
|
+
logger.error(`[${senderId}] prompt failed:`, err);
|
|
2186
|
+
await this.sendReply(creds, senderId, "Processing failed, please try again.");
|
|
1812
2187
|
}
|
|
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
|
-
}
|
|
1827
|
-
}
|
|
1828
|
-
async function sendReply(creds, chatId, text) {
|
|
1829
|
-
const contextToken = getContextToken(chatId);
|
|
1830
|
-
if (!contextToken) {
|
|
1831
|
-
logger.warn(`[${chatId}] No context_token, cannot reply`);
|
|
1832
|
-
return;
|
|
1833
2188
|
}
|
|
1834
|
-
|
|
1835
|
-
|
|
1836
|
-
|
|
1837
|
-
|
|
1838
|
-
|
|
1839
|
-
|
|
1840
|
-
|
|
1841
|
-
|
|
1842
|
-
|
|
1843
|
-
|
|
1844
|
-
|
|
1845
|
-
|
|
2189
|
+
async sendReply(creds, chatId, text) {
|
|
2190
|
+
const contextToken = this.pool?.getContextToken(chatId) ?? "";
|
|
2191
|
+
if (!contextToken) {
|
|
2192
|
+
logger.warn(`[${chatId}] No context_token, cannot reply`);
|
|
2193
|
+
return;
|
|
2194
|
+
}
|
|
2195
|
+
const chunks = chunkText(text, CHUNK_LIMIT);
|
|
2196
|
+
for (const chunk of chunks) {
|
|
2197
|
+
const clientId = `omp-wechat-${Date.now()}-${randomBytes3(4).toString("hex")}`;
|
|
2198
|
+
let retries = 0;
|
|
2199
|
+
while (retries <= MAX_SEND_RETRIES) {
|
|
2200
|
+
try {
|
|
2201
|
+
await sendMessage(creds, chatId, chunk, contextToken, clientId);
|
|
2202
|
+
break;
|
|
2203
|
+
} catch (err) {
|
|
2204
|
+
retries++;
|
|
2205
|
+
if (retries > MAX_SEND_RETRIES) {
|
|
2206
|
+
logger.error(`[${chatId}] Send failed, dropping:`, err);
|
|
2207
|
+
return;
|
|
2208
|
+
}
|
|
2209
|
+
logger.warn(`[${chatId}] Send retry ${retries}/${MAX_SEND_RETRIES}:`, err);
|
|
2210
|
+
await Bun.sleep(1000 * retries);
|
|
1846
2211
|
}
|
|
1847
|
-
logger.warn(`[${chatId}] Send retry ${retries}/${MAX_SEND_RETRIES}:`, err);
|
|
1848
|
-
await Bun.sleep(1000 * retries);
|
|
1849
2212
|
}
|
|
1850
2213
|
}
|
|
1851
2214
|
}
|
|
1852
2215
|
}
|
|
1853
2216
|
|
|
1854
2217
|
// src/service.ts
|
|
1855
|
-
import { platform, homedir as
|
|
1856
|
-
import { join as
|
|
1857
|
-
import { existsSync as
|
|
2218
|
+
import { platform, homedir as homedir7 } from "os";
|
|
2219
|
+
import { join as join7 } from "path";
|
|
2220
|
+
import { existsSync as existsSync3, mkdirSync as mkdirSync6, writeFileSync as writeFileSync4, rmSync as rmSync2 } from "fs";
|
|
1858
2221
|
var PLIST_LABEL = "com.omp-wechat";
|
|
1859
2222
|
var SERVICE_NAME = "omp-wechat";
|
|
1860
2223
|
function detectPlatform() {
|
|
@@ -1866,13 +2229,13 @@ function detectPlatform() {
|
|
|
1866
2229
|
return "other";
|
|
1867
2230
|
}
|
|
1868
2231
|
function getLogDir() {
|
|
1869
|
-
return
|
|
2232
|
+
return join7(homedir7(), ".omp", "logs");
|
|
1870
2233
|
}
|
|
1871
2234
|
function resolveHostBinary() {
|
|
1872
2235
|
return process.execPath || "omp";
|
|
1873
2236
|
}
|
|
1874
2237
|
function plistPath() {
|
|
1875
|
-
return
|
|
2238
|
+
return join7(homedir7(), "Library", "LaunchAgents", `${PLIST_LABEL}.plist`);
|
|
1876
2239
|
}
|
|
1877
2240
|
function generatePlist() {
|
|
1878
2241
|
const omp = resolveHostBinary();
|
|
@@ -1914,21 +2277,21 @@ function generatePlist() {
|
|
|
1914
2277
|
<key>PATH</key>
|
|
1915
2278
|
<string>/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin</string>
|
|
1916
2279
|
<key>HOME</key>
|
|
1917
|
-
<string>${
|
|
2280
|
+
<string>${homedir7()}</string>
|
|
1918
2281
|
</dict>
|
|
1919
2282
|
</dict>
|
|
1920
2283
|
</plist>
|
|
1921
2284
|
`;
|
|
1922
2285
|
}
|
|
1923
2286
|
function installLaunchd() {
|
|
1924
|
-
const dir =
|
|
1925
|
-
|
|
1926
|
-
|
|
2287
|
+
const dir = join7(homedir7(), "Library", "LaunchAgents");
|
|
2288
|
+
mkdirSync6(dir, { recursive: true });
|
|
2289
|
+
mkdirSync6(getLogDir(), { recursive: true });
|
|
1927
2290
|
const plist = plistPath();
|
|
1928
|
-
if (
|
|
2291
|
+
if (existsSync3(plist)) {
|
|
1929
2292
|
Bun.spawnSync(["launchctl", "unload", plist], { stderr: "ignore" });
|
|
1930
2293
|
}
|
|
1931
|
-
|
|
2294
|
+
writeFileSync4(plist, generatePlist());
|
|
1932
2295
|
const result = Bun.spawnSync(["launchctl", "load", plist], { stderr: "inherit" });
|
|
1933
2296
|
if (result.exitCode !== 0) {
|
|
1934
2297
|
throw new Error("launchctl load failed");
|
|
@@ -1937,11 +2300,11 @@ function installLaunchd() {
|
|
|
1937
2300
|
}
|
|
1938
2301
|
function uninstallLaunchd() {
|
|
1939
2302
|
const plist = plistPath();
|
|
1940
|
-
if (!
|
|
2303
|
+
if (!existsSync3(plist)) {
|
|
1941
2304
|
throw new Error("No launchd service found (may not be installed)");
|
|
1942
2305
|
}
|
|
1943
2306
|
Bun.spawnSync(["launchctl", "unload", plist], { stderr: "inherit" });
|
|
1944
|
-
|
|
2307
|
+
rmSync2(plist);
|
|
1945
2308
|
}
|
|
1946
2309
|
function servicePath() {
|
|
1947
2310
|
return `/etc/systemd/system/${SERVICE_NAME}.service`;
|
|
@@ -1964,7 +2327,7 @@ StandardInput=null
|
|
|
1964
2327
|
Restart=always
|
|
1965
2328
|
RestartSec=10
|
|
1966
2329
|
|
|
1967
|
-
Environment=HOME=${
|
|
2330
|
+
Environment=HOME=${homedir7()}
|
|
1968
2331
|
Environment=PATH=/usr/local/bin:/usr/bin:/bin
|
|
1969
2332
|
|
|
1970
2333
|
StandardOutput=append:${logDir}/rpc.log
|
|
@@ -1973,7 +2336,7 @@ StandardError=append:${logDir}/rpc.log
|
|
|
1973
2336
|
NoNewPrivileges=true
|
|
1974
2337
|
ProtectSystem=strict
|
|
1975
2338
|
ProtectHome=read-only
|
|
1976
|
-
ReadWritePaths=${logDir} ${
|
|
2339
|
+
ReadWritePaths=${logDir} ${join7(homedir7(), ".omp-wechat")} ${join7(homedir7(), ".omp")}
|
|
1977
2340
|
PrivateTmp=true
|
|
1978
2341
|
|
|
1979
2342
|
[Install]
|
|
@@ -1982,17 +2345,17 @@ WantedBy=multi-user.target
|
|
|
1982
2345
|
}
|
|
1983
2346
|
function installSystemd() {
|
|
1984
2347
|
const svc = servicePath();
|
|
1985
|
-
|
|
1986
|
-
if (
|
|
2348
|
+
mkdirSync6(getLogDir(), { recursive: true });
|
|
2349
|
+
if (existsSync3(svc)) {
|
|
1987
2350
|
Bun.spawnSync(["sudo", "systemctl", "stop", SERVICE_NAME], { stderr: "inherit" });
|
|
1988
2351
|
}
|
|
1989
2352
|
const tmp = `/tmp/${SERVICE_NAME}.service`;
|
|
1990
|
-
|
|
2353
|
+
writeFileSync4(tmp, generateService());
|
|
1991
2354
|
let result = Bun.spawnSync(["sudo", "te", tmp, svc], { stderr: "inherit" });
|
|
1992
2355
|
if (result.exitCode !== 0) {
|
|
1993
2356
|
result = Bun.spawnSync(["sudo", "cp", tmp, svc], { stderr: "inherit" });
|
|
1994
2357
|
}
|
|
1995
|
-
|
|
2358
|
+
rmSync2(tmp);
|
|
1996
2359
|
if (result.exitCode !== 0) {
|
|
1997
2360
|
throw new Error("Failed to write service file (need sudo)");
|
|
1998
2361
|
}
|
|
@@ -2004,7 +2367,7 @@ function installSystemd() {
|
|
|
2004
2367
|
}
|
|
2005
2368
|
function uninstallSystemd() {
|
|
2006
2369
|
const svc = servicePath();
|
|
2007
|
-
if (!
|
|
2370
|
+
if (!existsSync3(svc)) {
|
|
2008
2371
|
throw new Error("No systemd service found (may not be installed)");
|
|
2009
2372
|
}
|
|
2010
2373
|
Bun.spawnSync(["sudo", "systemctl", "stop", SERVICE_NAME], { stderr: "inherit" });
|
|
@@ -2042,30 +2405,32 @@ function isServiceInstalled() {
|
|
|
2042
2405
|
const p = detectPlatform();
|
|
2043
2406
|
switch (p) {
|
|
2044
2407
|
case "darwin":
|
|
2045
|
-
return
|
|
2408
|
+
return existsSync3(plistPath());
|
|
2046
2409
|
case "linux":
|
|
2047
|
-
return
|
|
2410
|
+
return existsSync3(servicePath());
|
|
2048
2411
|
default:
|
|
2049
2412
|
return false;
|
|
2050
2413
|
}
|
|
2051
2414
|
}
|
|
2052
2415
|
|
|
2053
2416
|
// src/index.ts
|
|
2417
|
+
var bridge = null;
|
|
2054
2418
|
var daemonState = null;
|
|
2055
2419
|
function wechatExtension(pi) {
|
|
2056
2420
|
if (pi.pi && typeof pi.setLabel === "function") {
|
|
2057
2421
|
pi.setLabel("OMP-Wechat Bridge");
|
|
2058
2422
|
}
|
|
2059
2423
|
pi.on("session_start", async (_event, ctx) => {
|
|
2060
|
-
|
|
2424
|
+
bridge = new WeChatBridge;
|
|
2425
|
+
daemonState = bridge.start();
|
|
2061
2426
|
if (daemonState.running) {
|
|
2062
2427
|
ctx.ui.notify("WeChat bridge started", "info");
|
|
2063
2428
|
} else {
|
|
2064
|
-
|
|
2429
|
+
logger.debug("WeChat bridge: another instance holds the lock, starting failover watch");
|
|
2065
2430
|
ctx.setInterval(() => {
|
|
2066
2431
|
if (daemonState?.running)
|
|
2067
2432
|
return;
|
|
2068
|
-
daemonState =
|
|
2433
|
+
daemonState = bridge.start();
|
|
2069
2434
|
if (daemonState.running) {
|
|
2070
2435
|
ctx.ui.notify("WeChat bridge: took over from failed instance", "info");
|
|
2071
2436
|
}
|
|
@@ -2073,7 +2438,7 @@ function wechatExtension(pi) {
|
|
|
2073
2438
|
}
|
|
2074
2439
|
});
|
|
2075
2440
|
pi.registerCommand("wechat", {
|
|
2076
|
-
description: "WeChat bridge: login, status, pair, allow, revoke, list, stop, install, uninstall",
|
|
2441
|
+
description: "WeChat bridge: login, status, pair, allow, revoke, list, stop, clear, install, uninstall",
|
|
2077
2442
|
handler: async (args, ctx) => {
|
|
2078
2443
|
const parts = args.trim().split(/\s+/);
|
|
2079
2444
|
const sub = parts[0] ?? "";
|
|
@@ -2083,18 +2448,18 @@ function wechatExtension(pi) {
|
|
|
2083
2448
|
case "status": {
|
|
2084
2449
|
const running = daemonState?.running ?? false;
|
|
2085
2450
|
const svc = isServiceInstalled();
|
|
2086
|
-
const
|
|
2451
|
+
const pool = bridge?.getPoolStatus() ?? { count: 0, max: 0, chats: [] };
|
|
2087
2452
|
const allowed = listAllowed();
|
|
2088
2453
|
const lines = [
|
|
2089
2454
|
`Poll loop: ${running ? "running" : "stopped"}`,
|
|
2090
2455
|
`Boot service: ${svc ? "installed" : "not installed"}`,
|
|
2091
2456
|
`Last error: ${daemonState?.lastError ?? "none"}`,
|
|
2092
|
-
`Session pool: ${
|
|
2457
|
+
`Session pool: ${pool.count}/${pool.max}`,
|
|
2093
2458
|
`Authorized users: ${allowed.length}`
|
|
2094
2459
|
];
|
|
2095
|
-
if (
|
|
2460
|
+
if (pool.chats.length > 0) {
|
|
2096
2461
|
lines.push("Active chats:");
|
|
2097
|
-
for (const chat of
|
|
2462
|
+
for (const chat of pool.chats) {
|
|
2098
2463
|
const ago = Math.round((Date.now() - chat.lastActive) / 1000);
|
|
2099
2464
|
lines.push(` ${chat.chatId} (${ago}s ago)`);
|
|
2100
2465
|
}
|
|
@@ -2146,7 +2511,10 @@ function wechatExtension(pi) {
|
|
|
2146
2511
|
break;
|
|
2147
2512
|
}
|
|
2148
2513
|
case "stop": {
|
|
2149
|
-
|
|
2514
|
+
if (bridge) {
|
|
2515
|
+
await bridge.stop();
|
|
2516
|
+
bridge = null;
|
|
2517
|
+
}
|
|
2150
2518
|
daemonState = null;
|
|
2151
2519
|
ctx.ui.notify("WeChat bridge stopped", "info");
|
|
2152
2520
|
break;
|
|
@@ -2155,7 +2523,7 @@ function wechatExtension(pi) {
|
|
|
2155
2523
|
try {
|
|
2156
2524
|
const r = installService();
|
|
2157
2525
|
ctx.ui.notify(`Boot service installed (${r.platform}): ${r.path}`, "info");
|
|
2158
|
-
|
|
2526
|
+
logger.info(`Service installed on ${r.platform} at ${r.path}
|
|
2159
2527
|
` + `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
2528
|
} catch (err) {
|
|
2161
2529
|
ctx.ui.notify(`Install failed: ${err}`, "error");
|
|
@@ -2171,8 +2539,18 @@ function wechatExtension(pi) {
|
|
|
2171
2539
|
}
|
|
2172
2540
|
break;
|
|
2173
2541
|
}
|
|
2542
|
+
case "clear": {
|
|
2543
|
+
if (bridge) {
|
|
2544
|
+
await bridge.stop();
|
|
2545
|
+
bridge = null;
|
|
2546
|
+
}
|
|
2547
|
+
daemonState = null;
|
|
2548
|
+
const removed = clearAllSessions();
|
|
2549
|
+
ctx.ui.notify(`Cleared ${removed} session(s)`, "info");
|
|
2550
|
+
break;
|
|
2551
|
+
}
|
|
2174
2552
|
default:
|
|
2175
|
-
ctx.ui.notify("Unknown subcommand. Available: login, status, pair, allow, revoke, list, stop, install, uninstall", "warn");
|
|
2553
|
+
ctx.ui.notify("Unknown subcommand. Available: login, status, pair, allow, revoke, list, stop, clear, install, uninstall", "warn");
|
|
2176
2554
|
}
|
|
2177
2555
|
}
|
|
2178
2556
|
});
|