@zmainer/dsh-wx-bridge 1.0.9 → 1.0.11
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/README.md +14 -0
- package/lib/client.js +6 -3
- package/lib/index.js +93 -49
- package/lib/kernel/bridge.mjs +69 -2
- package/package.json +1 -1
- package/src/client.js +6 -3
package/README.md
CHANGED
|
@@ -147,6 +147,20 @@ dsh plugin --profile <profile> add @zmainer/dsh-wx-bridge
|
|
|
147
147
|
|
|
148
148
|
## 最近变更
|
|
149
149
|
|
|
150
|
+
- **1.0.11**:三条体验/正确性修复(均来自用户反馈)——
|
|
151
|
+
① **非文本消息不再静默丢弃**:图片/语音/表情/文件此前是 `if (!from || !text) return` 直接丢弃,
|
|
152
|
+
连回执都没有(用户只看到"发了没反应");现在会回一句「只认文字消息」并把条目类型记进日志
|
|
153
|
+
(同一个人 60 秒内只提醒一次,避免连发图片被刷屏);
|
|
154
|
+
② **会话自动归组修好**:宿主半原来在开机补登记时直接读 `ctx.workspaceRegistry`,而没声明 inject
|
|
155
|
+
→ cordis 在**属性访问那一刻**就抛 `cannot get property "workspaceRegistry" without inject`,
|
|
156
|
+
"未注入就跳过"的兜底分支根本走不到 ⇒ 手机会话永远不进桌面 GUI 的工作区列表。
|
|
157
|
+
现在改用**注入进来的 webCtx**(并保留周期兜底扫描,10 分钟一次),失败只记日志、不影响其它功能;
|
|
158
|
+
③ **宿主端口探测修正**:`dsh-host-webserver` 暴露的是**方法** `webServer.port()`,不是属性——
|
|
159
|
+
之前写出的 `host-address.json` 里 `port: 0`,桥只能去扒 `desktop.log` 猜端口(日志一换就瞎)。
|
|
160
|
+
另外桥**新建 ACP 会话后会主动上报宿主**(`POST /wxbridge/attach`),手机对话即刻出现在工作区里。
|
|
161
|
+
- **1.0.10**:面板「运行时入口」一行区分「字段缺失」与「解析失败」——
|
|
162
|
+
升级了包但还没重启宿主时,宿主半仍是旧版、不上报该字段,此前会误显示成「未解析(手机对话会失败)」;
|
|
163
|
+
现在显示「—(宿主半未上报;重启宿主后显示)」。纯客户端修正。
|
|
150
164
|
- **1.0.9**:修「手机发消息永远没答复 / 5 分钟后才报 ACP 超时」——
|
|
151
165
|
根因是**运行时入口解析不到**(桌面端装在非标准目录时三级探测全部落空),ACP 子进程拿到空路径,
|
|
152
166
|
而 `node "" --profile acp` 会进入「读 stdin」模式:**不回应协议、也不退出**,只能等满超时。
|
package/lib/client.js
CHANGED
|
@@ -269,9 +269,12 @@ window.__ModuleLoader__.load({
|
|
|
269
269
|
const rows = [
|
|
270
270
|
['桥状态', state + '(' + (b.phase || 'n/a') + ')'],
|
|
271
271
|
['进程', b.pid ? ('pid ' + b.pid + '|心跳 ' + (b.ageSec === null ? 'n/a' : b.ageSec + 's 前')) : '无'],
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
272
|
+
// 字段缺失 ≠ 解析失败:升级了包但还没重启宿主时,宿主半仍是旧版、不上报这个字段。
|
|
273
|
+
['运行时入口', b.runtimeBin === undefined
|
|
274
|
+
? '—(宿主半未上报;重启宿主后显示)'
|
|
275
|
+
: (b.runtimeBin
|
|
276
|
+
? (b.runtimeBin + (b.runtimeBinOk ? '' : '(⚠️ 文件不存在)'))
|
|
277
|
+
: '⚠️ 未解析(手机对话会失败,请设置 dshBin)')],
|
|
275
278
|
['数据目录', b.dataDir || 'n/a'],
|
|
276
279
|
['默认工作区', b.cwd || 'n/a'],
|
|
277
280
|
]
|
package/lib/index.js
CHANGED
|
@@ -85,6 +85,7 @@ export function apply(ctx, config) {
|
|
|
85
85
|
let timer = null
|
|
86
86
|
let pairing = null
|
|
87
87
|
let runtimeInfo = { bin: '', ok: false, source: '' }
|
|
88
|
+
let injectedWebCtx = null
|
|
88
89
|
|
|
89
90
|
/** 按钮操作日志(环形缓冲):面板要能看到"我点了什么、结果如何"。 */
|
|
90
91
|
const actions = []
|
|
@@ -98,6 +99,83 @@ export function apply(ctx, config) {
|
|
|
98
99
|
try { return JSON.parse(readFileSync(f, 'utf8')) || {} } catch { return {} }
|
|
99
100
|
}
|
|
100
101
|
|
|
102
|
+
/**
|
|
103
|
+
* 从宿主的 webServer 服务里取本进程真正监听的端口。
|
|
104
|
+
* `dsh-host-webserver` 暴露的是**方法** `port()` / `host()`(config.port=0 时 port() 返回系统
|
|
105
|
+
* 分配的真值),不是属性——原来只试属性/底层 server,永远拿不到。
|
|
106
|
+
*/
|
|
107
|
+
const portFromWebCtx = (webCtx) => {
|
|
108
|
+
let port = 0
|
|
109
|
+
try {
|
|
110
|
+
const ws = (webCtx && webCtx.webServer) || {}
|
|
111
|
+
try { if (typeof ws.port === 'function') port = Number(ws.port() || 0) } catch {}
|
|
112
|
+
if (!port) {
|
|
113
|
+
for (const srv of [ws.server, ws.httpServer, ws.instance, ws.listener, ws.app]) {
|
|
114
|
+
try {
|
|
115
|
+
const a = srv && typeof srv.address === 'function' ? srv.address() : null
|
|
116
|
+
if (a && typeof a === 'object' && a.port) { port = Number(a.port); break }
|
|
117
|
+
} catch {}
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
if (!port) for (const k of ['port', 'portNumber', 'listenPort']) if (ws[k]) { port = Number(ws[k]); break }
|
|
121
|
+
} catch (e) { ctx.logger?.warn?.('wxbridge: 读宿主端口失败 ' + String(e?.message ?? e)) }
|
|
122
|
+
if (!port) port = Number(process.env.WXBRIDGE_HOST_PORT || 0)
|
|
123
|
+
return port
|
|
124
|
+
}
|
|
125
|
+
/** 数据目录(boot 完成后以 keeper 为准)。 */
|
|
126
|
+
const hostDataDir = () => (keeper && keeper.dataDir) || cfg.dataDir || readConfigFile().dataDir || ''
|
|
127
|
+
/**
|
|
128
|
+
* 写 host-address.json。**不许用较差的值覆盖本进程已知的好值**:
|
|
129
|
+
* 注入回调与 boot 有先后竞态,若晚到的 0 覆盖了真实端口,桥就只能回去扒日志(脆弱)。
|
|
130
|
+
*/
|
|
131
|
+
const writeHostAddress = (dataDir, port) => {
|
|
132
|
+
if (!dataDir) return
|
|
133
|
+
const f = join(dataDir, 'host-address.json')
|
|
134
|
+
try {
|
|
135
|
+
if (!port) {
|
|
136
|
+
try { const cur = JSON.parse(readFileSync(f, 'utf8')); if (cur && cur.port && cur.pid === process.pid) return } catch {}
|
|
137
|
+
}
|
|
138
|
+
writeFileSync(f, JSON.stringify({ pid: process.pid, port, at: new Date().toISOString() }, null, 2))
|
|
139
|
+
if (port) ctx.logger?.info?.('wxbridge: 宿主地址已写出(port=' + port + ')')
|
|
140
|
+
} catch (e) { ctx.logger?.warn?.('wxbridge: 写 host-address.json 失败 ' + String(e?.message ?? e)) }
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* 把「外部(桥/ACP/headless)写出来的会话」补登记进宿主工作区(按 cwd 匹配工作区 path)。
|
|
145
|
+
* **必须用注入进来的 webCtx**:直接读 `ctx.workspaceRegistry` 会在属性访问那一刻抛
|
|
146
|
+
* `cannot get property "workspaceRegistry" without inject`——连"未注入就跳过"的兜底分支都走不到
|
|
147
|
+
*(2026-09-22 实况:宿主每次开机都报同一条 warn,手机会话永远不进 GUI)。
|
|
148
|
+
* 失败一律降级为一条日志,绝不影响插件其它功能。
|
|
149
|
+
*/
|
|
150
|
+
const autoGroupSessions = async (webCtx, reason) => {
|
|
151
|
+
try {
|
|
152
|
+
const reg = webCtx && webCtx.workspaceRegistry
|
|
153
|
+
if (!reg) { ctx.logger?.info?.('wxbridge: 跳过自动归组(宿主未提供 workspaceRegistry)'); return { skipped: true } }
|
|
154
|
+
const raw = await reg.list()
|
|
155
|
+
const workspaces = Array.isArray(raw) ? raw : ((raw && raw.items) || [])
|
|
156
|
+
const norm = (v) => String(v || '').replace(/[\/]+$/, '').toLowerCase()
|
|
157
|
+
const known = new Set()
|
|
158
|
+
for (const w of workspaces) for (const id of (w.sessionIds || [])) known.add(id)
|
|
159
|
+
const persistence = webCtx.sessionPersistence
|
|
160
|
+
if (!persistence || typeof persistence.list !== 'function') return { skipped: true, note: '宿主未提供 sessionPersistence' }
|
|
161
|
+
const records = await persistence.list()
|
|
162
|
+
let n = 0
|
|
163
|
+
for (const rec of (records || [])) {
|
|
164
|
+
const header = (rec && (rec.header || rec)) || {}
|
|
165
|
+
const id = String(header.id || '')
|
|
166
|
+
if (!id.startsWith('session-') || known.has(id)) continue
|
|
167
|
+
const ws = workspaces.find((w) => norm(w.path) === norm(header.cwd))
|
|
168
|
+
if (!ws) continue
|
|
169
|
+
try { await ws.attachSession(id); known.add(id); n++ } catch {}
|
|
170
|
+
}
|
|
171
|
+
if (n) ctx.logger?.info?.('wxbridge: 自动归组 ' + n + ' 条会话到对应工作区' + (reason ? '(' + reason + ')' : ''))
|
|
172
|
+
return { grouped: n, scanned: (records || []).length, workspaces: workspaces.length }
|
|
173
|
+
} catch (e) {
|
|
174
|
+
ctx.logger?.warn?.('wxbridge: 自动归组失败(已降级,不影响其它功能) ' + String(e?.message ?? e))
|
|
175
|
+
return { error: String(e?.message ?? e) }
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
101
179
|
/**
|
|
102
180
|
* 宿主自己的运行时入口:**宿主进程就是 `<runtime>/lib/bin.js`**(argv[1]),
|
|
103
181
|
* 这是唯一无需猜测的权威答案(2026-09-22:其他用户装在非标准目录,三级探测全落空
|
|
@@ -169,34 +247,6 @@ export function apply(ctx, config) {
|
|
|
169
247
|
if (ownership !== 'host' && cfg.autoStart && process.env.WXBRIDGE_NO_AUTOSTART !== '1' && !st.pidOk) {
|
|
170
248
|
try { ctx.logger?.info?.('wxbridge: spawned bridge pid=' + keeper.startBridge()) } catch (e) { ctx.logger?.warn?.('wxbridge: spawn failed ' + String(e?.message ?? e)) }
|
|
171
249
|
}
|
|
172
|
-
// 开机自动归组:把外部(桥/headless)写出的、尚未登记到工作区的会话补登记
|
|
173
|
-
setTimeout(async () => {
|
|
174
|
-
try {
|
|
175
|
-
const fsMod = await import('node:fs')
|
|
176
|
-
const hostHome = process.env.DSH_HOME || ''
|
|
177
|
-
if (!hostHome) return
|
|
178
|
-
// 复用路由里的 attachOne/scanAndAttach 需要 webCtx;这里用等价的独立实现
|
|
179
|
-
const reg = ctx.workspaceRegistry
|
|
180
|
-
if (!reg) { ctx.logger?.info?.('wxbridge: 跳过自动归组(workspaceRegistry 未注入)'); return }
|
|
181
|
-
const workspaces = await reg.list()
|
|
182
|
-
const norm = (v) => String(v || '').replace(/[\/]+$/, '').toLowerCase()
|
|
183
|
-
const known = new Set()
|
|
184
|
-
for (const w of workspaces) for (const id of (w.sessionIds || [])) known.add(id)
|
|
185
|
-
const persistence = ctx.sessionPersistence
|
|
186
|
-
if (!persistence || typeof persistence.list !== 'function') return
|
|
187
|
-
const records = await persistence.list()
|
|
188
|
-
let n = 0
|
|
189
|
-
for (const rec of (records || [])) {
|
|
190
|
-
const header = (rec && (rec.header || rec)) || {}
|
|
191
|
-
const id = String(header.id || '')
|
|
192
|
-
if (!id.startsWith('session-') || known.has(id)) continue
|
|
193
|
-
const ws = workspaces.find((w) => norm(w.path) === norm(header.cwd))
|
|
194
|
-
if (!ws) continue
|
|
195
|
-
try { await ws.attachSession(id); known.add(id); n++ } catch {}
|
|
196
|
-
}
|
|
197
|
-
if (n) ctx.logger?.info?.('wxbridge: 自动归组 ' + n + ' 条会话到对应工作区')
|
|
198
|
-
} catch (e) { ctx.logger?.warn?.('wxbridge: 自动归组失败 ' + String(e?.message ?? e)) }
|
|
199
|
-
}, 15000)
|
|
200
250
|
if (cfg.autoSupervise && process.env.WXBRIDGE_NO_SUPERVISE !== '1') {
|
|
201
251
|
timer = setInterval(() => { try { keeper.tick() } catch (e) { ctx.logger?.warn?.('wxbridge tick: ' + String(e?.message ?? e)) } }, cfg.intervalMs || file.intervalMs || 300000)
|
|
202
252
|
if (timer.unref) timer.unref()
|
|
@@ -209,27 +259,12 @@ export function apply(ctx, config) {
|
|
|
209
259
|
log: (m) => ctx.logger?.info?.('wxbridge: ' + m),
|
|
210
260
|
})
|
|
211
261
|
pushAction({ action: 'host-boot', ok: true, detail: 'dataDir=' + st.dataDir + ' pidOk=' + st.pidOk + ' qr=' + (loadQrRenderer() ? 'qrcode' : 'text-only') })
|
|
212
|
-
// 把宿主自身的 HTTP
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
const cands = [ws.server, ws.httpServer, ws.instance, ws.listener, ws.app]
|
|
219
|
-
for (const srv of cands) {
|
|
220
|
-
if (srv && typeof srv.address === 'function') {
|
|
221
|
-
const addr = srv.address()
|
|
222
|
-
if (addr && typeof addr === 'object' && addr.port) { port = addr.port; break }
|
|
223
|
-
}
|
|
224
|
-
}
|
|
225
|
-
if (!port) {
|
|
226
|
-
for (const k of ['port', 'portNumber', 'listenPort']) if (ws[k]) { port = Number(ws[k]); break }
|
|
227
|
-
}
|
|
228
|
-
if (!port) port = Number(process.env.WXBRIDGE_HOST_PORT || 0)
|
|
229
|
-
} catch {}
|
|
230
|
-
const addrFile = join(st.dataDir, 'host-address.json')
|
|
231
|
-
writeFileSync(addrFile, JSON.stringify({ pid: process.pid, port, at: new Date().toISOString() }, null, 2))
|
|
232
|
-
} catch {}
|
|
262
|
+
// 把宿主自身的 HTTP 地址写给桥:桥据此把新生成的会话上报回来登记进工作区。
|
|
263
|
+
// 注意:此刻注入回调可能还没跑(webCtx 是回调参数、boot 里看不见它——原来这里直接写
|
|
264
|
+
// `webCtx.webServer` 是自由变量 → ReferenceError 被空 catch 吞掉 → port 恒为 0)。
|
|
265
|
+
// 所以先用"当前已有的信息"写一份,真正的端口由注入回调重写(见 writeHostAddress 的防覆盖规则)。
|
|
266
|
+
writeHostAddress(st.dataDir, portFromWebCtx(injectedWebCtx))
|
|
267
|
+
setTimeout(() => { try { writeHostAddress(hostDataDir(), portFromWebCtx(injectedWebCtx)) } catch {} }, 3000)
|
|
233
268
|
ctx.logger?.info?.('wxbridge: pairing ready (qr renderer=' + (loadQrRenderer() ? 'qrcode' : 'text-only') + ')')
|
|
234
269
|
}
|
|
235
270
|
|
|
@@ -243,6 +278,15 @@ export function apply(ctx, config) {
|
|
|
243
278
|
})
|
|
244
279
|
|
|
245
280
|
ctx.inject?.(['webServer', 'workspaceRegistry', 'sessions', 'agents', 'sessionPersistence', 'agentDefaultModel', 'sessionController'], (webCtx) => {
|
|
281
|
+
// 只有在这里 webCtx(含 webServer)才真实存在:把端口写进 host-address.json 供桥发现宿主。
|
|
282
|
+
injectedWebCtx = webCtx
|
|
283
|
+
setTimeout(() => { try { writeHostAddress(hostDataDir(), portFromWebCtx(webCtx)) } catch {} }, 1500)
|
|
284
|
+
// 开机补登记(等宿主把工作区/会话仓库读起来)+ 周期性兜底:
|
|
285
|
+
// 桥在新建 ACP 会话时会主动 /attach,这里负责"宿主不在时写下的会话"和其它来源的会话。
|
|
286
|
+
setTimeout(() => { void autoGroupSessions(webCtx, 'boot') }, 12000)
|
|
287
|
+
const groupTimer = setInterval(() => { void autoGroupSessions(webCtx, 'periodic') }, 10 * 60 * 1000)
|
|
288
|
+
if (groupTimer.unref) groupTimer.unref()
|
|
289
|
+
ctx.on?.('dispose', () => clearInterval(groupTimer))
|
|
246
290
|
const send = (res, o, code = 200) => { res.writeHead(code, { 'content-type': 'application/json', 'cache-control': 'no-store' }); res.end(JSON.stringify(o)) }
|
|
247
291
|
let lastError = null
|
|
248
292
|
|
package/lib/kernel/bridge.mjs
CHANGED
|
@@ -481,6 +481,23 @@ const sendText = (token, to, text, contextToken) => ilink('ilink/bot/sendmessage
|
|
|
481
481
|
})
|
|
482
482
|
|
|
483
483
|
function inboundText(msg) { for (const it of msg?.item_list ?? []) if (it?.type === ITEM_TYPE_TEXT && typeof it.text_item?.text === 'string') return it.text_item.text; return '' }
|
|
484
|
+
/** 入站消息里出现过的条目类型(排障 + 「只认文字」提示用)。 */
|
|
485
|
+
function inboundItemTypes(msg) {
|
|
486
|
+
const out = new Set()
|
|
487
|
+
for (const it of msg?.item_list ?? []) if (it && it.type !== undefined) out.add(Number(it.type))
|
|
488
|
+
return [...out]
|
|
489
|
+
}
|
|
490
|
+
/** 「只认文字」的提示:同一个人 60 秒内只提醒一次,避免连发图片被刷屏。 */
|
|
491
|
+
const NOTEXT_NOTICE_AT = new Map()
|
|
492
|
+
const NOTEXT_NOTICE = '只认文字消息:图片 / 语音 / 表情 / 文件我都收不到(微信侧也不会自动转文字)。'
|
|
493
|
+
+ NL + '把内容打成文字发我就好。'
|
|
494
|
+
/** 该不该给他回「只认文字」:同一个人 60 秒内只回一次。返回要发的文本,'' = 这次不回。 */
|
|
495
|
+
function nonTextNoticeFor(from, now = Date.now()) {
|
|
496
|
+
const last = NOTEXT_NOTICE_AT.get(from) || 0
|
|
497
|
+
if (now - last <= 60000) return ''
|
|
498
|
+
NOTEXT_NOTICE_AT.set(from, now)
|
|
499
|
+
return NOTEXT_NOTICE
|
|
500
|
+
}
|
|
484
501
|
const msgKey = (m) => 'id:' + (m?.msg_id ?? m?.message_id ?? m?.client_id ?? JSON.stringify(m).slice(0, 80))
|
|
485
502
|
|
|
486
503
|
const SECRET_PATTERNS = [/sk-[A-Za-z0-9]{16,}/g, /ghp_[A-Za-z0-9]{20,}/g, /AKIA[0-9A-Z]{16}/g, /-----BEGIN [A-Z ]*PRIVATE KEY-----[\s\S]{0,4000}?-----END [A-Z ]*PRIVATE KEY-----/g]
|
|
@@ -780,6 +797,8 @@ async function ensurePeerSession(peer) {
|
|
|
780
797
|
const r = await host.newSession(cwd)
|
|
781
798
|
slot.id = r?.sessionId || ''
|
|
782
799
|
slot.cwd = cwd
|
|
800
|
+
// 卖点落地:桥写出的会话要立刻出现在桌面 GUI 的工作区列表里(不靠人手动 /attach)。
|
|
801
|
+
void reportSessionToHost(slot.id, cwd)
|
|
783
802
|
flattenConfigOptions(r?.configOptions)
|
|
784
803
|
if (Array.isArray(r?.configOptions)) peer.acpConfig = r.configOptions.map((o) => o.id).join(',')
|
|
785
804
|
await applyPeerConfig(peer, slot.id)
|
|
@@ -788,6 +807,26 @@ async function ensurePeerSession(peer) {
|
|
|
788
807
|
return slot.id
|
|
789
808
|
}
|
|
790
809
|
|
|
810
|
+
/**
|
|
811
|
+
* 把桥创建的会话上报给宿主,登记进对应工作区(幂等;失败只影响"归组",不影响本轮任务)。
|
|
812
|
+
* 宿主路由 /wxbridge/attach 就是干这个的,且只有宿主进程内的插件能调 workspaceRegistry。
|
|
813
|
+
*/
|
|
814
|
+
async function reportSessionToHost(sessionId, cwd, isRetry) {
|
|
815
|
+
if (!sessionId || EXECUTION === 'headless') return
|
|
816
|
+
try {
|
|
817
|
+
const port = await discoverHost()
|
|
818
|
+
if (!port) { log('attach-report-skip', { session: sessionId, note: '宿主未发现(端口未探到)' }); return }
|
|
819
|
+
const r = await fetch('http://127.0.0.1:' + port + '/wxbridge/attach', {
|
|
820
|
+
method: 'POST', headers: { 'content-type': 'application/json' },
|
|
821
|
+
body: JSON.stringify({ sessionId, cwd }), signal: AbortSignal.timeout(8000),
|
|
822
|
+
})
|
|
823
|
+
const j = await r.json().catch(() => null)
|
|
824
|
+
log('attach-report', { session: sessionId, ok: !!(j && j.ok), workspace: (j && j.workspacePath) || null, error: (j && j.error) || null })
|
|
825
|
+
// 会话文件刚落盘时宿主可能还读不到 → 4 秒后补一次(仍幂等;再不行还有宿主侧 10 分钟的兜底扫描)
|
|
826
|
+
if (!(j && j.ok)) setTimeout(() => { void reportSessionToHost(sessionId, cwd, true) }, 4000)
|
|
827
|
+
} catch (e) { log('attach-report-failed', { session: sessionId, error: String(e?.message ?? e) }) }
|
|
828
|
+
}
|
|
829
|
+
|
|
791
830
|
/**
|
|
792
831
|
* 把 peer 选的模型/推理强度施加到当前会话。
|
|
793
832
|
* **必须每次 new/resume 后都做**:实测 resume 后会话会掉回 acp profile 的默认模型
|
|
@@ -1086,7 +1125,18 @@ async function handleMessage(token, msg) {
|
|
|
1086
1125
|
const from = String(msg?.from_user_id ?? '')
|
|
1087
1126
|
const text = inboundText(msg)
|
|
1088
1127
|
const ctx = msg?.context_token
|
|
1089
|
-
if (!from
|
|
1128
|
+
if (!from) return
|
|
1129
|
+
if (!text) {
|
|
1130
|
+
// 原来这里是一句 `if (!from || !text) return` —— 图片/语音/表情**连回执都没有**,
|
|
1131
|
+
// 用户只看到"发了没反应"。现在至少回一句"只认文字"(2026-09-22 其他用户反馈)。
|
|
1132
|
+
const types = inboundItemTypes(msg)
|
|
1133
|
+
log('inbound-nontext', { types, note: types.length ? '非文本,已回提示' : '空消息' })
|
|
1134
|
+
const notice = nonTextNoticeFor(from)
|
|
1135
|
+
if (notice) {
|
|
1136
|
+
try { await reply(token, from, notice, ctx) } catch (e) { log('send-failed', { error: String(e?.message ?? e) }) }
|
|
1137
|
+
}
|
|
1138
|
+
return
|
|
1139
|
+
}
|
|
1090
1140
|
const trimmed = text.trim()
|
|
1091
1141
|
const firstToken = trimmed.split(/\s+/)[0] || ''
|
|
1092
1142
|
const authed = firstToken === AUTH_TOKEN
|
|
@@ -1318,6 +1368,23 @@ async function main() {
|
|
|
1318
1368
|
}, null, 1))
|
|
1319
1369
|
process.exit(0)
|
|
1320
1370
|
}
|
|
1371
|
+
if (args.includes('--selftest-inbound')) {
|
|
1372
|
+
const mk = (items) => ({ from_user_id: 'u-test', item_list: items })
|
|
1373
|
+
const textMsg = mk([{ type: 1, text_item: { text: '你好' } }])
|
|
1374
|
+
const imgMsg = mk([{ type: 2, image_item: { url: 'x' } }])
|
|
1375
|
+
const bothMsg = mk([{ type: 2, image_item: { url: 'x' } }, { type: 1, text_item: { text: '这是什么' } }])
|
|
1376
|
+
const first = nonTextNoticeFor('u-cooldown', 1000000000000)
|
|
1377
|
+
const second = nonTextNoticeFor('u-cooldown', 1000000000000 + 30 * 1000)
|
|
1378
|
+
const third = nonTextNoticeFor('u-cooldown', 1000000000000 + 61 * 1000)
|
|
1379
|
+
console.log('[selftest-inbound] ' + JSON.stringify({
|
|
1380
|
+
text: { text: inboundText(textMsg), types: inboundItemTypes(textMsg) },
|
|
1381
|
+
image: { text: inboundText(imgMsg), types: inboundItemTypes(imgMsg), notice: first.slice(0, 24) + '…' },
|
|
1382
|
+
imagePlusText: { text: inboundText(bothMsg), types: inboundItemTypes(bothMsg) },
|
|
1383
|
+
cooldown: { at0: !!first, at30s: !!second, at61s: !!third },
|
|
1384
|
+
noticeText: NOTEXT_NOTICE,
|
|
1385
|
+
}, null, 1))
|
|
1386
|
+
process.exit(0)
|
|
1387
|
+
}
|
|
1321
1388
|
if (args.includes('--selftest-preset-check')) {
|
|
1322
1389
|
const peer = { cwd: CWD }
|
|
1323
1390
|
const sp = argOf('--selftest-preset-check', '')
|
|
@@ -1441,7 +1508,7 @@ async function main() {
|
|
|
1441
1508
|
const key = msgKey(msg)
|
|
1442
1509
|
if (seen.has(key)) continue
|
|
1443
1510
|
seen.add(key); state.processed.push(key)
|
|
1444
|
-
log('inbound', { text: inboundText(msg).slice(0, 80) })
|
|
1511
|
+
log('inbound', { text: inboundText(msg).slice(0, 80), types: inboundItemTypes(msg) })
|
|
1445
1512
|
try { await handleMessage(wechatToken, msg) } catch (e) { log('handle-error', { error: String(e?.message ?? e) }) }
|
|
1446
1513
|
}
|
|
1447
1514
|
await saveState(state)
|
package/package.json
CHANGED
package/src/client.js
CHANGED
|
@@ -263,9 +263,12 @@ function Panel() {
|
|
|
263
263
|
const rows = [
|
|
264
264
|
['桥状态', state + '(' + (b.phase || 'n/a') + ')'],
|
|
265
265
|
['进程', b.pid ? ('pid ' + b.pid + '|心跳 ' + (b.ageSec === null ? 'n/a' : b.ageSec + 's 前')) : '无'],
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
266
|
+
// 字段缺失 ≠ 解析失败:升级了包但还没重启宿主时,宿主半仍是旧版、不上报这个字段。
|
|
267
|
+
['运行时入口', b.runtimeBin === undefined
|
|
268
|
+
? '—(宿主半未上报;重启宿主后显示)'
|
|
269
|
+
: (b.runtimeBin
|
|
270
|
+
? (b.runtimeBin + (b.runtimeBinOk ? '' : '(⚠️ 文件不存在)'))
|
|
271
|
+
: '⚠️ 未解析(手机对话会失败,请设置 dshBin)')],
|
|
269
272
|
['数据目录', b.dataDir || 'n/a'],
|
|
270
273
|
['默认工作区', b.cwd || 'n/a'],
|
|
271
274
|
]
|