@peng272/dsh-wechat-ilink 0.7.1 → 0.8.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 +49 -0
- package/lib/cdn.js +218 -0
- package/lib/cli.js +75 -5
- package/lib/ilink.js +127 -0
- package/lib/index.js +41 -3
- package/package.json +2 -1
package/CHANGELOG.md
CHANGED
|
@@ -15,6 +15,55 @@ 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.8.0]
|
|
19
|
+
|
|
20
|
+
### Added
|
|
21
|
+
|
|
22
|
+
- **Outbound media: the channel can now send a file into the WeChat chat.**
|
|
23
|
+
`dsh-wechat-ilink send <path>` uploads a local file and delivers it to the
|
|
24
|
+
bound user. Images travel as image items (rendered inline) and everything else
|
|
25
|
+
as a file item (a downloadable attachment).
|
|
26
|
+
|
|
27
|
+
The protocol was already documented inside the 0.6.x sources — `getuploadurl`
|
|
28
|
+
-> AES-128-ECB -> CDN upload -> `x-encrypted-param` -> media item — but
|
|
29
|
+
`CdnClient.upload()` was never wired to anything, so neither series could
|
|
30
|
+
actually send media. It is implemented now in `lib/cdn.js`, and the upload
|
|
31
|
+
request parameters come from the 0.6.x `GetUploadUrlReq` type.
|
|
32
|
+
|
|
33
|
+
- The channel persists the newest `context_token` to `<stateDir>/outbound.json`
|
|
34
|
+
as inbound messages arrive. The gateway requires it on every send, and without
|
|
35
|
+
it a shell command started later has nothing to send with.
|
|
36
|
+
|
|
37
|
+
### Note
|
|
38
|
+
|
|
39
|
+
- Sending media is subject to the same gateway throttling as text, and a file is
|
|
40
|
+
far heavier than a message. Large files may be rejected by the gateway; treat
|
|
41
|
+
a failure as a normal outcome and fall back to a link.
|
|
42
|
+
|
|
43
|
+
- **Images are sent as files, not inline.** The image path (`media_type: 1`) is
|
|
44
|
+
not usable from this client: the gateway issues an upload URL for it, but the
|
|
45
|
+
CDN then answers
|
|
46
|
+
|
|
47
|
+
```
|
|
48
|
+
HTTP 400 x-error-code: -5102031
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
for bytes that the *same* request uploads successfully as `media_type: 3`.
|
|
52
|
+
`GetUploadUrlReq` carries `thumb_rawsize` / `thumb_rawfilemd5` and
|
|
53
|
+
`GetUploadUrlResp` carries `thumb_upload_param`, so the image path evidently
|
|
54
|
+
expects thumbnail metadata this client does not produce (resizing needs an
|
|
55
|
+
image codec). Pictures therefore arrive as downloadable attachments rather
|
|
56
|
+
than rendering inline. Verified with both a 382-byte and a 494-byte PNG, and
|
|
57
|
+
against a 5712-byte document that uploaded fine.## [0.7.2]
|
|
58
|
+
|
|
59
|
+
### Fixed
|
|
60
|
+
|
|
61
|
+
- **The startup banner reported the wrong version.** `PLUGIN_VERSION` was a
|
|
62
|
+
hardcoded literal mirrored from `package.json`, and it drifted on the first
|
|
63
|
+
release that bumped the version: 0.7.1 was loaded while the banner said 0.7.0.
|
|
64
|
+
Since the banner is deliberately the way a loaded build is identified, that
|
|
65
|
+
defeats its purpose. The version is now read from this package's own
|
|
66
|
+
`package.json` at load, so it cannot drift again.
|
|
18
67
|
## [0.7.1]
|
|
19
68
|
|
|
20
69
|
### Fixed
|
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,7 +18,7 @@ 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
23
|
|
|
24
24
|
const CHANNEL_VERSION = '2.4.9'
|
|
@@ -129,14 +129,81 @@ function cmdLogout() {
|
|
|
129
129
|
return 0
|
|
130
130
|
}
|
|
131
131
|
|
|
132
|
+
/**
|
|
133
|
+
* Send one local file to the bound WeChat user.
|
|
134
|
+
*
|
|
135
|
+
* This is the entry point the agent itself uses: it runs in its own shell, so a
|
|
136
|
+
* plain command is all it needs to hand a file back over the chat.
|
|
137
|
+
*/
|
|
138
|
+
async function cmdSend(args) {
|
|
139
|
+
const target = args[0]
|
|
140
|
+
if (!target) {
|
|
141
|
+
out('用法: dsh-wechat-ilink send <文件路径>')
|
|
142
|
+
out(' 把本机的一个文件发送给已绑定的微信用户。')
|
|
143
|
+
return 1
|
|
144
|
+
}
|
|
145
|
+
const abs = path.resolve(target)
|
|
146
|
+
if (!fs.existsSync(abs)) {
|
|
147
|
+
out(`文件不存在: ${abs}`)
|
|
148
|
+
return 1
|
|
149
|
+
}
|
|
150
|
+
if (!fs.statSync(abs).isFile()) {
|
|
151
|
+
out(`不是文件(目录暂不支持): ${abs}`)
|
|
152
|
+
return 1
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
const { store, client } = makeRuntime()
|
|
156
|
+
const ids = store.listAccountIds()
|
|
157
|
+
if (ids.length === 0) {
|
|
158
|
+
out('还没有绑定微信账号。先运行: dsh-wechat-ilink login')
|
|
159
|
+
return 1
|
|
160
|
+
}
|
|
161
|
+
const account = store.load(ids[0])
|
|
162
|
+
if (!account?.token) {
|
|
163
|
+
out('账号记录里没有 token,请重新 login。')
|
|
164
|
+
return 1
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// The gateway requires the newest `context_token`, which only arrives with an
|
|
168
|
+
// inbound message; the channel persists it for exactly this use.
|
|
169
|
+
const inboundFile = path.join(store.dir, 'outbound.json')
|
|
170
|
+
if (!fs.existsSync(inboundFile)) {
|
|
171
|
+
out('还拿不到 context_token —— 请先在微信里给机器人发一条消息,再重试。')
|
|
172
|
+
return 1
|
|
173
|
+
}
|
|
174
|
+
const inbound = JSON.parse(fs.readFileSync(inboundFile, 'utf-8'))
|
|
175
|
+
if (!inbound?.userId || !inbound?.contextToken) {
|
|
176
|
+
out(`${inboundFile} 里缺少 userId / contextToken,请先在微信里发一条消息。`)
|
|
177
|
+
return 1
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
const size = fs.statSync(abs).size
|
|
181
|
+
out(`发送中 ${path.basename(abs)} (${size} 字节) → ${inbound.userId}`)
|
|
182
|
+
try {
|
|
183
|
+
const result = await sendLocalFile({
|
|
184
|
+
client,
|
|
185
|
+
token: account.token,
|
|
186
|
+
toUserId: inbound.userId,
|
|
187
|
+
contextToken: inbound.contextToken,
|
|
188
|
+
filePath: abs,
|
|
189
|
+
})
|
|
190
|
+
out(`✅ 已发送 类型=${result.kind} 文件名=${result.fileName} 字节=${result.bytes}`)
|
|
191
|
+
return 0
|
|
192
|
+
} catch (error) {
|
|
193
|
+
out(`❌ 发送失败: ${error?.message ?? error}`)
|
|
194
|
+
return 1
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
132
198
|
function usage() {
|
|
133
199
|
out('dsh-wechat-ilink — 微信 ClawBot 通道')
|
|
134
200
|
out('')
|
|
135
201
|
out('用法:')
|
|
136
|
-
out(' dsh-wechat-ilink login
|
|
137
|
-
out(' dsh-wechat-ilink status
|
|
138
|
-
out(' dsh-wechat-ilink
|
|
139
|
-
out(' dsh-wechat-ilink
|
|
202
|
+
out(' dsh-wechat-ilink login 扫码绑定一个微信账号')
|
|
203
|
+
out(' dsh-wechat-ilink status 显示绑定状态与游标')
|
|
204
|
+
out(' dsh-wechat-ilink send <路径> 把本机文件发送给已绑定的微信用户')
|
|
205
|
+
out(' dsh-wechat-ilink logs [n] 打印通道日志尾部')
|
|
206
|
+
out(' dsh-wechat-ilink logout 解除全部绑定')
|
|
140
207
|
return 0
|
|
141
208
|
}
|
|
142
209
|
|
|
@@ -150,6 +217,9 @@ try {
|
|
|
150
217
|
case 'status':
|
|
151
218
|
code = await cmdStatus()
|
|
152
219
|
break
|
|
220
|
+
case 'send':
|
|
221
|
+
code = await cmdSend(rest)
|
|
222
|
+
break
|
|
153
223
|
case 'logs':
|
|
154
224
|
code = cmdLogs(rest)
|
|
155
225
|
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
|
@@ -16,6 +16,7 @@ import fs from 'node:fs'
|
|
|
16
16
|
import path from 'node:path'
|
|
17
17
|
import { spawn } from 'node:child_process'
|
|
18
18
|
import { randomUUID } from 'node:crypto'
|
|
19
|
+
import { fileURLToPath } from 'node:url'
|
|
19
20
|
|
|
20
21
|
import { AccountStore, resolveStateDir } from './accounts.js'
|
|
21
22
|
import { ILinkClient, QrLoginManager, DEFAULT_BASE_URL, renderQrPng } from './ilink.js'
|
|
@@ -24,8 +25,25 @@ import { Config } from './schema.js'
|
|
|
24
25
|
|
|
25
26
|
export const name = 'wechat-clawbot'
|
|
26
27
|
|
|
27
|
-
/**
|
|
28
|
-
|
|
28
|
+
/**
|
|
29
|
+
* Plugin version, read from this package's own `package.json`.
|
|
30
|
+
*
|
|
31
|
+
* This used to be a hardcoded literal mirrored from package.json, and it drifted
|
|
32
|
+
* on the very first release that bumped the version — the banner advertised
|
|
33
|
+
* 0.7.0 while 0.7.1 was loaded, which defeats the point of having a banner.
|
|
34
|
+
* Reading the file costs one synchronous read at load and cannot drift.
|
|
35
|
+
*/
|
|
36
|
+
function readOwnVersion() {
|
|
37
|
+
try {
|
|
38
|
+
const here = path.dirname(fileURLToPath(import.meta.url))
|
|
39
|
+
const pkg = JSON.parse(fs.readFileSync(path.join(here, '..', 'package.json'), 'utf-8'))
|
|
40
|
+
return typeof pkg.version === 'string' && pkg.version ? pkg.version : '0.0.0'
|
|
41
|
+
} catch {
|
|
42
|
+
return '0.0.0'
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export const PLUGIN_VERSION = readOwnVersion()
|
|
29
47
|
|
|
30
48
|
/**
|
|
31
49
|
* Services this plugin requires before it activates.
|
|
@@ -191,6 +209,23 @@ export function apply(ctx, rawConfig) {
|
|
|
191
209
|
const limiter = new RateLimiter(config.outgoingMaxPerSec ?? 0.5, config.outgoingBurst ?? 1)
|
|
192
210
|
let lastContextToken = ''
|
|
193
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
|
+
|
|
194
229
|
async function sendText(toUserId, text) {
|
|
195
230
|
if (!lastContextToken) {
|
|
196
231
|
log('error', `no context_token yet for ${toUserId}; they must send a message first`)
|
|
@@ -323,7 +358,10 @@ export function apply(ctx, rawConfig) {
|
|
|
323
358
|
if (!from) return
|
|
324
359
|
|
|
325
360
|
const token = String(msg?.context_token || '').trim()
|
|
326
|
-
if (token)
|
|
361
|
+
if (token) {
|
|
362
|
+
lastContextToken = token
|
|
363
|
+
persistOutbound(from, token)
|
|
364
|
+
}
|
|
327
365
|
|
|
328
366
|
// A sender is allowed when explicitly listed, otherwise only the account
|
|
329
367
|
// that scanned the QR may drive the agent.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@peng272/dsh-wechat-ilink",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.8.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,7 @@
|
|
|
10
10
|
"exports": {
|
|
11
11
|
".": "./lib/index.js",
|
|
12
12
|
"./schema": "./lib/schema.js",
|
|
13
|
+
"./cdn": "./lib/cdn.js",
|
|
13
14
|
"./cordis.patch.yml": "./cordis.patch.yml",
|
|
14
15
|
"./package.json": "./package.json"
|
|
15
16
|
},
|