@peng272/dsh-wechat-ilink 0.0.0-stage → 0.7.1

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/CHANGELOG.md ADDED
@@ -0,0 +1,115 @@
1
+ # Changelog
2
+
3
+ All notable changes to **dsh-wechat-ilink** are recorded here.
4
+
5
+ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
+ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
+
8
+ > **Note on versions.** `0.1.0` → `0.6.4` are the *same* number series used during
9
+ > development; the first public release is simply `0.6.4`. Keeping one continuous
10
+ > series is deliberate — if the repository restarted at `0.1.0` while the working
11
+ > copies were already at `0.6.x`, an internal build and a published build could
12
+ > carry the same number and be told apart only by guesswork.
13
+ >
14
+ > The pre-release entries are kept because the bug sequence is the most useful
15
+ > documentation this project has: every one of them was an assumed DSH contract
16
+ > that turned out to be wrong.
17
+
18
+ ## [0.7.1]
19
+
20
+ ### Fixed
21
+
22
+ - **A fresh conversation could never start.** Every turn passed
23
+ `--session-id <sessionId>`, but `dsh --profile headless --session-id` requires
24
+ that session to already exist. On a new install the recorded id
25
+ (`wechat-clawbot`) does not exist yet, so **every** inbound message failed with
26
+
27
+ ```
28
+ dsh: session "wechat-clawbot" does not exist; omit --session-id to start a new Session
29
+ ```
30
+
31
+ and produced no reply. The first turn now omits the flag (which creates the
32
+ session), the id the driver reports is persisted to `<stateDir>/session.json`,
33
+ and later turns resume it. A recorded session that has since disappeared is
34
+ detected and transparently restarted.
35
+
36
+ Found by reading the channel log of a live install; the failure was silent from
37
+ WeChat's side (the message simply got no answer).
38
+
39
+ - Driver-level failures emitted as a `{"type":"error"}` frame are now surfaced in
40
+ the log instead of being reported only as `reason=incomplete`.
41
+
42
+ ### Changed
43
+
44
+ - The package is published as **`@peng272/dsh-wechat-ilink`**. The bare name
45
+ `dsh-wechat-ilink` belongs to another maintainer and cannot be updated from
46
+ this account.
47
+ ## [0.7.0]
48
+
49
+ A reimplementation of the channel internals. The **runtime identities are
50
+ unchanged**, so an existing install upgrades in place.
51
+
52
+ ### Upgrade behaviour (verified)
53
+
54
+ | Thing | Result |
55
+ | --- | --- |
56
+ | Bundle row `id` | unchanged — `wechat-clawbot` |
57
+ | Config keys and defaults | unchanged — the same schema validates your existing row |
58
+ | Bound account | **kept** — read from `<DSH_HOME>/clawbot/accounts.json` |
59
+ | `get_updates_buf` cursor | **kept** — read from `accounts/<id>.sync.json` |
60
+ | Bound WeChat user | **kept** — `accounts/<id>.json` → `userId` |
61
+ | Durable log | same file, same line format (`<stateDir>/channel.log`) |
62
+ | CLI binary | unchanged — `dsh-wechat-ilink` |
63
+
64
+ **No re-scan and no config edit are required.**
65
+
66
+ ### Added
67
+
68
+ - **Single-instance guard.** Two pollers on one bot steal messages from each
69
+ other, silently. A lock file (`<stateDir>/bridge.lock`) records the holder's
70
+ pid; a fresh instance refuses to poll while that pid is alive, and takes over
71
+ when it is genuinely gone. `0.6.x` had no such guard, so a bridge left over
72
+ from a previous run (for example an orphaned child after a supervisor stop)
73
+ could keep polling alongside a new instance.
74
+
75
+ - **Durable log is now written directly by the channel**, independently of DSH's
76
+ own logging, so a failure is diagnosable after the fact even when the plugin
77
+ runs headless.
78
+
79
+ - **Session-expiry re-login.** `errcode -14` now starts a QR re-bind instead of
80
+ only being logged.
81
+
82
+ ### Changed
83
+
84
+ - **The conversation is driven through a `dsh --profile headless --json`
85
+ child process** instead of an in-process `agents.create()` handle. One process
86
+ per message, in exchange for not depending on the in-process agent path at all.
87
+ Consequence: this plugin no longer declares `inject`, so it cannot be blocked
88
+ from activating by a missing service.
89
+
90
+ - **QR codes are rendered by this package** (`lib/qr.js`, byte mode, versions
91
+ 1–10, ECC L/M, own PNG encoder). The `qrcode-terminal` runtime dependency is
92
+ gone; `login` renders in the terminal *and* writes `login-qr.png`.
93
+
94
+ - **The channel no longer carries credentials in the profile config.** They live
95
+ only in `<DSH_HOME>/clawbot/`, as before. A config-supplied `token` is no
96
+ longer read.
97
+
98
+ ### Not carried over from 0.6.5 — read this before upgrading
99
+
100
+ These config keys are still **accepted and validated** (so a 0.6.x row keeps
101
+ loading), but they currently have no effect, and the channel says so in the log
102
+ at startup:
103
+
104
+ - `typing` / `typingKeepaliveMs` — the "typing…" indicator is not implemented.
105
+ - `acceptImages` — inbound images are not handed to the model; image messages are
106
+ ignored (a log line is written).
107
+
108
+ Also not yet reimplemented:
109
+
110
+ - TypeScript declarations (`.d.ts`) — the package is plain ESM JavaScript.
111
+ - `selfTestOnStart` runs a single synthetic turn through the same headless path,
112
+ which is a weaker check than the 0.6.x in-process self-test.
113
+
114
+ `allowedUserIds` keeps its 0.6.x meaning: when empty, only the account that
115
+ scanned the QR code may drive the agent.
package/README.md CHANGED
@@ -1,3 +1,149 @@
1
- # Temporary Holding Version
1
+ # dsh-wechat-ilink
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ WeChat ClawBot (Tencent iLink) channel for **DeepSeek Harness** 0.2.0-rc.2 —
4
+ talk to DSH from your WeChat chat window.
5
+
6
+ ```
7
+ WeChat ──→ iLink gateway ──→ outbound long poll ──→ DSH session ──→ reply ──→ WeChat
8
+ (no public IP, no domain, no port forwarding)
9
+ ```
10
+
11
+ ## Install
12
+
13
+ ```powershell
14
+ dsh plugin --profile <profile> add dsh-wechat-ilink
15
+ ```
16
+
17
+ The bundle inserts one row (`wechat-clawbot`) and its patch carries the default
18
+ config. Nothing else is required: on first start, with no account bound, the
19
+ channel requests a login QR code and prints it — scan it with WeChat and confirm
20
+ on the phone. The credential is stored under `<DSH_HOME>/clawbot/` and reused on
21
+ every later start.
22
+
23
+ ### Upgrading from 0.6.x
24
+
25
+ Nothing to do. The runtime identities are unchanged, so an in-place upgrade keeps
26
+ your config, your bound account, and your message cursor. See
27
+ [CHANGELOG.md](CHANGELOG.md) for the one list that matters: which config keys are
28
+ currently accepted-but-inert (`typing`, `acceptImages`).
29
+
30
+ ## CLI
31
+
32
+ ```powershell
33
+ dsh-wechat-ilink login # scan a QR code to bind a WeChat account
34
+ dsh-wechat-ilink status # binding state and cursor
35
+ dsh-wechat-ilink logs [n] # durable log tail
36
+ dsh-wechat-ilink logout # unbind all accounts
37
+ ```
38
+
39
+ Runs standalone — no DSH runtime needed. Equivalent long form:
40
+ `node lib/cli.js status`.
41
+
42
+ ## Configuration
43
+
44
+ ```yaml
45
+ - id: wechat-clawbot
46
+ name: 'dsh-wechat-ilink'
47
+ config:
48
+ enabled: true
49
+ sessionId: 'wechat-clawbot'
50
+ cwd: 'C:\path\to\your\workspace'
51
+ allowedUserIds: []
52
+ typing: true
53
+ typingKeepaliveMs: 5000
54
+ maxMessageChars: 2000
55
+ acceptImages: true
56
+ progressNotice: true
57
+ turnTimeoutMs: 300000
58
+ selfTestOnStart: false
59
+ selfTestDelayMs: 3000
60
+ logLevel: info
61
+ ```
62
+
63
+ | Key | Default | Meaning |
64
+ | --- | --- | --- |
65
+ | `enabled` | `true` | Master switch. `false` loads the bundle without connecting. |
66
+ | `sessionId` | `wechat-clawbot` | The DSH session the conversation is bound to; reused across restarts. |
67
+ | `cwd` | workspace default | Working directory for the bound session. |
68
+ | `provider` / `model` / `reasoningEffort` | DSH default | Accepted; currently the headless profile decides the route. |
69
+ | `allowedUserIds` | `[]` | Extra WeChat ids allowed to drive the agent. **Empty means only the account that scanned the QR may talk to the bot.** |
70
+ | `typing` / `typingKeepaliveMs` | `true` / `5000` | Accepted but **not implemented in 0.7.0**. |
71
+ | `maxMessageChars` | `2000` | Outbound chunk size in characters. |
72
+ | `acceptImages` | `true` | Accepted but **not implemented in 0.7.0**; image messages are ignored. |
73
+ | `progressNotice` | `true` | Send a short "received, working on it" notice while a turn runs. |
74
+ | `turnTimeoutMs` | `300000` | Abort a turn that runs longer than this. |
75
+ | `selfTestOnStart` | `false` | Run one synthetic turn at startup and log the outcome. |
76
+ | `logLevel` | `info` | `silent` \| `error` \| `info` \| `debug`. `debug` logs inbound text. |
77
+
78
+ Extensions beyond the 0.6.x schema: `baseUrl`, `stateDir`, `outgoingMaxPerSec`,
79
+ `outgoingBurst`, `disableBridge`.
80
+
81
+ ## Where state lives
82
+
83
+ ```
84
+ <DSH_HOME>/clawbot/
85
+ accounts.json index of bound account ids
86
+ accounts/<accountId>.json token, baseUrl, bound userId
87
+ accounts/<accountId>.sync.json get_updates_buf cursor
88
+ channel.log durable log
89
+ bridge.lock single-instance guard
90
+ login-qr.png last rendered login QR (transient)
91
+ ```
92
+
93
+ `DSH_HOME` defaults to `%USERPROFILE%\.dsh`.
94
+
95
+ ## Design notes
96
+
97
+ ### Single-instance guard
98
+
99
+ **Two pollers on one bot steal messages from each other, silently.** The channel
100
+ takes a lock at `<stateDir>/bridge.lock` recording the holder's pid:
101
+
102
+ - holder **alive** → this instance refuses to poll and logs the holder's pid;
103
+ - holder **gone** → stale lock, this instance takes over.
104
+
105
+ The failure this prevents is real: a bridge is typically a supervisor chain
106
+ (`scheduled task → .cmd → node`), and stopping the supervisor kills the outer
107
+ layers while the `node` grandchild survives as an orphan and keeps polling. A
108
+ fresh start then runs alongside it.
109
+
110
+ Shutdown removes only its own lock, so a successor's lock is never deleted.
111
+
112
+ ### Conversation driving
113
+
114
+ Each message runs `dsh --profile headless --json --session-id <id> "<prompt>"`
115
+ and parses the NDJSON it emits (`session` / `final` / `status.turn_end`).
116
+ `--session-id` gives the thread continuity.
117
+
118
+ One process per message, in exchange for not touching the in-process agent path
119
+ at all — which also means this plugin declares no `inject` and cannot be blocked
120
+ from activating by a missing service.
121
+
122
+ ### QR rendering
123
+
124
+ The gateway returns only a **login URL**, never an image:
125
+
126
+ ```
127
+ https://liteapp.weixin.qq.com/q/xxxx?qrcode=<32 hex>&bot_type=3
128
+ ```
129
+
130
+ `lib/qr.js` renders it in-package: byte mode, versions 1–10, ECC L/M, its own PNG
131
+ encoder (1-bit greyscale, `zlib`), and a half-block terminal renderer. No runtime
132
+ dependency on a QR library, and no third-party service ever receives the login
133
+ credential.
134
+
135
+ **Rendering failure never blocks login** — the raw URL is always printed.
136
+
137
+ ## Known limits
138
+
139
+ - The gateway throttles sends: roughly 5–6 messages in a burst earns `ret=-2` for
140
+ about an hour, and **retrying during the penalty makes it worse**. The channel
141
+ paces outbound sends (`outgoingMaxPerSec`, default 0.5/s) and chunks long
142
+ replies to stay under it. Re-scanning resets the penalty but changes the bot
143
+ identity.
144
+ - Config is read at startup: restart the bridge after editing the profile patch.
145
+ - `typing` and `acceptImages` are accepted but inert in 0.7.0.
146
+
147
+ ## License
148
+
149
+ MIT
@@ -0,0 +1,51 @@
1
+ # dsh-wechat-ilink bundle layer for DeepSeek Harness 0.2.0-rc.2.
2
+ #
3
+ # A profile composes its entry list from: each bundle patch in
4
+ # `dsh.profile.bundles` order over an EMPTY root, then the profile's own
5
+ # cordis.patch.yml, then $DSH_HOME/cordis.patch.yml, then --patch overlays.
6
+ #
7
+ # `insert` WITHOUT an `id` appends rows to the root entry list.
8
+ # `name` is a module specifier resolved from the profile dir. This file is
9
+ # consumed from inside the package, so the bare package name resolves via the
10
+ # profile's node_modules.
11
+ #
12
+ # Row id and every config key below are the 0.6.x contract, kept verbatim so an
13
+ # existing profile keeps working across the upgrade without edits.
14
+ - insert:
15
+ - id: wechat-clawbot
16
+ name: '@peng272/dsh-wechat-ilink'
17
+ config:
18
+ # Master switch. Set false to load the bundle without connecting.
19
+ enabled: true
20
+ # The DSH session this WeChat conversation is bound to. Reused across
21
+ # restarts so the thread keeps its context.
22
+ sessionId: 'wechat-clawbot'
23
+ # Working directory for the bound session. Leave empty to inherit the
24
+ # workspace default (which is what you normally want).
25
+ # cwd: 'C:\\path\\to\\your\\workspace'
26
+ # Pin the model for WeChat turns. Leave empty to follow DSH's default.
27
+ # provider: deepseek-account
28
+ # model: deepseek-flash
29
+ # reasoningEffort: off
30
+ # Extra WeChat user ids allowed to drive the agent. When empty, only the
31
+ # account that scanned the QR code may talk to the bot.
32
+ allowedUserIds: []
33
+ # Show "typing…" in WeChat while a turn runs.
34
+ typing: true
35
+ typingKeepaliveMs: 5000
36
+ # Outbound message chunk size (characters).
37
+ maxMessageChars: 2000
38
+ # Hand inbound images to the model as image blocks.
39
+ acceptImages: true
40
+ # Send a short "received, working on it" notice while a turn runs.
41
+ progressNotice: true
42
+ # Abort a turn that runs longer than this.
43
+ turnTimeoutMs: 300000
44
+ # Startup self-test: run ONE synthetic turn through the DSH agent path
45
+ # and log the outcome, so the bridge is verified without needing an
46
+ # inbound WeChat message.
47
+ selfTestOnStart: true
48
+ selfTestDelayMs: 3000
49
+ # 'silent' | 'error' | 'info' | 'debug'
50
+ # 'debug' is worth using while diagnosing: it logs the inbound text.
51
+ logLevel: info
@@ -0,0 +1,145 @@
1
+ /**
2
+ * Credential and cursor persistence for the WeChat ClawBot channel.
3
+ *
4
+ * State lives under `<DSH_HOME>/clawbot/`:
5
+ * accounts.json — index of bound account ids
6
+ * accounts/<accountId>.json — bot token, base url, bound user id
7
+ * accounts/<accountId>.sync.json — opaque `get_updates_buf` cursor
8
+ *
9
+ * Credentials are written with mode 0600 where the platform supports it.
10
+ *
11
+ * NOTE: this layout is a STABLE RUNTIME IDENTITY. It is deliberately byte- and
12
+ * path-compatible with the 0.6.x series so an upgrade keeps the bound account,
13
+ * the cursor, and the user's conversation. Do not rename or relocate it.
14
+ */
15
+ import fs from 'node:fs'
16
+ import path from 'node:path'
17
+
18
+ /** Resolve the channel state directory (respects `DSH_HOME`). */
19
+ export function resolveStateDir(env = process.env) {
20
+ const home = env.DSH_HOME?.trim() || path.join(env.USERPROFILE ?? env.HOME ?? '.', '.dsh')
21
+ return path.join(home, 'clawbot')
22
+ }
23
+
24
+ export class AccountStore {
25
+ #dir
26
+ #accountsDir
27
+
28
+ constructor(stateDir) {
29
+ this.#dir = stateDir ?? resolveStateDir()
30
+ this.#accountsDir = path.join(this.#dir, 'accounts')
31
+ }
32
+
33
+ get dir() {
34
+ return this.#dir
35
+ }
36
+
37
+ #indexPath() {
38
+ return path.join(this.#dir, 'accounts.json')
39
+ }
40
+
41
+ #accountPath(accountId) {
42
+ return path.join(this.#accountsDir, `${accountId}.json`)
43
+ }
44
+
45
+ #syncPath(accountId) {
46
+ return path.join(this.#accountsDir, `${accountId}.sync.json`)
47
+ }
48
+
49
+ /** All bound account ids, oldest first. */
50
+ listAccountIds() {
51
+ try {
52
+ const parsed = JSON.parse(fs.readFileSync(this.#indexPath(), 'utf-8'))
53
+ if (!Array.isArray(parsed)) return []
54
+ return parsed.filter((id) => typeof id === 'string' && id.trim() !== '')
55
+ } catch {
56
+ return []
57
+ }
58
+ }
59
+
60
+ registerAccountId(accountId) {
61
+ fs.mkdirSync(this.#dir, { recursive: true })
62
+ const existing = this.listAccountIds()
63
+ if (existing.includes(accountId)) return
64
+ fs.writeFileSync(this.#indexPath(), JSON.stringify([...existing, accountId], null, 2), 'utf-8')
65
+ }
66
+
67
+ unregisterAccountId(accountId) {
68
+ const existing = this.listAccountIds()
69
+ const updated = existing.filter((id) => id !== accountId)
70
+ if (updated.length !== existing.length) {
71
+ fs.mkdirSync(this.#dir, { recursive: true })
72
+ fs.writeFileSync(this.#indexPath(), JSON.stringify(updated, null, 2), 'utf-8')
73
+ }
74
+ }
75
+
76
+ load(accountId) {
77
+ try {
78
+ return JSON.parse(fs.readFileSync(this.#accountPath(accountId), 'utf-8'))
79
+ } catch {
80
+ return null
81
+ }
82
+ }
83
+
84
+ /** Persist credentials, merging into any existing record. */
85
+ save(accountId, update) {
86
+ fs.mkdirSync(this.#accountsDir, { recursive: true })
87
+ const existing = this.load(accountId) ?? {}
88
+ const token = update.token?.trim() || existing.token
89
+ const baseUrl = update.baseUrl?.trim() || existing.baseUrl
90
+ const userId =
91
+ update.userId !== undefined ? update.userId.trim() || undefined : existing.userId?.trim() || undefined
92
+ const data = {
93
+ ...(token ? { token, savedAt: new Date().toISOString() } : {}),
94
+ ...(baseUrl ? { baseUrl } : {}),
95
+ ...(userId ? { userId } : {}),
96
+ }
97
+ const file = this.#accountPath(accountId)
98
+ fs.writeFileSync(file, JSON.stringify(data, null, 2), 'utf-8')
99
+ try {
100
+ fs.chmodSync(file, 0o600)
101
+ } catch {
102
+ // Best effort; Windows ACLs do not map onto POSIX modes.
103
+ }
104
+ }
105
+
106
+ /** Remove credentials and cursors for one account. */
107
+ clear(accountId) {
108
+ for (const file of [this.#accountPath(accountId), this.#syncPath(accountId)]) {
109
+ try {
110
+ fs.unlinkSync(file)
111
+ } catch {
112
+ // Already gone.
113
+ }
114
+ }
115
+ this.unregisterAccountId(accountId)
116
+ }
117
+
118
+ loadSyncBuf(accountId) {
119
+ try {
120
+ const parsed = JSON.parse(fs.readFileSync(this.#syncPath(accountId), 'utf-8'))
121
+ return typeof parsed.get_updates_buf === 'string' ? parsed.get_updates_buf : ''
122
+ } catch {
123
+ return ''
124
+ }
125
+ }
126
+
127
+ saveSyncBuf(accountId, getUpdatesBuf) {
128
+ fs.mkdirSync(this.#accountsDir, { recursive: true })
129
+ const file = this.#syncPath(accountId)
130
+ fs.writeFileSync(file, JSON.stringify({ get_updates_buf: getUpdatesBuf }, null, 2), 'utf-8')
131
+ try {
132
+ fs.chmodSync(file, 0o600)
133
+ } catch {
134
+ // Best effort.
135
+ }
136
+ }
137
+
138
+ clearSyncBuf(accountId) {
139
+ try {
140
+ fs.unlinkSync(this.#syncPath(accountId))
141
+ } catch {
142
+ // Already gone.
143
+ }
144
+ }
145
+ }
package/lib/cli.js ADDED
@@ -0,0 +1,166 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * `dsh-wechat-ilink` CLI — QR binding and diagnostics.
4
+ *
5
+ * dsh-wechat-ilink login scan a QR code to bind a WeChat account
6
+ * dsh-wechat-ilink status show binding state and cursor
7
+ * dsh-wechat-ilink logs [n] print the channel's durable log tail
8
+ * dsh-wechat-ilink logout unbind all accounts
9
+ *
10
+ * Equivalent long form, if you prefer not to rely on the bin shim:
11
+ *
12
+ * node lib/cli.js login
13
+ *
14
+ * Runs standalone (no DSH runtime needed): it only touches the iLink protocol
15
+ * layer and the on-disk credential store.
16
+ */
17
+ import fs from 'node:fs'
18
+ import path from 'node:path'
19
+
20
+ import { AccountStore, resolveStateDir } from './accounts.js'
21
+ import { ILinkClient, QrLoginManager, DEFAULT_BASE_URL, renderQrPng } from './ilink.js'
22
+ import { redactToken, readLogTail } from './support.js'
23
+
24
+ const CHANNEL_VERSION = '2.4.9'
25
+
26
+ function makeRuntime() {
27
+ const store = new AccountStore(resolveStateDir())
28
+ const client = new ILinkClient({
29
+ baseUrl: DEFAULT_BASE_URL,
30
+ channelVersion: CHANNEL_VERSION,
31
+ botAgent: 'DSH-ClawBot/0.1.0',
32
+ appId: 'bot',
33
+ })
34
+ return { store, client, login: new QrLoginManager({ store, client }) }
35
+ }
36
+
37
+ function out(line = '') {
38
+ process.stdout.write(`${line}\n`)
39
+ }
40
+
41
+ async function cmdLogin() {
42
+ const { store, login } = makeRuntime()
43
+ out('正在向微信申请登录二维码…')
44
+ const ticket = await login.start()
45
+
46
+ out('\n请用手机微信扫描下面的二维码(微信 → 扫一扫):\n')
47
+ try {
48
+ const { art } = renderQrPng(ticket.url)
49
+ out(art)
50
+ } catch (error) {
51
+ out(`(二维码渲染失败:${error?.message ?? error})`)
52
+ }
53
+ out(`\n二维码链接:${ticket.url}`)
54
+ out('\n等待扫码确认…(Ctrl+C 取消)')
55
+
56
+ for (;;) {
57
+ const outcome = await login.poll({ ticketId: ticket.id })
58
+ switch (outcome.status) {
59
+ case 'confirmed': {
60
+ out('\n✅ 绑定成功!')
61
+ out(` 账号 ${outcome.accountId}`)
62
+ out(` 凭据 ${store.dir}`)
63
+ out('\n重启 DSH(或在 DSH 里重新加载插件)后即可在微信里发消息。')
64
+ return 0
65
+ }
66
+ case 'scaned':
67
+ out(' 已扫码,请在手机上点击确认…')
68
+ break
69
+ case 'expired':
70
+ out('\n❌ 二维码已过期,请重新运行 login。')
71
+ return 1
72
+ default:
73
+ break
74
+ }
75
+ await new Promise((r) => setTimeout(r, 2000))
76
+ }
77
+ }
78
+
79
+ async function cmdStatus() {
80
+ const { store } = makeRuntime()
81
+ const ids = store.listAccountIds()
82
+ out(`状态目录 ${store.dir}`)
83
+ if (ids.length === 0) {
84
+ out('绑定账号 (无)')
85
+ out('\n还没有绑定微信账号。运行:dsh-wechat-ilink login')
86
+ return 0
87
+ }
88
+ out(`绑定账号 ${ids.length} 个`)
89
+ for (const id of ids) {
90
+ const record = store.load(id)
91
+ const cursor = store.loadSyncBuf(id)
92
+ out('')
93
+ out(` accountId ${id}`)
94
+ out(` token ${redactToken(record?.token)}`)
95
+ out(` baseUrl ${record?.baseUrl ?? '(默认)'}`)
96
+ out(` userId ${record?.userId ?? '(未知,等对方先发一条消息)'}`)
97
+ out(` savedAt ${record?.savedAt ?? '(未知)'}`)
98
+ out(` cursor ${cursor ? `${cursor.length} 字符` : '(空)'}`)
99
+ }
100
+ return 0
101
+ }
102
+
103
+ function cmdLogs(args) {
104
+ const n = Number(args[0])
105
+ const lines = Number.isFinite(n) && n > 0 ? n : 40
106
+ const file = path.join(resolveStateDir(), 'channel.log')
107
+ if (!fs.existsSync(file)) {
108
+ out(`没有日志文件:${file}`)
109
+ out('(插件还没运行过,或用了自定义 stateDir)')
110
+ return 0
111
+ }
112
+ out(`--- ${file} (末 ${lines} 行) ---`)
113
+ for (const line of readLogTail(file, lines)) out(line)
114
+ return 0
115
+ }
116
+
117
+ function cmdLogout() {
118
+ const { store } = makeRuntime()
119
+ const ids = store.listAccountIds()
120
+ if (ids.length === 0) {
121
+ out('没有已绑定的账号。')
122
+ return 0
123
+ }
124
+ for (const id of ids) {
125
+ store.clear(id)
126
+ out(`已解除绑定:${id}`)
127
+ }
128
+ out('\n凭据与游标已删除。下次启动 DSH 时会重新要求扫码。')
129
+ return 0
130
+ }
131
+
132
+ function usage() {
133
+ out('dsh-wechat-ilink — 微信 ClawBot 通道')
134
+ out('')
135
+ out('用法:')
136
+ out(' dsh-wechat-ilink login 扫码绑定一个微信账号')
137
+ out(' dsh-wechat-ilink status 显示绑定状态与游标')
138
+ out(' dsh-wechat-ilink logs [n] 打印通道日志尾部')
139
+ out(' dsh-wechat-ilink logout 解除全部绑定')
140
+ return 0
141
+ }
142
+
143
+ const [command, ...rest] = process.argv.slice(2)
144
+ let code
145
+ try {
146
+ switch (command) {
147
+ case 'login':
148
+ code = await cmdLogin()
149
+ break
150
+ case 'status':
151
+ code = await cmdStatus()
152
+ break
153
+ case 'logs':
154
+ code = cmdLogs(rest)
155
+ break
156
+ case 'logout':
157
+ code = cmdLogout()
158
+ break
159
+ default:
160
+ code = usage()
161
+ }
162
+ } catch (error) {
163
+ process.stderr.write(`dsh-wechat-ilink: ${error?.message ?? error}\n`)
164
+ code = 1
165
+ }
166
+ process.exit(code)