@peng272/dsh-wechat-ilink 0.7.1 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -15,6 +15,55 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
15
15
  > documentation this project has: every one of them was an assumed DSH contract
16
16
  > that turned out to be wrong.
17
17
 
18
+ ## [0.8.0]
19
+
20
+ ### Added
21
+
22
+ - **Outbound media: the channel can now send a file into the WeChat chat.**
23
+ `dsh-wechat-ilink send <path>` uploads a local file and delivers it to the
24
+ bound user. Images travel as image items (rendered inline) and everything else
25
+ as a file item (a downloadable attachment).
26
+
27
+ The protocol was already documented inside the 0.6.x sources — `getuploadurl`
28
+ -> AES-128-ECB -> CDN upload -> `x-encrypted-param` -> media item — but
29
+ `CdnClient.upload()` was never wired to anything, so neither series could
30
+ actually send media. It is implemented now in `lib/cdn.js`, and the upload
31
+ request parameters come from the 0.6.x `GetUploadUrlReq` type.
32
+
33
+ - The channel persists the newest `context_token` to `<stateDir>/outbound.json`
34
+ as inbound messages arrive. The gateway requires it on every send, and without
35
+ it a shell command started later has nothing to send with.
36
+
37
+ ### Note
38
+
39
+ - Sending media is subject to the same gateway throttling as text, and a file is
40
+ far heavier than a message. Large files may be rejected by the gateway; treat
41
+ a failure as a normal outcome and fall back to a link.
42
+
43
+ - **Images are sent as files, not inline.** The image path (`media_type: 1`) is
44
+ not usable from this client: the gateway issues an upload URL for it, but the
45
+ CDN then answers
46
+
47
+ ```
48
+ HTTP 400 x-error-code: -5102031
49
+ ```
50
+
51
+ for bytes that the *same* request uploads successfully as `media_type: 3`.
52
+ `GetUploadUrlReq` carries `thumb_rawsize` / `thumb_rawfilemd5` and
53
+ `GetUploadUrlResp` carries `thumb_upload_param`, so the image path evidently
54
+ expects thumbnail metadata this client does not produce (resizing needs an
55
+ image codec). Pictures therefore arrive as downloadable attachments rather
56
+ than rendering inline. Verified with both a 382-byte and a 494-byte PNG, and
57
+ against a 5712-byte document that uploaded fine.## [0.7.2]
58
+
59
+ ### Fixed
60
+
61
+ - **The startup banner reported the wrong version.** `PLUGIN_VERSION` was a
62
+ hardcoded literal mirrored from `package.json`, and it drifted on the first
63
+ release that bumped the version: 0.7.1 was loaded while the banner said 0.7.0.
64
+ Since the banner is deliberately the way a loaded build is identified, that
65
+ defeats its purpose. The version is now read from this package's own
66
+ `package.json` at load, so it cannot drift again.
18
67
  ## [0.7.1]
19
68
 
20
69
  ### Fixed
