omp-wechat 1.5.0 → 1.7.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/README.md +38 -3
- package/dist/index.js +457 -34
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -32,6 +32,9 @@ For boot-time persistence, install a launchd/systemd service via `/wechat instal
|
|
|
32
32
|
- **Failover**: 30s timer takes over automatically if the lock holder crashes
|
|
33
33
|
- **Bidirectional**: receive and reply to WeChat text messages
|
|
34
34
|
- **Image recognition**: inbound images are downloaded from WeChat CDN, AES-decrypted, and passed to the vision model
|
|
35
|
+
- **Inbound file content**: text files (`.txt/.md/.csv/.json/.py/…`) sent by the user are downloaded, decrypted, and their content is handed to the AI; binary files arrive as metadata placeholders
|
|
36
|
+
- **Markdown stripping**: AI replies are stripped of markdown formatting before delivery — WeChat renders plain text only
|
|
37
|
+
- **File delivery**: AI-generated files (documents, images, PDFs, spreadsheets, code…) written to a per-chat outbox directory are automatically uploaded to the WeChat CDN (AES-128-ECB encrypted) and sent back to the user as file/image messages
|
|
35
38
|
- **Per-chat sessions**: each WeChat chat gets an independent AI session (concurrent, isolated)
|
|
36
39
|
- **LRU pool**: caps memory usage by evicting least-recently-used sessions (default: 50)
|
|
37
40
|
- **Typing indicator**: native WeChat "Typing..." shown during AI processing
|
|
@@ -110,11 +113,39 @@ systemPrompt: |
|
|
|
110
113
|
| `model` | OMP default | Default model: role alias (`@smol`, `@slow`) or `provider/id` |
|
|
111
114
|
| `cwd` | `process.cwd()` | Working directory for AI sessions — determines which project context (CLAUDE.md, .omp/) the agent loads |
|
|
112
115
|
| `systemPrompt` | Built-in | System prompt for WeChat chat sessions |
|
|
116
|
+
| `outboxDir` | `~/.omp-wechat/outbox` | Base directory for per-chat outboxes (see File Delivery below) |
|
|
117
|
+
| `maxFileSizeMb` | `100` | Max file size delivered via WeChat, in MB |
|
|
118
|
+
| `sendFiles` | `true` | Set to `false` to disable file delivery entirely |
|
|
113
119
|
|
|
114
120
|
> **Model 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.
|
|
115
121
|
>
|
|
116
122
|
> **Image recognition** requires a vision model role configured in OMP (e.g. `omp model role vision xfyun/xopkimik25`). If no vision role is set, inbound images are skipped — only the text placeholder is sent to the AI.
|
|
117
123
|
|
|
124
|
+
## File Delivery
|
|
125
|
+
|
|
126
|
+
Ask the AI to generate a file (report, PDF, spreadsheet, image, code, …) — the finished artifact is sent back to you as a WeChat file or image message automatically.
|
|
127
|
+
|
|
128
|
+
### How it works
|
|
129
|
+
|
|
130
|
+
1. Every chat gets a private **outbox directory**: `~/.omp-wechat/outbox/<wxid>/`
|
|
131
|
+
2. The session's system prompt teaches the AI to write final deliverables there (and only there)
|
|
132
|
+
3. When the AI finishes a turn, the plugin diffs the outbox and uploads every new/changed file:
|
|
133
|
+
- `getuploadurl` → CDN upload (AES-128-ECB encrypted) → `sendmessage` as a file item
|
|
134
|
+
- Images (`.png/.jpg/.jpeg/.gif/.webp/.bmp`) are auto-routed to image messages; everything else goes as a file
|
|
135
|
+
4. Delivered files are removed from the outbox; failed or oversized files stay on disk and a text notice is sent instead
|
|
136
|
+
|
|
137
|
+
### Example
|
|
138
|
+
|
|
139
|
+
> **You**: Please generate a weekly report as a PDF
|
|
140
|
+
> **AI**: *(writes `weekly-report.pdf` to the outbox)* Done — the report is attached.
|
|
141
|
+
> **You** (WeChat): receives the text reply **plus** the `weekly-report.pdf` file
|
|
142
|
+
|
|
143
|
+
### Limitations
|
|
144
|
+
|
|
145
|
+
- Files are sent via `sendmessage` using the latest inbound `context_token`, same as text replies — an expired token (long-running task, restart) may fail delivery until you message again
|
|
146
|
+
- Files larger than `maxFileSizeMb` are skipped with a text notice
|
|
147
|
+
- Only files in the per-chat outbox are ever sent — the AI cannot exfiltrate arbitrary paths
|
|
148
|
+
|
|
118
149
|
## Slash Commands
|
|
119
150
|
|
|
120
151
|
| Command | Description |
|
|
@@ -174,9 +205,11 @@ OMP-Wechat/
|
|
|
174
205
|
│ ├── ilink/
|
|
175
206
|
│ │ ├── types.ts # iLink Bot API type definitions
|
|
176
207
|
│ │ ├── client.ts # iLink API client (poll/send/typing)
|
|
208
|
+
│ │ ├── upload.ts # Outbound media: CDN upload + file/image send
|
|
209
|
+
│ │ ├── cdn.ts # CDN media download/upload + AES-128-ECB crypto
|
|
177
210
|
│ │ └── login.ts # QR code login flow
|
|
178
211
|
│ ├── engine/
|
|
179
|
-
│ │ ├── session.ts # AI session creation + reply subscription
|
|
212
|
+
│ │ ├── session.ts # AI session creation + reply/outbox subscription
|
|
180
213
|
│ │ └── pool.ts # Session pool (LRU eviction, concurrency)
|
|
181
214
|
│ ├── access/
|
|
182
215
|
│ │ └── control.ts # Access control (pairing/allowlist/disabled)
|
|
@@ -191,15 +224,17 @@ OMP-Wechat/
|
|
|
191
224
|
|
|
192
225
|
## Limitations
|
|
193
226
|
|
|
194
|
-
- **Reply-only**: iLink requires `context_token` from an inbound message; you cannot initiate conversations
|
|
227
|
+
- **Reply-only**: iLink requires `context_token` from an inbound message; you cannot initiate conversations (applies to text and file replies alike)
|
|
195
228
|
- **1:1 only**: iLink Bot API does not support group chats
|
|
196
229
|
- **Single instance**: iLink allows only one bot connection per account
|
|
197
|
-
- **Media**: inbound images
|
|
230
|
+
- **Media**: inbound images and text files are fully processed (vision model / content extraction); voice (unless the server provides transcription) and video remain as placeholders
|
|
198
231
|
|
|
199
232
|
## Roadmap
|
|
200
233
|
|
|
201
234
|
- [x] **Phase 2a**: Inbound image support (CDN download + AES decrypt + vision model)
|
|
202
235
|
- [ ] **Phase 2b**: Voice transcription / video support
|
|
236
|
+
- [x] **Phase 2c**: Outbound file delivery — AI-generated files sent back via WeChat (CDN upload + file/image messages)
|
|
237
|
+
- [x] **Phase 2d**: Inbound file content extraction + markdown stripping for replies
|
|
203
238
|
- [x] **Phase 3**: Persistent sessions — `SessionManager.continueRecent()` per chat, context survives restarts
|
|
204
239
|
- [x] **Phase 4**: Per-chat model selection — `/model` `/models` chat commands for manual switching
|
|
205
240
|
- [ ] **Phase 5**: Fine-grained permissions (per-user tool restrictions, bash approval via WeChat)
|
package/dist/index.js
CHANGED
|
@@ -1326,6 +1326,13 @@ function saveSyncBuf(buf) {
|
|
|
1326
1326
|
mkdirSync2(STATE_DIR, { recursive: true });
|
|
1327
1327
|
writeFileSync(SYNC_BUF_FILE, buf);
|
|
1328
1328
|
}
|
|
1329
|
+
function formatSize(bytes) {
|
|
1330
|
+
if (bytes < 1024)
|
|
1331
|
+
return `${bytes}B`;
|
|
1332
|
+
if (bytes < 1024 * 1024)
|
|
1333
|
+
return `${(bytes / 1024).toFixed(1)}KB`;
|
|
1334
|
+
return `${(bytes / (1024 * 1024)).toFixed(1)}MB`;
|
|
1335
|
+
}
|
|
1329
1336
|
function extractInboundText(msg, includeImagePlaceholder = true) {
|
|
1330
1337
|
const items = msg.item_list ?? [];
|
|
1331
1338
|
const parts = [];
|
|
@@ -1343,7 +1350,11 @@ function extractInboundText(msg, includeImagePlaceholder = true) {
|
|
|
1343
1350
|
parts.push(item.voice_item?.text ?? "(voice)");
|
|
1344
1351
|
break;
|
|
1345
1352
|
case 4:
|
|
1346
|
-
|
|
1353
|
+
{
|
|
1354
|
+
const name = item.file_item?.file_name ?? "unknown";
|
|
1355
|
+
const len = parseInt(item.file_item?.len ?? "", 10);
|
|
1356
|
+
parts.push(`(file: ${name}${Number.isFinite(len) && len > 0 ? ", " + formatSize(len) : ""})`);
|
|
1357
|
+
}
|
|
1347
1358
|
break;
|
|
1348
1359
|
case 5:
|
|
1349
1360
|
parts.push("(video)");
|
|
@@ -1572,7 +1583,9 @@ async function pollQrStatus(qrcode, baseUrl) {
|
|
|
1572
1583
|
}
|
|
1573
1584
|
|
|
1574
1585
|
// src/bridge.ts
|
|
1575
|
-
import { randomBytes as
|
|
1586
|
+
import { randomBytes as randomBytes5 } from "crypto";
|
|
1587
|
+
import { rmSync as rmSync2 } from "fs";
|
|
1588
|
+
import { basename as basename3, extname as extname2 } from "path";
|
|
1576
1589
|
|
|
1577
1590
|
// src/config.ts
|
|
1578
1591
|
import { homedir as homedir4 } from "os";
|
|
@@ -1607,6 +1620,15 @@ function loadConfig() {
|
|
|
1607
1620
|
config.cwd = expandTilde(parsed.cwd);
|
|
1608
1621
|
if (parsed.systemPrompt)
|
|
1609
1622
|
config.systemPrompt = parsed.systemPrompt;
|
|
1623
|
+
if (parsed.outboxDir)
|
|
1624
|
+
config.outboxDir = expandTilde(parsed.outboxDir);
|
|
1625
|
+
if (parsed.maxFileSizeMb) {
|
|
1626
|
+
const mb = parseInt(parsed.maxFileSizeMb, 10);
|
|
1627
|
+
if (Number.isFinite(mb) && mb > 0)
|
|
1628
|
+
config.maxFileSizeMb = mb;
|
|
1629
|
+
}
|
|
1630
|
+
if (parsed.sendFiles === "false")
|
|
1631
|
+
config.sendFiles = false;
|
|
1610
1632
|
}
|
|
1611
1633
|
} catch (err) {
|
|
1612
1634
|
logger.warn("Failed to load config.yml, using defaults", err);
|
|
@@ -1742,7 +1764,7 @@ function isDuplicate(key) {
|
|
|
1742
1764
|
}
|
|
1743
1765
|
|
|
1744
1766
|
// src/ilink/cdn.ts
|
|
1745
|
-
import { createDecipheriv } from "crypto";
|
|
1767
|
+
import { createCipheriv, createDecipheriv, randomBytes as randomBytes3 } from "crypto";
|
|
1746
1768
|
var CDN_BASE_URL = "https://novac2c.cdn.weixin.qq.com/c2c";
|
|
1747
1769
|
function parseAesKey(aeskeyHex, aesKeyBase64) {
|
|
1748
1770
|
if (aeskeyHex && /^[0-9a-fA-F]{32}$/.test(aeskeyHex)) {
|
|
@@ -1762,6 +1784,13 @@ function decryptAesEcb(ciphertext, key) {
|
|
|
1762
1784
|
const decipher = createDecipheriv("aes-128-ecb", key, null);
|
|
1763
1785
|
return Buffer.concat([decipher.update(ciphertext), decipher.final()]);
|
|
1764
1786
|
}
|
|
1787
|
+
function encryptAesEcb(plaintext, key) {
|
|
1788
|
+
const cipher = createCipheriv("aes-128-ecb", key, null);
|
|
1789
|
+
return Buffer.concat([cipher.update(plaintext), cipher.final()]);
|
|
1790
|
+
}
|
|
1791
|
+
function generateAesKey() {
|
|
1792
|
+
return randomBytes3(16);
|
|
1793
|
+
}
|
|
1765
1794
|
function buildCdnUrl(encryptQueryParam, fullUrl) {
|
|
1766
1795
|
if (fullUrl)
|
|
1767
1796
|
return fullUrl;
|
|
@@ -1796,22 +1825,201 @@ async function downloadAndDecrypt(encryptQueryParam, fullUrl, aeskeyHex, aesKeyB
|
|
|
1796
1825
|
}
|
|
1797
1826
|
}
|
|
1798
1827
|
|
|
1828
|
+
// src/ilink/upload.ts
|
|
1829
|
+
import { createHash, randomBytes as randomBytes4 } from "crypto";
|
|
1830
|
+
import { readFileSync as readFileSync5, statSync as statSync2 } from "fs";
|
|
1831
|
+
import { basename as basename2, extname } from "path";
|
|
1832
|
+
var CDN_BASE_URL2 = "https://novac2c.cdn.weixin.qq.com/c2c";
|
|
1833
|
+
var UPLOAD_MAX_RETRIES = 3;
|
|
1834
|
+
var SEND_MAX_RETRIES = 2;
|
|
1835
|
+
var IMAGE_EXTENSIONS = {
|
|
1836
|
+
".png": true,
|
|
1837
|
+
".jpg": true,
|
|
1838
|
+
".jpeg": true,
|
|
1839
|
+
".gif": true,
|
|
1840
|
+
".webp": true,
|
|
1841
|
+
".bmp": true
|
|
1842
|
+
};
|
|
1843
|
+
function mediaTypeFor(fileName) {
|
|
1844
|
+
return IMAGE_EXTENSIONS[extname(fileName).toLowerCase()] ? 1 /* IMAGE */ : 3 /* FILE */;
|
|
1845
|
+
}
|
|
1846
|
+
async function uploadAndSendFile(creds, to, contextToken, filePath, maxSizeBytes = 100 * 1024 * 1024) {
|
|
1847
|
+
let size;
|
|
1848
|
+
try {
|
|
1849
|
+
size = statSync2(filePath).size;
|
|
1850
|
+
} catch (err) {
|
|
1851
|
+
return { status: "error", error: `cannot stat: ${err instanceof Error ? err.message : String(err)}` };
|
|
1852
|
+
}
|
|
1853
|
+
if (size <= 0) {
|
|
1854
|
+
return { status: "error", error: "empty file" };
|
|
1855
|
+
}
|
|
1856
|
+
if (size > maxSizeBytes) {
|
|
1857
|
+
return { status: "too-large", bytes: size, maxBytes: maxSizeBytes };
|
|
1858
|
+
}
|
|
1859
|
+
const fileName = basename2(filePath);
|
|
1860
|
+
const mediaType = mediaTypeFor(fileName);
|
|
1861
|
+
const data = readFileSync5(filePath);
|
|
1862
|
+
try {
|
|
1863
|
+
const aesKey = generateAesKey();
|
|
1864
|
+
const ciphertext = encryptAesEcb(data, aesKey);
|
|
1865
|
+
const filekey = randomBytes4(16).toString("hex");
|
|
1866
|
+
const rawMd5 = createHash("md5").update(data).digest("hex");
|
|
1867
|
+
const uploadParams = await apiFetch(creds, "ilink/bot/getuploadurl", {
|
|
1868
|
+
filekey,
|
|
1869
|
+
media_type: mediaType,
|
|
1870
|
+
to_user_id: to,
|
|
1871
|
+
rawsize: data.length,
|
|
1872
|
+
rawfilemd5: rawMd5,
|
|
1873
|
+
filesize: ciphertext.length,
|
|
1874
|
+
no_need_thumb: true,
|
|
1875
|
+
aeskey: aesKey.toString("hex"),
|
|
1876
|
+
base_info: { channel_version: "0.1.0" }
|
|
1877
|
+
}, 15000);
|
|
1878
|
+
const uploadFullUrl = uploadParams.upload_full_url?.trim();
|
|
1879
|
+
if (!uploadFullUrl && !uploadParams.upload_param) {
|
|
1880
|
+
return { status: "error", error: "getuploadurl returned no upload URL" };
|
|
1881
|
+
}
|
|
1882
|
+
const uploadUrl = uploadFullUrl || `${CDN_BASE_URL2}/upload?encrypted_query_param=${encodeURIComponent(uploadParams.upload_param)}&filekey=${encodeURIComponent(filekey)}`;
|
|
1883
|
+
let encryptQueryParam;
|
|
1884
|
+
let lastError;
|
|
1885
|
+
for (let attempt = 1;attempt <= UPLOAD_MAX_RETRIES; attempt++) {
|
|
1886
|
+
try {
|
|
1887
|
+
const resp = await fetch(uploadUrl, {
|
|
1888
|
+
method: "POST",
|
|
1889
|
+
headers: { "Content-Type": "application/octet-stream" },
|
|
1890
|
+
body: new Uint8Array(ciphertext),
|
|
1891
|
+
signal: AbortSignal.timeout(60000)
|
|
1892
|
+
});
|
|
1893
|
+
if (resp.status >= 400 && resp.status < 500) {
|
|
1894
|
+
return {
|
|
1895
|
+
status: "error",
|
|
1896
|
+
error: `CDN upload client error ${resp.status}: ${resp.headers.get("x-error-message") ?? "rejected"}`
|
|
1897
|
+
};
|
|
1898
|
+
}
|
|
1899
|
+
if (!resp.ok) {
|
|
1900
|
+
throw new Error(`CDN upload server error: ${resp.status}`);
|
|
1901
|
+
}
|
|
1902
|
+
encryptQueryParam = resp.headers.get("x-encrypted-param") ?? undefined;
|
|
1903
|
+
if (!encryptQueryParam) {
|
|
1904
|
+
throw new Error("CDN upload response missing x-encrypted-param header");
|
|
1905
|
+
}
|
|
1906
|
+
break;
|
|
1907
|
+
} catch (err) {
|
|
1908
|
+
lastError = err;
|
|
1909
|
+
if (attempt < UPLOAD_MAX_RETRIES) {
|
|
1910
|
+
logger.warn(`CDN upload attempt ${attempt}/${UPLOAD_MAX_RETRIES} failed, retrying:`, err);
|
|
1911
|
+
await Bun.sleep(1000 * attempt);
|
|
1912
|
+
}
|
|
1913
|
+
}
|
|
1914
|
+
}
|
|
1915
|
+
if (!encryptQueryParam) {
|
|
1916
|
+
return {
|
|
1917
|
+
status: "error",
|
|
1918
|
+
error: `CDN upload failed after ${UPLOAD_MAX_RETRIES} attempts: ${lastError instanceof Error ? lastError.message : String(lastError)}`
|
|
1919
|
+
};
|
|
1920
|
+
}
|
|
1921
|
+
const media = {
|
|
1922
|
+
encrypt_query_param: encryptQueryParam,
|
|
1923
|
+
aes_key: aesKey.toString("base64"),
|
|
1924
|
+
encrypt_type: 1
|
|
1925
|
+
};
|
|
1926
|
+
const item = mediaType === 1 /* IMAGE */ ? {
|
|
1927
|
+
type: 2,
|
|
1928
|
+
image_item: { media, mid_size: ciphertext.length }
|
|
1929
|
+
} : {
|
|
1930
|
+
type: 4,
|
|
1931
|
+
file_item: {
|
|
1932
|
+
media,
|
|
1933
|
+
file_name: fileName,
|
|
1934
|
+
md5: rawMd5,
|
|
1935
|
+
len: String(data.length)
|
|
1936
|
+
}
|
|
1937
|
+
};
|
|
1938
|
+
let lastSendError;
|
|
1939
|
+
for (let attempt = 0;attempt <= SEND_MAX_RETRIES; attempt++) {
|
|
1940
|
+
try {
|
|
1941
|
+
await apiFetch(creds, "ilink/bot/sendmessage", {
|
|
1942
|
+
msg: {
|
|
1943
|
+
from_user_id: "",
|
|
1944
|
+
to_user_id: to,
|
|
1945
|
+
client_id: `omp-wechat-${Date.now()}-${randomBytes4(4).toString("hex")}`,
|
|
1946
|
+
message_type: 2,
|
|
1947
|
+
message_state: 2,
|
|
1948
|
+
item_list: [item],
|
|
1949
|
+
context_token: contextToken
|
|
1950
|
+
},
|
|
1951
|
+
base_info: { channel_version: "0.1.0" }
|
|
1952
|
+
}, 15000);
|
|
1953
|
+
logger.info(`Sent ${mediaType === 1 /* IMAGE */ ? "image" : "file"} to ${to}: ${fileName} (${data.length} bytes)`);
|
|
1954
|
+
return { status: "sent", mediaType, bytes: data.length };
|
|
1955
|
+
} catch (err) {
|
|
1956
|
+
lastSendError = err;
|
|
1957
|
+
if (attempt < SEND_MAX_RETRIES) {
|
|
1958
|
+
logger.warn(`Send retry ${attempt + 1}/${SEND_MAX_RETRIES}:`, err);
|
|
1959
|
+
await Bun.sleep(1000 * (attempt + 1));
|
|
1960
|
+
}
|
|
1961
|
+
}
|
|
1962
|
+
}
|
|
1963
|
+
return {
|
|
1964
|
+
status: "error",
|
|
1965
|
+
error: `send failed: ${lastSendError instanceof Error ? lastSendError.message : String(lastSendError)}`
|
|
1966
|
+
};
|
|
1967
|
+
} catch (err) {
|
|
1968
|
+
return { status: "error", error: err instanceof Error ? err.message : String(err) };
|
|
1969
|
+
}
|
|
1970
|
+
}
|
|
1971
|
+
|
|
1972
|
+
// src/utils/markdown.ts
|
|
1973
|
+
function stripMarkdown(text) {
|
|
1974
|
+
let result = text;
|
|
1975
|
+
result = result.replace(/```[^\n]*\n?([\s\S]*?)```/g, (_match, code) => code.trim());
|
|
1976
|
+
result = result.replace(/`([^`]+)`/g, "$1");
|
|
1977
|
+
result = result.replace(/!\[[^\]]*\]\([^)]*\)/g, "");
|
|
1978
|
+
result = result.replace(/\[([^\]]+)\]\([^)]*\)/g, "$1");
|
|
1979
|
+
result = result.replace(/^\|[\s:|-]+\|$/gm, "");
|
|
1980
|
+
result = result.replace(/^\|(.+)\|$/gm, (_match, inner) => inner.split("|").map((cell) => cell.trim()).join(" "));
|
|
1981
|
+
result = result.replace(/^#{1,6}\s+/gm, "");
|
|
1982
|
+
result = result.replace(/^[-*_]{3,}\s*$/gm, "");
|
|
1983
|
+
result = result.replace(/\*\*\*(.+?)\*\*\*/g, "$1");
|
|
1984
|
+
result = result.replace(/\*\*(.+?)\*\*/g, "$1");
|
|
1985
|
+
result = result.replace(/\*(.+?)\*/g, "$1");
|
|
1986
|
+
result = result.replace(/___(.+?)___/g, "$1");
|
|
1987
|
+
result = result.replace(/__(.+?)__/g, "$1");
|
|
1988
|
+
result = result.replace(/_(.+?)_/g, "$1");
|
|
1989
|
+
result = result.replace(/~~(.+?)~~/g, "$1");
|
|
1990
|
+
result = result.replace(/^>\s?/gm, "");
|
|
1991
|
+
result = result.replace(/^[\s]*[-*+]\s+/gm, "\u2022 ");
|
|
1992
|
+
result = result.replace(/^[\s]*\d+\.\s+/gm, "");
|
|
1993
|
+
result = result.replace(/<[^>]+>/g, "");
|
|
1994
|
+
result = result.replace(/\n{3,}/g, `
|
|
1995
|
+
|
|
1996
|
+
`);
|
|
1997
|
+
return result.trim();
|
|
1998
|
+
}
|
|
1999
|
+
|
|
1799
2000
|
// src/engine/session.ts
|
|
1800
2001
|
import { createAgentSession, SessionManager } from "@oh-my-pi/pi-coding-agent";
|
|
1801
2002
|
|
|
1802
2003
|
// src/engine/session-store.ts
|
|
1803
2004
|
import { join as join7 } from "path";
|
|
1804
2005
|
import { homedir as homedir6 } from "os";
|
|
1805
|
-
import { existsSync as existsSync2, mkdirSync as mkdirSync5, readdirSync as readdirSync2, statSync as
|
|
2006
|
+
import { existsSync as existsSync2, mkdirSync as mkdirSync5, readdirSync as readdirSync2, statSync as statSync3, rmSync } from "fs";
|
|
1806
2007
|
var STATE_DIR4 = join7(homedir6(), ".omp-wechat");
|
|
1807
2008
|
var SESSIONS_DIR = join7(STATE_DIR4, "sessions");
|
|
2009
|
+
var DEFAULT_OUTBOX_BASE = join7(STATE_DIR4, "outbox");
|
|
1808
2010
|
var STALE_THRESHOLD_MS = 30 * 24 * 60 * 60 * 1000;
|
|
1809
2011
|
function sanitizeChatId(chatId) {
|
|
1810
2012
|
return chatId.replace(/[^a-zA-Z0-9_@.-]/g, "_").slice(0, 200);
|
|
1811
2013
|
}
|
|
2014
|
+
function sanitizeOutboxChatId(chatId) {
|
|
2015
|
+
return chatId.replace(/[^a-zA-Z0-9_-]/g, "_");
|
|
2016
|
+
}
|
|
1812
2017
|
function sessionDirFor(chatId) {
|
|
1813
2018
|
return join7(SESSIONS_DIR, sanitizeChatId(chatId));
|
|
1814
2019
|
}
|
|
2020
|
+
function outboxDirFor(chatId, outboxBase) {
|
|
2021
|
+
return join7(outboxBase ?? DEFAULT_OUTBOX_BASE, sanitizeOutboxChatId(chatId));
|
|
2022
|
+
}
|
|
1815
2023
|
function ensureSessionsDir() {
|
|
1816
2024
|
mkdirSync5(SESSIONS_DIR, { recursive: true, mode: 448 });
|
|
1817
2025
|
}
|
|
@@ -1827,6 +2035,54 @@ function removeSessionDir(chatId) {
|
|
|
1827
2035
|
return false;
|
|
1828
2036
|
}
|
|
1829
2037
|
}
|
|
2038
|
+
function removeOutboxDir(chatId, outboxBase) {
|
|
2039
|
+
const dir = outboxDirFor(chatId, outboxBase);
|
|
2040
|
+
if (!existsSync2(dir))
|
|
2041
|
+
return false;
|
|
2042
|
+
try {
|
|
2043
|
+
rmSync(dir, { recursive: true, force: true });
|
|
2044
|
+
logger.info(`Removed outbox dir: ${dir}`);
|
|
2045
|
+
return true;
|
|
2046
|
+
} catch (err) {
|
|
2047
|
+
logger.warn(`Failed to remove outbox dir ${dir}: ${err}`);
|
|
2048
|
+
return false;
|
|
2049
|
+
}
|
|
2050
|
+
}
|
|
2051
|
+
function cleanupStaleOutboxes(outboxBase) {
|
|
2052
|
+
const base = outboxBase ?? DEFAULT_OUTBOX_BASE;
|
|
2053
|
+
if (!existsSync2(base))
|
|
2054
|
+
return 0;
|
|
2055
|
+
const now = Date.now();
|
|
2056
|
+
let removed = 0;
|
|
2057
|
+
for (const entry of readdirSync2(base, { withFileTypes: true })) {
|
|
2058
|
+
if (!entry.isDirectory())
|
|
2059
|
+
continue;
|
|
2060
|
+
const dir = join7(base, entry.name);
|
|
2061
|
+
let newestMtime = 0;
|
|
2062
|
+
try {
|
|
2063
|
+
for (const file of readdirSync2(dir)) {
|
|
2064
|
+
const mtime = statSync3(join7(dir, file)).mtimeMs;
|
|
2065
|
+
if (mtime > newestMtime)
|
|
2066
|
+
newestMtime = mtime;
|
|
2067
|
+
}
|
|
2068
|
+
} catch {
|
|
2069
|
+
continue;
|
|
2070
|
+
}
|
|
2071
|
+
if (newestMtime === 0 || now - newestMtime > STALE_THRESHOLD_MS) {
|
|
2072
|
+
try {
|
|
2073
|
+
rmSync(dir, { recursive: true, force: true });
|
|
2074
|
+
removed++;
|
|
2075
|
+
logger.info(`Cleaned up stale outbox dir: ${entry.name}`);
|
|
2076
|
+
} catch (err) {
|
|
2077
|
+
logger.warn(`Failed to cleanup outbox dir ${entry.name}: ${err}`);
|
|
2078
|
+
}
|
|
2079
|
+
}
|
|
2080
|
+
}
|
|
2081
|
+
if (removed > 0) {
|
|
2082
|
+
logger.info(`Outbox cleanup: removed ${removed} stale outbox(es)`);
|
|
2083
|
+
}
|
|
2084
|
+
return removed;
|
|
2085
|
+
}
|
|
1830
2086
|
function cleanupStaleSessions() {
|
|
1831
2087
|
if (!existsSync2(SESSIONS_DIR))
|
|
1832
2088
|
return 0;
|
|
@@ -1839,7 +2095,7 @@ function cleanupStaleSessions() {
|
|
|
1839
2095
|
let newestMtime = 0;
|
|
1840
2096
|
try {
|
|
1841
2097
|
for (const file of readdirSync2(dir)) {
|
|
1842
|
-
const mtime =
|
|
2098
|
+
const mtime = statSync3(join7(dir, file)).mtimeMs;
|
|
1843
2099
|
if (mtime > newestMtime)
|
|
1844
2100
|
newestMtime = mtime;
|
|
1845
2101
|
}
|
|
@@ -1879,6 +2135,20 @@ function clearAllSessions() {
|
|
|
1879
2135
|
}
|
|
1880
2136
|
|
|
1881
2137
|
// src/engine/session.ts
|
|
2138
|
+
import { mkdirSync as mkdirSync6, readdirSync as readdirSync3, statSync as statSync4 } from "fs";
|
|
2139
|
+
import { join as join8 } from "path";
|
|
2140
|
+
function outboxInstructions(outboxDir) {
|
|
2141
|
+
return `## File delivery to WeChat
|
|
2142
|
+
|
|
2143
|
+
You can send files (documents, spreadsheets, images, PDFs, code, etc.) to the WeChat user.
|
|
2144
|
+
|
|
2145
|
+
Rules:
|
|
2146
|
+
- Write each final deliverable to the outbox directory:
|
|
2147
|
+
${outboxDir}
|
|
2148
|
+
- Only final deliverables belong there \u2014 never intermediate or scratch files.
|
|
2149
|
+
- You may write multiple files; every new file in the outbox is delivered to the user after your turn.
|
|
2150
|
+
- Mention the file name(s) in your reply so the user knows what to expect.`;
|
|
2151
|
+
}
|
|
1882
2152
|
function extractAssistantText(message) {
|
|
1883
2153
|
const content = message.content;
|
|
1884
2154
|
if (!Array.isArray(content))
|
|
@@ -1899,14 +2169,30 @@ class ChatSession {
|
|
|
1899
2169
|
contextToken;
|
|
1900
2170
|
lastActive;
|
|
1901
2171
|
replyCount = 0;
|
|
1902
|
-
|
|
2172
|
+
outboxDir;
|
|
2173
|
+
sendFilesEnabled;
|
|
2174
|
+
onFiles;
|
|
2175
|
+
outboxSnapshot = new Map;
|
|
2176
|
+
constructor(session, chatId, contextToken, outboxDir, sendFilesEnabled, onFiles) {
|
|
1903
2177
|
this.session = session;
|
|
1904
2178
|
this.chatId = chatId;
|
|
1905
2179
|
this.contextToken = contextToken;
|
|
1906
2180
|
this.lastActive = Date.now();
|
|
2181
|
+
this.outboxDir = outboxDir;
|
|
2182
|
+
this.sendFilesEnabled = sendFilesEnabled;
|
|
2183
|
+
this.onFiles = onFiles;
|
|
1907
2184
|
}
|
|
1908
|
-
static async create(chatId, contextToken, config, onReply) {
|
|
2185
|
+
static async create(chatId, contextToken, config, onReply, onFiles) {
|
|
1909
2186
|
logger.info(`Creating session: ${chatId}`);
|
|
2187
|
+
const outboxDir = outboxDirFor(chatId, config.outboxDir);
|
|
2188
|
+
const sendFilesEnabled = config.sendFiles !== false;
|
|
2189
|
+
if (sendFilesEnabled) {
|
|
2190
|
+
try {
|
|
2191
|
+
mkdirSync6(outboxDir, { recursive: true });
|
|
2192
|
+
} catch (err) {
|
|
2193
|
+
logger.warn(`[${chatId}] Could not create outbox dir ${outboxDir}:`, err);
|
|
2194
|
+
}
|
|
2195
|
+
}
|
|
1910
2196
|
ensureSessionsDir();
|
|
1911
2197
|
const sessionDir = sessionDirFor(chatId);
|
|
1912
2198
|
const sessionManager = await SessionManager.continueRecent(config.cwd || process.cwd(), sessionDir);
|
|
@@ -1915,17 +2201,23 @@ class ChatSession {
|
|
|
1915
2201
|
sessionManager,
|
|
1916
2202
|
enableMCP: false,
|
|
1917
2203
|
enableLsp: false,
|
|
1918
|
-
systemPrompt: config.systemPrompt
|
|
2204
|
+
systemPrompt: sendFilesEnabled ? `${config.systemPrompt}
|
|
2205
|
+
|
|
2206
|
+
${outboxInstructions(outboxDir)}` : config.systemPrompt,
|
|
1919
2207
|
modelPattern: config.model
|
|
1920
2208
|
});
|
|
1921
2209
|
session.setAdvisorEnabled(false);
|
|
1922
2210
|
if (modelFallbackMessage) {
|
|
1923
2211
|
logger.warn(`Model fallback: ${modelFallbackMessage}`);
|
|
1924
2212
|
}
|
|
1925
|
-
const wrapper = new ChatSession(session, chatId, contextToken);
|
|
2213
|
+
const wrapper = new ChatSession(session, chatId, contextToken, outboxDir, sendFilesEnabled, onFiles);
|
|
1926
2214
|
const visionRole = session.settings.getModelRole("vision");
|
|
1927
2215
|
logger.info(`[${chatId}] Model: ${session.model?.id ?? "unknown"}, vision role: ${visionRole ?? "(not configured)"}`);
|
|
1928
2216
|
session.subscribe((event) => {
|
|
2217
|
+
if (event.type === "turn_end") {
|
|
2218
|
+
wrapper.flushOutbox();
|
|
2219
|
+
return;
|
|
2220
|
+
}
|
|
1929
2221
|
if (event.type !== "message_end")
|
|
1930
2222
|
return;
|
|
1931
2223
|
if (event.message.role !== "assistant")
|
|
@@ -1941,8 +2233,39 @@ class ChatSession {
|
|
|
1941
2233
|
}
|
|
1942
2234
|
async prompt(text, images) {
|
|
1943
2235
|
this.lastActive = Date.now();
|
|
2236
|
+
this.outboxSnapshot = this.snapshotOutbox();
|
|
1944
2237
|
await this.session.prompt(text, images?.length ? { images } : undefined);
|
|
1945
2238
|
}
|
|
2239
|
+
flushOutbox() {
|
|
2240
|
+
if (!this.sendFilesEnabled)
|
|
2241
|
+
return;
|
|
2242
|
+
const current = this.snapshotOutbox();
|
|
2243
|
+
const fresh = [];
|
|
2244
|
+
for (const [name, stat] of current) {
|
|
2245
|
+
const prev = this.outboxSnapshot.get(name);
|
|
2246
|
+
if (!prev || prev.size !== stat.size || prev.mtimeMs !== stat.mtimeMs) {
|
|
2247
|
+
fresh.push(join8(this.outboxDir, name));
|
|
2248
|
+
}
|
|
2249
|
+
}
|
|
2250
|
+
this.outboxSnapshot = current;
|
|
2251
|
+
if (fresh.length > 0) {
|
|
2252
|
+
logger.info(`[${this.chatId}] Outbox: ${fresh.length} new file(s) for delivery`);
|
|
2253
|
+
this.onFiles(this.chatId, fresh);
|
|
2254
|
+
}
|
|
2255
|
+
}
|
|
2256
|
+
snapshotOutbox() {
|
|
2257
|
+
const map = new Map;
|
|
2258
|
+
try {
|
|
2259
|
+
for (const name of readdirSync3(this.outboxDir)) {
|
|
2260
|
+
try {
|
|
2261
|
+
const st = statSync4(join8(this.outboxDir, name));
|
|
2262
|
+
if (st.isFile())
|
|
2263
|
+
map.set(name, { size: st.size, mtimeMs: st.mtimeMs });
|
|
2264
|
+
} catch {}
|
|
2265
|
+
}
|
|
2266
|
+
} catch {}
|
|
2267
|
+
return map;
|
|
2268
|
+
}
|
|
1946
2269
|
supportsVision() {
|
|
1947
2270
|
return this.session.settings.getModelRole("vision") !== undefined;
|
|
1948
2271
|
}
|
|
@@ -1965,9 +2288,11 @@ class SessionPool {
|
|
|
1965
2288
|
pool = new Map;
|
|
1966
2289
|
maxSessions;
|
|
1967
2290
|
replyHandler;
|
|
1968
|
-
|
|
2291
|
+
fileHandler;
|
|
2292
|
+
constructor(maxSessions, replyHandler, fileHandler) {
|
|
1969
2293
|
this.maxSessions = maxSessions;
|
|
1970
2294
|
this.replyHandler = replyHandler;
|
|
2295
|
+
this.fileHandler = fileHandler;
|
|
1971
2296
|
}
|
|
1972
2297
|
setMaxSessions(n) {
|
|
1973
2298
|
this.maxSessions = n;
|
|
@@ -1981,7 +2306,7 @@ class SessionPool {
|
|
|
1981
2306
|
if (this.pool.size >= this.maxSessions) {
|
|
1982
2307
|
this.evictOldest();
|
|
1983
2308
|
}
|
|
1984
|
-
entry = await ChatSession.create(chatId, contextToken, config, this.replyHandler);
|
|
2309
|
+
entry = await ChatSession.create(chatId, contextToken, config, this.replyHandler, this.fileHandler);
|
|
1985
2310
|
this.pool.set(chatId, entry);
|
|
1986
2311
|
return entry;
|
|
1987
2312
|
}
|
|
@@ -1992,13 +2317,14 @@ class SessionPool {
|
|
|
1992
2317
|
get(chatId) {
|
|
1993
2318
|
return this.pool.get(chatId);
|
|
1994
2319
|
}
|
|
1995
|
-
async resetSession(chatId) {
|
|
2320
|
+
async resetSession(chatId, config) {
|
|
1996
2321
|
const entry = this.pool.get(chatId);
|
|
1997
2322
|
if (entry) {
|
|
1998
2323
|
await entry.dispose();
|
|
1999
2324
|
this.pool.delete(chatId);
|
|
2000
2325
|
}
|
|
2001
2326
|
removeSessionDir(chatId);
|
|
2327
|
+
removeOutboxDir(chatId, config.outboxDir);
|
|
2002
2328
|
}
|
|
2003
2329
|
getContextToken(chatId) {
|
|
2004
2330
|
return this.pool.get(chatId)?.getContextToken() ?? "";
|
|
@@ -2201,7 +2527,7 @@ class ModelCommand {
|
|
|
2201
2527
|
// src/command/new-session-command.ts
|
|
2202
2528
|
class NewSessionInvocation {
|
|
2203
2529
|
async execute(ctx) {
|
|
2204
|
-
await ctx.pool.resetSession(ctx.chatId);
|
|
2530
|
+
await ctx.pool.resetSession(ctx.chatId, ctx.config);
|
|
2205
2531
|
return "Session reset. Your next message starts a fresh conversation.";
|
|
2206
2532
|
}
|
|
2207
2533
|
}
|
|
@@ -2223,6 +2549,34 @@ var RETRY_MS = 2000;
|
|
|
2223
2549
|
var MAX_SEND_RETRIES = 2;
|
|
2224
2550
|
var CHUNK_LIMIT = 2000;
|
|
2225
2551
|
var LOCK_PORT = 19821;
|
|
2552
|
+
var FILE_TEXT_LIMIT = 1e4;
|
|
2553
|
+
var TEXT_FILE_EXTENSIONS = {
|
|
2554
|
+
".txt": true,
|
|
2555
|
+
".md": true,
|
|
2556
|
+
".csv": true,
|
|
2557
|
+
".json": true,
|
|
2558
|
+
".xml": true,
|
|
2559
|
+
".html": true,
|
|
2560
|
+
".yaml": true,
|
|
2561
|
+
".yml": true,
|
|
2562
|
+
".toml": true,
|
|
2563
|
+
".log": true,
|
|
2564
|
+
".py": true,
|
|
2565
|
+
".js": true,
|
|
2566
|
+
".jsx": true,
|
|
2567
|
+
".ts": true,
|
|
2568
|
+
".tsx": true,
|
|
2569
|
+
".go": true,
|
|
2570
|
+
".rs": true,
|
|
2571
|
+
".java": true,
|
|
2572
|
+
".c": true,
|
|
2573
|
+
".cpp": true,
|
|
2574
|
+
".h": true,
|
|
2575
|
+
".sh": true,
|
|
2576
|
+
".bash": true,
|
|
2577
|
+
".sql": true,
|
|
2578
|
+
".css": true
|
|
2579
|
+
};
|
|
2226
2580
|
|
|
2227
2581
|
class WeChatBridge {
|
|
2228
2582
|
state = null;
|
|
@@ -2257,7 +2611,12 @@ class WeChatBridge {
|
|
|
2257
2611
|
logger.error(`[${chatId}] Reply send failed:`, err);
|
|
2258
2612
|
});
|
|
2259
2613
|
};
|
|
2260
|
-
|
|
2614
|
+
const fileHandler = (chatId, files) => {
|
|
2615
|
+
this.sendFiles(creds, chatId, files).catch((err) => {
|
|
2616
|
+
logger.error(`[${chatId}] File delivery failed:`, err);
|
|
2617
|
+
});
|
|
2618
|
+
};
|
|
2619
|
+
this.pool = new SessionPool(config.maxSessions, replyHandler, fileHandler);
|
|
2261
2620
|
this.state = { running: true, config, creds, lastError: null };
|
|
2262
2621
|
logger.info("OMP-Wechat poll loop starting", {
|
|
2263
2622
|
maxSessions: config.maxSessions,
|
|
@@ -2272,7 +2631,11 @@ class WeChatBridge {
|
|
|
2272
2631
|
this.releaseLock();
|
|
2273
2632
|
});
|
|
2274
2633
|
cleanupStaleSessions();
|
|
2275
|
-
|
|
2634
|
+
cleanupStaleOutboxes(config.outboxDir);
|
|
2635
|
+
this.cleanupTimer = setInterval(() => {
|
|
2636
|
+
cleanupStaleSessions();
|
|
2637
|
+
cleanupStaleOutboxes(config.outboxDir);
|
|
2638
|
+
}, 6 * 60 * 60 * 1000);
|
|
2276
2639
|
return this.state;
|
|
2277
2640
|
}
|
|
2278
2641
|
async stop() {
|
|
@@ -2402,7 +2765,11 @@ class WeChatBridge {
|
|
|
2402
2765
|
if (!text && !hasImages)
|
|
2403
2766
|
return;
|
|
2404
2767
|
const images = await this.downloadImages(creds, msg, senderId);
|
|
2405
|
-
await
|
|
2768
|
+
const fileTexts = await this.downloadFileTexts(creds, msg, senderId);
|
|
2769
|
+
const fullText = [text, ...fileTexts].filter(Boolean).join(`
|
|
2770
|
+
|
|
2771
|
+
`);
|
|
2772
|
+
await session.prompt(fullText, images);
|
|
2406
2773
|
} catch (err) {
|
|
2407
2774
|
logger.error(`[${senderId}] prompt failed:`, err);
|
|
2408
2775
|
await this.sendReply(creds, senderId, "Processing failed, please try again.");
|
|
@@ -2435,15 +2802,71 @@ class WeChatBridge {
|
|
|
2435
2802
|
}
|
|
2436
2803
|
return results;
|
|
2437
2804
|
}
|
|
2805
|
+
async sendFiles(creds, chatId, files) {
|
|
2806
|
+
const contextToken = this.pool?.getContextToken(chatId) ?? "";
|
|
2807
|
+
if (!contextToken) {
|
|
2808
|
+
logger.warn(`[${chatId}] No context_token, cannot send files`);
|
|
2809
|
+
return;
|
|
2810
|
+
}
|
|
2811
|
+
const config = loadConfig();
|
|
2812
|
+
const maxBytes = (config.maxFileSizeMb ?? 100) * 1024 * 1024;
|
|
2813
|
+
for (const filePath of files) {
|
|
2814
|
+
const result = await uploadAndSendFile(creds, chatId, contextToken, filePath, maxBytes);
|
|
2815
|
+
switch (result.status) {
|
|
2816
|
+
case "sent":
|
|
2817
|
+
logger.info(`[${chatId}] Delivered: ${filePath}`);
|
|
2818
|
+
try {
|
|
2819
|
+
rmSync2(filePath, { force: true });
|
|
2820
|
+
} catch (err) {
|
|
2821
|
+
logger.warn(`[${chatId}] Could not remove delivered file ${filePath}:`, err);
|
|
2822
|
+
}
|
|
2823
|
+
break;
|
|
2824
|
+
case "too-large":
|
|
2825
|
+
logger.warn(`[${chatId}] File too large, skipped: ${filePath}`);
|
|
2826
|
+
await this.sendReply(creds, chatId, `[File not sent: ${basename3(filePath)} exceeds ${Math.round(result.maxBytes / 1024 / 1024)}MB limit]`);
|
|
2827
|
+
break;
|
|
2828
|
+
case "error":
|
|
2829
|
+
logger.error(`[${chatId}] File send failed: ${filePath}: ${result.error}`);
|
|
2830
|
+
await this.sendReply(creds, chatId, `[File delivery failed: ${basename3(filePath)} \u2014 ${result.error}]`);
|
|
2831
|
+
break;
|
|
2832
|
+
}
|
|
2833
|
+
}
|
|
2834
|
+
}
|
|
2835
|
+
async downloadFileTexts(_creds, msg, chatId) {
|
|
2836
|
+
const items = (msg.item_list ?? []).filter((item) => item.type === 4);
|
|
2837
|
+
if (items.length === 0)
|
|
2838
|
+
return [];
|
|
2839
|
+
const results = [];
|
|
2840
|
+
for (const item of items) {
|
|
2841
|
+
const file = item.file_item;
|
|
2842
|
+
const name = file.file_name ?? "unknown";
|
|
2843
|
+
if (!TEXT_FILE_EXTENSIONS[extname2(name).toLowerCase()])
|
|
2844
|
+
continue;
|
|
2845
|
+
const buf = await downloadAndDecrypt(file.media?.encrypt_query_param, file.media?.full_url, undefined, file.media?.aes_key, `file[${chatId}]`);
|
|
2846
|
+
if (!buf)
|
|
2847
|
+
continue;
|
|
2848
|
+
const len = parseInt(file.len ?? "", 10);
|
|
2849
|
+
const text = buf.toString("utf-8");
|
|
2850
|
+
const truncated = text.length > FILE_TEXT_LIMIT ? `${text.slice(0, FILE_TEXT_LIMIT)}
|
|
2851
|
+
... [truncated]` : text;
|
|
2852
|
+
results.push(`[File: ${name}${Number.isFinite(len) && len > 0 ? ` (${formatSize(len)})` : ""}]
|
|
2853
|
+
|
|
2854
|
+
\`\`\`
|
|
2855
|
+
${truncated}
|
|
2856
|
+
\`\`\``);
|
|
2857
|
+
logger.info(`[${chatId}] Extracted text from file: ${name} (${buf.length} bytes)`);
|
|
2858
|
+
}
|
|
2859
|
+
return results;
|
|
2860
|
+
}
|
|
2438
2861
|
async sendReply(creds, chatId, text) {
|
|
2439
2862
|
const contextToken = this.pool?.getContextToken(chatId) ?? "";
|
|
2440
2863
|
if (!contextToken) {
|
|
2441
2864
|
logger.warn(`[${chatId}] No context_token, cannot reply`);
|
|
2442
2865
|
return;
|
|
2443
2866
|
}
|
|
2444
|
-
const chunks = chunkText(text, CHUNK_LIMIT);
|
|
2867
|
+
const chunks = chunkText(stripMarkdown(text), CHUNK_LIMIT);
|
|
2445
2868
|
for (const chunk of chunks) {
|
|
2446
|
-
const clientId = `omp-wechat-${Date.now()}-${
|
|
2869
|
+
const clientId = `omp-wechat-${Date.now()}-${randomBytes5(4).toString("hex")}`;
|
|
2447
2870
|
let retries = 0;
|
|
2448
2871
|
while (retries <= MAX_SEND_RETRIES) {
|
|
2449
2872
|
try {
|
|
@@ -2465,8 +2888,8 @@ class WeChatBridge {
|
|
|
2465
2888
|
|
|
2466
2889
|
// src/service.ts
|
|
2467
2890
|
import { platform, homedir as homedir7 } from "os";
|
|
2468
|
-
import { join as
|
|
2469
|
-
import { existsSync as existsSync3, mkdirSync as
|
|
2891
|
+
import { join as join9, basename as basename4 } from "path";
|
|
2892
|
+
import { existsSync as existsSync3, mkdirSync as mkdirSync7, writeFileSync as writeFileSync4, rmSync as rmSync3 } from "fs";
|
|
2470
2893
|
var PLIST_LABEL = "com.omp-wechat";
|
|
2471
2894
|
var SERVICE_NAME = "omp-wechat";
|
|
2472
2895
|
function detectPlatform() {
|
|
@@ -2480,14 +2903,14 @@ function detectPlatform() {
|
|
|
2480
2903
|
return "other";
|
|
2481
2904
|
}
|
|
2482
2905
|
function getLogDir() {
|
|
2483
|
-
return
|
|
2906
|
+
return join9(homedir7(), ".omp", "logs");
|
|
2484
2907
|
}
|
|
2485
2908
|
function resolveHostBinary() {
|
|
2486
2909
|
const exe = process.execPath || "omp";
|
|
2487
|
-
return
|
|
2910
|
+
return basename4(exe);
|
|
2488
2911
|
}
|
|
2489
2912
|
function plistPath() {
|
|
2490
|
-
return
|
|
2913
|
+
return join9(homedir7(), "Library", "LaunchAgents", `${PLIST_LABEL}.plist`);
|
|
2491
2914
|
}
|
|
2492
2915
|
function generatePlist() {
|
|
2493
2916
|
const omp = resolveHostBinary();
|
|
@@ -2530,9 +2953,9 @@ function generatePlist() {
|
|
|
2530
2953
|
`;
|
|
2531
2954
|
}
|
|
2532
2955
|
function installLaunchd() {
|
|
2533
|
-
const dir =
|
|
2534
|
-
|
|
2535
|
-
|
|
2956
|
+
const dir = join9(homedir7(), "Library", "LaunchAgents");
|
|
2957
|
+
mkdirSync7(dir, { recursive: true });
|
|
2958
|
+
mkdirSync7(getLogDir(), { recursive: true });
|
|
2536
2959
|
const plist = plistPath();
|
|
2537
2960
|
if (existsSync3(plist)) {
|
|
2538
2961
|
Bun.spawnSync(["launchctl", "unload", plist], { stderr: "ignore" });
|
|
@@ -2550,7 +2973,7 @@ function uninstallLaunchd() {
|
|
|
2550
2973
|
throw new Error("No launchd service found (may not be installed)");
|
|
2551
2974
|
}
|
|
2552
2975
|
Bun.spawnSync(["launchctl", "unload", plist], { stderr: "inherit" });
|
|
2553
|
-
|
|
2976
|
+
rmSync3(plist);
|
|
2554
2977
|
}
|
|
2555
2978
|
function servicePath() {
|
|
2556
2979
|
return `/etc/systemd/system/${SERVICE_NAME}.service`;
|
|
@@ -2581,7 +3004,7 @@ StandardError=append:${logDir}/rpc.log
|
|
|
2581
3004
|
NoNewPrivileges=true
|
|
2582
3005
|
ProtectSystem=strict
|
|
2583
3006
|
ProtectHome=read-only
|
|
2584
|
-
ReadWritePaths=${logDir} ${
|
|
3007
|
+
ReadWritePaths=${logDir} ${join9(homedir7(), ".omp-wechat")} ${join9(homedir7(), ".omp")}
|
|
2585
3008
|
PrivateTmp=true
|
|
2586
3009
|
|
|
2587
3010
|
[Install]
|
|
@@ -2590,7 +3013,7 @@ WantedBy=multi-user.target
|
|
|
2590
3013
|
}
|
|
2591
3014
|
function installSystemd() {
|
|
2592
3015
|
const svc = servicePath();
|
|
2593
|
-
|
|
3016
|
+
mkdirSync7(getLogDir(), { recursive: true });
|
|
2594
3017
|
if (existsSync3(svc)) {
|
|
2595
3018
|
Bun.spawnSync(["sudo", "systemctl", "stop", SERVICE_NAME], { stderr: "inherit" });
|
|
2596
3019
|
}
|
|
@@ -2600,7 +3023,7 @@ function installSystemd() {
|
|
|
2600
3023
|
if (result.exitCode !== 0) {
|
|
2601
3024
|
result = Bun.spawnSync(["sudo", "cp", tmp, svc], { stderr: "inherit" });
|
|
2602
3025
|
}
|
|
2603
|
-
|
|
3026
|
+
rmSync3(tmp);
|
|
2604
3027
|
if (result.exitCode !== 0) {
|
|
2605
3028
|
throw new Error("Failed to write service file (need sudo)");
|
|
2606
3029
|
}
|
|
@@ -2622,7 +3045,7 @@ function uninstallSystemd() {
|
|
|
2622
3045
|
}
|
|
2623
3046
|
var WIN_TASK_NAME = "OMP-Wechat";
|
|
2624
3047
|
function winScriptPath() {
|
|
2625
|
-
return
|
|
3048
|
+
return join9(homedir7(), ".omp-wechat", "omp-wechat-rpc.ps1");
|
|
2626
3049
|
}
|
|
2627
3050
|
function generateWinScript() {
|
|
2628
3051
|
const omp = process.execPath || "omp";
|
|
@@ -2665,8 +3088,8 @@ while ($true) {
|
|
|
2665
3088
|
`;
|
|
2666
3089
|
}
|
|
2667
3090
|
function installWinTask() {
|
|
2668
|
-
|
|
2669
|
-
|
|
3091
|
+
mkdirSync7(join9(homedir7(), ".omp-wechat"), { recursive: true });
|
|
3092
|
+
mkdirSync7(getLogDir(), { recursive: true });
|
|
2670
3093
|
writeFileSync4(winScriptPath(), generateWinScript());
|
|
2671
3094
|
Bun.spawnSync(["schtasks", "/end", "/tn", WIN_TASK_NAME], { stderr: "ignore" });
|
|
2672
3095
|
Bun.spawnSync(["schtasks", "/delete", "/tn", WIN_TASK_NAME, "/f"], { stderr: "ignore" });
|
|
@@ -2689,7 +3112,7 @@ function uninstallWinTask() {
|
|
|
2689
3112
|
}
|
|
2690
3113
|
const script = winScriptPath();
|
|
2691
3114
|
if (existsSync3(script)) {
|
|
2692
|
-
|
|
3115
|
+
rmSync3(script);
|
|
2693
3116
|
}
|
|
2694
3117
|
}
|
|
2695
3118
|
function winTaskExists() {
|