@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 +32 -0
- package/lib/cli.js +90 -1
- package/lib/share.js +173 -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,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
|
|
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) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[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.
|
|
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
|
},
|