@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 +115 -0
- package/README.md +148 -2
- package/cordis.patch.yml +51 -0
- package/lib/accounts.js +145 -0
- package/lib/cli.js +166 -0
- package/lib/ilink.js +164 -0
- package/lib/index.js +452 -0
- package/lib/qr.js +531 -0
- package/lib/schema.js +122 -0
- package/lib/support.js +245 -0
- package/package.json +33 -3
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
|
-
#
|
|
1
|
+
# dsh-wechat-ilink
|
|
2
2
|
|
|
3
|
-
|
|
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
|
package/cordis.patch.yml
ADDED
|
@@ -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
|
package/lib/accounts.js
ADDED
|
@@ -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)
|