@peng272/dsh-wechat-ilink 0.8.0 → 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,6 +15,38 @@ 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.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.
18
50
  ## [0.8.0]
19
51
 
20
52
  ### Added
package/lib/cli.js CHANGED
@@ -20,6 +20,7 @@ import path from 'node:path'
20
20
  import { AccountStore, resolveStateDir } from './accounts.js'
21
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
 
@@ -195,13 +196,95 @@ async function cmdSend(args) {
195
196
  }
196
197
  }
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
+
198
279
  function usage() {
199
280
  out('dsh-wechat-ilink — 微信 ClawBot 通道')
200
281
  out('')
201
282
  out('用法:')
202
283
  out(' dsh-wechat-ilink login 扫码绑定一个微信账号')
203
284
  out(' dsh-wechat-ilink status 显示绑定状态与游标')
204
- out(' dsh-wechat-ilink send <路径> 把本机文件发送给已绑定的微信用户')
285
+ out(' dsh-wechat-ilink share <路径> 把本机文件发布成一条可点击的下载链接(推荐)')
286
+ out(' dsh-wechat-ilink send <路径> 以附件形式发送(实测微信 ClawBot 不显示附件)')
287
+ out(' dsh-wechat-ilink serve [端口] 启动局域网文件共享服务(供 share 使用)')
205
288
  out(' dsh-wechat-ilink logs [n] 打印通道日志尾部')
206
289
  out(' dsh-wechat-ilink logout 解除全部绑定')
207
290
  return 0
@@ -220,6 +303,12 @@ try {
220
303
  case 'send':
221
304
  code = await cmdSend(rest)
222
305
  break
306
+ case 'share':
307
+ code = await cmdShare(rest)
308
+ break
309
+ case 'serve':
310
+ code = await cmdServe(rest)
311
+ break
223
312
  case 'logs':
224
313
  code = cmdLogs(rest)
225
314
  break
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.8.0",
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",
@@ -11,6 +11,7 @@
11
11
  ".": "./lib/index.js",
12
12
  "./schema": "./lib/schema.js",
13
13
  "./cdn": "./lib/cdn.js",
14
+ "./share": "./lib/share.js",
14
15
  "./cordis.patch.yml": "./cordis.patch.yml",
15
16
  "./package.json": "./package.json"
16
17
  },