@peng272/dsh-wechat-ilink 0.0.0-stage → 0.7.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 +86 -0
- package/README.md +148 -2
- package/cordis.patch.yml +51 -0
- package/lib/accounts.js +145 -0
- package/lib/cli.js +166 -0
- package/lib/ilink.js +164 -0
- package/lib/index.js +406 -0
- package/lib/qr.js +531 -0
- package/lib/schema.js +122 -0
- package/lib/support.js +245 -0
- package/package.json +33 -3
package/lib/ilink.js
ADDED
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* iLink (Tencent ClawBot) protocol layer: HTTP client and QR login.
|
|
3
|
+
*
|
|
4
|
+
* Endpoints:
|
|
5
|
+
* POST /ilink/bot/getupdates long poll, carries the `get_updates_buf` cursor
|
|
6
|
+
* POST /ilink/bot/sendmessage send, requires a `context_token`
|
|
7
|
+
* POST /ilink/bot/get_bot_qrcode start a QR login
|
|
8
|
+
* GET /ilink/bot/get_qrcode_status poll that login
|
|
9
|
+
*/
|
|
10
|
+
import { randomUUID } from 'node:crypto'
|
|
11
|
+
import { encodeQr, qrToPng, qrToTerminal } from './qr.js'
|
|
12
|
+
|
|
13
|
+
export const DEFAULT_BASE_URL = 'https://ilinkai.weixin.qq.com'
|
|
14
|
+
/** Long-poll window the gateway honours; the client timeout sits above it. */
|
|
15
|
+
export const LONG_POLL_MS = 35000
|
|
16
|
+
|
|
17
|
+
export class ILinkClient {
|
|
18
|
+
constructor({ baseUrl, channelVersion, botAgent, appId } = {}) {
|
|
19
|
+
this.baseUrl = String(baseUrl || DEFAULT_BASE_URL).replace(/\/+$/, '')
|
|
20
|
+
this.channelVersion = channelVersion || '1.0.0'
|
|
21
|
+
this.botAgent = botAgent || 'DSH-ClawBot/0.1.0'
|
|
22
|
+
this.appId = appId || 'bot'
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
headers(token) {
|
|
26
|
+
const h = {
|
|
27
|
+
'Content-Type': 'application/json',
|
|
28
|
+
AuthorizationType: 'ilink_bot_token',
|
|
29
|
+
'X-WECHAT-UIN': Buffer.from(String(Math.floor(Math.random() * 4294967295))).toString('base64'),
|
|
30
|
+
}
|
|
31
|
+
const t = String(token || '').trim()
|
|
32
|
+
if (t) h.Authorization = `Bearer ${t}`
|
|
33
|
+
return h
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
baseInfo() {
|
|
37
|
+
return { channel_version: this.channelVersion, bot_agent: this.botAgent, app_id: this.appId }
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
async post(endpoint, body, { token, timeoutMs, signal } = {}) {
|
|
41
|
+
const ctrl = new AbortController()
|
|
42
|
+
const timer = timeoutMs ? setTimeout(() => ctrl.abort(), timeoutMs) : null
|
|
43
|
+
const onAbort = () => ctrl.abort()
|
|
44
|
+
if (signal) signal.addEventListener('abort', onAbort, { once: true })
|
|
45
|
+
try {
|
|
46
|
+
const res = await fetch(`${this.baseUrl}/${String(endpoint).replace(/^\/+/, '')}`, {
|
|
47
|
+
method: 'POST',
|
|
48
|
+
headers: this.headers(token),
|
|
49
|
+
body: JSON.stringify(body),
|
|
50
|
+
signal: ctrl.signal,
|
|
51
|
+
})
|
|
52
|
+
const text = await res.text()
|
|
53
|
+
if (!res.ok) throw new Error(`HTTP ${res.status}: ${text.slice(0, 300)}`)
|
|
54
|
+
return text ? JSON.parse(text) : {}
|
|
55
|
+
} finally {
|
|
56
|
+
if (timer) clearTimeout(timer)
|
|
57
|
+
if (signal) signal.removeEventListener('abort', onAbort)
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
async get(url, headers, timeoutMs) {
|
|
62
|
+
const ctrl = new AbortController()
|
|
63
|
+
const timer = timeoutMs ? setTimeout(() => ctrl.abort(), timeoutMs) : null
|
|
64
|
+
try {
|
|
65
|
+
const res = await fetch(url, { headers, signal: ctrl.signal })
|
|
66
|
+
const text = await res.text()
|
|
67
|
+
if (!res.ok) throw new Error(`HTTP ${res.status}`)
|
|
68
|
+
return JSON.parse(text)
|
|
69
|
+
} finally {
|
|
70
|
+
if (timer) clearTimeout(timer)
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** A long-poll timeout returning the same cursor and no messages is normal. */
|
|
75
|
+
async getUpdates(token, cursor, signal) {
|
|
76
|
+
return this.post(
|
|
77
|
+
'ilink/bot/getupdates',
|
|
78
|
+
{ get_updates_buf: cursor || '', base_info: this.baseInfo() },
|
|
79
|
+
{ token, timeoutMs: LONG_POLL_MS + 10000, signal },
|
|
80
|
+
)
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** Send one text message. The gateway requires `context_token`. */
|
|
84
|
+
async sendText(token, toUserId, text, contextToken, signal) {
|
|
85
|
+
if (!contextToken) throw new Error('context_token is required for send')
|
|
86
|
+
return this.post(
|
|
87
|
+
'ilink/bot/sendmessage',
|
|
88
|
+
{
|
|
89
|
+
msg: {
|
|
90
|
+
from_user_id: '',
|
|
91
|
+
to_user_id: toUserId,
|
|
92
|
+
client_id: randomUUID(),
|
|
93
|
+
message_type: 2,
|
|
94
|
+
message_state: 2,
|
|
95
|
+
context_token: contextToken,
|
|
96
|
+
item_list: [{ type: 1, text_item: { text } }],
|
|
97
|
+
},
|
|
98
|
+
base_info: this.baseInfo(),
|
|
99
|
+
},
|
|
100
|
+
{ token, timeoutMs: 20000, signal },
|
|
101
|
+
)
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
async requestQrCode() {
|
|
105
|
+
return this.post('ilink/bot/get_bot_qrcode?bot_type=3', { local_token_list: [] }, { timeoutMs: 20000 })
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
async pollQrStatus(qrcode) {
|
|
109
|
+
const url = `${this.baseUrl}/ilink/bot/get_qrcode_status?qrcode=${encodeURIComponent(qrcode)}`
|
|
110
|
+
return this.get(url, { 'iLink-App-ClientVersion': '1' }, 40000)
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* QR login: start a ticket, then poll it to resolution.
|
|
116
|
+
*
|
|
117
|
+
* The gateway returns only the login URL (`qrcode_img_content`), not an image.
|
|
118
|
+
* Rendering is therefore the client's job — see `./qr.js`.
|
|
119
|
+
*/
|
|
120
|
+
export class QrLoginManager {
|
|
121
|
+
constructor({ client, store }) {
|
|
122
|
+
this.client = client
|
|
123
|
+
this.store = store
|
|
124
|
+
this.ticket = null
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
async start() {
|
|
128
|
+
const qr = await this.client.requestQrCode()
|
|
129
|
+
if (!qr || !qr.qrcode) throw new Error('get_bot_qrcode returned no qrcode')
|
|
130
|
+
this.ticket = { id: qr.qrcode, url: typeof qr.qrcode_img_content === 'string' ? qr.qrcode_img_content : '' }
|
|
131
|
+
return this.ticket
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Poll once.
|
|
136
|
+
* @returns {Promise<{status:'wait'|'scaned'|'confirmed'|'expired', accountId?:string, userId?:string, baseUrl?:string}>}
|
|
137
|
+
*/
|
|
138
|
+
async poll({ ticketId } = {}) {
|
|
139
|
+
const id = ticketId ?? this.ticket?.id
|
|
140
|
+
if (!id) throw new Error('no login ticket; call start() first')
|
|
141
|
+
const status = await this.client.pollQrStatus(id)
|
|
142
|
+
const state = String(status?.status ?? '')
|
|
143
|
+
if (state === 'confirmed') {
|
|
144
|
+
const token = String(status.bot_token || '').trim()
|
|
145
|
+
if (!token) throw new Error('confirmed but no bot_token')
|
|
146
|
+
const baseUrl = status.base_url || DEFAULT_BASE_URL
|
|
147
|
+
// Account identity is derived from the token's own prefix, which is the
|
|
148
|
+
// stable bot account id the gateway uses.
|
|
149
|
+
const accountId = token.split(':')[0] || 'default'
|
|
150
|
+
this.store.registerAccountId(accountId)
|
|
151
|
+
this.store.save(accountId, { token, baseUrl })
|
|
152
|
+
return { status: 'confirmed', accountId, baseUrl }
|
|
153
|
+
}
|
|
154
|
+
if (state === 'scaned' || state === 'scanned') return { status: 'scaned' }
|
|
155
|
+
if (state === 'expired' || state === 'verify_code_blocked') return { status: 'expired' }
|
|
156
|
+
return { status: 'wait' }
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/** Render a login URL into a PNG buffer. */
|
|
161
|
+
export function renderQrPng(url, { scale = 8, margin = 4 } = {}) {
|
|
162
|
+
const qr = encodeQr(url, { ecc: 'M' })
|
|
163
|
+
return { png: qrToPng(qr.modules, { scale, margin }), art: qrToTerminal(qr.modules, { margin: 2 }), meta: qr }
|
|
164
|
+
}
|
package/lib/index.js
ADDED
|
@@ -0,0 +1,406 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* WeChat ClawBot (Tencent iLink) channel for DeepSeek Harness.
|
|
3
|
+
*
|
|
4
|
+
* Talk to DSH from WeChat: an outbound long poll receives messages, each message
|
|
5
|
+
* drives one DSH session, and the reply goes back to the chat.
|
|
6
|
+
*
|
|
7
|
+
* STABLE RUNTIME IDENTITIES (do not rename — they are what an existing install
|
|
8
|
+
* is keyed on):
|
|
9
|
+
* Cordis plugin `name` (the log tag) wechat-clawbot
|
|
10
|
+
* Bundle row `id` wechat-clawbot
|
|
11
|
+
* Default `sessionId` wechat-clawbot
|
|
12
|
+
* State directory <DSH_HOME>/clawbot/
|
|
13
|
+
* CLI binary dsh-wechat-ilink
|
|
14
|
+
*/
|
|
15
|
+
import fs from 'node:fs'
|
|
16
|
+
import path from 'node:path'
|
|
17
|
+
import { spawn } from 'node:child_process'
|
|
18
|
+
import { randomUUID } from 'node:crypto'
|
|
19
|
+
|
|
20
|
+
import { AccountStore, resolveStateDir } from './accounts.js'
|
|
21
|
+
import { ILinkClient, QrLoginManager, DEFAULT_BASE_URL, renderQrPng } from './ilink.js'
|
|
22
|
+
import { Logger, resolveConfig, isSenderAllowed, SingleInstanceLock, redactToken } from './support.js'
|
|
23
|
+
import { Config } from './schema.js'
|
|
24
|
+
|
|
25
|
+
export const name = 'wechat-clawbot'
|
|
26
|
+
|
|
27
|
+
/** Plugin version, mirrored from package.json (a literal keeps the bundle JSON-free). */
|
|
28
|
+
export const PLUGIN_VERSION = '0.7.0'
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Services this plugin requires before it activates.
|
|
32
|
+
*
|
|
33
|
+
* Deliberately empty: the conversation is driven through a `dsh --profile
|
|
34
|
+
* headless` child process, so this plugin needs no in-process DSH service and
|
|
35
|
+
* therefore cannot be blocked from activating by one that is missing.
|
|
36
|
+
*/
|
|
37
|
+
export const inject = []
|
|
38
|
+
|
|
39
|
+
/** Re-exported so DSH validates the bundle row's `config` automatically. */
|
|
40
|
+
export { Config }
|
|
41
|
+
|
|
42
|
+
const CHANNEL_VERSION = '2.4.9'
|
|
43
|
+
const BOT_AGENT = 'DSH-ClawBot/0.1.0'
|
|
44
|
+
|
|
45
|
+
/** Split a long reply into chat-sized chunks. */
|
|
46
|
+
function chunkText(text, maxChars) {
|
|
47
|
+
const limit = Number.isFinite(maxChars) && maxChars > 0 ? maxChars : 2000
|
|
48
|
+
const chunks = []
|
|
49
|
+
let rest = String(text ?? '')
|
|
50
|
+
while (rest.length > limit) {
|
|
51
|
+
let cut = rest.lastIndexOf('\n', limit)
|
|
52
|
+
if (cut < limit * 0.5) cut = limit
|
|
53
|
+
chunks.push(rest.slice(0, cut))
|
|
54
|
+
rest = rest.slice(cut).replace(/^\n+/, '')
|
|
55
|
+
}
|
|
56
|
+
if (rest.length > 0) chunks.push(rest)
|
|
57
|
+
return chunks
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Token bucket for outbound sends; the gateway throttles aggressively. */
|
|
61
|
+
class RateLimiter {
|
|
62
|
+
constructor(rate, burst) {
|
|
63
|
+
this.rate = Number(rate) > 0 ? Number(rate) : 0
|
|
64
|
+
this.burst = Number(burst) > 0 ? Number(burst) : 1
|
|
65
|
+
this.tokens = this.burst
|
|
66
|
+
this.last = Date.now()
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
async take(signal) {
|
|
70
|
+
if (this.rate <= 0) return
|
|
71
|
+
for (;;) {
|
|
72
|
+
if (signal?.aborted) throw new Error('aborted')
|
|
73
|
+
const now = Date.now()
|
|
74
|
+
this.tokens = Math.min(this.burst, this.tokens + ((now - this.last) / 1000) * this.rate)
|
|
75
|
+
this.last = now
|
|
76
|
+
if (this.tokens >= 1) {
|
|
77
|
+
this.tokens -= 1
|
|
78
|
+
return
|
|
79
|
+
}
|
|
80
|
+
const waitMs = Math.ceil(((1 - this.tokens) / this.rate) * 1000)
|
|
81
|
+
await new Promise((r) => setTimeout(r, Math.min(Math.max(waitMs, 20), 1000)))
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Derive the argv prefix that re-launches DSH.
|
|
88
|
+
*
|
|
89
|
+
* This process IS DSH (Electron running as node), so `process.execPath` plus the
|
|
90
|
+
* `cli.js` entry from argv is exactly the pair the `dsh` shim uses.
|
|
91
|
+
*/
|
|
92
|
+
function resolveDshPrefix(config) {
|
|
93
|
+
if (Array.isArray(config.dshArgv) && config.dshArgv.length > 0) return config.dshArgv.map(String)
|
|
94
|
+
const exe = process.execPath
|
|
95
|
+
const cli = Array.isArray(process.argv) ? process.argv.find((a) => typeof a === 'string' && a.endsWith('cli.js')) : undefined
|
|
96
|
+
if (exe && cli) return [exe, '--expose-internals', cli]
|
|
97
|
+
return null
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** Parse the NDJSON emitted by `dsh --profile headless --json`. */
|
|
101
|
+
export function parseHeadlessJson(stdout) {
|
|
102
|
+
let sessionId
|
|
103
|
+
let text = ''
|
|
104
|
+
let reason
|
|
105
|
+
for (const line of String(stdout ?? '').split('\n')) {
|
|
106
|
+
const trimmed = line.trim()
|
|
107
|
+
if (!trimmed) continue
|
|
108
|
+
let event
|
|
109
|
+
try {
|
|
110
|
+
event = JSON.parse(trimmed)
|
|
111
|
+
} catch {
|
|
112
|
+
continue
|
|
113
|
+
}
|
|
114
|
+
if (event.type === 'session' && typeof event.sessionId === 'string') sessionId = event.sessionId
|
|
115
|
+
else if (event.type === 'final' && typeof event.text === 'string') text = event.text
|
|
116
|
+
else if (event.type === 'status' && event.phase === 'turn_end' && event.reason) reason = event.reason
|
|
117
|
+
}
|
|
118
|
+
return {
|
|
119
|
+
sessionId,
|
|
120
|
+
text,
|
|
121
|
+
reason: reason ? reason.kind : 'incomplete',
|
|
122
|
+
error: reason?.error?.message,
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Cordis plugin entry.
|
|
128
|
+
*
|
|
129
|
+
* @param ctx the plugin's Cordis context.
|
|
130
|
+
* @param rawConfig validated config from the bundle row.
|
|
131
|
+
*/
|
|
132
|
+
export function apply(ctx, rawConfig) {
|
|
133
|
+
const config = resolveConfig(rawConfig)
|
|
134
|
+
const stateDir = config.stateDir || resolveStateDir()
|
|
135
|
+
const logFile = path.join(stateDir, 'channel.log')
|
|
136
|
+
// A plugin inside DSH has no readable stderr, so every line also lands in a
|
|
137
|
+
// durable file next to the credentials. This is the only way a live failure
|
|
138
|
+
// can be diagnosed after the fact.
|
|
139
|
+
const logger = new Logger(config.logLevel, 'wechat-clawbot', (line) => process.stderr.write(line + '\n'), logFile)
|
|
140
|
+
const log = (level, message) => logger[level]?.(message)
|
|
141
|
+
|
|
142
|
+
log('info', `=== wechat-clawbot v${PLUGIN_VERSION} loading (state dir=${stateDir}) ===`)
|
|
143
|
+
|
|
144
|
+
if (config.enabled !== true) {
|
|
145
|
+
log('info', 'disabled by config (enabled: false); not connecting')
|
|
146
|
+
return
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
const store = new AccountStore(stateDir)
|
|
150
|
+
const client = new ILinkClient({
|
|
151
|
+
baseUrl: config.baseUrl || DEFAULT_BASE_URL,
|
|
152
|
+
channelVersion: CHANNEL_VERSION,
|
|
153
|
+
botAgent: BOT_AGENT,
|
|
154
|
+
appId: 'bot',
|
|
155
|
+
})
|
|
156
|
+
const login = new QrLoginManager({ client, store })
|
|
157
|
+
const abort = new AbortController()
|
|
158
|
+
let stopped = false
|
|
159
|
+
|
|
160
|
+
// Two pollers on one bot steal each other's messages, silently. Refuse to be
|
|
161
|
+
// the second one.
|
|
162
|
+
const lock = new SingleInstanceLock(stateDir, log)
|
|
163
|
+
if (!lock.acquire()) return
|
|
164
|
+
|
|
165
|
+
ctx.effect(
|
|
166
|
+
() => () => {
|
|
167
|
+
stopped = true
|
|
168
|
+
abort.abort()
|
|
169
|
+
lock.release()
|
|
170
|
+
},
|
|
171
|
+
'wechat-clawbot lifecycle',
|
|
172
|
+
)
|
|
173
|
+
|
|
174
|
+
// ---- account ----
|
|
175
|
+
let accountId = store.listAccountIds()[0]
|
|
176
|
+
let account = accountId ? store.load(accountId) : null
|
|
177
|
+
let boundUserId = account?.userId || ''
|
|
178
|
+
|
|
179
|
+
const persistBoundUser = (userId) => {
|
|
180
|
+
if (!userId || boundUserId === userId) return
|
|
181
|
+
boundUserId = userId
|
|
182
|
+
if (accountId) store.save(accountId, { userId })
|
|
183
|
+
log('info', `bound WeChat user recorded: ${userId}`)
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// ---- outbound ----
|
|
187
|
+
const limiter = new RateLimiter(config.outgoingMaxPerSec ?? 0.5, config.outgoingBurst ?? 1)
|
|
188
|
+
let lastContextToken = ''
|
|
189
|
+
|
|
190
|
+
async function sendText(toUserId, text) {
|
|
191
|
+
if (!lastContextToken) {
|
|
192
|
+
log('error', `no context_token yet for ${toUserId}; they must send a message first`)
|
|
193
|
+
return false
|
|
194
|
+
}
|
|
195
|
+
for (const chunk of chunkText(text, config.maxMessageChars)) {
|
|
196
|
+
await limiter.take(abort.signal)
|
|
197
|
+
try {
|
|
198
|
+
const res = await client.sendText(account.token, toUserId, chunk, lastContextToken, abort.signal)
|
|
199
|
+
const ret = Number(res?.ret)
|
|
200
|
+
if (Number.isFinite(ret) && ret !== 0) {
|
|
201
|
+
log('error', `sendmessage declined ret=${ret} errcode=${res?.errcode} errmsg=${res?.errmsg}`)
|
|
202
|
+
return false
|
|
203
|
+
}
|
|
204
|
+
} catch (error) {
|
|
205
|
+
log('error', `send failed: ${error?.message ?? error}`)
|
|
206
|
+
return false
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
return true
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// ---- session driving ----
|
|
213
|
+
const dshPrefix = resolveDshPrefix(config)
|
|
214
|
+
const workspace = config.cwd || process.cwd()
|
|
215
|
+
let sessionId = config.sessionId?.trim() || 'wechat-clawbot'
|
|
216
|
+
let chain = Promise.resolve()
|
|
217
|
+
|
|
218
|
+
function runHeadless(prompt) {
|
|
219
|
+
if (!dshPrefix) return Promise.resolve({ text: '', reason: 'no-dsh-prefix' })
|
|
220
|
+
const argv = [...dshPrefix, '--profile', 'headless', '--json', '--session-id', sessionId, prompt]
|
|
221
|
+
return new Promise((resolve) => {
|
|
222
|
+
let child
|
|
223
|
+
try {
|
|
224
|
+
child = spawn(argv[0], argv.slice(1), { cwd: workspace, windowsHide: true, stdio: ['ignore', 'pipe', 'pipe'] })
|
|
225
|
+
} catch (error) {
|
|
226
|
+
resolve({ text: '', reason: `spawn: ${error?.message ?? error}` })
|
|
227
|
+
return
|
|
228
|
+
}
|
|
229
|
+
let stdout = ''
|
|
230
|
+
let stderr = ''
|
|
231
|
+
let settled = false
|
|
232
|
+
const finish = (r) => {
|
|
233
|
+
if (settled) return
|
|
234
|
+
settled = true
|
|
235
|
+
clearTimeout(timer)
|
|
236
|
+
resolve(r)
|
|
237
|
+
}
|
|
238
|
+
const timer = setTimeout(() => {
|
|
239
|
+
log('error', `turn exceeded turnTimeoutMs=${config.turnTimeoutMs}; terminating`)
|
|
240
|
+
try {
|
|
241
|
+
child.kill()
|
|
242
|
+
} catch {
|
|
243
|
+
/* ignore */
|
|
244
|
+
}
|
|
245
|
+
finish({ text: '', reason: 'timeout' })
|
|
246
|
+
}, config.turnTimeoutMs)
|
|
247
|
+
child.stdout.on('data', (d) => {
|
|
248
|
+
stdout += d.toString('utf8')
|
|
249
|
+
})
|
|
250
|
+
child.stderr.on('data', (d) => {
|
|
251
|
+
stderr += d.toString('utf8')
|
|
252
|
+
})
|
|
253
|
+
child.on('error', (error) => finish({ text: '', reason: `error: ${error?.message ?? error}` }))
|
|
254
|
+
child.on('close', (code) => {
|
|
255
|
+
const parsed = parseHeadlessJson(stdout)
|
|
256
|
+
if (parsed.reason === 'incomplete' && stderr.trim()) {
|
|
257
|
+
log('error', `headless exited code=${code}: ${stderr.trim().slice(0, 400)}`)
|
|
258
|
+
}
|
|
259
|
+
finish(parsed)
|
|
260
|
+
})
|
|
261
|
+
})
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
// ---- inbound ----
|
|
265
|
+
function extractText(msg) {
|
|
266
|
+
const items = Array.isArray(msg?.item_list) ? msg.item_list : []
|
|
267
|
+
const parts = []
|
|
268
|
+
for (const item of items) {
|
|
269
|
+
if (item?.type === 1 && typeof item.text_item?.text === 'string') parts.push(item.text_item.text)
|
|
270
|
+
else if (item?.type === 3 && typeof item.voice_item?.text === 'string') parts.push(item.voice_item.text)
|
|
271
|
+
}
|
|
272
|
+
return parts.join('\n').trim()
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
async function handleInbound(msg) {
|
|
276
|
+
const from = String(msg?.from_user_id || '').trim()
|
|
277
|
+
if (!from) return
|
|
278
|
+
|
|
279
|
+
const token = String(msg?.context_token || '').trim()
|
|
280
|
+
if (token) lastContextToken = token
|
|
281
|
+
|
|
282
|
+
// A sender is allowed when explicitly listed, otherwise only the account
|
|
283
|
+
// that scanned the QR may drive the agent.
|
|
284
|
+
if (!isSenderAllowed(config, from, boundUserId)) {
|
|
285
|
+
log('error', `sender not allowed, ignored: ${from} (bind with the CLI, or add to allowedUserIds)`)
|
|
286
|
+
return
|
|
287
|
+
}
|
|
288
|
+
persistBoundUser(from)
|
|
289
|
+
|
|
290
|
+
const text = extractText(msg)
|
|
291
|
+
if (!text) {
|
|
292
|
+
log('debug', `non-text message from ${from} ignored`)
|
|
293
|
+
return
|
|
294
|
+
}
|
|
295
|
+
log('info', `inbound from ${from} (${text.length} chars)`)
|
|
296
|
+
|
|
297
|
+
if (config.progressNotice) await sendText(from, '🤔 收到,正在处理…').catch(() => {})
|
|
298
|
+
|
|
299
|
+
chain = chain
|
|
300
|
+
.catch(() => {})
|
|
301
|
+
.then(async () => {
|
|
302
|
+
const result = await runHeadless(text)
|
|
303
|
+
if (result.sessionId) sessionId = result.sessionId
|
|
304
|
+
if (result.text) await sendText(from, result.text)
|
|
305
|
+
else log('error', `no reply text (reason=${result.reason}${result.error ? ` error=${result.error}` : ''})`)
|
|
306
|
+
})
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
// ---- poll ----
|
|
310
|
+
async function pollLoop() {
|
|
311
|
+
let backoffMs = 1000
|
|
312
|
+
let cursor = accountId ? store.loadSyncBuf(accountId) : ''
|
|
313
|
+
log('info', `polling as ${accountId} (token ${redactToken(account.token)}, cursor ${cursor.length} chars)`)
|
|
314
|
+
while (!stopped) {
|
|
315
|
+
try {
|
|
316
|
+
const res = await client.getUpdates(account.token, cursor, abort.signal)
|
|
317
|
+
backoffMs = 1000
|
|
318
|
+
|
|
319
|
+
if (Number(res?.errcode) === -14) {
|
|
320
|
+
log('error', 'session expired (errcode -14) — re-login required')
|
|
321
|
+
await new Promise((r) => setTimeout(r, 30000))
|
|
322
|
+
continue
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
const next = res?.get_updates_buf
|
|
326
|
+
if (typeof next === 'string' && next.length > 0 && next !== cursor) {
|
|
327
|
+
cursor = next
|
|
328
|
+
if (accountId) store.saveSyncBuf(accountId, cursor)
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
for (const msg of Array.isArray(res?.msgs) ? res.msgs : []) {
|
|
332
|
+
handleInbound(msg).catch((error) => log('error', `inbound handling failed: ${error?.message ?? error}`))
|
|
333
|
+
}
|
|
334
|
+
} catch (error) {
|
|
335
|
+
if (stopped || /aborted/i.test(String(error?.message))) return
|
|
336
|
+
log('error', `poll error: ${error?.message ?? error}`)
|
|
337
|
+
await new Promise((r) => setTimeout(r, backoffMs))
|
|
338
|
+
backoffMs = Math.min(backoffMs * 2, 30000)
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
// ---- QR binding when nothing is bound ----
|
|
344
|
+
async function bindViaQr() {
|
|
345
|
+
log('info', 'no bound account — requesting a login QR code')
|
|
346
|
+
const ticket = await login.start()
|
|
347
|
+
log('info', `login URL: ${ticket.url}`)
|
|
348
|
+
try {
|
|
349
|
+
const { png, art } = renderQrPng(ticket.url)
|
|
350
|
+
const qrPath = path.join(stateDir, 'login-qr.png')
|
|
351
|
+
fs.mkdirSync(stateDir, { recursive: true })
|
|
352
|
+
fs.writeFileSync(qrPath, png)
|
|
353
|
+
log('info', `QR image written to ${qrPath}`)
|
|
354
|
+
for (const line of art.split('\n')) log('info', `QR ${line}`)
|
|
355
|
+
} catch (error) {
|
|
356
|
+
log('error', `QR rendering failed (use the URL above): ${error?.message ?? error}`)
|
|
357
|
+
}
|
|
358
|
+
const deadline = Date.now() + 10 * 60 * 1000
|
|
359
|
+
while (!stopped && Date.now() < deadline) {
|
|
360
|
+
const outcome = await login.poll({ ticketId: ticket.id })
|
|
361
|
+
if (outcome.status === 'confirmed') {
|
|
362
|
+
accountId = outcome.accountId
|
|
363
|
+
account = store.load(accountId)
|
|
364
|
+
log('info', `bound account ${accountId}; credentials stored under ${stateDir}`)
|
|
365
|
+
return true
|
|
366
|
+
}
|
|
367
|
+
if (outcome.status === 'expired') throw new Error('QR code expired')
|
|
368
|
+
await new Promise((r) => setTimeout(r, 2000))
|
|
369
|
+
}
|
|
370
|
+
throw new Error('QR binding timed out')
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
// ---- start ----
|
|
374
|
+
;(async () => {
|
|
375
|
+
if (!dshPrefix) {
|
|
376
|
+
log('error', 'cannot derive the dsh argv prefix (process.execPath / cli.js); set config.dshArgv explicitly')
|
|
377
|
+
} else {
|
|
378
|
+
log('debug', `dsh prefix: ${dshPrefix.join(' ')}`)
|
|
379
|
+
}
|
|
380
|
+
if (!config.typing) log('debug', 'typing indicator disabled by config')
|
|
381
|
+
else log('info', 'NOTE: the typing indicator is not implemented in 0.7.0 (config accepted, ignored)')
|
|
382
|
+
if (config.acceptImages) log('info', 'NOTE: inbound images are not implemented in 0.7.0 (config accepted, ignored)')
|
|
383
|
+
|
|
384
|
+
try {
|
|
385
|
+
if (!account?.token) {
|
|
386
|
+
const ok = await bindViaQr()
|
|
387
|
+
if (!ok) return
|
|
388
|
+
}
|
|
389
|
+
log('info', `wechat-clawbot ready (session=${sessionId}, state dir=${store.dir})`)
|
|
390
|
+
|
|
391
|
+
if (config.selfTestOnStart) {
|
|
392
|
+
setTimeout(() => {
|
|
393
|
+
log('info', 'running startup self-test turn…')
|
|
394
|
+
runHeadless('Reply with exactly: SELFTEST_OK')
|
|
395
|
+
.then((r) => log(r.text.includes('SELFTEST_OK') ? 'info' : 'error', `self-test: reason=${r.reason} text=${JSON.stringify(r.text.slice(0, 120))}`))
|
|
396
|
+
.catch((error) => log('error', `self-test threw: ${error?.message ?? error}`))
|
|
397
|
+
}, config.selfTestDelayMs)
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
await pollLoop()
|
|
401
|
+
} catch (error) {
|
|
402
|
+
log('error', `channel failed to start: ${error?.message ?? error}`)
|
|
403
|
+
lock.release()
|
|
404
|
+
}
|
|
405
|
+
})()
|
|
406
|
+
}
|