package/lib/cdn.js ADDED
@@ -0,0 +1,218 @@
1
+ /**
2
+ * WeChat CDN media transport.
3
+ *
4
+ * Outbound: `ilink/bot/getuploadurl` -> AES-128-ECB encrypt -> POST the
5
+ * ciphertext to the CDN -> use the `x-encrypted-param` response header as
6
+ * `encrypt_query_param` in the sent message item.
7
+ *
8
+ * Inbound: GET `/download?encrypted_query_param=...` -> AES-128-ECB decrypt.
9
+ *
10
+ * `aes_key` on the wire has two encodings; inbound decoding must accept both.
11
+ */
12
+ import crypto from 'node:crypto'
13
+
14
+ export const CDN_BASE_URL = 'https://novac2c.cdn.weixin.qq.com/c2c'
15
+
16
+ /** Message item types (subset used here). */
17
+ export const MessageItemType = { TEXT: 1, IMAGE: 2, VOICE: 3, FILE: 4 }
18
+
19
+ /** Requested upload kind. */
20
+ export const UploadMediaType = { IMAGE: 1, VIDEO: 2, FILE: 3, VOICE: 4 }
21
+
22
+ /** Ciphertext length after PKCS7 padding. */
23
+ export function encryptedSize(plaintextSize) {
24
+ return Math.ceil((plaintextSize + 1) / 16) * 16
25
+ }
26
+
27
+ /** Random 16-byte key, hex encoded (32 chars). */
28
+ export function randomAesKeyHex() {
29
+ return crypto.randomBytes(16).toString('hex')
30
+ }
31
+
32
+ /** Random 16-byte file key, hex encoded. */
33
+ export function randomFileKey() {
34
+ return crypto.randomBytes(16).toString('hex')
35
+ }
36
+
37
+ export function md5Hex(data) {
38
+ return crypto.createHash('md5').update(data).digest('hex')
39
+ }
40
+
41
+ export function encryptAesEcb(plaintext, key) {
42
+ const cipher = crypto.createCipheriv('aes-128-ecb', key, null)
43
+ return Buffer.concat([cipher.update(plaintext), cipher.final()])
44
+ }
45
+
46
+ export function decryptAesEcb(ciphertext, key) {
47
+ const decipher = crypto.createDecipheriv('aes-128-ecb', key, null)
48
+ return Buffer.concat([decipher.update(ciphertext), decipher.final()])
49
+ }
50
+
51
+ /**
52
+ * Decode a wire `aes_key` into a raw 16-byte key.
53
+ *
54
+ * Accepts both encodings seen in the wild:
55
+ * A. base64(raw 16 bytes)
56
+ * B. base64(32-char hex string)
57
+ */
58
+ export function decodeAesKey(aesKey) {
59
+ if (!aesKey) return null
60
+ const trimmed = String(aesKey).trim()
61
+ try {
62
+ const a = Buffer.from(trimmed, 'base64')
63
+ if (a.length === 16) return a
64
+ if (a.length === 32 && /^[0-9a-fA-F]{32}$/.test(a.toString('utf-8'))) {
65
+ return Buffer.from(a.toString('utf-8'), 'hex')
66
+ }
67
+ } catch {
68
+ /* fall through */
69
+ }
70
+ // Bare hex (32 chars) — as used by `image_item.aeskey`.
71
+ if (/^[0-9a-fA-F]{32}$/.test(trimmed)) return Buffer.from(trimmed, 'hex')
72
+ return null
73
+ }
74
+
75
+ /** Encode a raw 16-byte key the way official clients do for outbound media. */
76
+ export function encodeAesKeyForWire(keyHex) {
77
+ return Buffer.from(keyHex, 'utf-8').toString('base64')
78
+ }
79
+
80
+ /** Identify an image type from its magic bytes; null when it is not an image. */
81
+ export function sniffImageMediaType(bytes) {
82
+ if (bytes.length >= 8 && bytes.subarray(0, 8).equals(Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]))) {
83
+ return 'image/png'
84
+ }
85
+ if (bytes.length >= 3 && bytes[0] === 0xff && bytes[1] === 0xd8 && bytes[2] === 0xff) return 'image/jpeg'
86
+ if (
87
+ bytes.length >= 12 &&
88
+ bytes.subarray(0, 4).toString('ascii') === 'RIFF' &&
89
+ bytes.subarray(8, 12).toString('ascii') === 'WEBP'
90
+ ) {
91
+ return 'image/webp'
92
+ }
93
+ if (bytes.length >= 6 && bytes.subarray(0, 6).toString('ascii').startsWith('GIF8')) return 'image/gif'
94
+ if (bytes.length >= 12 && bytes.subarray(4, 12).toString('ascii').startsWith('ftyp')) return 'image/heic'
95
+ return null
96
+ }
97
+
98
+ /** Wire format of a CDN media reference. */
99
+ export function cdnMedia(encryptQueryParam, keyHex) {
100
+ return {
101
+ encrypt_query_param: encryptQueryParam,
102
+ aes_key: encodeAesKeyForWire(keyHex),
103
+ encrypt_type: 0,
104
+ }
105
+ }
106
+
107
+ /**
108
+ * Build the outbound message item for an uploaded media object.
109
+ *
110
+ * @param {Buffer} plaintext the exact bytes that were encrypted
111
+ * @param {{mediaType: 1|2|3|4, encryptQueryParam: string, keyHex: string, fileName?: string}} uploaded
112
+ */
113
+ export function buildMediaItem(plaintext, uploaded) {
114
+ const media = cdnMedia(uploaded.encryptQueryParam, uploaded.keyHex)
115
+ if (uploaded.mediaType === UploadMediaType.IMAGE) {
116
+ return {
117
+ type: MessageItemType.IMAGE,
118
+ image_item: {
119
+ media,
120
+ // Raw 32-char hex is preferred over media.aes_key for images.
121
+ aeskey: uploaded.keyHex,
122
+ mid_size: plaintext.length,
123
+ },
124
+ }
125
+ }
126
+ return {
127
+ type: MessageItemType.FILE,
128
+ file_item: {
129
+ media,
130
+ file_name: uploaded.fileName || 'file',
131
+ md5: md5Hex(plaintext),
132
+ // Plaintext size, as a string.
133
+ len: String(plaintext.length),
134
+ },
135
+ }
136
+ }
137
+
138
+ export class CdnClient {
139
+ #cdnBaseUrl
140
+ #fetch
141
+
142
+ constructor(opts = {}) {
143
+ this.#cdnBaseUrl = opts.cdnBaseUrl?.trim() || CDN_BASE_URL
144
+ this.#fetch = opts.fetchImpl ?? globalThis.fetch
145
+ }
146
+
147
+ /**
148
+ * Upload ciphertext to the CDN and return the `x-encrypted-param` value.
149
+ * `uploadFullUrl` (when the server provided one) takes precedence.
150
+ */
151
+ async upload(params) {
152
+ const url = params.uploadFullUrl?.trim()
153
+ ? params.uploadFullUrl.trim()
154
+ : (() => {
155
+ const base = this.#cdnBaseUrl.endsWith('/') ? this.#cdnBaseUrl : `${this.#cdnBaseUrl}/`
156
+ const u = new URL('upload', base)
157
+ u.searchParams.set('encrypted_query_param', params.encryptedQueryParam ?? '')
158
+ u.searchParams.set('filekey', params.fileKey)
159
+ return u.toString()
160
+ })()
161
+ const res = await this.#fetch(url, {
162
+ method: 'POST',
163
+ headers: { 'Content-Type': 'application/octet-stream' },
164
+ body: new Uint8Array(params.ciphertext),
165
+ ...(params.signal ? { signal: params.signal } : {}),
166
+ })
167
+ if (!res.ok) {
168
+ const detail = res.headers.get('x-error-message') ?? ''
169
+ throw new Error(`CDN upload HTTP ${res.status}${detail ? `: ${detail}` : ''}`)
170
+ }
171
+ const param = res.headers.get('x-encrypted-param')
172
+ if (!param) throw new Error('CDN upload succeeded but returned no x-encrypted-param header')
173
+ return param
174
+ }
175
+
176
+ /** Download and AES-128-ECB decrypt one CDN object. */
177
+ async download(params) {
178
+ const url = params.fullUrl?.trim()
179
+ ? params.fullUrl.trim()
180
+ : (() => {
181
+ const base = this.#cdnBaseUrl.endsWith('/') ? this.#cdnBaseUrl : `${this.#cdnBaseUrl}/`
182
+ const u = new URL('download', base)
183
+ u.searchParams.set('encrypted_query_param', params.encryptQueryParam ?? '')
184
+ return u.toString()
185
+ })()
186
+ const res = await this.#fetch(url, { ...(params.signal ? { signal: params.signal } : {}) })
187
+ if (!res.ok) throw new Error(`CDN download HTTP ${res.status}`)
188
+ const ciphertext = Buffer.from(await res.arrayBuffer())
189
+ const key = decodeAesKey(params.aesKeyHex)
190
+ if (!key) throw new Error('CDN download: could not decode aes_key')
191
+ return decryptAesEcb(ciphertext, key)
192
+ }
193
+
194
+ /**
195
+ * Full outbound pipeline: encrypt, upload, and return everything the message
196
+ * item needs.
197
+ *
198
+ * @param {{plaintext: Buffer, fileKey: string, uploadParam: string, uploadFullUrl?: string, mediaType: number, signal?: AbortSignal}} params
199
+ * @returns {Promise<{encryptQueryParam: string, aesKeyHex: string, fileKey: string, cipherSize: number}>}
200
+ */
201
+ async uploadEncrypted(params) {
202
+ const keyHex = randomAesKeyHex()
203
+ const ciphertext = encryptAesEcb(params.plaintext, Buffer.from(keyHex, 'hex'))
204
+ const encryptQueryParam = await this.upload({
205
+ ...(params.uploadFullUrl ? { uploadFullUrl: params.uploadFullUrl } : {}),
206
+ encryptedQueryParam: params.uploadParam,
207
+ fileKey: params.fileKey,
208
+ ciphertext,
209
+ ...(params.signal ? { signal: params.signal } : {}),
210
+ })
211
+ return {
212
+ encryptQueryParam,
213
+ aesKeyHex: keyHex,
214
+ fileKey: params.fileKey,
215
+ cipherSize: ciphertext.length,
216
+ }
217
+ }
218
+ }
package/lib/cli.js CHANGED
@@ -18,7 +18,7 @@ import fs from 'node:fs'
18
18
  import path from 'node:path'
