@peng272/dsh-wechat-ilink 0.7.2 → 0.9.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,7 +15,78 @@ 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.7.2]
18
+ ## [0.9.0]
19
+
20
+ ### Added
21
+
22
+ - **`dsh-wechat-ilink share <path>` — deliver a file as a link.** The file is
23
+ published into a share directory and a plain text message carrying an
24
+ unguessable download URL is sent to the bound user.
25
+
26
+ ### Why not an attachment
27
+
28
+ 0.8.0 tried to send media items. They do not work, and the measurements are
29
+ unambiguous:
30
+
31
+ | Path | Gateway | CDN | Chat |
32
+ | --- | --- | --- | --- |
33
+ | `media_type: 1` (image) | issues an upload URL | **HTTP 400 `x-error-code -5102031`** | — |
34
+ | `media_type: 3` (file) | issues an upload URL | HTTP 200 + `x-encrypted-param` | **nothing appears** |
35
+ | text | — | — | arrives |
36
+
37
+ `sendmessage` returns a `message_id` for file items, so the gateway accepts
38
+ them; the ClawBot chat UI simply does not render attachment messages. Two
39
+ variants were tested against a live chat and neither appeared: an image item
40
+ over a file-type upload (to isolate the renderer), and a file item carrying
41
+ `create_time_ms` + `is_completed` (to rule out missing fields).
42
+
43
+ Text is the only channel proven to arrive, so a file becomes a link.
44
+ `send` is kept for completeness but is documented as not rendering.
45
+
46
+ - **`dsh-wechat-ilink serve [port]`** — the LAN share server (default port
47
+ 8791). It serves only the share directory, requires the token embedded in
48
+ every URL, refuses traversal outside the share directory, and is intended to
49
+ run under a scheduled task.
50
+ ## [0.8.0]
51
+
52
+ ### Added
53
+
54
+ - **Outbound media: the channel can now send a file into the WeChat chat.**
55
+ `dsh-wechat-ilink send <path>` uploads a local file and delivers it to the
56
+ bound user. Images travel as image items (rendered inline) and everything else
57
+ as a file item (a downloadable attachment).
58
+
59
+ The protocol was already documented inside the 0.6.x sources — `getuploadurl`
60
+ -> AES-128-ECB -> CDN upload -> `x-encrypted-param` -> media item — but
61
+ `CdnClient.upload()` was never wired to anything, so neither series could
62
+ actually send media. It is implemented now in `lib/cdn.js`, and the upload
63
+ request parameters come from the 0.6.x `GetUploadUrlReq` type.
64
+
65
+ - The channel persists the newest `context_token` to `<stateDir>/outbound.json`
66
+ as inbound messages arrive. The gateway requires it on every send, and without
67
+ it a shell command started later has nothing to send with.
68
+
69
+ ### Note
70
+
71
+ - Sending media is subject to the same gateway throttling as text, and a file is
72
+ far heavier than a message. Large files may be rejected by the gateway; treat
73
+ a failure as a normal outcome and fall back to a link.
74
+
75
+ - **Images are sent as files, not inline.** The image path (`media_type: 1`) is
76
+ not usable from this client: the gateway issues an upload URL for it, but the
77
+ CDN then answers
78
+
79
+ ```
80
+ HTTP 400 x-error-code: -5102031
81
+ ```
82
+
83
+ for bytes that the *same* request uploads successfully as `media_type: 3`.
84
+ `GetUploadUrlReq` carries `thumb_rawsize` / `thumb_rawfilemd5` and
85
+ `GetUploadUrlResp` carries `thumb_upload_param`, so the image path evidently
86
+ expects thumbnail metadata this client does not produce (resizing needs an
87
+ image codec). Pictures therefore arrive as downloadable attachments rather
88
+ than rendering inline. Verified with both a 382-byte and a 494-byte PNG, and
89
+ against a 5712-byte document that uploaded fine.## [0.7.2]
19
90
 
20
91
  ### Fixed
21
92
 
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,8 +18,9 @@ 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
+ import { DEFAULT_PORT, publishFile, shareUrl, startShareServer, lanAddress } from './share.js'
23
24
 
24
25
  const CHANNEL_VERSION = '2.4.9'
25
26
 
@@ -129,14 +130,163 @@ function cmdLogout() {
129
130
  return 0
130
131
  }
