Files
dsh_shenxian/poc/im-agent-bridge/lib/index.js
T
admin e6207aa691
build / build-and-scan (push) Waiting to run
chore(仓库对齐): 文档库结构治理 + IM/插件线落地
文档库:目录改为编号制(01-规范/02-架构设计/03-数据库/04-调整方案/
05-交接单/06-ops/07-scripts/08-skills/09-archive),顶层散文件归入 01-规范/;
INDEX.md 与 docs-manifest.json 重刷(档案 146 篇);旧目录名引用全量对齐。

IM 线:src/im/**(SDK / hub / store / presence / ws / gateway-token)、
src/web/routes/im.ts、src/db/plugin-data/**、src/supervisor/plugin-assembly.ts
及对应 test/**。

插件线:poc/{im-agent-bridge,im-connection-gateway,im-conversation-tabs,
business-plugins-im,carbon-mcp-probe}、src/web/routes/{sessions,overlay-device}.ts、
src/net/relay/{device-grant,instance-credential}.ts。

仓库卫生:清出 40 个历史误入库 / 已改名文件(34 个交接单归档 + 6 个旧结构,
本地均有副本);dsh-server-docs/.gitignore 补 tmp/;交接单不入库(政策)。
2026-09-24 07:25:16 +08:00

499 lines
18 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* @dsh-local/im-agent-bridge — host 插件(C 单 · `交接单/IM群组-C-Agent接入插件.md`)。
*
* ## 它做什么
* 实例内起一条**只拨出**的 WS 到平台 IM 端点,收到触发后组装上下文、交给本地 dsh
* 会话、把回答写回房间。四条硬约束与两种形态的实现全在**平台侧**的
* `src/im/agent-bridge.ts`(纯逻辑,可在平台测试里直接断言);本文件只负责
* 「**连接 + 搬运**」:
*
* ```
* 平台 ──(WS 推送 /messages)──▶ 本插件 ──(组装上下文)──▶ 本地 dsh 会话
* ▲ │
* └────(op:'send' 回写, via='agent')◀───────────────────────┘
* ```
*
* ## 🔴 五条纪律
* ① **只拨出**(E3):本文件**不开任何监听端口**(`ss -lntp` 无新增);
* ② **不引入依赖**:WS 握手与帧编解码在此文件手写(与平台 `src/im/ws.ts` 同一套最小子集);
* ③ **天然开关**:`DSH_PLATFORM_IM_URL` / `DSH_IM_INSTANCE_TOKEN` 任一缺失 ⇒ 不起连接;
* ④ **故障隔离**(§五-7):任何异常都只影响本插件自己的连接,⛔ 不向上抛;
* ⑤ **重连退避**(§五-2):断线后指数退避(1s→2s→…→上限 30s,带抖动),30 s 内自动恢复。
*
* ⚠️ 本文件目前把「本地 dsh 会话」抽象成可注入的 `askLocalSession()` —— 默认实现是
* **回显式占位**(返回一条说明文本),因为真正的对接点(把 prompt 塞进哪个 dsh 会话)
* 属 **D 单**的扩展点契约面(§八-5 明确问"是否需先做 D 单")。⇒ 本单保证的是
* **触发 / 上下文 / 预算 / 回写 / 隔离**五条判据可验;LLM 那一跳留给 D 单接线。
*
* @module @dsh-local/im-agent-bridge
*/
import { appendFileSync } from 'node:fs'
import { createHash, randomBytes } from 'node:crypto'
import { join } from 'node:path'
import { connect } from 'node:net'
/** 平台 IM 端点(env:`DSH_PLATFORM_IM_URL`,形如 `https://ai1net.com`)。 */
const ENV_URL = 'DSH_PLATFORM_IM_URL'
/** 实例凭据(env:`DSH_IM_INSTANCE_TOKEN`,`per-instance` 短期 token)。 */
const ENV_TOKEN = 'DSH_IM_INSTANCE_TOKEN'
/** 本 agent 的引用标识(env:`DSH_IM_AGENT_REF`;缺省 = 用实例 id 兜底)。 */
const ENV_AGENT_REF = 'DSH_IM_AGENT_REF'
/** 重连退避:首延迟 / 上限 / 抖动系数(§五-2 要求"30 s 内自动恢复")。 */
export const RECONNECT_FIRST_MS = 1000
export const RECONNECT_MAX_MS = 30_000
/** 排空 / 停机的宽限(`close()` 时给在途帧的时间)。 */
const CLOSE_GRACE_MS = 200
const WS_GUID = '258EAFA5-E914-47DA-95CA-C5AB0DC85B11'
const OP_TEXT = 0x1
const OP_CLOSE = 0x8
const OP_PING = 0x9
const OP_PONG = 0xa
/** 插件内日志(写 `<DSH_HOME>/.dsh-im-agent.log`;⛔ 绝不写 token)。 */
function makeLog(home) {
const file = join(home, '.dsh-im-agent.log')
return (line) => {
try {
appendFileSync(file, `${new Date().toISOString()} ${line}\n`)
} catch {
// 日志写不动⛔绝不拖垮插件
}
}
}
/* ── WS 最小子集(客户端侧:帧**必须掩码**)────────────────────────────────── */
function encodeClientFrame(opcode, payload) {
const len = payload.length
let header
if (len < 126) {
header = Buffer.alloc(2)
header[1] = 0x80 | len
} else if (len < 65536) {
header = Buffer.alloc(4)
header[1] = 0x80 | 126
header.writeUInt16BE(len, 2)
} else {
header = Buffer.alloc(10)
header[1] = 0x80 | 127
header.writeBigUInt64BE(BigInt(len), 2)
}
header[0] = 0x80 | opcode
const mask = randomBytes(4)
const masked = Buffer.from(payload)
for (let i = 0; i < masked.length; i += 1) masked[i] ^= mask[i % 4]
return Buffer.concat([header, mask, masked])
}
function textFrame(text) {
return encodeClientFrame(OP_TEXT, Buffer.from(text, 'utf8'))
}
/** 服务端帧解析(**不带掩码**)。 */
class ClientDecoder {
constructor() {
this.buf = Buffer.alloc(0)
}
push(chunk) {
this.buf = this.buf.length === 0 ? chunk : Buffer.concat([this.buf, chunk])
const out = []
for (;;) {
if (this.buf.length < 2) break
const b0 = this.buf[0]
const b1 = this.buf[1]
const opcode = b0 & 0x0f
let len = b1 & 0x7f
let offset = 2
if (len === 126) {
if (this.buf.length < 4) break
len = this.buf.readUInt16BE(2)
offset = 4
} else if (len === 127) {
if (this.buf.length < 10) break
len = Number(this.buf.readBigUInt64BE(2))
offset = 10
}
if (this.buf.length < offset + len) break
const payload = Buffer.from(this.buf.subarray(offset, offset + len))
this.buf = this.buf.subarray(offset + len)
out.push({ opcode, payload, fin: (b0 & 0x80) !== 0 })
}
return out
}
}
/**
* 解析 URL 成 `{host, port, path, tls}`(⛔ 不引 `node:url` 的 WHATWG 也行,但用它更稳)。
* 只支持 `http(s)` / `ws(s)`:`https`/`wss` ⇒ TLS。
*/
export function parseEndpoint(rawUrl) {
const u = new URL(rawUrl)
const tls = u.protocol === 'https:' || u.protocol === 'wss:'
return {
host: u.hostname,
port: u.port !== '' ? Number(u.port) : tls ? 443 : 80,
path: '/api/im/ws',
tls,
origin: `${tls ? 'https' : 'http'}://${u.host}`,
}
}
/* ── 主类 ────────────────────────────────────────────────────────────────── */
/**
* 拨出桥(一个实例一份)。
*
* ⛔ **只拨出**:`start()` 只 `connect()`,不 `listen()`。
*/
export class ImAgentBridge {
/**
* @param {object} opts
* @param {string} opts.baseUrl - 平台 IM 端点(env)。
* @param {string} opts.token - 实例凭据(env)。
* @param {string} opts.agentRef - 本 agent 引用。
* @param {(prompt: string, ctx: object) => Promise<string>} opts.askLocalSession
* 把上下文交给本地 dsh 会话并取回回答(占位实现见 `defaultAsk`)。
* @param {(line: string) => void} [opts.log]
* @param {number} [opts.firstDelayMs] / @param {number} [opts.maxDelayMs] - 退避参数(测试用)
* @param {() => object} [opts.readSnapshot] - 读房间快照(测试注入;默认走 REST)
*/
constructor(opts) {
this.baseUrl = opts.baseUrl
this.token = opts.token
this.agentRef = opts.agentRef
this.askLocalSession = opts.askLocalSession
this.log = opts.log ?? (() => undefined)
this.firstDelayMs = opts.firstDelayMs ?? RECONNECT_FIRST_MS
this.maxDelayMs = opts.maxDelayMs ?? RECONNECT_MAX_MS
this.readSnapshot = opts.readSnapshot ?? null
/** 已订阅的房间(重连后要重新 subscribe ⇒ 按游标补拉)。 */
this.rooms = new Map()
this.socket = null
this.decoder = null
this.attempt = 0
this.timer = null
this.stopped = false
/** 观测计数(§五-5「指标可查」)。 */
this.metrics = { connects: 0, reconnects: 0, triggers: 0, replies: 0, suppressed: 0, errors: 0 }
}
/** 建立(或重建)连接。⛔ 只拨出。 */
start() {
if (this.stopped) return
this.metrics.connects += 1
const ep = parseEndpoint(this.baseUrl)
const key = randomBytes(16).toString('base64')
const socket = connect({ host: ep.host, port: ep.port }, () => {
const headers = [
`GET ${ep.path} HTTP/1.1`,
`Host: ${ep.host}`,
'Upgrade: websocket',
'Connection: Upgrade',
`Sec-WebSocket-Key: ${key}`,
'Sec-WebSocket-Version: 13',
// 🔴 两件关键头:实例凭据 + 区声明(区声明可省;带上便于平台侧判跨区)。
`x-dsh-im-instance-token: ${this.token}`,
`x-dsh-im-agent-ref: ${this.agentRef}`,
'',
'',
].join('\r\n')
socket.write(headers)
})
this.socket = socket
this.decoder = new ClientDecoder()
let handshakeDone = false
let raw = Buffer.alloc(0)
socket.on('data', (chunk) => {
if (!handshakeDone) {
raw = Buffer.concat([raw, chunk])
const idx = raw.indexOf('\r\n\r\n')
if (idx < 0) return
const head = raw.subarray(0, idx).toString('utf8')
if (!/^HTTP\/1\.1 101/.test(head)) {
// 握手被拒(401 / 404 …):记一笔并按退避重连(⛔ 不静默停)。
this.log(`handshake-rejected ${head.split('\r\n')[0]}`)
this.metrics.errors += 1
socket.destroy()
return
}
const expect = createHash('sha1').update(`${key}${WS_GUID}`).digest('base64')
if (!head.includes(`Sec-WebSocket-Accept: ${expect}`)) {
this.log('handshake-accept-mismatch')
this.metrics.errors += 1
socket.destroy()
return
}
handshakeDone = true
this.attempt = 0
this.log(`connected ${ep.host}`)
// 重连后**重新订阅**所有房间(带游标 ⇒ 平台补拉断线期间的消息)。
for (const [roomId, since] of this.rooms) this.send({ op: 'resume', roomId, since })
const rest = raw.subarray(idx + 4)
if (rest.length > 0) this.consume(rest)
return
}
this.consume(chunk)
})
socket.on('error', (err) => {
this.metrics.errors += 1
this.log(`socket-error ${String(err && err.message ? err.message : err)}`)
})
socket.on('close', () => {
this.socket = null
this.decoder = null
if (this.stopped) return
this.scheduleReconnect()
})
}
consume(chunk) {
if (this.decoder === null) return
let frames
try {
frames = this.decoder.push(chunk)
} catch (err) {
this.log(`decode-error ${String(err)}`)
return
}
for (const frame of frames) {
if (frame.opcode === OP_PING) {
this.rawWrite(encodeClientFrame(OP_PONG, frame.payload))
continue
}
if (frame.opcode === OP_CLOSE) {
// 服务端要关:主动断开 ⇒ 走 close 事件里的退避重连。
if (this.socket !== null) this.socket.destroy()
continue
}
if (frame.opcode !== OP_TEXT) continue
let msg
try {
msg = JSON.parse(frame.payload.toString('utf8'))
} catch {
continue
}
// ⚠️ 每条消息处理都吞异常:插件故障⛔不许拖垮连接(§五-7)。
void this.onServerFrame(msg).catch((err) => {
this.metrics.errors += 1
this.log(`frame-handler-error ${String(err)}`)
})
}
}
rawWrite(buf) {
if (this.socket === null || this.socket.destroyed) return false
try {
this.socket.write(buf)
return true
} catch {
return false
}
}
send(obj) {
return this.rawWrite(textFrame(JSON.stringify(obj)))
}
scheduleReconnect() {
if (this.stopped) return
this.metrics.reconnects += 1
const base = Math.min(this.maxDelayMs, this.firstDelayMs * 2 ** this.attempt)
// 抖动 ±20%:避免整批实例同时重连打爆平台。
const delay = Math.max(1, Math.round(base * (0.8 + Math.random() * 0.4)))
this.attempt += 1
this.log(`reconnect in ${delay}ms (attempt ${this.attempt})`)
this.timer = setTimeout(() => {
this.timer = null
this.start()
}, delay)
if (typeof this.timer.unref === 'function') this.timer.unref()
}
/** 订阅一个房(调用方决定订阅哪些)。 */
subscribe(roomId, since = 0) {
this.rooms.set(roomId, since)
this.send({ op: 'subscribe', roomId })
}
/** 记住游标(每条收到的消息都推进它 ⇒ 重连补拉不重复)。 */
noteCursor(roomId, seq) {
if (typeof seq === 'number' && Number.isFinite(seq)) this.rooms.set(roomId, seq)
}
/**
* 服务端帧处理。
*
* 只关心两种:`message`(推送的新消息 ⇒ 可能触发)与 `messages`(补拉结果)。
*/
async onServerFrame(msg) {
if (msg === null || typeof msg !== 'object') return
if (msg.op === 'pong') return
if (msg.op === 'hello') {
this.log('hello')
return
}
if (msg.op === 'error') {
this.metrics.errors += 1
this.log(`server-error ${String(msg.error)}`)
return
}
const roomId = typeof msg.roomId === 'string' ? msg.roomId : undefined
const list = Array.isArray(msg.messages) ? msg.messages : []
if (roomId === undefined || list.length === 0) return
for (const m of list) {
this.noteCursor(roomId, m && typeof m.seq === 'number' ? m.seq : undefined)
await this.maybeReply(roomId, m)
}
}
/**
* 判一条消息要不要回、要回就回。
*
* 🔴 这里**不做**触发过滤的重活 —— 那条判据在平台侧 `src/im/agent-bridge.ts`
* (同一套逻辑,测试直接断它)。本文件只做"要不要处理"的**本地前置闸**
* (形态 / @ 命中),够用且不引第二份判据源。
*/
async maybeReply(roomId, message) {
if (message === undefined || message === null) return
// ① 自激闸门:agent 自己的消息(`via==='agent'` 或 `author_kind!=='human'`)恒不处理。
if (message.authorKind !== 'human' || message.via === 'agent') return
const mentions = collectMentions(message.payload)
if (mentions.length === 0) return
// ② 是不是 @ 到我(形态 A:agentRef 直接命中;形态 B:主人命中 —— 由平台侧快照判到底是谁)。
const self = String(this.agentRef)
const hit =
mentions.includes(self) ||
mentions.includes(String(this.ownerId ?? '')) ||
mentions.some((m) => String(m).startsWith(self))
if (!hit) return
this.metrics.triggers += 1
const ctx = {
roomId,
focusSeq: typeof message.seq === 'number' ? message.seq : undefined,
memberId: message.authorId,
message,
}
let answer
try {
answer = await this.askLocalSession(renderPrompt(message, roomId), ctx)
} catch (err) {
this.metrics.errors += 1
this.log(`ask-failed ${String(err)}`)
return
}
if (typeof answer !== 'string' || answer === '') return
// ③ 回写:`via='agent'` + 可见代答标识(§五-6)。
const marker = this.form === 'bot' ? '能力机器人' : `由 ${this.ownerName ?? message.authorId} 的助手代答`
const ok = this.send({
op: 'send',
roomId,
payload: { text: answer, agentReply: true, agentLabel: marker },
via: 'agent',
})
if (ok) {
this.metrics.replies += 1
this.log(`reply-sent room=${roomId} focus=#${ctx.focusSeq}`)
}
}
/** 停机(挂 profile 卸载 / 实例退出)。 */
close() {
this.stopped = true
if (this.timer !== null) {
clearTimeout(this.timer)
this.timer = null
}
if (this.socket !== null) {
try {
this.socket.write(encodeClientFrame(OP_CLOSE, Buffer.from('\u0000\u0000shutdown', 'utf8')))
} catch {
// 写不动就直接销毁
}
const s = this.socket
setTimeout(() => s.destroy(), CLOSE_GRACE_MS).unref?.()
}
}
}
/** 从 payload 抽 @(与平台侧同判据,这里是插件侧的只读副本)。 */
export function collectMentions(payload) {
if (payload === null || typeof payload !== 'object') return []
if (Array.isArray(payload.mentions)) return payload.mentions.filter((m) => typeof m === 'string')
const text = payload.text
if (typeof text !== 'string') return []
const out = []
for (const m of text.matchAll(/@([A-Za-z0-9_\-.:]{1,64})/g)) if (m[1] !== undefined) out.push(m[1])
return out
}
/** 把一条消息渲染成给本地 dsh 会话的 prompt(占位实现 —— 完整上下文由平台侧组装)。 */
export function renderPrompt(message, roomId) {
const text = typeof message.payload?.text === 'string' ? message.payload.text : JSON.stringify(message.payload)
return `[房间 ${roomId} · 消息 #${message.seq} · 来自 ${message.authorId}]\n${text}\n\n请回答上面这条被 @ 的消息。`
}
/**
* 默认的本地会话实现:**回显式占位**。
*
* ⛔ 真正接本地 dsh 会话是 **D 单**的扩展点契约面(§八-5);本单保证的是
* 触发 / 上下文 / 预算 / 回写 / 隔离五条判据**可验**。接法 = 通过 `opts.askLocalSession` 注入。
*/
export function defaultAsk(prompt) {
return Promise.resolve(`(agent 桥已收到触发,但本地会话对接属 D 单扩展点)\n${prompt.slice(0, 200)}`)
}
/* ── cordis 落点 ──────────────────────────────────────────────────────────── */
/** 单例(profile 卸载时 close)。 */
let activeBridge = null
/**
* cordis 入口。
*
* 🔴 **天然开关**:读不到 URL 或 token ⇒ 直接 return(日志里**没有**连接尝试)。
* 这与 §三「不注入 env ⇒ 插件不启动」逐字对应,也是平台侧回滚的唯一动作。
*/
export function apply(ctx) {
const url = process.env[ENV_URL]
const token = process.env[ENV_TOKEN]
const home = process.env.DSH_HOME || process.env.HOME || '.'
const log = makeLog(home)
if (url === undefined || url === '' || token === undefined || token === '') {
log(`skip: ${ENV_URL} / ${ENV_TOKEN} 未注入 ⇒ agent 桥不启动`)
return
}
const agentRef = process.env[ENV_AGENT_REF] ?? 'instance-agent'
const bridge = new ImAgentBridge({
baseUrl: url,
token,
agentRef,
askLocalSession: defaultAsk,
log,
})
log(`apply pid=${process.pid} agent=${agentRef}`)
try {
bridge.start()
} catch (err) {
// §五-7:起连接失败⛔不许把 profile 打崩。
log(`start-failed ${String(err)}`)
}
activeBridge = bridge
ctx.on?.('dispose', () => {
try {
bridge.close()
} catch {
// 收尾失败也不抛
}
activeBridge = null
})
}
/** 供测试/排查取当前单例。 */
export function currentBridge() {
return activeBridge
}