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.
Files changed (3) hide show
  1. package/README.md +48 -10
  2. package/dist/index.js +505 -37
  3. 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) or systemd (Linux) service that runs the host (`omp --mode rpc` or `pi --mode rpc`) at boot. 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). `KeepAlive`/`Restart=always` handles crashes and reboots.
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: `launchctl start|stop com.omp-wechat` (macOS) or `sudo systemctl start|stop omp-wechat` (Linux)
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 | Service auto-starts the host (if installed), poll loop resumes |
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 are downloaded from WeChat CDN, AES-decrypted, and passed to the vision model (if `modelRoles.vision` is configured); voice/video remain as placeholders
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
- return new Date().toISOString();
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
- logger.error("Not logged in \u2014 run: omp-wechat login");
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
- parts.push(`(file: ${item.file_item?.file_name ?? "unknown"})`);
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 randomBytes3 } from "crypto";
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 statSync2, rmSync } from "fs";
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 = statSync2(join7(dir, file)).mtimeMs;
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
- constructor(session, chatId, contextToken) {
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
- constructor(maxSessions, replyHandler) {
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
- const creds = getCredentials();
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
- this.pool = new SessionPool(config.maxSessions, replyHandler);
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 session.prompt(text, images);
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()}-${randomBytes3(4).toString("hex")}`;
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 homedir7 } from "os";
2459
- import { join as join8 } from "path";
2460
- import { existsSync as existsSync3, mkdirSync as mkdirSync6, writeFileSync as writeFileSync4, rmSync as rmSync2 } from "fs";
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 join8(homedir7(), ".omp", "logs");
2854
+ return join9(homedir8(), ".omp", "logs");
2473
2855
  }
2474
2856
  function resolveHostBinary() {
2475
2857
  const exe = process.execPath || "omp";
2476
- const basename2 = exe.split("/").pop() || "omp";
2477
- return basename2;
2858
+ return basename4(exe);
2478
2859
  }
2479
2860
  function plistPath() {
2480
- return join8(homedir7(), "Library", "LaunchAgents", `${PLIST_LABEL}.plist`);
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>${homedir7()}</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 = join8(homedir7(), "Library", "LaunchAgents");
2524
- mkdirSync6(dir, { recursive: true });
2525
- mkdirSync6(getLogDir(), { recursive: true });
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
- rmSync2(plist);
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=${homedir7()}
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} ${join8(homedir7(), ".omp-wechat")} ${join8(homedir7(), ".omp")}
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
- mkdirSync6(getLogDir(), { recursive: true });
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
- rmSync2(tmp);
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: another instance holds the lock, starting failover watch");
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/systemd at boot. ` + `Manage: ${r.platform === "darwin" ? "launchctl start|stop com.omp-wechat" : "sudo systemctl start|stop omp-wechat"}`);
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
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "omp-wechat",
3
- "version": "1.4.1",
3
+ "version": "1.6.0",
4
4
  "description": "OMP/Pi extension: bridge WeChat messages to OMP's AI engine via the iLink Bot API",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",