131
132
 
133
+ /**
134
+ * Send one local file to the bound WeChat user.
135
+ *
136
+ * This is the entry point the agent itself uses: it runs in its own shell, so a
137
+ * plain command is all it needs to hand a file back over the chat.
138
+ */
139
+ async function cmdSend(args) {
140
+ const target = args[0]
141
+ if (!target) {
142
+ out('用法: dsh-wechat-ilink send <文件路径>')
143
+ out(' 把本机的一个文件发送给已绑定的微信用户。')
144
+ return 1
145
+ }
146
+ const abs = path.resolve(target)
147
+ if (!fs.existsSync(abs)) {
148
+ out(`文件不存在: ${abs}`)
149
+ return 1
150
+ }
151
+ if (!fs.statSync(abs).isFile()) {
152
+ out(`不是文件(目录暂不支持): ${abs}`)
153
+ return 1
154
+ }
155
+
156
+ const { store, client } = makeRuntime()
157
+ const ids = store.listAccountIds()
158
+ if (ids.length === 0) {
159
+ out('还没有绑定微信账号。先运行: dsh-wechat-ilink login')
160
+ return 1
161
+ }
162
+ const account = store.load(ids[0])
163
+ if (!account?.token) {
164
+ out('账号记录里没有 token,请重新 login。')
165
+ return 1
166
+ }
167
+
168
+ // The gateway requires the newest `context_token`, which only arrives with an
169
+ // inbound message; the channel persists it for exactly this use.
170
+ const inboundFile = path.join(store.dir, 'outbound.json')
171
+ if (!fs.existsSync(inboundFile)) {
172
+ out('还拿不到 context_token —— 请先在微信里给机器人发一条消息,再重试。')
173
+ return 1
174
+ }
175
+ const inbound = JSON.parse(fs.readFileSync(inboundFile, 'utf-8'))
176
+ if (!inbound?.userId || !inbound?.contextToken) {
177
+ out(`${inboundFile} 里缺少 userId / contextToken,请先在微信里发一条消息。`)
178
+ return 1
179
+ }
180
+
181
+ const size = fs.statSync(abs).size
182
+ out(`发送中 ${path.basename(abs)} (${size} 字节) → ${inbound.userId}`)
183
+ try {
184
+ const result = await sendLocalFile({
185
+ client,
186
+ token: account.token,
187
+ toUserId: inbound.userId,
188
+ contextToken: inbound.contextToken,
189
+ filePath: abs,
190
+ })
191
+ out(`✅ 已发送 类型=${result.kind} 文件名=${result.fileName} 字节=${result.bytes}`)
192
+ return 0
193
+ } catch (error) {
194
+ out(`❌ 发送失败: ${error?.message ?? error}`)
195
+ return 1
196
+ }
197
+ }
198
+
199
+ /**
200
+ * Publish a file into the share directory and send the download link as text.
201
+ *
202
+ * This is the route that actually reaches the phone: text always renders, media
203
+ * items never do (see lib/share.js for the measurements).
204
+ */
205
+ async function cmdShare(args) {
206
+ const target = args[0]
207
+ if (!target) {
208
+ out('用法: dsh-wechat-ilink share <文件路径> [端口]')
209
+ out(' 把文件发布成一条链接并发送给已绑定的微信用户。')
210
+ return 1
211
+ }
212
+ const abs = path.resolve(target)
213
+ if (!fs.existsSync(abs) || !fs.statSync(abs).isFile()) {
214
+ out(`文件不存在或不是文件: ${abs}`)
215
+ return 1
216
+ }
217
+
218
+ const { store, client } = makeRuntime()
219
+ const ids = store.listAccountIds()
220
+ if (ids.length === 0) {
221
+ out('还没有绑定微信账号。先运行: dsh-wechat-ilink login')
222
+ return 1
223
+ }
224
+ const account = store.load(ids[0])
225
+ const inboundFile = path.join(store.dir, 'outbound.json')
226
+ if (!fs.existsSync(inboundFile)) {
227
+ out('还拿不到 context_token —— 请先在微信里给机器人发一条消息,再重试。')
228
+ return 1
229
+ }
230
+ const inbound = JSON.parse(fs.readFileSync(inboundFile, 'utf-8'))
231
+ if (!inbound?.userId || !inbound?.contextToken) {
232
+ out(`${inboundFile} 里缺少 userId / contextToken。`)
233
+ return 1
234
+ }
235
+
236
+ const shareDir = resolveShareDir()
237
+ const port = Number(args[1]) || DEFAULT_PORT
238
+ const published = publishFile(shareDir, abs)
239
+ const url = shareUrl(shareDir, published.storedName, port)
240
+
241
+ out(`已发布 ${published.originalName} (${published.bytes} 字节)`)
242
+ out(`存放于 ${shareDir}`)
243
+ out(`链接 ${url}`)
244
+ if (!lanAddress()) out('⚠️ 本机没有局域网 IPv4 地址,手机可能访问不到这个链接。')
245
+
246
+ const res = await client.sendText(
247
+ account.token,
248
+ inbound.userId,
249
+ `📎 ${published.originalName}(${published.bytes} 字节)\n在手机上点开下载:\n${url}`,
250
+ inbound.contextToken,
251
+ )
252
+ if (res?.message_id) {
253
+ out(`✅ 链接消息已发送 message_id=${res.message_id}`)
254
+ out(' (请确认手机收到;若打不开,通常是手机不在同一 Wi-Fi)')
255
+ return 0
256
+ }
257
+ out(`⚠️ 发送返回: ${JSON.stringify(res)}`)
258
+ return 1
259
+ }
260
+
261
+ /** Run the LAN share server in the foreground. */
262
+ function cmdServe(args) {
263
+ const port = Number(args[0]) || DEFAULT_PORT
264
+ const shareDir = resolveShareDir()
265
+ const server = startShareServer({ shareDir, port, log: (m) => out(`[${new Date().toISOString()}] ${m}`) })
266
+ server.on('error', (error) => {
267
+ out(`❌ 共享服务启动失败: ${error?.message ?? error}`)
268
+ process.exit(1)
269
+ })
270
+ // Keep the process alive.
271
+ return new Promise(() => {})
272
+ }
273
+
274
+ /** Share directory: next to the credentials, overridable for tests. */
275
+ function resolveShareDir() {
276
+ return process.env.DSH_WECHAT_SHARE_DIR?.trim() || path.join(resolveStateDir(), 'share')
277
+ }
278
+
132
279
  function usage() {
133
280
  out('dsh-wechat-ilink — 微信 ClawBot 通道')
134
281
  out('')
135
282
  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 解除全部绑定')
283
+ out(' dsh-wechat-ilink login 扫码绑定一个微信账号')
284
+ out(' dsh-wechat-ilink status 显示绑定状态与游标')
285
+ out(' dsh-wechat-ilink share <路径> 把本机文件发布成一条可点击的下载链接(推荐)')
286
+ out(' dsh-wechat-ilink send <路径> 以附件形式发送(实测微信 ClawBot 不显示附件)')
287
+ out(' dsh-wechat-ilink serve [端口] 启动局域网文件共享服务(供 share 使用)')
288
+ out(' dsh-wechat-ilink logs [n] 打印通道日志尾部')
289
+ out(' dsh-wechat-ilink logout 解除全部绑定')
140
290
  return 0
141
291
  }