19
19
 
20
20
  import { AccountStore, resolveStateDir } from './accounts.js'
21
- import { ILinkClient, QrLoginManager, DEFAULT_BASE_URL, renderQrPng } from './ilink.js'
21
+ import { ILinkClient, QrLoginManager, DEFAULT_BASE_URL, renderQrPng, sendLocalFile } from './ilink.js'
22
22
  import { redactToken, readLogTail } from './support.js'
23
23
 
24
24
  const CHANNEL_VERSION = '2.4.9'
@@ -129,14 +129,81 @@ function cmdLogout() {
129
129
  return 0
130
130
  }
131
131
 
132
+ /**
133
+ * Send one local file to the bound WeChat user.
134
+ *
135
+ * This is the entry point the agent itself uses: it runs in its own shell, so a
136
+ * plain command is all it needs to hand a file back over the chat.
137
+ */
138
+ async function cmdSend(args) {
139
+ const target = args[0]
140
+ if (!target) {
141
+ out('用法: dsh-wechat-ilink send <文件路径>')
142
+ out(' 把本机的一个文件发送给已绑定的微信用户。')
143
+ return 1
144
+ }
145
+ const abs = path.resolve(target)
146
+ if (!fs.existsSync(abs)) {
147
+ out(`文件不存在: ${abs}`)
148
+ return 1
149
+ }
150
+ if (!fs.statSync(abs).isFile()) {
151
+ out(`不是文件(目录暂不支持): ${abs}`)
152
+ return 1
153
+ }
154
+
155
+ const { store, client } = makeRuntime()
156
+ const ids = store.listAccountIds()
157
+ if (ids.length === 0) {
158
+ out('还没有绑定微信账号。先运行: dsh-wechat-ilink login')
159
+ return 1
160
+ }
161
+ const account = store.load(ids[0])
162
+ if (!account?.token) {
163
+ out('账号记录里没有 token,请重新 login。')
164
+ return 1
165
+ }
166
+
167
+ // The gateway requires the newest `context_token`, which only arrives with an
168
+ // inbound message; the channel persists it for exactly this use.
169
+ const inboundFile = path.join(store.dir, 'outbound.json')
170
+ if (!fs.existsSync(inboundFile)) {
171
+ out('还拿不到 context_token —— 请先在微信里给机器人发一条消息,再重试。')
172
+ return 1
173
+ }
174
+ const inbound = JSON.parse(fs.readFileSync(inboundFile, 'utf-8'))
175
+ if (!inbound?.userId || !inbound?.contextToken) {
176
+ out(`${inboundFile} 里缺少 userId / contextToken,请先在微信里发一条消息。`)
177
+ return 1
178
+ }
179
+
180
+ const size = fs.statSync(abs).size
181
+ out(`发送中 ${path.basename(abs)} (${size} 字节) → ${inbound.userId}`)
182
+ try {
183
+ const result = await sendLocalFile({
184
+ client,
185
+ token: account.token,
186
+ toUserId: inbound.userId,
187
+ contextToken: inbound.contextToken,
188
+ filePath: abs,
189
+ })
190
+ out(`✅ 已发送 类型=${result.kind} 文件名=${result.fileName} 字节=${result.bytes}`)
191
+ return 0
192
+ } catch (error) {
193
+ out(`❌ 发送失败: ${error?.message ?? error}`)
194
+ return 1
195
+ }
196
+ }
197
+
132
198
  function usage() {
133
199
  out('dsh-wechat-ilink — 微信 ClawBot 通道')
134
200
  out('')
135
201
  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 解除全部绑定')
202
+ out(' dsh-wechat-ilink login 扫码绑定一个微信账号')
203
+ out(' dsh-wechat-ilink status 显示绑定状态与游标')
204
+ out(' dsh-wechat-ilink send <路径> 把本机文件发送给已绑定的微信用户')
205
+ out(' dsh-wechat-ilink logs [n] 打印通道日志尾部')
206
+ out(' dsh-wechat-ilink logout 解除全部绑定')
140
207
  return 0
141
208
  }
