@peng272/dsh-wechat-ilink 0.8.0 → 0.9.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 +32 -0
- package/lib/cli.js +93 -1
- package/lib/share.js +207 -0
- package/package.json +2 -1
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,98 @@ 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 urlLan = shareUrl(shareDir, published.storedName, port)
|
|
240
|
+
const urlV6 = shareUrlV6(shareDir, published.storedName, port)
|
|
241
|
+
|
|
242
|
+
out(`已发布 ${published.originalName} (${published.bytes} 字节)`)
|
|
243
|
+
out(`存放于 ${shareDir}`)
|
|
244
|
+
out(`局域网 ${urlLan}`)
|
|
245
|
+
out(`公网v6 ${urlV6 ?? '(本机没有公网 IPv6)'}`)
|
|
246
|
+
if (!lanAddress()) out('⚠️ 本机没有局域网 IPv4 地址。')
|
|
247
|
+
|
|
248
|
+
// Two links on purpose: which one works depends on where the phone is, and the
|
|
249
|
+
// phone is the only place that can decide. Same Wi-Fi -> the LAN link; mobile
|
|
250
|
+
// data -> the IPv6 link (if the carrier routes inbound).
|
|
251
|
+
const lines = [`📎 ${published.originalName}(${published.bytes} 字节)`, '', '手机和电脑在同一个 Wi-Fi 时,点这个:', urlLan]
|
|
252
|
+
if (urlV6) lines.push('', '手机用流量(不同网络)时,点这个:', urlV6)
|
|
253
|
+
else lines.push('', '(本机没有公网 IPv6,离开局域网需要别的通道)')
|
|
254
|
+
|
|
255
|
+
const res = await client.sendText(account.token, inbound.userId, lines.join('\n'), inbound.contextToken)
|
|
256
|
+
if (res?.message_id) {
|
|
257
|
+
out(`✅ 链接消息已发送 message_id=${res.message_id}`)
|
|
258
|
+
return 0
|
|
259
|
+
}
|
|
260
|
+
out(`⚠️ 发送返回: ${JSON.stringify(res)}`)
|
|
261
|
+
return 1
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/** Run the LAN share server in the foreground. */
|
|
265
|
+
function cmdServe(args) {
|
|
266
|
+
const port = Number(args[0]) || DEFAULT_PORT
|
|
267
|
+
const shareDir = resolveShareDir()
|
|
268
|
+
const server = startShareServer({ shareDir, port, log: (m) => out(`[${new Date().toISOString()}] ${m}`) })
|
|
269
|
+
server.on('error', (error) => {
|
|
270
|
+
out(`❌ 共享服务启动失败: ${error?.message ?? error}`)
|
|
271
|
+
process.exit(1)
|
|
272
|
+
})
|
|
273
|
+
// Keep the process alive.
|
|
274
|
+
return new Promise(() => {})
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/** Share directory: next to the credentials, overridable for tests. */
|
|
278
|
+
function resolveShareDir() {
|
|
279
|
+
return process.env.DSH_WECHAT_SHARE_DIR?.trim() || path.join(resolveStateDir(), 'share')
|
|
280
|
+
}
|
|
281
|
+
|
|
198
282
|
function usage() {
|
|
199
283
|
out('dsh-wechat-ilink — 微信 ClawBot 通道')
|
|
200
284
|
out('')
|
|
201
285
|
out('用法:')
|
|
202
286
|
out(' dsh-wechat-ilink login 扫码绑定一个微信账号')
|
|
203
287
|
out(' dsh-wechat-ilink status 显示绑定状态与游标')
|
|
204
|
-
out(' dsh-wechat-ilink
|
|
288
|
+
out(' dsh-wechat-ilink share <路径> 把本机文件发布成一条可点击的下载链接(推荐)')
|
|
289
|
+
out(' dsh-wechat-ilink send <路径> 以附件形式发送(实测微信 ClawBot 不显示附件)')
|
|
290
|
+
out(' dsh-wechat-ilink serve [端口] 启动局域网文件共享服务(供 share 使用)')
|
|
205
291
|
out(' dsh-wechat-ilink logs [n] 打印通道日志尾部')
|
|
206
292
|
out(' dsh-wechat-ilink logout 解除全部绑定')
|
|
207
293
|
return 0
|
|
@@ -220,6 +306,12 @@ try {
|
|
|
220
306
|
case 'send':
|
|
221
307
|
code = await cmdSend(rest)
|
|
222
308
|
break
|
|
309
|
+
case 'share':
|
|
310
|
+
code = await cmdShare(rest)
|
|
311
|
+
break
|
|
312
|
+
case 'serve':
|
|
313
|
+
code = await cmdServe(rest)
|
|
314
|
+
break
|
|
223
315
|
case 'logs':
|
|
224
316
|
code = cmdLogs(rest)
|
|
225
317
|
break
|
package/lib/share.js
ADDED
|
@@ -0,0 +1,207 @@
|
|
|
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
|
+
/**
|
|
37
|
+
* Global (public) IPv6 address, or null.
|
|
38
|
+
*
|
|
39
|
+
* Prefers a stable interface identifier (`SuffixOrigin: link`, i.e. EUI-64 style)
|
|
40
|
+
* over a privacy-extension one, because the prefix may rotate while the suffix
|
|
41
|
+
* does not. Falls back to any global unicast address.
|
|
42
|
+
*/
|
|
43
|
+
export function publicV6Address() {
|
|
44
|
+
const ifaces = os.networkInterfaces()
|
|
45
|
+
const globals = []
|
|
46
|
+
for (const list of Object.values(ifaces)) {
|
|
47
|
+
for (const entry of list ?? []) {
|
|
48
|
+
if (entry.family !== 'IPv6' || entry.internal) continue
|
|
49
|
+
if (!/^(2|3)/.test(entry.address)) continue // global unicast only (2000::/3)
|
|
50
|
+
globals.push(entry)
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
if (globals.length === 0) return null
|
|
54
|
+
const stable = globals.find((e) => e.scopeid === 0 && /^(240e|2400|2401|2409|2408)/.test(e.address))
|
|
55
|
+
return (stable ?? globals[0]).address
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Read the share token, creating it on first use. */
|
|
59
|
+
export function ensureToken(shareDir) {
|
|
60
|
+
fs.mkdirSync(shareDir, { recursive: true })
|
|
61
|
+
const file = path.join(shareDir, '.token')
|
|
62
|
+
try {
|
|
63
|
+
const existing = fs.readFileSync(file, 'utf-8').trim()
|
|
64
|
+
if (existing) return existing
|
|
65
|
+
} catch {
|
|
66
|
+
/* create below */
|
|
67
|
+
}
|
|
68
|
+
const token = crypto.randomBytes(16).toString('hex')
|
|
69
|
+
fs.writeFileSync(file, token, 'utf-8')
|
|
70
|
+
return token
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Publish one local file into the share directory.
|
|
75
|
+
*
|
|
76
|
+
* The stored name carries a short random prefix so a link cannot be guessed from
|
|
77
|
+
* the original file name alone.
|
|
78
|
+
*
|
|
79
|
+
* @returns {{storedName: string, bytes: number, originalName: string}}
|
|
80
|
+
*/
|
|
81
|
+
export function publishFile(shareDir, sourcePath) {
|
|
82
|
+
fs.mkdirSync(shareDir, { recursive: true })
|
|
83
|
+
const originalName = path.basename(sourcePath)
|
|
84
|
+
const safe = originalName.replace(/[^\w.\-\u4e00-\u9fa5]+/g, '_').slice(0, 80) || 'file'
|
|
85
|
+
const storedName = `${crypto.randomBytes(4).toString('hex')}-${safe}`
|
|
86
|
+
fs.copyFileSync(sourcePath, path.join(shareDir, storedName))
|
|
87
|
+
return { storedName, bytes: fs.statSync(sourcePath).size, originalName }
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** Build the URL a phone can open over the local network. */
|
|
91
|
+
export function shareUrl(shareDir, storedName, port = DEFAULT_PORT) {
|
|
92
|
+
const token = ensureToken(shareDir)
|
|
93
|
+
const host = lanAddress() ?? '127.0.0.1'
|
|
94
|
+
return `http://${host}:${port}/f/${token}/${encodeURIComponent(storedName)}`
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** Same file over the public IPv6 address (reachable when the phone is NOT on the LAN). */
|
|
98
|
+
export function shareUrlV6(shareDir, storedName, port = DEFAULT_PORT) {
|
|
99
|
+
const host = publicV6Address()
|
|
100
|
+
if (!host) return null
|
|
101
|
+
const token = ensureToken(shareDir)
|
|
102
|
+
return `http://[${host}]:${port}/f/${token}/${encodeURIComponent(storedName)}`
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function escapeHtml(text) {
|
|
106
|
+
return String(text).replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[c])
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const MIME = {
|
|
110
|
+
'.txt': 'text/plain; charset=utf-8',
|
|
111
|
+
'.md': 'text/markdown; charset=utf-8',
|
|
112
|
+
'.json': 'application/json; charset=utf-8',
|
|
113
|
+
'.pdf': 'application/pdf',
|
|
114
|
+
'.png': 'image/png',
|
|
115
|
+
'.jpg': 'image/jpeg',
|
|
116
|
+
'.jpeg': 'image/jpeg',
|
|
117
|
+
'.gif': 'image/gif',
|
|
118
|
+
'.webp': 'image/webp',
|
|
119
|
+
'.zip': 'application/zip',
|
|
120
|
+
'.csv': 'text/csv; charset=utf-8',
|
|
121
|
+
'.log': 'text/plain; charset=utf-8',
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Start the share server.
|
|
126
|
+
*
|
|
127
|
+
* @param {{shareDir: string, port?: number, log?: (m: string) => void}} options
|
|
128
|
+
* @returns {import('node:http').Server}
|
|
129
|
+
*/
|
|
130
|
+
export function startShareServer({ shareDir, port = DEFAULT_PORT, log = () => {} }) {
|
|
131
|
+
const token = ensureToken(shareDir)
|
|
132
|
+
|
|
133
|
+
const server = http.createServer((req, res) => {
|
|
134
|
+
let url
|
|
135
|
+
try {
|
|
136
|
+
url = new URL(req.url ?? '/', 'http://localhost')
|
|
137
|
+
} catch {
|
|
138
|
+
res.writeHead(400).end('bad request')
|
|
139
|
+
return
|
|
140
|
+
}
|
|
141
|
+
const parts = url.pathname.split('/').filter(Boolean)
|
|
142
|
+
// /f/<token>/<name> and /f/<token>/ (index)
|
|
143
|
+
if (parts[0] !== 'f' || parts[1] !== token) {
|
|
144
|
+
log(`denied ${req.method} ${url.pathname} from ${req.socket.remoteAddress}`)
|
|
145
|
+
res.writeHead(404).end('not found')
|
|
146
|
+
return
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
if (parts.length === 2) {
|
|
150
|
+
let entries = []
|
|
151
|
+
try {
|
|
152
|
+
entries = fs.readdirSync(shareDir).filter((n) => !n.startsWith('.'))
|
|
153
|
+
} catch {
|
|
154
|
+
/* empty */
|
|
155
|
+
}
|
|
156
|
+
const items = entries
|
|
157
|
+
.map((n) => {
|
|
158
|
+
const st = fs.statSync(path.join(shareDir, n))
|
|
159
|
+
return `<li><a href="/f/${token}/${encodeURIComponent(n)}">${escapeHtml(n)}</a> <small>${st.size} B</small></li>`
|
|
160
|
+
})
|
|
161
|
+
.join('')
|
|
162
|
+
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' })
|
|
163
|
+
res.end(`<!doctype html><meta name="viewport" content="width=device-width,initial-scale=1"><h3>共享文件</h3><ul>${items || '<li>(空)</li>'}</ul>`)
|
|
164
|
+
log(`index served (${entries.length} file(s))`)
|
|
165
|
+
return
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
const name = decodeURIComponent(parts.slice(2).join('/'))
|
|
169
|
+
// Never let a crafted name escape the share directory.
|
|
170
|
+
const resolved = path.resolve(shareDir, name)
|
|
171
|
+
if (!resolved.startsWith(path.resolve(shareDir) + path.sep)) {
|
|
172
|
+
log(`escape attempt blocked: ${name}`)
|
|
173
|
+
res.writeHead(403).end('forbidden')
|
|
174
|
+
return
|
|
175
|
+
}
|
|
176
|
+
let st
|
|
177
|
+
try {
|
|
178
|
+
st = fs.statSync(resolved)
|
|
179
|
+
} catch {
|
|
180
|
+
res.writeHead(404).end('not found')
|
|
181
|
+
return
|
|
182
|
+
}
|
|
183
|
+
if (!st.isFile()) {
|
|
184
|
+
res.writeHead(404).end('not found')
|
|
185
|
+
return
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
log(`served ${name} (${st.size} B) to ${req.socket.remoteAddress}`)
|
|
189
|
+
res.writeHead(200, {
|
|
190
|
+
'Content-Type': MIME[path.extname(resolved).toLowerCase()] ?? 'application/octet-stream',
|
|
191
|
+
'Content-Length': st.size,
|
|
192
|
+
'Content-Disposition': `attachment; filename*=UTF-8''${encodeURIComponent(path.basename(resolved))}`,
|
|
193
|
+
})
|
|
194
|
+
fs.createReadStream(resolved).pipe(res)
|
|
195
|
+
})
|
|
196
|
+
|
|
197
|
+
// Dual-stack: bind the IPv6 wildcard, which also accepts IPv4 on Windows, so
|
|
198
|
+
// the same port serves the LAN URL and the public IPv6 URL.
|
|
199
|
+
server.listen(port, '::', () => {
|
|
200
|
+
log(`share server listening on [::]:${port} dir=${shareDir}`)
|
|
201
|
+
log(`LAN URL base : http://${lanAddress() ?? '<no-lan-ip>'}:${port}/f/${token}/`)
|
|
202
|
+
const v6 = publicV6Address()
|
|
203
|
+
log(`public V6 base: ${v6 ? `http://[${v6}]:${port}/f/${token}/` : '(no global IPv6)'}`)
|
|
204
|
+
})
|
|
205
|
+
|
|
206
|
+
return server
|
|
207
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@peng272/dsh-wechat-ilink",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.9.1",
|
|
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
|
},
|