omp-wechat 1.4.1 → 1.6.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 +48 -10
- package/dist/index.js +505 -37
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -32,12 +32,15 @@ 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
|
|
38
41
|
- **Access control**: pairing / allowlist / disabled modes
|
|
39
42
|
- **Long text chunking**: splits replies >2000 chars at paragraph/line/space boundaries
|
|
40
|
-
- **Boot service**: optional launchd/systemd service for auto-start on boot
|
|
43
|
+
- **Boot service**: optional launchd/systemd/Task Scheduler service for auto-start on boot
|
|
41
44
|
|
|
42
45
|
## Quick Start
|
|
43
46
|
|
|
@@ -78,10 +81,13 @@ To check status: `/wechat status`. To stop: `/wechat stop`.
|
|
|
78
81
|
/wechat install
|
|
79
82
|
```
|
|
80
83
|
|
|
81
|
-
Installs a launchd (macOS)
|
|
84
|
+
Installs a launchd (macOS), systemd (Linux), or Task Scheduler (Windows) service that runs the host (`omp --mode rpc` or `pi --mode rpc`) at boot (macOS/Linux) or user logon (Windows). A `get_state` JSON-RPC heartbeat is piped to stdin every 5s to keep the process alive (without an active RPC client, `omp --mode rpc` exits on idle stdin). launchd `KeepAlive`/systemd `Restart=always`/PowerShell restart-loop handles crashes. On Windows, the task uses `/sc onlogon` (no admin required); the host starts when the user logs in, not at bare-metal boot.
|
|
82
85
|
|
|
83
86
|
Logs: `~/.omp/logs/rpc.log` (stderr only; stdout discarded) and `~/.omp/logs/wechat.log` (poll loop)
|
|
84
|
-
Manage:
|
|
87
|
+
Manage:
|
|
88
|
+
- macOS: `launchctl start|stop com.omp-wechat`
|
|
89
|
+
- Linux: `sudo systemctl start|stop omp-wechat`
|
|
90
|
+
- Windows: `schtasks /run|/end /tn OMP-Wechat`
|
|
85
91
|
|
|
86
92
|
To remove: `/wechat uninstall`
|
|
87
93
|
|
|
@@ -107,11 +113,39 @@ systemPrompt: |
|
|
|
107
113
|
| `model` | OMP default | Default model: role alias (`@smol`, `@slow`) or `provider/id` |
|
|
108
114
|
| `cwd` | `process.cwd()` | Working directory for AI sessions — determines which project context (CLAUDE.md, .omp/) the agent loads |
|
|
109
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 |
|
|
110
119
|
|
|
111
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.
|
|
112
121
|
>
|
|
113
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.
|
|
114
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
|
+
|
|
115
149
|
## Slash Commands
|
|
116
150
|
|
|
117
151
|
| Command | Description |
|
|
@@ -123,7 +157,7 @@ systemPrompt: |
|
|
|
123
157
|
| `/wechat revoke <wxid>` | Revoke a user's authorization |
|
|
124
158
|
| `/wechat list` | List authorized users |
|
|
125
159
|
| `/wechat stop` | Stop the poll loop |
|
|
126
|
-
| `/wechat install` | Install boot-time launchd/systemd service |
|
|
160
|
+
| `/wechat install` | Install boot-time launchd/systemd/Task Scheduler service |
|
|
127
161
|
| `/wechat uninstall` | Remove boot-time service |
|
|
128
162
|
|
|
129
163
|
### Chat Commands (via WeChat message)
|
|
@@ -152,8 +186,8 @@ The logged-in user (who scanned the QR code) is automatically added to the allow
|
|
|
152
186
|
| Host process starts | Poll loop starts at extension load time (acquires singleton lock) |
|
|
153
187
|
| Other host processes | Standby with 30s failover timer, take over if lock holder dies |
|
|
154
188
|
| Host process exits | Poll loop stops, lock released, all sessions disposed |
|
|
155
|
-
| Host crashes | Failover timer in another process detects dead lock and takes over; or launchd/systemd restarts the host (if `/wechat install` was run) |
|
|
156
|
-
| Machine reboots |
|
|
189
|
+
| Host crashes | Failover timer in another process detects dead lock and takes over; or launchd/systemd/Task Scheduler restarts the host (if `/wechat install` was run) |
|
|
190
|
+
| Machine reboots | macOS/Linux: service auto-starts at boot; Windows: service starts at user logon (if installed), poll loop resumes |
|
|
157
191
|
| No boot service | Poll loop only runs while a host process is active |
|
|
158
192
|
|
|
159
193
|
Logs: `~/.omp/logs/wechat.log` (poll loop) and `~/.omp/logs/rpc.log` (boot service stderr)
|
|
@@ -166,14 +200,16 @@ OMP-Wechat/
|
|
|
166
200
|
├── src/
|
|
167
201
|
│ ├── index.ts # OMP/Pi extension entry (extension load + /wechat commands)
|
|
168
202
|
│ ├── bridge.ts # In-process poll loop + message handling + singleton port lock
|
|
169
|
-
│ ├── service.ts # Boot-time launchd/systemd install
|
|
203
|
+
│ ├── service.ts # Boot-time launchd/systemd/Task Scheduler install
|
|
170
204
|
│ ├── config.ts # Config loading (config.yml + defaults)
|
|
171
205
|
│ ├── ilink/
|
|
172
206
|
│ │ ├── types.ts # iLink Bot API type definitions
|
|
173
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
|
|
174
210
|
│ │ └── login.ts # QR code login flow
|
|
175
211
|
│ ├── engine/
|
|
176
|
-
│ │ ├── session.ts # AI session creation + reply subscription
|
|
212
|
+
│ │ ├── session.ts # AI session creation + reply/outbox subscription
|
|
177
213
|
│ │ └── pool.ts # Session pool (LRU eviction, concurrency)
|
|
178
214
|
│ ├── access/
|
|
179
215
|
│ │ └── control.ts # Access control (pairing/allowlist/disabled)
|
|
@@ -188,15 +224,17 @@ OMP-Wechat/
|
|
|
188
224
|
|
|
189
225
|
## Limitations
|
|
190
226
|
|
|
191
|
-
- **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)
|
|
192
228
|
- **1:1 only**: iLink Bot API does not support group chats
|
|
193
229
|
- **Single instance**: iLink allows only one bot connection per account
|
|
194
|
-
- **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
|
|
195
231
|
|
|
196
232
|
## Roadmap
|
|
197
233
|
|
|
198
234
|
- [x] **Phase 2a**: Inbound image support (CDN download + AES decrypt + vision model)
|
|
199
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
|
|
200
238
|
- [x] **Phase 3**: Persistent sessions — `SessionManager.continueRecent()` per chat, context survives restarts
|
|
201
239
|
- [x] **Phase 4**: Per-chat model selection — `/model` `/models` chat commands for manual switching
|
|
202
240
|
- [ ] **Phase 5**: Fine-grained permissions (per-user tool restrictions, bash approval via WeChat)
|
package/dist/index.js
CHANGED
|
@@ -1161,7 +1161,12 @@ var rotatingLog = new RotatingLog({
|
|
|
1161
1161
|
});
|
|
1162
1162
|
rotatingLog.cleanStale();
|
|
1163
1163
|
function ts() {
|
|
1164
|
-
|
|
1164
|
+
const d = new Date;
|
|
1165
|
+
const off = -d.getTimezoneOffset();
|
|
1166
|
+
const sign = off >= 0 ? "+" : "-";
|
|
1167
|
+
const pad = (n) => String(Math.abs(n)).padStart(2, "0");
|
|
1168
|
+
const tz = `${sign}${pad(Math.trunc(off / 60))}:${pad(off % 60)}`;
|
|
1169
|
+
return new Date(d.getTime() - d.getTimezoneOffset() * 60000).toISOString().replace("Z", tz);
|
|
1165
1170
|
}
|
|
1166
1171
|
function log(level, msg, meta) {
|
|
1167
1172
|
if (LEVEL_ORDER[level] < LEVEL_ORDER[minLevel])
|
|
@@ -1200,8 +1205,7 @@ function saveCredentials(creds) {
|
|
|
1200
1205
|
function getCredentials() {
|
|
1201
1206
|
const creds = loadCredentials();
|
|
1202
1207
|
if (!creds?.token || !creds?.baseUrl) {
|
|
1203
|
-
|
|
1204
|
-
process.exit(1);
|
|
1208
|
+
throw new Error("Not logged in \u2014 run /wechat login");
|
|
1205
1209
|
}
|
|
1206
1210
|
return creds;
|
|
1207
1211
|
}
|
|
@@ -1322,6 +1326,13 @@ function saveSyncBuf(buf) {
|
|
|
1322
1326
|
mkdirSync2(STATE_DIR, { recursive: true });
|
|
1323
1327
|
writeFileSync(SYNC_BUF_FILE, buf);
|
|
1324
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
|
+
}
|
|
1325
1336
|
function extractInboundText(msg, includeImagePlaceholder = true) {
|
|
1326
1337
|
const items = msg.item_list ?? [];
|
|
1327
1338
|
const parts = [];
|
|
@@ -1339,7 +1350,11 @@ function extractInboundText(msg, includeImagePlaceholder = true) {
|
|
|
1339
1350
|
parts.push(item.voice_item?.text ?? "(voice)");
|
|
1340
1351
|
break;
|
|
1341
1352
|
case 4:
|
|
1342
|
-
|
|
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
|
+
}
|
|
1343
1358
|
break;
|
|
1344
1359
|
case 5:
|
|
1345
1360
|
parts.push("(video)");
|
|
@@ -1568,7 +1583,9 @@ async function pollQrStatus(qrcode, baseUrl) {
|
|
|
1568
1583
|
}
|
|
1569
1584
|
|
|
1570
1585
|
// src/bridge.ts
|
|
1571
|
-
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";
|
|
1572
1589
|
|
|
1573
1590
|
// src/config.ts
|
|
1574
1591
|
import { homedir as homedir4 } from "os";
|
|
@@ -1603,6 +1620,15 @@ function loadConfig() {
|
|
|
1603
1620
|
config.cwd = expandTilde(parsed.cwd);
|
|
1604
1621
|
if (parsed.systemPrompt)
|
|
1605
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;
|
|
1606
1632
|
}
|
|
1607
1633
|
} catch (err) {
|
|
1608
1634
|
logger.warn("Failed to load config.yml, using defaults", err);
|
|
@@ -1738,7 +1764,7 @@ function isDuplicate(key) {
|
|
|
1738
1764
|
}
|
|
1739
1765
|
|
|
1740
1766
|
// src/ilink/cdn.ts
|
|
1741
|
-
import { createDecipheriv } from "crypto";
|
|
1767
|
+
import { createCipheriv, createDecipheriv, randomBytes as randomBytes3 } from "crypto";
|
|
1742
1768
|
var CDN_BASE_URL = "https://novac2c.cdn.weixin.qq.com/c2c";
|
|
1743
1769
|
function parseAesKey(aeskeyHex, aesKeyBase64) {
|
|
1744
1770
|
if (aeskeyHex && /^[0-9a-fA-F]{32}$/.test(aeskeyHex)) {
|
|
@@ -1758,6 +1784,13 @@ function decryptAesEcb(ciphertext, key) {
|
|
|
1758
1784
|
const decipher = createDecipheriv("aes-128-ecb", key, null);
|
|
1759
1785
|
return Buffer.concat([decipher.update(ciphertext), decipher.final()]);
|
|
1760
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
|
+
}
|
|
1761
1794
|
function buildCdnUrl(encryptQueryParam, fullUrl) {
|
|
1762
1795
|
if (fullUrl)
|
|
1763
1796
|
return fullUrl;
|
|
@@ -1792,13 +1825,185 @@ async function downloadAndDecrypt(encryptQueryParam, fullUrl, aeskeyHex, aesKeyB
|
|
|
1792
1825
|
}
|
|
1793
1826
|
}
|
|
1794
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
|
+
|
|
1795
2000
|
// src/engine/session.ts
|
|
1796
2001
|
import { createAgentSession, SessionManager } from "@oh-my-pi/pi-coding-agent";
|
|
1797
2002
|
|
|
1798
2003
|
// src/engine/session-store.ts
|
|
1799
2004
|
import { join as join7 } from "path";
|
|
1800
2005
|
import { homedir as homedir6 } from "os";
|
|
1801
|
-
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";
|
|
1802
2007
|
var STATE_DIR4 = join7(homedir6(), ".omp-wechat");
|
|
1803
2008
|
var SESSIONS_DIR = join7(STATE_DIR4, "sessions");
|
|
1804
2009
|
var STALE_THRESHOLD_MS = 30 * 24 * 60 * 60 * 1000;
|
|
@@ -1835,7 +2040,7 @@ function cleanupStaleSessions() {
|
|
|
1835
2040
|
let newestMtime = 0;
|
|
1836
2041
|
try {
|
|
1837
2042
|
for (const file of readdirSync2(dir)) {
|
|
1838
|
-
const mtime =
|
|
2043
|
+
const mtime = statSync3(join7(dir, file)).mtimeMs;
|
|
1839
2044
|
if (mtime > newestMtime)
|
|
1840
2045
|
newestMtime = mtime;
|
|
1841
2046
|
}
|
|
@@ -1875,6 +2080,28 @@ function clearAllSessions() {
|
|
|
1875
2080
|
}
|
|
1876
2081
|
|
|
1877
2082
|
// src/engine/session.ts
|
|
2083
|
+
import { mkdirSync as mkdirSync6, readdirSync as readdirSync3, statSync as statSync4 } from "fs";
|
|
2084
|
+
import { homedir as homedir7 } from "os";
|
|
2085
|
+
import { join as join8 } from "path";
|
|
2086
|
+
var DEFAULT_OUTBOX_BASE = join8(homedir7(), ".omp-wechat", "outbox");
|
|
2087
|
+
function sanitizeChatId2(chatId) {
|
|
2088
|
+
return chatId.replace(/[^a-zA-Z0-9_-]/g, "_");
|
|
2089
|
+
}
|
|
2090
|
+
function outboxDirFor(chatId, config) {
|
|
2091
|
+
return join8(config.outboxDir ?? DEFAULT_OUTBOX_BASE, sanitizeChatId2(chatId));
|
|
2092
|
+
}
|
|
2093
|
+
function outboxInstructions(outboxDir) {
|
|
2094
|
+
return `## File delivery to WeChat
|
|
2095
|
+
|
|
2096
|
+
You can send files (documents, spreadsheets, images, PDFs, code, etc.) to the WeChat user.
|
|
2097
|
+
|
|
2098
|
+
Rules:
|
|
2099
|
+
- Write each final deliverable to the outbox directory:
|
|
2100
|
+
${outboxDir}
|
|
2101
|
+
- Only final deliverables belong there \u2014 never intermediate or scratch files.
|
|
2102
|
+
- You may write multiple files; every new file in the outbox is delivered to the user after your turn.
|
|
2103
|
+
- Mention the file name(s) in your reply so the user knows what to expect.`;
|
|
2104
|
+
}
|
|
1878
2105
|
function extractAssistantText(message) {
|
|
1879
2106
|
const content = message.content;
|
|
1880
2107
|
if (!Array.isArray(content))
|
|
@@ -1895,14 +2122,30 @@ class ChatSession {
|
|
|
1895
2122
|
contextToken;
|
|
1896
2123
|
lastActive;
|
|
1897
2124
|
replyCount = 0;
|
|
1898
|
-
|
|
2125
|
+
outboxDir;
|
|
2126
|
+
sendFilesEnabled;
|
|
2127
|
+
onFiles;
|
|
2128
|
+
outboxSnapshot = new Map;
|
|
2129
|
+
constructor(session, chatId, contextToken, outboxDir, sendFilesEnabled, onFiles) {
|
|
1899
2130
|
this.session = session;
|
|
1900
2131
|
this.chatId = chatId;
|
|
1901
2132
|
this.contextToken = contextToken;
|
|
1902
2133
|
this.lastActive = Date.now();
|
|
2134
|
+
this.outboxDir = outboxDir;
|
|
2135
|
+
this.sendFilesEnabled = sendFilesEnabled;
|
|
2136
|
+
this.onFiles = onFiles;
|
|
1903
2137
|
}
|
|
1904
|
-
static async create(chatId, contextToken, config, onReply) {
|
|
2138
|
+
static async create(chatId, contextToken, config, onReply, onFiles) {
|
|
1905
2139
|
logger.info(`Creating session: ${chatId}`);
|
|
2140
|
+
const outboxDir = outboxDirFor(chatId, config);
|
|
2141
|
+
const sendFilesEnabled = config.sendFiles !== false;
|
|
2142
|
+
if (sendFilesEnabled) {
|
|
2143
|
+
try {
|
|
2144
|
+
mkdirSync6(outboxDir, { recursive: true });
|
|
2145
|
+
} catch (err) {
|
|
2146
|
+
logger.warn(`[${chatId}] Could not create outbox dir ${outboxDir}:`, err);
|
|
2147
|
+
}
|
|
2148
|
+
}
|
|
1906
2149
|
ensureSessionsDir();
|
|
1907
2150
|
const sessionDir = sessionDirFor(chatId);
|
|
1908
2151
|
const sessionManager = await SessionManager.continueRecent(config.cwd || process.cwd(), sessionDir);
|
|
@@ -1911,17 +2154,23 @@ class ChatSession {
|
|
|
1911
2154
|
sessionManager,
|
|
1912
2155
|
enableMCP: false,
|
|
1913
2156
|
enableLsp: false,
|
|
1914
|
-
systemPrompt: config.systemPrompt
|
|
2157
|
+
systemPrompt: sendFilesEnabled ? `${config.systemPrompt}
|
|
2158
|
+
|
|
2159
|
+
${outboxInstructions(outboxDir)}` : config.systemPrompt,
|
|
1915
2160
|
modelPattern: config.model
|
|
1916
2161
|
});
|
|
1917
2162
|
session.setAdvisorEnabled(false);
|
|
1918
2163
|
if (modelFallbackMessage) {
|
|
1919
2164
|
logger.warn(`Model fallback: ${modelFallbackMessage}`);
|
|
1920
2165
|
}
|
|
1921
|
-
const wrapper = new ChatSession(session, chatId, contextToken);
|
|
2166
|
+
const wrapper = new ChatSession(session, chatId, contextToken, outboxDir, sendFilesEnabled, onFiles);
|
|
1922
2167
|
const visionRole = session.settings.getModelRole("vision");
|
|
1923
2168
|
logger.info(`[${chatId}] Model: ${session.model?.id ?? "unknown"}, vision role: ${visionRole ?? "(not configured)"}`);
|
|
1924
2169
|
session.subscribe((event) => {
|
|
2170
|
+
if (event.type === "turn_end") {
|
|
2171
|
+
wrapper.flushOutbox();
|
|
2172
|
+
return;
|
|
2173
|
+
}
|
|
1925
2174
|
if (event.type !== "message_end")
|
|
1926
2175
|
return;
|
|
1927
2176
|
if (event.message.role !== "assistant")
|
|
@@ -1937,8 +2186,39 @@ class ChatSession {
|
|
|
1937
2186
|
}
|
|
1938
2187
|
async prompt(text, images) {
|
|
1939
2188
|
this.lastActive = Date.now();
|
|
2189
|
+
this.outboxSnapshot = this.snapshotOutbox();
|
|
1940
2190
|
await this.session.prompt(text, images?.length ? { images } : undefined);
|
|
1941
2191
|
}
|
|
2192
|
+
flushOutbox() {
|
|
2193
|
+
if (!this.sendFilesEnabled)
|
|
2194
|
+
return;
|
|
2195
|
+
const current = this.snapshotOutbox();
|
|
2196
|
+
const fresh = [];
|
|
2197
|
+
for (const [name, stat] of current) {
|
|
2198
|
+
const prev = this.outboxSnapshot.get(name);
|
|
2199
|
+
if (!prev || prev.size !== stat.size || prev.mtimeMs !== stat.mtimeMs) {
|
|
2200
|
+
fresh.push(join8(this.outboxDir, name));
|
|
2201
|
+
}
|
|
2202
|
+
}
|
|
2203
|
+
this.outboxSnapshot = current;
|
|
2204
|
+
if (fresh.length > 0) {
|
|
2205
|
+
logger.info(`[${this.chatId}] Outbox: ${fresh.length} new file(s) for delivery`);
|
|
2206
|
+
this.onFiles(this.chatId, fresh);
|
|
2207
|
+
}
|
|
2208
|
+
}
|
|
2209
|
+
snapshotOutbox() {
|
|
2210
|
+
const map = new Map;
|
|
2211
|
+
try {
|
|
2212
|
+
for (const name of readdirSync3(this.outboxDir)) {
|
|
2213
|
+
try {
|
|
2214
|
+
const st = statSync4(join8(this.outboxDir, name));
|
|
2215
|
+
if (st.isFile())
|
|
2216
|
+
map.set(name, { size: st.size, mtimeMs: st.mtimeMs });
|
|
2217
|
+
} catch {}
|
|
2218
|
+
}
|
|
2219
|
+
} catch {}
|
|
2220
|
+
return map;
|
|
2221
|
+
}
|
|
1942
2222
|
supportsVision() {
|
|
1943
2223
|
return this.session.settings.getModelRole("vision") !== undefined;
|
|
1944
2224
|
}
|
|
@@ -1961,9 +2241,11 @@ class SessionPool {
|
|
|
1961
2241
|
pool = new Map;
|
|
1962
2242
|
maxSessions;
|
|
1963
2243
|
replyHandler;
|
|
1964
|
-
|
|
2244
|
+
fileHandler;
|
|
2245
|
+
constructor(maxSessions, replyHandler, fileHandler) {
|
|
1965
2246
|
this.maxSessions = maxSessions;
|
|
1966
2247
|
this.replyHandler = replyHandler;
|
|
2248
|
+
this.fileHandler = fileHandler;
|
|
1967
2249
|
}
|
|
1968
2250
|
setMaxSessions(n) {
|
|
1969
2251
|
this.maxSessions = n;
|
|
@@ -1977,7 +2259,7 @@ class SessionPool {
|
|
|
1977
2259
|
if (this.pool.size >= this.maxSessions) {
|
|
1978
2260
|
this.evictOldest();
|
|
1979
2261
|
}
|
|
1980
|
-
entry = await ChatSession.create(chatId, contextToken, config, this.replyHandler);
|
|
2262
|
+
entry = await ChatSession.create(chatId, contextToken, config, this.replyHandler, this.fileHandler);
|
|
1981
2263
|
this.pool.set(chatId, entry);
|
|
1982
2264
|
return entry;
|
|
1983
2265
|
}
|
|
@@ -2219,6 +2501,34 @@ var RETRY_MS = 2000;
|
|
|
2219
2501
|
var MAX_SEND_RETRIES = 2;
|
|
2220
2502
|
var CHUNK_LIMIT = 2000;
|
|
2221
2503
|
var LOCK_PORT = 19821;
|
|
2504
|
+
var FILE_TEXT_LIMIT = 1e4;
|
|
2505
|
+
var TEXT_FILE_EXTENSIONS = {
|
|
2506
|
+
".txt": true,
|
|
2507
|
+
".md": true,
|
|
2508
|
+
".csv": true,
|
|
2509
|
+
".json": true,
|
|
2510
|
+
".xml": true,
|
|
2511
|
+
".html": true,
|
|
2512
|
+
".yaml": true,
|
|
2513
|
+
".yml": true,
|
|
2514
|
+
".toml": true,
|
|
2515
|
+
".log": true,
|
|
2516
|
+
".py": true,
|
|
2517
|
+
".js": true,
|
|
2518
|
+
".jsx": true,
|
|
2519
|
+
".ts": true,
|
|
2520
|
+
".tsx": true,
|
|
2521
|
+
".go": true,
|
|
2522
|
+
".rs": true,
|
|
2523
|
+
".java": true,
|
|
2524
|
+
".c": true,
|
|
2525
|
+
".cpp": true,
|
|
2526
|
+
".h": true,
|
|
2527
|
+
".sh": true,
|
|
2528
|
+
".bash": true,
|
|
2529
|
+
".sql": true,
|
|
2530
|
+
".css": true
|
|
2531
|
+
};
|
|
2222
2532
|
|
|
2223
2533
|
class WeChatBridge {
|
|
2224
2534
|
state = null;
|
|
@@ -2233,7 +2543,12 @@ class WeChatBridge {
|
|
|
2233
2543
|
}
|
|
2234
2544
|
start() {
|
|
2235
2545
|
const config = loadConfig();
|
|
2236
|
-
|
|
2546
|
+
let creds;
|
|
2547
|
+
try {
|
|
2548
|
+
creds = getCredentials();
|
|
2549
|
+
} catch (err) {
|
|
2550
|
+
return { running: false, config, creds: null, lastError: String(err) };
|
|
2551
|
+
}
|
|
2237
2552
|
if (this.pollActive) {
|
|
2238
2553
|
return this.state;
|
|
2239
2554
|
}
|
|
@@ -2248,7 +2563,12 @@ class WeChatBridge {
|
|
|
2248
2563
|
logger.error(`[${chatId}] Reply send failed:`, err);
|
|
2249
2564
|
});
|
|
2250
2565
|
};
|
|
2251
|
-
|
|
2566
|
+
const fileHandler = (chatId, files) => {
|
|
2567
|
+
this.sendFiles(creds, chatId, files).catch((err) => {
|
|
2568
|
+
logger.error(`[${chatId}] File delivery failed:`, err);
|
|
2569
|
+
});
|
|
2570
|
+
};
|
|
2571
|
+
this.pool = new SessionPool(config.maxSessions, replyHandler, fileHandler);
|
|
2252
2572
|
this.state = { running: true, config, creds, lastError: null };
|
|
2253
2573
|
logger.info("OMP-Wechat poll loop starting", {
|
|
2254
2574
|
maxSessions: config.maxSessions,
|
|
@@ -2393,7 +2713,11 @@ class WeChatBridge {
|
|
|
2393
2713
|
if (!text && !hasImages)
|
|
2394
2714
|
return;
|
|
2395
2715
|
const images = await this.downloadImages(creds, msg, senderId);
|
|
2396
|
-
await
|
|
2716
|
+
const fileTexts = await this.downloadFileTexts(creds, msg, senderId);
|
|
2717
|
+
const fullText = [text, ...fileTexts].filter(Boolean).join(`
|
|
2718
|
+
|
|
2719
|
+
`);
|
|
2720
|
+
await session.prompt(fullText, images);
|
|
2397
2721
|
} catch (err) {
|
|
2398
2722
|
logger.error(`[${senderId}] prompt failed:`, err);
|
|
2399
2723
|
await this.sendReply(creds, senderId, "Processing failed, please try again.");
|
|
@@ -2426,15 +2750,71 @@ class WeChatBridge {
|
|
|
2426
2750
|
}
|
|
2427
2751
|
return results;
|
|
2428
2752
|
}
|
|
2753
|
+
async sendFiles(creds, chatId, files) {
|
|
2754
|
+
const contextToken = this.pool?.getContextToken(chatId) ?? "";
|
|
2755
|
+
if (!contextToken) {
|
|
2756
|
+
logger.warn(`[${chatId}] No context_token, cannot send files`);
|
|
2757
|
+
return;
|
|
2758
|
+
}
|
|
2759
|
+
const config = loadConfig();
|
|
2760
|
+
const maxBytes = (config.maxFileSizeMb ?? 100) * 1024 * 1024;
|
|
2761
|
+
for (const filePath of files) {
|
|
2762
|
+
const result = await uploadAndSendFile(creds, chatId, contextToken, filePath, maxBytes);
|
|
2763
|
+
switch (result.status) {
|
|
2764
|
+
case "sent":
|
|
2765
|
+
logger.info(`[${chatId}] Delivered: ${filePath}`);
|
|
2766
|
+
try {
|
|
2767
|
+
rmSync2(filePath, { force: true });
|
|
2768
|
+
} catch (err) {
|
|
2769
|
+
logger.warn(`[${chatId}] Could not remove delivered file ${filePath}:`, err);
|
|
2770
|
+
}
|
|
2771
|
+
break;
|
|
2772
|
+
case "too-large":
|
|
2773
|
+
logger.warn(`[${chatId}] File too large, skipped: ${filePath}`);
|
|
2774
|
+
await this.sendReply(creds, chatId, `[File not sent: ${basename3(filePath)} exceeds ${Math.round(result.maxBytes / 1024 / 1024)}MB limit]`);
|
|
2775
|
+
break;
|
|
2776
|
+
case "error":
|
|
2777
|
+
logger.error(`[${chatId}] File send failed: ${filePath}: ${result.error}`);
|
|
2778
|
+
await this.sendReply(creds, chatId, `[File delivery failed: ${basename3(filePath)} \u2014 ${result.error}]`);
|
|
2779
|
+
break;
|
|
2780
|
+
}
|
|
2781
|
+
}
|
|
2782
|
+
}
|
|
2783
|
+
async downloadFileTexts(_creds, msg, chatId) {
|
|
2784
|
+
const items = (msg.item_list ?? []).filter((item) => item.type === 4);
|
|
2785
|
+
if (items.length === 0)
|
|
2786
|
+
return [];
|
|
2787
|
+
const results = [];
|
|
2788
|
+
for (const item of items) {
|
|
2789
|
+
const file = item.file_item;
|
|
2790
|
+
const name = file.file_name ?? "unknown";
|
|
2791
|
+
if (!TEXT_FILE_EXTENSIONS[extname2(name).toLowerCase()])
|
|
2792
|
+
continue;
|
|
2793
|
+
const buf = await downloadAndDecrypt(file.media?.encrypt_query_param, file.media?.full_url, undefined, file.media?.aes_key, `file[${chatId}]`);
|
|
2794
|
+
if (!buf)
|
|
2795
|
+
continue;
|
|
2796
|
+
const len = parseInt(file.len ?? "", 10);
|
|
2797
|
+
const text = buf.toString("utf-8");
|
|
2798
|
+
const truncated = text.length > FILE_TEXT_LIMIT ? `${text.slice(0, FILE_TEXT_LIMIT)}
|
|
2799
|
+
... [truncated]` : text;
|
|
2800
|
+
results.push(`[File: ${name}${Number.isFinite(len) && len > 0 ? ` (${formatSize(len)})` : ""}]
|
|
2801
|
+
|
|
2802
|
+
\`\`\`
|
|
2803
|
+
${truncated}
|
|
2804
|
+
\`\`\``);
|
|
2805
|
+
logger.info(`[${chatId}] Extracted text from file: ${name} (${buf.length} bytes)`);
|
|
2806
|
+
}
|
|
2807
|
+
return results;
|
|
2808
|
+
}
|
|
2429
2809
|
async sendReply(creds, chatId, text) {
|
|
2430
2810
|
const contextToken = this.pool?.getContextToken(chatId) ?? "";
|
|
2431
2811
|
if (!contextToken) {
|
|
2432
2812
|
logger.warn(`[${chatId}] No context_token, cannot reply`);
|
|
2433
2813
|
return;
|
|
2434
2814
|
}
|
|
2435
|
-
const chunks = chunkText(text, CHUNK_LIMIT);
|
|
2815
|
+
const chunks = chunkText(stripMarkdown(text), CHUNK_LIMIT);
|
|
2436
2816
|
for (const chunk of chunks) {
|
|
2437
|
-
const clientId = `omp-wechat-${Date.now()}-${
|
|
2817
|
+
const clientId = `omp-wechat-${Date.now()}-${randomBytes5(4).toString("hex")}`;
|
|
2438
2818
|
let retries = 0;
|
|
2439
2819
|
while (retries <= MAX_SEND_RETRIES) {
|
|
2440
2820
|
try {
|
|
@@ -2455,9 +2835,9 @@ class WeChatBridge {
|
|
|
2455
2835
|
}
|
|
2456
2836
|
|
|
2457
2837
|
// src/service.ts
|
|
2458
|
-
import { platform, homedir as
|
|
2459
|
-
import { join as
|
|
2460
|
-
import { existsSync as existsSync3, mkdirSync as
|
|
2838
|
+
import { platform, homedir as homedir8 } from "os";
|
|
2839
|
+
import { join as join9, basename as basename4 } from "path";
|
|
2840
|
+
import { existsSync as existsSync3, mkdirSync as mkdirSync7, writeFileSync as writeFileSync4, rmSync as rmSync3 } from "fs";
|
|
2461
2841
|
var PLIST_LABEL = "com.omp-wechat";
|
|
2462
2842
|
var SERVICE_NAME = "omp-wechat";
|
|
2463
2843
|
function detectPlatform() {
|
|
@@ -2466,18 +2846,19 @@ function detectPlatform() {
|
|
|
2466
2846
|
return "darwin";
|
|
2467
2847
|
if (p === "linux")
|
|
2468
2848
|
return "linux";
|
|
2849
|
+
if (p === "win32")
|
|
2850
|
+
return "win32";
|
|
2469
2851
|
return "other";
|
|
2470
2852
|
}
|
|
2471
2853
|
function getLogDir() {
|
|
2472
|
-
return
|
|
2854
|
+
return join9(homedir8(), ".omp", "logs");
|
|
2473
2855
|
}
|
|
2474
2856
|
function resolveHostBinary() {
|
|
2475
2857
|
const exe = process.execPath || "omp";
|
|
2476
|
-
|
|
2477
|
-
return basename2;
|
|
2858
|
+
return basename4(exe);
|
|
2478
2859
|
}
|
|
2479
2860
|
function plistPath() {
|
|
2480
|
-
return
|
|
2861
|
+
return join9(homedir8(), "Library", "LaunchAgents", `${PLIST_LABEL}.plist`);
|
|
2481
2862
|
}
|
|
2482
2863
|
function generatePlist() {
|
|
2483
2864
|
const omp = resolveHostBinary();
|
|
@@ -2513,16 +2894,16 @@ function generatePlist() {
|
|
|
2513
2894
|
<key>PATH</key>
|
|
2514
2895
|
<string>/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin</string>
|
|
2515
2896
|
<key>HOME</key>
|
|
2516
|
-
<string>${
|
|
2897
|
+
<string>${homedir8()}</string>
|
|
2517
2898
|
</dict>
|
|
2518
2899
|
</dict>
|
|
2519
2900
|
</plist>
|
|
2520
2901
|
`;
|
|
2521
2902
|
}
|
|
2522
2903
|
function installLaunchd() {
|
|
2523
|
-
const dir =
|
|
2524
|
-
|
|
2525
|
-
|
|
2904
|
+
const dir = join9(homedir8(), "Library", "LaunchAgents");
|
|
2905
|
+
mkdirSync7(dir, { recursive: true });
|
|
2906
|
+
mkdirSync7(getLogDir(), { recursive: true });
|
|
2526
2907
|
const plist = plistPath();
|
|
2527
2908
|
if (existsSync3(plist)) {
|
|
2528
2909
|
Bun.spawnSync(["launchctl", "unload", plist], { stderr: "ignore" });
|
|
@@ -2540,7 +2921,7 @@ function uninstallLaunchd() {
|
|
|
2540
2921
|
throw new Error("No launchd service found (may not be installed)");
|
|
2541
2922
|
}
|
|
2542
2923
|
Bun.spawnSync(["launchctl", "unload", plist], { stderr: "inherit" });
|
|
2543
|
-
|
|
2924
|
+
rmSync3(plist);
|
|
2544
2925
|
}
|
|
2545
2926
|
function servicePath() {
|
|
2546
2927
|
return `/etc/systemd/system/${SERVICE_NAME}.service`;
|
|
@@ -2562,7 +2943,7 @@ ExecStart=/bin/sh -c 'while true; do echo "{\\"id\\":\\"ka\\",\\"type\\":\\"get_
|
|
|
2562
2943
|
Restart=always
|
|
2563
2944
|
RestartSec=10
|
|
2564
2945
|
|
|
2565
|
-
Environment=HOME=${
|
|
2946
|
+
Environment=HOME=${homedir8()}
|
|
2566
2947
|
Environment=PATH=/usr/local/bin:/usr/bin:/bin
|
|
2567
2948
|
|
|
2568
2949
|
StandardOutput=null
|
|
@@ -2571,7 +2952,7 @@ StandardError=append:${logDir}/rpc.log
|
|
|
2571
2952
|
NoNewPrivileges=true
|
|
2572
2953
|
ProtectSystem=strict
|
|
2573
2954
|
ProtectHome=read-only
|
|
2574
|
-
ReadWritePaths=${logDir} ${
|
|
2955
|
+
ReadWritePaths=${logDir} ${join9(homedir8(), ".omp-wechat")} ${join9(homedir8(), ".omp")}
|
|
2575
2956
|
PrivateTmp=true
|
|
2576
2957
|
|
|
2577
2958
|
[Install]
|
|
@@ -2580,7 +2961,7 @@ WantedBy=multi-user.target
|
|
|
2580
2961
|
}
|
|
2581
2962
|
function installSystemd() {
|
|
2582
2963
|
const svc = servicePath();
|
|
2583
|
-
|
|
2964
|
+
mkdirSync7(getLogDir(), { recursive: true });
|
|
2584
2965
|
if (existsSync3(svc)) {
|
|
2585
2966
|
Bun.spawnSync(["sudo", "systemctl", "stop", SERVICE_NAME], { stderr: "inherit" });
|
|
2586
2967
|
}
|
|
@@ -2590,7 +2971,7 @@ function installSystemd() {
|
|
|
2590
2971
|
if (result.exitCode !== 0) {
|
|
2591
2972
|
result = Bun.spawnSync(["sudo", "cp", tmp, svc], { stderr: "inherit" });
|
|
2592
2973
|
}
|
|
2593
|
-
|
|
2974
|
+
rmSync3(tmp);
|
|
2594
2975
|
if (result.exitCode !== 0) {
|
|
2595
2976
|
throw new Error("Failed to write service file (need sudo)");
|
|
2596
2977
|
}
|
|
@@ -2610,6 +2991,85 @@ function uninstallSystemd() {
|
|
|
2610
2991
|
Bun.spawnSync(["sudo", "rm", svc], { stderr: "inherit" });
|
|
2611
2992
|
Bun.spawnSync(["sudo", "systemctl", "daemon-reload"], { stderr: "inherit" });
|
|
2612
2993
|
}
|
|
2994
|
+
var WIN_TASK_NAME = "OMP-Wechat";
|
|
2995
|
+
function winScriptPath() {
|
|
2996
|
+
return join9(homedir8(), ".omp-wechat", "omp-wechat-rpc.ps1");
|
|
2997
|
+
}
|
|
2998
|
+
function generateWinScript() {
|
|
2999
|
+
const omp = process.execPath || "omp";
|
|
3000
|
+
const ompEscaped = omp.replace(/'/g, "''");
|
|
3001
|
+
return `# OMP-Wechat RPC heartbeat wrapper \u2014 auto-generated by /wechat install
|
|
3002
|
+
# Pipes a get_state JSON-RPC heartbeat to omp stdin every 5s to keep
|
|
3003
|
+
# the --mode rpc process alive (omp exits on idle stdin without an RPC client).
|
|
3004
|
+
# Outer while-loop restarts omp if it crashes, matching launchd KeepAlive
|
|
3005
|
+
# and systemd Restart=always.
|
|
3006
|
+
$ErrorActionPreference = 'Stop'
|
|
3007
|
+
$ompPath = '${ompEscaped}'
|
|
3008
|
+
$logPath = Join-Path $env:USERPROFILE '.omp\\logs\\rpc.log'
|
|
3009
|
+
$logDir = Split-Path $logPath
|
|
3010
|
+
if (-not (Test-Path $logDir)) { New-Item -ItemType Directory -Force -Path $logDir | Out-Null }
|
|
3011
|
+
while ($true) {
|
|
3012
|
+
$p = $null
|
|
3013
|
+
$errTask = $null
|
|
3014
|
+
try {
|
|
3015
|
+
$psi = New-Object System.Diagnostics.ProcessStartInfo
|
|
3016
|
+
$psi.FileName = $ompPath
|
|
3017
|
+
$psi.Arguments = '--mode rpc --no-title'
|
|
3018
|
+
$psi.UseShellExecute = $false
|
|
3019
|
+
$psi.RedirectStandardInput = $true
|
|
3020
|
+
$psi.RedirectStandardError = $true
|
|
3021
|
+
$p = [System.Diagnostics.Process]::Start($psi)
|
|
3022
|
+
$errTask = $p.StandardError.ReadToEndAsync()
|
|
3023
|
+
while (-not $p.HasExited) {
|
|
3024
|
+
$p.StandardInput.WriteLine('{"id":"ka","type":"get_state"}')
|
|
3025
|
+
Start-Sleep -Seconds 5
|
|
3026
|
+
}
|
|
3027
|
+
} catch {
|
|
3028
|
+
# omp failed to start or exited \u2014 loop will restart
|
|
3029
|
+
} finally {
|
|
3030
|
+
if ($p -and -not $p.HasExited) { $p.Kill() }
|
|
3031
|
+
if ($p) { $p.WaitForExit() }
|
|
3032
|
+
if ($errTask) { $err = $errTask.Result; if ($err) { Add-Content -Path $logPath -Value $err } }
|
|
3033
|
+
}
|
|
3034
|
+
Start-Sleep -Seconds 10
|
|
3035
|
+
}
|
|
3036
|
+
`;
|
|
3037
|
+
}
|
|
3038
|
+
function installWinTask() {
|
|
3039
|
+
mkdirSync7(join9(homedir8(), ".omp-wechat"), { recursive: true });
|
|
3040
|
+
mkdirSync7(getLogDir(), { recursive: true });
|
|
3041
|
+
writeFileSync4(winScriptPath(), generateWinScript());
|
|
3042
|
+
Bun.spawnSync(["schtasks", "/end", "/tn", WIN_TASK_NAME], { stderr: "ignore" });
|
|
3043
|
+
Bun.spawnSync(["schtasks", "/delete", "/tn", WIN_TASK_NAME, "/f"], { stderr: "ignore" });
|
|
3044
|
+
const scriptPath = winScriptPath();
|
|
3045
|
+
const taskCmd = `powershell.exe -NoProfile -ExecutionPolicy Bypass -WindowStyle Hidden -File "${scriptPath}"`;
|
|
3046
|
+
const result = Bun.spawnSync(["schtasks", "/create", "/tn", WIN_TASK_NAME, "/tr", taskCmd, "/sc", "onlogon", "/rl", "limited", "/f"], { stderr: "inherit" });
|
|
3047
|
+
if (result.exitCode !== 0) {
|
|
3048
|
+
throw new Error("schtasks /create failed");
|
|
3049
|
+
}
|
|
3050
|
+
const runResult = Bun.spawnSync(["schtasks", "/run", "/tn", WIN_TASK_NAME], { stderr: "inherit" });
|
|
3051
|
+
if (runResult.exitCode !== 0) {
|
|
3052
|
+
logger.warn(`schtasks /run failed (exit ${runResult.exitCode}) \u2014 task will start at next logon`);
|
|
3053
|
+
}
|
|
3054
|
+
}
|
|
3055
|
+
function uninstallWinTask() {
|
|
3056
|
+
Bun.spawnSync(["schtasks", "/end", "/tn", WIN_TASK_NAME], { stderr: "ignore" });
|
|
3057
|
+
const result = Bun.spawnSync(["schtasks", "/delete", "/tn", WIN_TASK_NAME, "/f"], { stderr: "inherit" });
|
|
3058
|
+
if (result.exitCode !== 0) {
|
|
3059
|
+
throw new Error("Failed to delete scheduled task (may not be installed)");
|
|
3060
|
+
}
|
|
3061
|
+
const script = winScriptPath();
|
|
3062
|
+
if (existsSync3(script)) {
|
|
3063
|
+
rmSync3(script);
|
|
3064
|
+
}
|
|
3065
|
+
}
|
|
3066
|
+
function winTaskExists() {
|
|
3067
|
+
const r = Bun.spawnSync(["schtasks", "/query", "/tn", WIN_TASK_NAME, "/fo", "list"], {
|
|
3068
|
+
stdout: "ignore",
|
|
3069
|
+
stderr: "ignore"
|
|
3070
|
+
});
|
|
3071
|
+
return r.exitCode === 0;
|
|
3072
|
+
}
|
|
2613
3073
|
function installService() {
|
|
2614
3074
|
const p = detectPlatform();
|
|
2615
3075
|
switch (p) {
|
|
@@ -2619,6 +3079,9 @@ function installService() {
|
|
|
2619
3079
|
case "linux":
|
|
2620
3080
|
installSystemd();
|
|
2621
3081
|
return { platform: p, path: servicePath() };
|
|
3082
|
+
case "win32":
|
|
3083
|
+
installWinTask();
|
|
3084
|
+
return { platform: p, path: winScriptPath() };
|
|
2622
3085
|
default:
|
|
2623
3086
|
throw new Error(`Platform ${p} does not support auto-installing a boot service`);
|
|
2624
3087
|
}
|
|
@@ -2632,6 +3095,9 @@ function uninstallService() {
|
|
|
2632
3095
|
case "linux":
|
|
2633
3096
|
uninstallSystemd();
|
|
2634
3097
|
return { platform: p, path: servicePath() };
|
|
3098
|
+
case "win32":
|
|
3099
|
+
uninstallWinTask();
|
|
3100
|
+
return { platform: p, path: winScriptPath() };
|
|
2635
3101
|
default:
|
|
2636
3102
|
throw new Error(`Platform ${p} does not support auto-uninstalling a boot service`);
|
|
2637
3103
|
}
|
|
@@ -2643,6 +3109,8 @@ function isServiceInstalled() {
|
|
|
2643
3109
|
return existsSync3(plistPath());
|
|
2644
3110
|
case "linux":
|
|
2645
3111
|
return existsSync3(servicePath());
|
|
3112
|
+
case "win32":
|
|
3113
|
+
return winTaskExists();
|
|
2646
3114
|
default:
|
|
2647
3115
|
return false;
|
|
2648
3116
|
}
|
|
@@ -2660,7 +3128,7 @@ function wechatExtension(pi) {
|
|
|
2660
3128
|
if (daemonState.running) {
|
|
2661
3129
|
logger.info("WeChat bridge started at extension load");
|
|
2662
3130
|
} else {
|
|
2663
|
-
logger.debug("WeChat bridge
|
|
3131
|
+
logger.debug("WeChat bridge not running, starting 30s retry", { lastError: daemonState.lastError });
|
|
2664
3132
|
setInterval(() => {
|
|
2665
3133
|
if (daemonState?.running)
|
|
2666
3134
|
return;
|
|
@@ -2765,7 +3233,7 @@ function wechatExtension(pi) {
|
|
|
2765
3233
|
const r = installService();
|
|
2766
3234
|
ctx.ui.notify(`Boot service installed (${r.platform}): ${r.path}`, "info");
|
|
2767
3235
|
logger.info(`Service installed on ${r.platform} at ${r.path}
|
|
2768
|
-
` + `OMP will run via launchd
|
|
3236
|
+
` + `OMP will run via ${r.platform === "darwin" ? "launchd" : r.platform === "win32" ? "Task Scheduler" : "systemd"} at boot. ` + `Manage: ${r.platform === "darwin" ? "launchctl start|stop com.omp-wechat" : r.platform === "win32" ? "schtasks /run|/end /tn OMP-Wechat" : "sudo systemctl start|stop omp-wechat"}`);
|
|
2769
3237
|
} catch (err) {
|
|
2770
3238
|
ctx.ui.notify(`Install failed: ${err}`, "error");
|
|
2771
3239
|
}
|