142
209
 
@@ -150,6 +217,9 @@ try {
150
217
  case 'status':
151
218
  code = await cmdStatus()
152
219
  break
220
+ case 'send':
221
+ code = await cmdSend(rest)
222
+ break
153
223
  case 'logs':
154
224
  code = cmdLogs(rest)
155
225
  break
package/lib/ilink.js CHANGED
@@ -7,8 +7,19 @@
7
7
  * POST /ilink/bot/get_bot_qrcode start a QR login
8
8
  * GET /ilink/bot/get_qrcode_status poll that login
9
9
  */
10
+ import fs from 'node:fs'
11
+ import path from 'node:path'
10
12
  import { randomUUID } from 'node:crypto'
11
13
  import { encodeQr, qrToPng, qrToTerminal } from './qr.js'
14
+ import {
15
+ CdnClient,
16
+ UploadMediaType,
17
+ buildMediaItem,
18
+ encryptedSize,
19
+ md5Hex,
20
+ randomFileKey,
21
+ sniffImageMediaType,
22
+ } from './cdn.js'
12
23
 
13
24
  export const DEFAULT_BASE_URL = 'https://ilinkai.weixin.qq.com'
14
25
  /** Long-poll window the gateway honours; the client timeout sits above it. */
@@ -101,6 +112,37 @@ export class ILinkClient {
101
112
  )
102
113
  }