142
292
 
@@ -150,6 +300,15 @@ try {
150
300
  case 'status':
151
301
  code = await cmdStatus()
152
302
  break
303
+ case 'send':
304
+ code = await cmdSend(rest)
305
+ break
306
+ case 'share':
307
+ code = await cmdShare(rest)
308
+ break
309
+ case 'serve':
310
+ code = await cmdServe(rest)
311
+ break
153
312
  case 'logs':
154
313
  code = cmdLogs(rest)
155
314
  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
@@ -209,6 +209,23 @@ export function apply(ctx, rawConfig) {
209
209
  const limiter = new RateLimiter(config.outgoingMaxPerSec ?? 0.5, config.outgoingBurst ?? 1)
210
210
  let lastContextToken = ''
211
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
+
212
229
  async function sendText(toUserId, text) {
213
230
  if (!lastContextToken) {
214
231
  log('error', `no context_token yet for ${toUserId}; they must send a message first`)
@@ -341,7 +358,10 @@ export function apply(ctx, rawConfig) {
341
358
  if (!from) return
342
359
 
343
360
  const token = String(msg?.context_token || '').trim()
344
- if (token) lastContextToken = token
361
+ if (token) {
362
+ lastContextToken = token
363
+ persistOutbound(from, token)
364
+ }
345
365
 
346
366
  // A sender is allowed when explicitly listed, otherwise only the account
347
367
  // that scanned the QR may drive the agent.
package/lib/share.js ADDED
@@ -0,0 +1,173 @@
1
+ /**
2
+ * LAN file share for the WeChat channel.
3
+ *
4
+ * Why this exists: the WeChat ClawBot chat UI does not render attachment
5
+ * messages. Measured behaviour —
6
+ * - uploads of `media_type: 3` succeed and `sendmessage` returns a
7
+ * `message_id`, yet nothing appears in the chat;
8
+ * - uploads of `media_type: 1` are rejected outright by the CDN
9
+ * (`HTTP 400 x-error-code -5102031`).
10
+ * Text, by contrast, always arrives. So instead of fighting the media format,
11
+ * a file becomes a plain text message containing a link the phone can open.
12
+ *
13
+ * The share directory holds only files deliberately published through
14
+ * `dsh-wechat-ilink share`, and every URL carries an unguessable token, so the
15
+ * port being reachable on the LAN does not expose the rest of the machine.
16
+ */
17
+ import fs from 'node:fs'
18
+ import http from 'node:http'
19
+ import os from 'node:os'
20
+ import path from 'node:path'
21
+ import crypto from 'node:crypto'
22
+
23
+ export const DEFAULT_PORT = 8791
24
+
25
+ /** First non-loopback IPv4 address, or null when there is none. */
26
+ export function lanAddress() {
27
+ const ifaces = os.networkInterfaces()
28
+ for (const list of Object.values(ifaces)) {
29
+ for (const entry of list ?? []) {
30
+ if (entry.family === 'IPv4' && !entry.internal) return entry.address
31
+ }
32
+ }
33
+ return null
34
+ }
35
+
36
+ /** Read the share token, creating it on first use. */
37
+ export function ensureToken(shareDir) {
38
+ fs.mkdirSync(shareDir, { recursive: true })
39
+ const file = path.join(shareDir, '.token')
40
+ try {
41
+ const existing = fs.readFileSync(file, 'utf-8').trim()
42
+ if (existing) return existing
43
+ } catch {
44
+ /* create below */
45
+ }
46
+ const token = crypto.randomBytes(16).toString('hex')
47
+ fs.writeFileSync(file, token, 'utf-8')
48
+ return token
49
+ }
50
+
51
+ /**
52
+ * Publish one local file into the share directory.
53
+ *
54
+ * The stored name carries a short random prefix so a link cannot be guessed from
55
+ * the original file name alone.
56
+ *
57
+ * @returns {{storedName: string, bytes: number, originalName: string}}
58
+ */
59
+ export function publishFile(shareDir, sourcePath) {
60
+ fs.mkdirSync(shareDir, { recursive: true })
61
+ const originalName = path.basename(sourcePath)
62
+ const safe = originalName.replace(/[^\w.\-\u4e00-\u9fa5]+/g, '_').slice(0, 80) || 'file'
63
+ const storedName = `${crypto.randomBytes(4).toString('hex')}-${safe}`
64
+ fs.copyFileSync(sourcePath, path.join(shareDir, storedName))
65
+ return { storedName, bytes: fs.statSync(sourcePath).size, originalName }
66
+ }
67
+
68
+ /** Build the URL a phone can open. */
69
+ export function shareUrl(shareDir, storedName, port = DEFAULT_PORT) {
70
+ const token = ensureToken(shareDir)
71
+ const host = lanAddress() ?? '127.0.0.1'
72
+ return `http://${host}:${port}/f/${token}/${encodeURIComponent(storedName)}`
73
+ }
74
+
75
+ function escapeHtml(text) {
76
+ return String(text).replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' })[c])
77
+ }
78
+
79
+ const MIME = {
80
+ '.txt': 'text/plain; charset=utf-8',
81
+ '.md': 'text/markdown; charset=utf-8',
82
+ '.json': 'application/json; charset=utf-8',
83
+ '.pdf': 'application/pdf',
84
+ '.png': 'image/png',
85
+ '.jpg': 'image/jpeg',
86
+ '.jpeg': 'image/jpeg',
87
+ '.gif': 'image/gif',
88
+ '.webp': 'image/webp',
89
+ '.zip': 'application/zip',
90
+ '.csv': 'text/csv; charset=utf-8',
91
+ '.log': 'text/plain; charset=utf-8',
92
+ }
93
+
94
+ /**
95
+ * Start the share server.
96
+ *
97
+ * @param {{shareDir: string, port?: number, log?: (m: string) => void}} options
98
+ * @returns {import('node:http').Server}
99
+ */
100
+ export function startShareServer({ shareDir, port = DEFAULT_PORT, log = () => {} }) {
101
+ const token = ensureToken(shareDir)
102
+
103
+ const server = http.createServer((req, res) => {
104
+ let url
105
+ try {
106
+ url = new URL(req.url ?? '/', 'http://localhost')
107
+ } catch {
108
+ res.writeHead(400).end('bad request')
109
+ return
110
+ }
111
+ const parts = url.pathname.split('/').filter(Boolean)
112
+ // /f/<token>/<name> and /f/<token>/ (index)
113
+ if (parts[0] !== 'f' || parts[1] !== token) {
114
+ log(`denied ${req.method} ${url.pathname} from ${req.socket.remoteAddress}`)
115
+ res.writeHead(404).end('not found')
116
+ return
117
+ }
118
+
119
+ if (parts.length === 2) {
120
+ let entries = []
121
+ try {
122
+ entries = fs.readdirSync(shareDir).filter((n) => !n.startsWith('.'))
123
+ } catch {
124
+ /* empty */
125
+ }
126
+ const items = entries
127
+ .map((n) => {
128
+ const st = fs.statSync(path.join(shareDir, n))
129
+ return `<li><a href="/f/${token}/${encodeURIComponent(n)}">${escapeHtml(n)}</a> <small>${st.size} B</small></li>`
130
+ })
131
+ .join('')
132
+ res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' })
133
+ res.end(`<!doctype html><meta name="viewport" content="width=device-width,initial-scale=1"><h3>共享文件</h3><ul>${items || '<li>(空)</li>'}</ul>`)
134
+ log(`index served (${entries.length} file(s))`)
135
+ return
136
+ }
137
+
138
+ const name = decodeURIComponent(parts.slice(2).join('/'))
139
+ // Never let a crafted name escape the share directory.
140
+ const resolved = path.resolve(shareDir, name)
141
+ if (!resolved.startsWith(path.resolve(shareDir) + path.sep)) {
142
+ log(`escape attempt blocked: ${name}`)
143
+ res.writeHead(403).end('forbidden')
144
+ return
145
+ }
146
+ let st
147
+ try {
148
+ st = fs.statSync(resolved)
149
+ } catch {
150
+ res.writeHead(404).end('not found')
151
+ return
152
+ }
153
+ if (!st.isFile()) {
154
+ res.writeHead(404).end('not found')
155
+ return
156
+ }
157
+
158
+ log(`served ${name} (${st.size} B) to ${req.socket.remoteAddress}`)
159
+ res.writeHead(200, {
160
+ 'Content-Type': MIME[path.extname(resolved).toLowerCase()] ?? 'application/octet-stream',
161
+ 'Content-Length': st.size,
162
+ 'Content-Disposition': `attachment; filename*=UTF-8''${encodeURIComponent(path.basename(resolved))}`,
163
+ })
164
+ fs.createReadStream(resolved).pipe(res)
165
+ })
166
+
167
+ server.listen(port, '0.0.0.0', () => {
168
+ log(`share server listening on 0.0.0.0:${port} dir=${shareDir}`)
169
+ log(`phone URL base: http://${lanAddress() ?? '<no-lan-ip>'}:${port}/f/${token}/`)
170
+ })
171
+
172
+ return server
173
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@peng272/dsh-wechat-ilink",
3
- "version": "0.7.2",
3
+ "version": "0.9.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,8 @@
10
10
  "exports": {
11
11
  ".": "./lib/index.js",
12
12
  "./schema": "./lib/schema.js",
13
+ "./cdn": "./lib/cdn.js",
14
+ "./share": "./lib/share.js",
13
15
  "./cordis.patch.yml": "./cordis.patch.yml",
14
16
  "./package.json": "./package.json"
15
17
  },