@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/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
+ }