103
114
 
115
+ /**
116
+ * Send one message carrying a caller-built `item_list` entry.
117
+ *
118
+ * `sendText` is the text-only special case of this; media items (image/file)
119
+ * go through exactly the same envelope.
120
+ */
121
+ async sendItem(token, toUserId, item, contextToken, signal) {
122
+ if (!contextToken) throw new Error('context_token is required for send')
123
+ return this.post(
124
+ 'ilink/bot/sendmessage',
125
+ {
126
+ msg: {
127
+ from_user_id: '',
128
+ to_user_id: toUserId,
129
+ client_id: randomUUID(),
130
+ message_type: 2,
131
+ message_state: 2,
132
+ context_token: contextToken,
133
+ item_list: [item],
134
+ },
135
+ base_info: this.baseInfo(),
136
+ },
137
+ { token, timeoutMs: 30000, signal },
138
+ )
139
+ }
140
+
141
+ /** Request a pre-signed CDN upload URL for outbound media. */
142
+ async getUploadUrl(token, req, signal) {
143
+ return this.post('ilink/bot/getuploadurl', { ...req, base_info: this.baseInfo() }, { token, timeoutMs: 30000, signal })
144
+ }
145
+
104
146
  async requestQrCode() {
105
147
  return this.post('ilink/bot/get_bot_qrcode?bot_type=3', { local_token_list: [] }, { timeoutMs: 20000 })
106
148
  }
@@ -162,3 +204,88 @@ export function renderQrPng(url, { scale = 8, margin = 4 } = {}) {
162
204
  const qr = encodeQr(url, { ecc: 'M' })
163
205
  return { png: qrToPng(qr.modules, { scale, margin }), art: qrToTerminal(qr.modules, { margin: 2 }), meta: qr }
164
206
  }
