@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 +72 -1
- package/lib/cdn.js +218 -0
- package/lib/cli.js +164 -5
- package/lib/ilink.js +127 -0
- package/lib/index.js +21 -1
- package/lib/share.js +173 -0
- package/package.json +3 -1
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.
|
|
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
|
|
139
|
-
out(' dsh-wechat-ilink
|
|
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)
|
|
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) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[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",
|
|
@@ -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
|
},
|