207
+
208
+ /**
209
+ * Send one local file to a WeChat user.
210
+ *
211
+ * Images travel as image items (the chat renders them inline); anything else
212
+ * travels as a file item (the chat shows a downloadable attachment). The
213
+ * protocol is identical for both:
214
+ *
215
+ * 1. `ilink/bot/getuploadurl` -> pre-signed CDN upload parameter
216
+ * 2. AES-128-ECB encrypt the bytes with a fresh random key
217
+ * 3. POST the ciphertext to the CDN -> `x-encrypted-param`
218
+ * 4. `ilink/bot/sendmessage` with the media item
219
+ *
220
+ * @returns {Promise<{fileName: string, bytes: number, kind: 'image'|'file', messageId?: string}>}
221
+ */
222
+ export async function sendLocalFile({
223
+ client,
224
+ cdn = new CdnClient(),
225
+ token,
226
+ toUserId,
227
+ contextToken,
228
+ filePath,
229
+ signal,
230
+ }) {
231
+ if (!contextToken) throw new Error('no context_token yet; the user must send a message first')
232
+ const plaintext = fs.readFileSync(filePath)
233
+ const fileName = path.basename(filePath)
234
+ const isImage = Boolean(sniffImageMediaType(plaintext))
235
+
236
+ // Everything travels as a file item, images included.
237
+ //
238
+ // The image path (media_type=1) is NOT usable from this client shape: the
239
+ // gateway issues an upload URL for it, but the CDN then rejects the upload with
240
+ // HTTP 400 x-error-code: -5102031
241
+ // Measured against the same bytes uploaded as media_type=3, which returns 200.
242
+ // `GetUploadUrlReq` carries `thumb_rawsize` / `thumb_rawfilemd5` and
243
+ // `GetUploadUrlResp` carries `thumb_upload_param`, so the image path evidently
244
+ // expects thumbnail metadata this client does not produce (resizing needs an
245
+ // image codec). Sending images as files is the honest fallback: the recipient
246
+ // still receives the picture, as a downloadable attachment rather than inline.
247
+ const mediaType = UploadMediaType.FILE
248
+ const fileKey = randomFileKey()
249
+
250
+ const up = await client.getUploadUrl(
251
+ token,
252
+ {
253
+ filekey: fileKey,
254
+ media_type: mediaType,
255
+ to_user_id: toUserId,
256
+ rawsize: plaintext.length,
257
+ rawfilemd5: md5Hex(plaintext),
258
+ filesize: encryptedSize(plaintext.length),
259
+ },
260
+ signal,
261
+ )
262
+ const ret = Number(up?.ret)
263
+ if (Number.isFinite(ret) && ret !== 0) {
264
+ throw new Error(`getuploadurl declined ret=${ret} errcode=${up?.errcode} errmsg=${up?.errmsg}`)
265
+ }
266
+ if (!up?.upload_param && !up?.upload_full_url) throw new Error('getuploadurl returned no upload_param')
267
+
268
+ const uploaded = await cdn.uploadEncrypted({
269
+ plaintext,
270
+ fileKey,
271
+ uploadParam: up.upload_param,
272
+ ...(up.upload_full_url ? { uploadFullUrl: up.upload_full_url } : {}),
273
+ mediaType,
274
+ ...(signal ? { signal } : {}),
275
+ })
276
+
277
+ const item = buildMediaItem(plaintext, {
278
+ mediaType,
279
+ encryptQueryParam: uploaded.encryptQueryParam,
280
+ keyHex: uploaded.aesKeyHex,
281
+ fileName,
282
+ })
283
+
284
+ const res = await client.sendItem(token, toUserId, item, contextToken, signal)
285
+ const sendRet = Number(res?.ret)
286
+ if (Number.isFinite(sendRet) && sendRet !== 0) {
287
+ throw new Error(`sendmessage declined ret=${sendRet} errcode=${res?.errcode} errmsg=${res?.errmsg}`)
288
+ }
289
+
290
+ return { fileName, bytes: plaintext.length, kind: isImage ? 'image' : 'file', messageId: res?.message_id }
291
+ }
package/lib/index.js CHANGED
@@ -16,6 +16,7 @@ import fs from 'node:fs'
16
16
  import path from 'node:path'
17
17
  import { spawn } from 'node:child_process'
18
18
  import { randomUUID } from 'node:crypto'
19
+ import { fileURLToPath } from 'node:url'
19
20
 
20
21
  import { AccountStore, resolveStateDir } from './accounts.js'
21
22
  import { ILinkClient, QrLoginManager, DEFAULT_BASE_URL, renderQrPng } from './ilink.js'
@@ -24,8 +25,25 @@ import { Config } from './schema.js'
24
25
 
25
26
  export const name = 'wechat-clawbot'
26
27
 
27
- /** Plugin version, mirrored from package.json (a literal keeps the bundle JSON-free). */
28
- export const PLUGIN_VERSION = '0.7.0'
28
+ /**
29
+ * Plugin version, read from this package's own `package.json`.
30
+ *
31
+ * This used to be a hardcoded literal mirrored from package.json, and it drifted
32
+ * on the very first release that bumped the version — the banner advertised
33
+ * 0.7.0 while 0.7.1 was loaded, which defeats the point of having a banner.
34
+ * Reading the file costs one synchronous read at load and cannot drift.
35
+ */
36
+ function readOwnVersion() {
37
+ try {
38
+ const here = path.dirname(fileURLToPath(import.meta.url))
39
+ const pkg = JSON.parse(fs.readFileSync(path.join(here, '..', 'package.json'), 'utf-8'))
40
+ return typeof pkg.version === 'string' && pkg.version ? pkg.version : '0.0.0'
41
+ } catch {
42
+ return '0.0.0'
43
+ }
44
+ }
45
+
46
+ export const PLUGIN_VERSION = readOwnVersion()
29
47
 
30
48
  /**
31
49
  * Services this plugin requires before it activates.
@@ -191,6 +209,23 @@ export function apply(ctx, rawConfig) {
191
209
  const limiter = new RateLimiter(config.outgoingMaxPerSec ?? 0.5, config.outgoingBurst ?? 1)
192
210
  let lastContextToken = ''
193
211
 
212
+ // The gateway's `context_token` only arrives with an inbound message, and it is
213
+ // required for every outbound send. Persisting the newest one lets the CLI
214
+ // (and therefore the agent's own shell) send media without holding live state.
215
+ const outboundFile = path.join(stateDir, 'outbound.json')
216
+ function persistOutbound(userId, contextToken) {
217
+ try {
218
+ fs.mkdirSync(stateDir, { recursive: true })
219
+ fs.writeFileSync(
220
+ outboundFile,
221
+ JSON.stringify({ userId, contextToken, at: new Date().toISOString() }, null, 2),
222
+ 'utf-8',
223
+ )
224
+ } catch (error) {
225
+ log('error', `could not persist outbound context: ${error?.message ?? error}`)
226
+ }
227
+ }
228
+
194
229
  async function sendText(toUserId, text) {
195
230
  if (!lastContextToken) {
196
231
  log('error', `no context_token yet for ${toUserId}; they must send a message first`)
@@ -323,7 +358,10 @@ export function apply(ctx, rawConfig) {
323
358
  if (!from) return
324
359
 
325
360
  const token = String(msg?.context_token || '').trim()
326
- if (token) lastContextToken = token
361
+ if (token) {
362
+ lastContextToken = token
363
+ persistOutbound(from, token)
364
+ }
327
365
 
328
366
  // A sender is allowed when explicitly listed, otherwise only the account
329
367
  // that scanned the QR may drive the agent.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@peng272/dsh-wechat-ilink",
3
- "version": "0.7.1",
3
+ "version": "0.8.0",
4
4
  "description": "WeChat ClawBot (Tencent iLink) channel for DeepSeek Harness 0.2.0-rc.2 — talk to DSH from WeChat.",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
@@ -10,6 +10,7 @@
10
10
  "exports": {
11
11
  ".": "./lib/index.js",
12
12
  "./schema": "./lib/schema.js",
13
+ "./cdn": "./lib/cdn.js",
13
14
  "./cordis.patch.yml": "./cordis.patch.yml",
14
15
  "./package.json": "./package.json"
15
16
  },