feat: 重写agent hub连接器,新增对话记录和微信输入状态

- 新增 Agent Hub 对话记录面板,按会话回看机器人与微信用户的完整收发内容
- 新增微信原生正在输入状态,长任务维持 typing,异常路径强制收尾
This commit is contained in:
Wxw-Gu
2026-09-17 17:32:37 +08:00
parent 1337bcb7de
commit 4b2395a8d2
73 changed files with 8855 additions and 2890 deletions
+39
View File
@@ -109,6 +109,8 @@ import {
} from './services/bootstrap-cache'
import { installSafeConsole } from './safe-log'
import { agentHubService } from './services/agent-hub-service'
import { WechatConnectorService } from './services/wechat-ilink'
import { wechatSendGateway } from './services/wechat-send-gateway'
import { groupExitMonitorService } from './services/group-exit-monitor-service'
import { wechatActionLogService } from './services/wechat-action-log-service'
import { wechatActionGateway } from './services/wechat-action-gateway'
@@ -196,6 +198,12 @@ import type {
// handler. Wrap console.* before any other module logs anything.
installSafeConsole()
/**
* 进程内微信 iLink 连接器:inbound 回调与 outbound 发送都在主进程内完成,
* 不依赖子进程,也不开本地 HTTP 端口。
*/
const wechatConnectorService = new WechatConnectorService()
let voiceService: VoiceService | null = null
let voiceRecognition: VoiceRecognitionUseCase | null = null
let voiceBatchService: VoiceBatchService | null = null
@@ -774,6 +782,28 @@ app.whenReady().then(async () => {
})
})
agentHubService.setQueryAgentService(queryAgentService)
// 微信 iLink 连接器直接跑在主进程内:inbound 回调与 outbound 发送都不经过本地 HTTP 桥。
agentHubService.setWechatConnector(wechatConnectorService)
wechatSendGateway.configureIlinkSender(async (request) => {
const target = {
to: request.to,
...(request.account_id ? { accountId: request.account_id } : {}),
...(request.context_token ? { contextToken: request.context_token } : {})
}
if (request.type === 'text') {
await wechatConnectorService.sendText({ ...target, text: request.msg })
return
}
if (request.type === 'image' || request.type === 'file') {
if (/^https?:\/\//i.test(request.msg)) {
await wechatConnectorService.sendMediaUrl({ ...target, mediaUrl: request.msg })
} else {
await wechatConnectorService.sendMediaPath({ ...target, filePath: request.msg })
}
return
}
throw new Error('iLink 通道暂不支持发送语音')
})
knowledgeSearchService.onStatusChange((status) => {
for (const window of BrowserWindow.getAllWindows()) {
if (!window.isDestroyed()) window.webContents.send('knowledge:status', status)
@@ -2292,6 +2322,15 @@ app.whenReady().then(async () => {
ipcMain.handle('agent-hub:cancelLogin', () => agentHubService.cancelLogin())
ipcMain.handle('agent-hub:reconnect', () => agentHubService.reconnect())
ipcMain.handle('agent-hub:disconnect', () => agentHubService.disconnect())
// 对话记录:完整收发回看,仅本机,不进日志。
ipcMain.handle('agent-hub:getConversations', () => agentHubService.listConversations())
ipcMain.handle('agent-hub:getConversation', (_, userId: string) =>
agentHubService.getConversation(String(userId || ''))
)
ipcMain.handle('agent-hub:clearConversations', () => {
agentHubService.clearConversations()
return { success: true }
})
ipcMain.handle('wechat-personal:getStatus', () => personalWechatSendService.getStatus())
ipcMain.handle('wechat-personal:getKeepProcess', () =>
personalWechatSendService.getKeepOneBotProcess()
@@ -0,0 +1,193 @@
import { chmodSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs'
import { dirname } from 'node:path'
import { randomUUID } from 'node:crypto'
import {
agentHubKindPlaceholder,
type AgentHubConversation,
type AgentHubConversationMessage,
type AgentHubConversationSummary,
type AgentHubMessageKind,
type AgentHubMessageStatus
} from '../../shared/agent-hub-conversation'
/**
* Agent Hub 对话记录存储。
*
* 设计取向:
* - 一个 JSON 文件装全部会话,按「最近活跃」排序,避免为几十个会话开目录;
* - 每个会话最多保留 N 条(默认 500),超出丢最旧的,文件不会无限增长;
* - 会话数也有上限(默认 50),只保留最近活跃的;
* - 原子写(tmp + rename)+ 0600,任何一个环节失败都不能影响真实收发。
*
* 与 `WechatInboundInbox` 的区别:收件箱是**待处理的在途消息**(处理完即删),
* 这里是**供人回看的历史**(按上限长期保留)。
*/
const DEFAULT_MAX_MESSAGES_PER_CONVERSATION = 500
const DEFAULT_MAX_CONVERSATIONS = 50
interface ConversationFile {
version: 1
conversations: AgentHubConversation[]
}
export interface AgentHubConversationStoreOptions {
filePath: () => string
maxMessagesPerConversation?: number
maxConversations?: number
now?: () => number
createId?: () => string
}
export interface AppendInput {
userId: string
accountId?: string
direction: 'in' | 'out'
kind: AgentHubMessageKind
text: string
messageId?: string
status?: AgentHubMessageStatus
errorCode?: string
createdAt?: number
}
export class AgentHubConversationStore {
private readonly options: AgentHubConversationStoreOptions
private readonly maxMessages: number
private readonly maxConversations: number
private cache: AgentHubConversation[] | null = null
constructor(options: AgentHubConversationStoreOptions) {
this.options = options
this.maxMessages = options.maxMessagesPerConversation ?? DEFAULT_MAX_MESSAGES_PER_CONVERSATION
this.maxConversations = options.maxConversations ?? DEFAULT_MAX_CONVERSATIONS
}
/** 追加一条消息,返回受影响会话的摘要与这条消息(供 UI 增量刷新)。 */
append(
input: AppendInput
): { summary: AgentHubConversationSummary; message: AgentHubConversationMessage } | null {
const userId = String(input.userId || '').trim()
if (!userId) return null
const createdAt = input.createdAt ?? this.options.now?.() ?? Date.now()
const message: AgentHubConversationMessage = {
id: this.options.createId?.() ?? randomUUID(),
direction: input.direction,
kind: input.kind,
text: String(input.text ?? ''),
createdAt,
...(input.status ? { status: input.status } : {}),
...(input.errorCode ? { errorCode: input.errorCode } : {}),
...(input.messageId ? { messageId: input.messageId } : {})
}
try {
const conversations = this.load()
const index = conversations.findIndex((item) => item.userId === userId)
if (index >= 0) {
const existing = conversations[index]
const messages = [...existing.messages, message].slice(-this.maxMessages)
conversations[index] = {
...existing,
...(input.accountId ? { accountId: input.accountId } : {}),
lastAt: createdAt,
messages
}
} else {
conversations.push({
userId,
...(input.accountId ? { accountId: input.accountId } : {}),
firstAt: createdAt,
lastAt: createdAt,
messages: [message]
})
}
// 只保留最近活跃的 maxConversations 个会话。
const trimmed = conversations
.sort((left, right) => right.lastAt - left.lastAt)
.slice(0, this.maxConversations)
this.persist(trimmed)
this.cache = trimmed
const summary = this.toSummary(trimmed.find((item) => item.userId === userId) ?? null)
return summary ? { summary, message } : null
} catch (error) {
// 记录历史失败绝不能影响真实收发。
console.warn('[AgentHubConversation] 写入对话记录失败:', error)
return null
}
}
listSummaries(): AgentHubConversationSummary[] {
return this.load()
.slice()
.sort((left, right) => right.lastAt - left.lastAt)
.map((conversation) => this.toSummary(conversation))
.filter((summary): summary is AgentHubConversationSummary => summary !== null)
}
get(userId: string): AgentHubConversation | null {
const normalized = String(userId || '').trim()
if (!normalized) return null
const found = this.load().find((item) => item.userId === normalized)
if (!found) return null
return { ...found, messages: found.messages.map((message) => ({ ...message })) }
}
clear(): void {
this.persist([])
this.cache = []
}
private toSummary(conversation: AgentHubConversation | null): AgentHubConversationSummary | null {
if (!conversation) return null
const last = conversation.messages[conversation.messages.length - 1]
const preview = last ? last.text.trim() || agentHubKindPlaceholder(last.kind) : ''
return {
userId: conversation.userId,
...(conversation.accountId ? { accountId: conversation.accountId } : {}),
firstAt: conversation.firstAt,
lastAt: conversation.lastAt,
messageCount: conversation.messages.length,
lastPreview: preview,
lastDirection: last?.direction ?? 'in'
}
}
private load(): AgentHubConversation[] {
if (this.cache) return this.cache
let parsed: ConversationFile | null = null
try {
parsed = JSON.parse(readFileSync(this.options.filePath(), 'utf8')) as ConversationFile
} catch {
parsed = null
}
const conversations = Array.isArray(parsed?.conversations)
? parsed!.conversations.filter((item): item is AgentHubConversation =>
Boolean(item && typeof item === 'object' && String(item.userId || '').trim())
)
: []
this.cache = conversations
return conversations
}
private persist(conversations: AgentHubConversation[]): void {
const path = this.options.filePath()
mkdirSync(dirname(path), { recursive: true, mode: 0o700 })
const tempPath = `${path}.tmp-${process.pid}-${Date.now()}`
try {
writeFileSync(
tempPath,
JSON.stringify({ version: 1, conversations } satisfies ConversationFile),
{ encoding: 'utf8', mode: 0o600 }
)
chmodSync(tempPath, 0o600)
renameSync(tempPath, path)
chmodSync(path, 0o600)
} catch (error) {
rmSync(tempPath, { force: true })
throw error
}
}
}
File diff suppressed because it is too large Load Diff
+49
View File
@@ -0,0 +1,49 @@
import { createHash } from 'node:crypto'
/**
* 统一日志脱敏。
*
* 微信连接器与发送日志共用同一套规则:任何 secret 都不能以原值进入普通日志,
* 包括 bot_token、context_token、typing_ticket、AES key、二维码凭据与 Authorization 头。
*
* 只用于自由文本日志;结构化字段(如 sha256 摘要)不要经过这里,否则会被误伤。
*/
const REDACTION_RULES: ReadonlyArray<readonly [RegExp, string]> = [
// Authorization 头
[/Bearer\s+[A-Za-z0-9._~+/-]+=*/gi, 'Bearer [已隐藏]'],
// 二维码图片内容(base64 data URL 可能长达数百 KB)
[/data:image\/[^;]+;base64,[A-Za-z0-9+/=]+/gi, 'data:image/[二维码已隐藏]'],
// JSON / query 形态的 secret 字段
[
/("?(?:bot_token|context_token|typing_ticket|aeskey|aes_key|access_token|refresh_token|authorization)"?\s*[:=]\s*)"?[^\s",}&]+"?/gi,
'$1"[已隐藏]"'
],
// 兼容历史写法:token=xxx / token: xxx
[/(\btoken\s*[:=]\s*)[^\s,}]+/gi, '$1[已隐藏]'],
// iLink 上传参数
[/(encrypted_query_param=)[^\s&"]+/gi, '$1[已隐藏]']
]
/** 对任意自由文本日志做脱敏。 */
export function redactSecrets(message: string): string {
let result = String(message ?? '')
for (const [pattern, replacement] of REDACTION_RULES) {
result = result.replace(pattern, replacement)
}
return result
}
/** 日志中只允许出现 secret 是否存在,不允许出现原值。 */
export function describeSecretPresence(value: string | undefined | null): string {
return value ? 'present' : 'absent'
}
/**
* 生成不可逆的短指纹,用于需要关联同一 secret 又不能落盘的诊断场景。
* 只保留 sha256 前 8 位十六进制,无法反推原值。
*/
export function secretFingerprint(value: string | undefined | null): string {
if (!value) return 'absent'
return createHash('sha256').update(String(value)).digest('hex').slice(0, 8)
}
+3 -2
View File
@@ -17,7 +17,7 @@ import type {
WechatActionResult
} from '../../shared/wechat-action'
import { personalWechatCapabilityService } from './personal-wechat-capability-service'
import { personalWechatSendService } from './personal-wechat-send-service'
import { wechatSendGateway } from './wechat-send-gateway'
const MAX_AUDIT_RECORDS = 500
const MAX_CONTENT_PREVIEW_LENGTH = 240
@@ -54,7 +54,8 @@ const defaultDependencies = (): Required<
>
> => ({
getCapability: () => personalWechatCapabilityService.getPersonalWechatSendCapability(),
send: (request) => personalWechatSendService.send(request),
// 高层业务审计之后仍然统一走 WechatSendGateway,保证每一次真实发送都有 Send Log。
send: (request) => wechatSendGateway.sendPersonal(request),
getUserDataPath: () => app.getPath('userData'),
now: () => new Date(),
wait: (milliseconds) => new Promise((resolve) => setTimeout(resolve, milliseconds))
@@ -0,0 +1,262 @@
import {
chmodSync,
existsSync,
mkdirSync,
readdirSync,
readFileSync,
renameSync,
rmSync,
writeFileSync,
type Dirent
} from 'node:fs'
import { homedir } from 'node:os'
import { join } from 'node:path'
import type { ILinkCredentials } from './types'
/**
* 账号与游标持久化。
*
* 目录布局沿用历史命名(含 `wechat-connector` 这一层),保证升级后**不需要重新扫码**:
* ~/.tracememo/wechat-connector/accounts/<account-id>.json 凭据
* ~/.tracememo/wechat-connector/accounts/<account-id>.sync.json 长轮询游标
* ~/.tracememo/wechat-connector/accounts/<account-id>.context.json 会话上下文令牌
*
* 历史目录 ~/.wechatexplorer/wechat-connector/accounts 只读兼容,不删除、不覆写。
*/
const CONNECTOR_DIR_NAME = 'wechat-connector'
const ACCOUNTS_DIR_NAME = 'accounts'
const CURRENT_ROOT = '.tracememo'
const LEGACY_ROOT = '.wechatexplorer'
const FILE_MODE = 0o600
const DIR_MODE = 0o700
export type HomeDirectoryResolver = () => string
export function normalizeAccountId(raw: string): string {
const value = String(raw ?? '')
if (!value) return ''
// @ . : 统一替换为 -,使账号 ID 可以作为文件名。
return value.replace(/[@.:]/g, '-')
}
export function accountsDirectory(home: HomeDirectoryResolver = homedir): string {
return join(home(), CURRENT_ROOT, CONNECTOR_DIR_NAME, ACCOUNTS_DIR_NAME)
}
export function legacyAccountsDirectory(home: HomeDirectoryResolver = homedir): string {
return join(home(), LEGACY_ROOT, CONNECTOR_DIR_NAME, ACCOUNTS_DIR_NAME)
}
function ensureDirectory(directory: string): void {
mkdirSync(directory, { recursive: true, mode: DIR_MODE })
}
/** 原子写入:先写临时文件再 rename,避免进程退出留下半截 JSON。 */
function writeJsonAtomically(path: string, value: unknown): void {
const tempPath = `${path}.tmp-${process.pid}-${Date.now()}`
try {
writeFileSync(tempPath, JSON.stringify(value, null, 2), { encoding: 'utf8', mode: FILE_MODE })
chmodSync(tempPath, FILE_MODE)
renameSync(tempPath, path)
chmodSync(path, FILE_MODE)
} catch (error) {
try {
rmSync(tempPath, { force: true })
} catch {
// 清理失败不影响主流程:文件本身不会覆盖有效数据。
}
throw error
}
}
function readJsonIfValid<T>(path: string, validate: (value: T) => boolean): T | undefined {
try {
const parsed = JSON.parse(readFileSync(path, 'utf8')) as T
return validate(parsed) ? parsed : undefined
} catch {
return undefined
}
}
function isCredentials(value: unknown): value is ILinkCredentials {
const candidate = value as Partial<ILinkCredentials> | null
return Boolean(candidate && typeof candidate.bot_token === 'string' && candidate.bot_token)
}
/** 找到某个账号凭据实际所在目录:优先当前目录,其次历史目录。 */
export function resolveAccountDirectory(
accountId: string,
home: HomeDirectoryResolver = homedir
): { directory: string; legacy: boolean } {
const current = accountsDirectory(home)
if (existsSync(join(current, `${accountId}.json`))) return { directory: current, legacy: false }
const legacy = legacyAccountsDirectory(home)
if (existsSync(join(legacy, `${accountId}.json`))) return { directory: legacy, legacy: true }
return { directory: current, legacy: false }
}
/**
* 保存新登录的凭据。
* 先写入新凭据再清理旧账号文件,因此一次失败的登录不会摧毁上一个可用账号。
* 同时保留同前缀的 `<id>.sync.json` / `<id>.context.json`。
*/
export function saveCredentials(
credentials: ILinkCredentials,
home: HomeDirectoryResolver = homedir
): string {
const directory = accountsDirectory(home)
ensureDirectory(directory)
const accountId = normalizeAccountId(credentials.ilink_bot_id)
if (!accountId) throw new Error('登录凭据缺少 ilink_bot_id,无法保存')
const path = join(directory, `${accountId}.json`)
writeJsonAtomically(path, credentials)
const keepPrefix = `${accountId}.`
for (const entry of readdirSync(directory, { withFileTypes: true })) {
if (entry.isDirectory()) continue
if (!entry.name.endsWith('.json')) continue
if (entry.name.startsWith(keepPrefix)) continue
try {
rmSync(join(directory, entry.name), { force: true })
} catch {
// 旧凭据清理失败不影响新凭据可用性。
}
}
return path
}
function loadCredentialsFromDirectory(directory: string): ILinkCredentials[] {
let entries: Dirent[]
try {
entries = readdirSync(directory, { withFileTypes: true })
} catch {
return []
}
const result: ILinkCredentials[] = []
for (const entry of entries) {
if (entry.isDirectory() || !entry.name.endsWith('.json')) continue
const credential = readJsonIfValid<ILinkCredentials>(join(directory, entry.name), isCredentials)
if (credential) result.push(credential)
}
return result
}
/** 当前目录优先;当前目录为空时回退到历史目录(一次性升级兼容,不迁移不删除)。 */
export function loadAllCredentials(home: HomeDirectoryResolver = homedir): ILinkCredentials[] {
const current = loadCredentialsFromDirectory(accountsDirectory(home))
if (current.length > 0) return current
return loadCredentialsFromDirectory(legacyAccountsDirectory(home))
}
export function findCredentials(
accountId: string,
home: HomeDirectoryResolver = homedir
): ILinkCredentials | undefined {
const { directory } = resolveAccountDirectory(accountId, home)
const credential = readJsonIfValid<ILinkCredentials>(
join(directory, `${accountId}.json`),
isCredentials
)
if (credential) return credential
return loadAllCredentials(home).find(
(item) => normalizeAccountId(item.ilink_bot_id) === accountId || item.ilink_bot_id === accountId
)
}
/* ------------------------------------------------------------------ */
/* 长轮询游标 */
/* ------------------------------------------------------------------ */
interface SyncRecord {
get_updates_buf: string
}
/**
* 游标按账号隔离保存。切换账号时绝不沿用上一个账号的游标。
*/
export function loadCursor(accountId: string, home: HomeDirectoryResolver = homedir): string {
const { directory } = resolveAccountDirectory(accountId, home)
const record = readJsonIfValid<SyncRecord>(join(directory, `${accountId}.sync.json`), (value) =>
Boolean(value && typeof value.get_updates_buf === 'string')
)
return record?.get_updates_buf ?? ''
}
export function saveCursor(
accountId: string,
getUpdatesBuf: string,
home: HomeDirectoryResolver = homedir
): void {
// 必须写到 loadCursor 实际读取的目录:如果凭据仍在历史目录(尚未迁移),
// 写进当前目录会导致游标永远读不回来,长轮询就会反复重投同一条消息。
const { directory } = resolveAccountDirectory(accountId, home)
ensureDirectory(directory)
writeJsonAtomically(join(directory, `${accountId}.sync.json`), {
get_updates_buf: getUpdatesBuf
} satisfies SyncRecord)
}
export function clearCursor(accountId: string, home: HomeDirectoryResolver = homedir): void {
const { directory } = resolveAccountDirectory(accountId, home)
try {
rmSync(join(directory, `${accountId}.sync.json`), { force: true })
} catch {
// 游标清理失败不阻断重连流程。
}
}
/* ------------------------------------------------------------------ */
/* 会话上下文令牌 */
/* ------------------------------------------------------------------ */
interface ContextRecord {
tokens: Record<string, { context_token: string; updated_at: number }>
}
/**
* context_token 属于**会话上下文**,不是账号长期凭据。
* 按「账号 + 用户」保存最近一次有效值,用于重启后的主动发送(例如定时日报)。
* 永不写入日志,文件权限 0600。
*/
export function saveContextToken(
accountId: string,
toUserId: string,
contextToken: string,
home: HomeDirectoryResolver = homedir,
now: () => number = Date.now
): void {
if (!contextToken) return
const { directory } = resolveAccountDirectory(accountId, home)
ensureDirectory(directory)
const path = join(directory, `${accountId}.context.json`)
const existing = readJsonIfValid<ContextRecord>(path, (value) =>
Boolean(value && typeof value.tokens === 'object' && value.tokens !== null)
) ?? { tokens: {} }
const tokens: ContextRecord['tokens'] = {
...existing.tokens,
[toUserId]: { context_token: contextToken, updated_at: now() }
}
// 只保留最近 500 条,避免文件无限增长。
const entries = Object.entries(tokens).sort(
(left, right) => right[1].updated_at - left[1].updated_at
)
const trimmed = Object.fromEntries(entries.slice(0, 500))
writeJsonAtomically(path, { tokens: trimmed } satisfies ContextRecord)
}
export function loadContextToken(
accountId: string,
toUserId: string,
home: HomeDirectoryResolver = homedir
): string | undefined {
const { directory } = resolveAccountDirectory(accountId, home)
const record = readJsonIfValid<ContextRecord>(
join(directory, `${accountId}.context.json`),
(value) => Boolean(value && typeof value.tokens === 'object' && value.tokens !== null)
)
return record?.tokens?.[toUserId]?.context_token || undefined
}
+293
View File
@@ -0,0 +1,293 @@
import { ILinkError } from './errors'
import { ILinkClient, type FetchLike } from './client'
import { loadAllCredentials, saveCredentials, type HomeDirectoryResolver } from './account-store'
import {
ILINK_DEFAULT_BASE_URL,
ILINK_QR_STATUS_TIMEOUT_MS,
type ILinkCredentials,
type ILinkQrCodeResponse,
type ILinkQrStatusResponse,
type WechatLoginEvent
} from './types'
const QR_CODE_PATH = '/ilink/bot/get_bot_qrcode?bot_type=3'
const QR_STATUS_PATH = '/ilink/bot/get_qrcode_status'
/** 官方客户端策略(非协议常量):本地二维码 TTL 约 5 分钟,整体等待约 480 秒。 */
const QR_SESSION_TTL_MS = 5 * 60_000
const LOGIN_DEADLINE_MS = 480_000
const MAX_QR_REFRESHES = 3
/** 等待用户输入数字配对码的上限。 */
const VERIFY_CODE_WAIT_MS = 60_000
export type QrEncoder = (content: string) => Promise<string>
export interface QrLoginDependencies {
fetchImpl?: FetchLike
home?: HomeDirectoryResolver
baseUrl?: string
signal?: AbortSignal
onEvent?: (event: WechatLoginEvent) => void
/** 把二维码内容渲染成可直接给 <img src> 的 data URL。 */
qrEncoder?: QrEncoder
/** 手机端要求数字配对码时,由宿主提供;返回 undefined 表示放弃这次登录。 */
verifyCodeProvider?: () => Promise<string | undefined>
now?: () => number
sleep?: (milliseconds: number) => Promise<void>
maxQrRefreshes?: number
deadlineMs?: number
}
/**
* 默认二维码渲染器:把服务端返回的内容编码成二维码 PNG。
* 服务端返回的是二维码页面 URL,因此这里必须自己编码,而不是当作图片直传。
*/
const defaultQrEncoder: QrEncoder = async (content) => {
if (/^data:image\//i.test(content)) return content
const { toDataURL } = await import('qrcode')
return toDataURL(content, { errorCorrectionLevel: 'L', margin: 1, width: 320 })
}
interface LoginState {
host: string
verifyCode?: string
pendingVerifyCode?: boolean
}
/** 等待用户输入配对码;超时视为放弃本次尝试,改为刷新二维码而不是无限等待。 */
function awaitVerifyCode(
provider: () => Promise<string | undefined>,
waitMs: number
): Promise<string | undefined> {
return new Promise<string | undefined>((resolve) => {
let settled = false
const finish = (value: string | undefined): void => {
if (settled) return
settled = true
clearTimeout(timer)
resolve(value)
}
const timer = setTimeout(() => finish(undefined), waitMs)
provider().then(
(value) => finish(value),
() => finish(undefined)
)
})
}
function isCancelled(signal: AbortSignal | undefined): boolean {
return signal?.aborted === true
}
interface QrStatusPollResult {
outcome: 'continue' | 'refresh' | 'done'
credentials?: ILinkCredentials
}
async function pollOnce(
client: ILinkClient,
qrcode: string,
state: LoginState,
deps: Required<Pick<QrLoginDependencies, 'onEvent' | 'now'>> & QrLoginDependencies
): Promise<QrStatusPollResult> {
const query = new URLSearchParams({ qrcode })
if (state.verifyCode) query.set('verify_code', state.verifyCode)
const url = `${state.host}${QR_STATUS_PATH}?${query.toString()}`
let response: ILinkQrStatusResponse
try {
response = await client.getAnonymous<ILinkQrStatusResponse>(url, '二维码状态轮询', {
timeoutMs: ILINK_QR_STATUS_TIMEOUT_MS,
...(deps.signal ? { signal: deps.signal } : {})
})
} catch (error) {
if (isCancelled(deps.signal)) throw error
// 网络超时 / 网关 524 属于长轮询正常控制流:保持当前二维码继续轮询。
return { outcome: 'continue' }
}
const status = String(response.status || '').trim()
switch (status) {
case 'wait':
deps.onEvent({ status: 'wait' })
return { outcome: 'continue' }
case 'scaned':
// 已扫码:若上一次轮询携带过配对码,说明已被接受,清除暂存值。
state.verifyCode = undefined
deps.onEvent({ status: 'scaned' })
return { outcome: 'continue' }
case 'need_verifycode': {
deps.onEvent({ status: 'need_verifycode' })
state.verifyCode = undefined
if (state.pendingVerifyCode) return { outcome: 'continue' }
state.pendingVerifyCode = true
const provider = deps.verifyCodeProvider
if (!provider) return { outcome: 'refresh' }
const code = await awaitVerifyCode(provider, VERIFY_CODE_WAIT_MS)
state.pendingVerifyCode = false
const normalized = String(code ?? '').trim()
if (!normalized) return { outcome: 'refresh' }
state.verifyCode = normalized
return { outcome: 'continue' }
}
case 'verify_code_blocked':
// 多次输入错误被限制:清除配对码并刷新二维码,由调用方计数。
deps.onEvent({ status: 'verify_code_blocked' })
state.verifyCode = undefined
state.pendingVerifyCode = false
return { outcome: 'refresh' }
case 'scaned_but_redirect': {
// 状态轮询需要切换 IDC 节点;只切换状态轮询主机,不影响已保存的 baseurl。
const redirectHost = String(response.redirect_host || '').trim()
if (redirectHost) {
state.host = /^https?:\/\//.test(redirectHost)
? redirectHost.replace(/\/+$/, '')
: `https://${redirectHost.replace(/\/+$/, '')}`
}
return { outcome: 'continue' }
}
case 'binded_redirect': {
// 账号已绑定到本客户端:只有本地确实仍有可用凭据时才能视为成功。
const existing = loadAllCredentials(deps.home)
const reusable = existing[existing.length - 1]
if (!reusable) return { outcome: 'refresh' }
deps.onEvent({ status: 'confirmed' })
return { outcome: 'done', credentials: reusable }
}
case 'expired':
deps.onEvent({ status: 'expired' })
return { outcome: 'refresh' }
case 'confirmed': {
const accountId = String(response.ilink_bot_id || '').trim()
if (!accountId) {
throw new ILinkError({
kind: 'protocol',
message: '登录已确认,但服务端未返回 ilink_bot_id'
})
}
const credentials: ILinkCredentials = {
bot_token: String(response.bot_token || ''),
ilink_bot_id: accountId,
baseurl: String(response.baseurl || deps.baseUrl || ILINK_DEFAULT_BASE_URL),
ilink_user_id: String(response.ilink_user_id || '')
}
if (!credentials.bot_token) {
throw new ILinkError({ kind: 'protocol', message: '登录已确认,但服务端未返回 bot_token' })
}
deps.onEvent({ status: 'confirmed' })
return { outcome: 'done', credentials }
}
default:
// 未知状态按 wait 处理,避免因为服务端新增状态直接打断登录。
return { outcome: 'continue' }
}
}
/**
* 扫码登录。
*
* 状态机与官方客户端对齐:wait / scaned / need_verifycode / verify_code_blocked /
* scaned_but_redirect / binded_redirect / expired / confirmed。
* 成功后立即原子落盘,且先写新凭据再清理旧账号,失败登录不会摧毁可用凭据。
*/
export async function runQrLogin(deps: QrLoginDependencies = {}): Promise<ILinkCredentials> {
const baseUrl = (deps.baseUrl || ILINK_DEFAULT_BASE_URL).replace(/\/+$/, '')
const now = deps.now ?? Date.now
const sleep =
deps.sleep ?? ((milliseconds: number) => new Promise<void>((r) => setTimeout(r, milliseconds)))
const onEvent = deps.onEvent ?? ((): void => undefined)
const maxRefreshes = deps.maxQrRefreshes ?? MAX_QR_REFRESHES
const deadline = deps.deadlineMs ?? LOGIN_DEADLINE_MS
const qrEncoder = deps.qrEncoder ?? defaultQrEncoder
const client = new ILinkClient({
baseUrl,
...(deps.fetchImpl ? { fetchImpl: deps.fetchImpl } : {})
})
const startedAt = now()
let refreshes = 0
while (true) {
if (isCancelled(deps.signal)) {
throw new ILinkError({ kind: 'aborted', message: '登录已取消' })
}
if (now() - startedAt > deadline) {
throw new ILinkError({ kind: 'timeout', message: '登录超时,请重新获取二维码' })
}
// local_token_list 只在取二维码时上报,便于服务端判断是否已绑定。
const localTokenList = loadAllCredentials(deps.home)
.map((item) => item.bot_token)
.filter(Boolean)
.slice(-10)
const qrResponse = await client.postAnonymous<ILinkQrCodeResponse>(
QR_CODE_PATH,
{ local_token_list: localTokenList },
'获取登录二维码',
{ timeoutMs: 20_000, ...(deps.signal ? { signal: deps.signal } : {}) }
)
const qrcode = String(qrResponse.qrcode || '').trim()
const qrcodeContent = String(qrResponse.qrcode_img_content || '').trim()
if (!qrcode || !qrcodeContent) {
throw new ILinkError({ kind: 'protocol', message: '服务端未返回有效的登录二维码' })
}
const qrCodeDataUrl = await qrEncoder(qrcodeContent)
onEvent({ status: 'qrcode', qrCodeDataUrl })
const state: LoginState = { host: baseUrl }
const sessionDeadline = now() + QR_SESSION_TTL_MS
let refresh = false
while (!refresh) {
if (isCancelled(deps.signal)) {
throw new ILinkError({ kind: 'aborted', message: '登录已取消' })
}
if (now() > sessionDeadline || now() - startedAt > deadline) {
throw new ILinkError({ kind: 'timeout', message: '二维码已过期,请重新获取' })
}
const result = await pollOnce(client, qrcode, state, {
...deps,
onEvent,
now
})
if (result.outcome === 'done') {
const credentials = result.credentials!
saveCredentials(credentials, deps.home)
onEvent({
status: 'active',
accountId: credentials.ilink_bot_id,
wechatUserId: credentials.ilink_user_id
})
return credentials
}
if (result.outcome === 'refresh') {
refreshes += 1
if (refreshes > maxRefreshes) {
throw new ILinkError({
kind: 'protocol',
message: '二维码多次刷新后仍未完成登录,请稍后重试'
})
}
refresh = true
continue
}
// 避免空转打满接口。
await sleep(300)
}
}
}
+284
View File
@@ -0,0 +1,284 @@
import { classifyNetworkError, describeErrorChain, excerptBody, ILinkError } from './errors'
import {
buildAuthorizedHeaders,
buildBaseInfo,
buildCommonHeaders,
buildQrStatusHeaders,
type ILinkHeaderOptions
} from './headers'
import {
ILINK_CONFIG_TIMEOUT_MS,
ILINK_DEFAULT_BASE_URL,
ILINK_SEND_TIMEOUT_MS,
type ILinkGetConfigResponse,
type ILinkGetUpdatesResponse,
type ILinkGetUploadUrlRequest,
type ILinkGetUploadUrlResponse,
type ILinkSendMessageRequest,
type ILinkSendMessageResponse,
type ILinkSendTypingResponse
} from './types'
export type FetchLike = typeof fetch
export interface ILinkClientOptions {
baseUrl?: string
botToken?: string
fetchImpl?: FetchLike
headers?: ILinkHeaderOptions
}
export interface ILinkRequestOptions {
timeoutMs: number
signal?: AbortSignal
/** 是否在失败时把响应体拼进错误信息(默认只带 HTTP 状态码)。 */
includeBody?: boolean
}
function withTimeout(
signal: AbortSignal | undefined,
timeoutMs: number
): { signal: AbortSignal; cleanup: () => void } {
const controller = new AbortController()
const timer = setTimeout(() => controller.abort(new Error('ilink request timeout')), timeoutMs)
const onAbort = (): void => controller.abort(signal?.reason)
if (signal) {
if (signal.aborted) controller.abort(signal.reason)
else signal.addEventListener('abort', onAbort, { once: true })
}
return {
signal: controller.signal,
cleanup: () => {
clearTimeout(timer)
signal?.removeEventListener('abort', onAbort)
}
}
}
async function parseJson<T>(response: Response, context: string, includeBody: boolean): Promise<T> {
const text = await response.text()
if (!response.ok) {
throw new ILinkError({
kind: 'http',
message: includeBody
? `${context}失败:HTTP ${response.status} ${excerptBody(text)}`
: `${context}失败:HTTP ${response.status}`,
httpStatus: response.status
})
}
if (!text.trim()) return {} as T
try {
return JSON.parse(text) as T
} catch {
throw new ILinkError({
kind: 'protocol',
message: `${context}失败:响应不是合法 JSON`,
httpStatus: response.status
})
}
}
/**
* iLink HTTP 客户端。
*
* 只负责传输层:网络失败 / 超时 / 非 2xx / JSON 解析失败会抛 ILinkError。
* 业务层 ret / errcode 的判断交给调用方(长轮询需要区分 -14)。
*/
export class ILinkClient {
private baseUrlValue: string
private botTokenValue: string
private readonly fetchImpl: FetchLike
private readonly headerOptions: ILinkHeaderOptions
constructor(options: ILinkClientOptions = {}) {
this.baseUrlValue = (options.baseUrl || ILINK_DEFAULT_BASE_URL).replace(/\/+$/, '')
this.botTokenValue = options.botToken || ''
this.fetchImpl = options.fetchImpl || globalThis.fetch
this.headerOptions = options.headers || {}
}
get baseUrl(): string {
return this.baseUrlValue
}
/** 登录返回 baseurl / redirect_host 后更新后续业务 API 节点。 */
setBaseUrl(baseUrl: string): void {
const normalized = String(baseUrl || '').trim()
if (!normalized) return
this.baseUrlValue = /^https?:\/\//.test(normalized)
? normalized.replace(/\/+$/, '')
: `https://${normalized.replace(/\/+$/, '')}`
}
setBotToken(botToken: string): void {
this.botTokenValue = botToken
}
get hasBotToken(): boolean {
return Boolean(this.botTokenValue)
}
private async request(
url: string,
init: RequestInit,
context: string,
options: ILinkRequestOptions
): Promise<Response> {
const { signal, cleanup } = withTimeout(options.signal, options.timeoutMs)
try {
return await this.fetchImpl(url, { ...init, signal })
} catch (error) {
const kind = classifyNetworkError(error)
const aborted = options.signal?.aborted === true
throw new ILinkError({
kind: aborted ? 'aborted' : kind,
// 带上完整错误链:顶层 fetch failed 没有信息量,原因在 cause 上。
message: `${context}失败:${describeErrorChain(error)}`,
cause: error
})
} finally {
cleanup()
}
}
/** 未鉴权 POST(获取二维码)。 */
async postAnonymous<T>(
path: string,
body: unknown,
context: string,
options: ILinkRequestOptions
): Promise<T> {
const response = await this.request(
`${this.baseUrlValue}${path}`,
{
method: 'POST',
headers: buildCommonHeaders(),
body: JSON.stringify(body)
},
context,
options
)
return parseJson<T>(response, context, options.includeBody === true)
}
/** 鉴权 POST(登录后的全部业务接口)。 */
async post<T>(
path: string,
body: unknown,
context: string,
options: ILinkRequestOptions
): Promise<T> {
const response = await this.request(
`${this.baseUrlValue}${path}`,
{
method: 'POST',
headers: buildAuthorizedHeaders(this.botTokenValue),
body: JSON.stringify(body)
},
context,
options
)
return parseJson<T>(response, context, options.includeBody === true)
}
/** 二维码状态轮询使用裸 URL(可能指向 redirect_host)。 */
async getAnonymous<T>(url: string, context: string, options: ILinkRequestOptions): Promise<T> {
const response = await this.request(
url,
{ method: 'GET', headers: buildQrStatusHeaders() },
context,
options
)
return parseJson<T>(response, context, options.includeBody === true)
}
private baseInfo(): ReturnType<typeof buildBaseInfo> {
return buildBaseInfo(this.headerOptions)
}
async getUpdates(
getUpdatesBuf: string,
options: ILinkRequestOptions
): Promise<ILinkGetUpdatesResponse> {
return this.post<ILinkGetUpdatesResponse>(
'/ilink/bot/getupdates',
{ get_updates_buf: getUpdatesBuf, base_info: this.baseInfo() },
'getupdates',
options
)
}
async sendMessage(
msg: ILinkSendMessageRequest['msg'],
signal?: AbortSignal
): Promise<ILinkSendMessageResponse> {
return this.post<ILinkSendMessageResponse>(
'/ilink/bot/sendmessage',
{ msg, base_info: this.baseInfo() },
'sendmessage',
{ timeoutMs: ILINK_SEND_TIMEOUT_MS, signal, includeBody: true }
)
}
async getUploadUrl(
request: Omit<ILinkGetUploadUrlRequest, 'base_info'>,
signal?: AbortSignal
): Promise<ILinkGetUploadUrlResponse> {
return this.post<ILinkGetUploadUrlResponse>(
'/ilink/bot/getuploadurl',
{ ...request, base_info: this.baseInfo() },
'getuploadurl',
{ timeoutMs: ILINK_SEND_TIMEOUT_MS, signal, includeBody: true }
)
}
async getConfig(
ilinkUserId: string,
contextToken: string,
signal?: AbortSignal
): Promise<ILinkGetConfigResponse> {
return this.post<ILinkGetConfigResponse>(
'/ilink/bot/getconfig',
{
ilink_user_id: ilinkUserId,
...(contextToken ? { context_token: contextToken } : {}),
base_info: this.baseInfo()
},
'getconfig',
{ timeoutMs: ILINK_CONFIG_TIMEOUT_MS, signal }
)
}
/**
* sendtyping:status=1 开始输入、status=2 取消。
* 只用于输入状态,**不是** sendmessage 的鉴权凭据。
*/
async sendTyping(
ilinkUserId: string,
typingTicket: string,
status: number,
signal?: AbortSignal
): Promise<ILinkSendTypingResponse> {
return this.post<ILinkSendTypingResponse>(
'/ilink/bot/sendtyping',
{
ilink_user_id: ilinkUserId,
typing_ticket: typingTicket,
status,
base_info: this.baseInfo()
},
'sendtyping',
{ timeoutMs: ILINK_CONFIG_TIMEOUT_MS, signal }
)
}
/** 生命周期通知:notifystart / notifystop。失败只告警,不阻断消息循环。 */
async notifyLifecycle(action: 'start' | 'stop', signal?: AbortSignal): Promise<void> {
await this.post<{ ret?: number; errmsg?: string }>(
`/ilink/bot/msg/notify${action}`,
{ base_info: this.baseInfo() },
`notify${action}`,
{ timeoutMs: ILINK_CONFIG_TIMEOUT_MS, signal }
)
}
}
+166
View File
@@ -0,0 +1,166 @@
import { ILINK_STALE_TOKEN_CODE } from './types'
export type ILinkErrorKind =
/** DNS / TCP / TLS 等网络层失败 */
| 'network'
/** 客户端主动超时(长轮询正常控制流) */
| 'timeout'
/** 非 2xx HTTP 响应 */
| 'http'
/** 响应体不是合法 JSON,或业务 ret/errcode 非 0 */
| 'protocol'
/** bot token 失效(ret/errcode = -14),必须重新登录 */
| 'stale_token'
/** 调用方主动取消 */
| 'aborted'
const MAX_BODY_EXCERPT = 400
export interface ILinkErrorOptions {
kind: ILinkErrorKind
message: string
httpStatus?: number
ret?: number
errcode?: number
errmsg?: string
cause?: unknown
}
/**
* iLink 统一错误类型。
*
* 刻意不携带请求头 / token:错误对象可能被上层原样写入日志,
* 因此只暴露可安全打印的字段。
*/
export class ILinkError extends Error {
readonly kind: ILinkErrorKind
readonly httpStatus?: number
readonly ret?: number
readonly errcode?: number
readonly errmsg?: string
override readonly cause?: unknown
constructor(options: ILinkErrorOptions) {
super(options.message)
this.name = 'ILinkError'
this.kind = options.kind
if (options.httpStatus !== undefined) this.httpStatus = options.httpStatus
if (options.ret !== undefined) this.ret = options.ret
if (options.errcode !== undefined) this.errcode = options.errcode
if (options.errmsg !== undefined) this.errmsg = options.errmsg
if (options.cause !== undefined) this.cause = options.cause
}
get isStaleToken(): boolean {
return (
this.kind === 'stale_token' ||
this.ret === ILINK_STALE_TOKEN_CODE ||
this.errcode === ILINK_STALE_TOKEN_CODE
)
}
}
export function isILinkError(error: unknown): error is ILinkError {
return error instanceof ILinkError
}
/**
* 把 undici / Node 的错误链压平成可诊断文本。
*
* fetch 层失败时顶层只有一句没有信息量的 `fetch failed`,真正的原因
* (ENOTFOUND / ECONNREFUSED / TLS 握手失败 / socket 超时)在 `error.cause` 链上。
* 日志里必须能看到它,否则线上只能看到"连不上"却不知道为什么。
*/
export function describeErrorChain(error: unknown, maxDepth = 4): string {
const parts: string[] = []
let current: unknown = error
for (let depth = 0; depth < maxDepth && current; depth += 1) {
const candidate = current as {
name?: unknown
code?: unknown
message?: unknown
cause?: unknown
}
const fragments = [candidate.name, candidate.code, candidate.message]
.map((value) => (typeof value === 'string' ? value.trim() : ''))
.filter(Boolean)
parts.push(fragments.join(' ') || String(current))
current = candidate.cause
}
return parts.join(' <- ')
}
/** 沿错误链取第一个有值的 errno 风格 code。 */
function firstErrorCode(error: unknown, maxDepth = 4): string {
let current: unknown = error
for (let depth = 0; depth < maxDepth && current; depth += 1) {
const code = (current as { code?: unknown } | null)?.code
if (typeof code === 'string' && code.trim()) return code.trim()
current = (current as { cause?: unknown } | null)?.cause
}
return ''
}
/** 网络层错误分类;用于决定退避策略。 */
export function classifyNetworkError(error: unknown): ILinkErrorKind {
const code = firstErrorCode(error)
const combined = `${code} ${describeErrorChain(error)}`.toUpperCase()
if (/ABORT|CANCEL/.test(combined)) return 'aborted'
if (
/TIMEOUT|ETIMEDOUT|UND_ERR_CONNECT_TIMEOUT|UND_ERR_HEADERS_TIMEOUT|UND_ERR_BODY_TIMEOUT/.test(
combined
)
) {
return 'timeout'
}
return 'network'
}
/** 截断响应体,避免把大段 HTML / 二进制写进日志。 */
export function excerptBody(body: string): string {
const normalized = String(body ?? '')
.replace(/\s+/g, ' ')
.trim()
return normalized.length > MAX_BODY_EXCERPT
? `${normalized.slice(0, MAX_BODY_EXCERPT)}…`
: normalized
}
/** 把任意 error 压成可安全记录的字符串。 */
export function describeError(error: unknown): string {
if (!isILinkError(error) && error instanceof Error) return describeErrorChain(error)
if (!isILinkError(error) && !(error instanceof Error)) return String(error)
if (isILinkError(error)) {
const parts = [error.message]
if (error.httpStatus !== undefined) parts.push(`http=${error.httpStatus}`)
if (error.ret !== undefined) parts.push(`ret=${error.ret}`)
if (error.errcode !== undefined) parts.push(`errcode=${error.errcode}`)
if (error.errmsg) parts.push(`errmsg=${error.errmsg}`)
return parts.join(' | ')
}
return error instanceof Error ? error.message : String(error)
}
/**
* 协议层业务错误断言:ret / errcode 非 0 即失败。
* HTTP 200 不能单独证明调用成功。
*/
export function assertBusinessOk(
response: { ret?: number; errcode?: number; errmsg?: string },
context: string
): void {
const ret = Number(response.ret ?? 0)
const errcode = Number(response.errcode ?? 0)
if (ret === 0 && errcode === 0) return
const stale = ret === ILINK_STALE_TOKEN_CODE || errcode === ILINK_STALE_TOKEN_CODE
throw new ILinkError({
kind: stale ? 'stale_token' : 'protocol',
message: stale
? `${context}失败:微信登录凭证已失效`
: `${context}失败:ret=${ret} errcode=${errcode}${response.errmsg ? ` errmsg=${response.errmsg}` : ''}`,
ret,
errcode,
...(response.errmsg ? { errmsg: response.errmsg } : {})
})
}
+84
View File
@@ -0,0 +1,84 @@
import { randomBytes } from 'node:crypto'
import {
ILINK_APP_CLIENT_VERSION,
ILINK_APP_ID,
ILINK_CHANNEL_VERSION,
ILINK_DEFAULT_BOT_AGENT,
type ILinkBaseInfo
} from './types'
/** bot_agent 的官方约束:仅 ASCII、总分不超过 256 字节、非法 token 丢弃。 */
const BOT_AGENT_MAX_BYTES = 256
const BOT_AGENT_FALLBACK = 'OpenClaw'
const BOT_AGENT_TOKEN = /^[!-~]+(?:\/[!-~]+)?(?:\([!-~ ]*\))?$/
export interface ILinkHeaderOptions {
/** 覆盖 bot_agent,用于多产品共用同一实现时做归因。仅进入 base_info,不额外造头。 */
botAgent?: string
}
/**
* 清洗 bot_agent。
* 该字段只用于服务端观测聚合,不参与鉴权与路由,因此宁可回退也不抛错。
*/
export function sanitizeBotAgent(value: string | undefined): string {
const tokens = String(value ?? '')
.split(/\s+/)
.map((token) => token.trim())
.filter((token) => token.length > 0 && BOT_AGENT_TOKEN.test(token))
let result = tokens.join(' ')
if (!result) result = BOT_AGENT_FALLBACK
while (Buffer.byteLength(result, 'utf8') > BOT_AGENT_MAX_BYTES && result.includes(' ')) {
result = result.slice(0, result.lastIndexOf(' '))
}
if (Buffer.byteLength(result, 'utf8') > BOT_AGENT_MAX_BYTES) result = BOT_AGENT_FALLBACK
return result
}
/**
* X-WECHAT-UIN:随机 uint32 的十进制字符串再做 base64。
* 官方要求**每次请求重新生成**以防重放,一个客户端只生成一次是不合规的。
*/
export function generateWechatUin(): string {
const value = randomBytes(4).readUInt32LE(0)
return Buffer.from(String(value), 'utf8').toString('base64')
}
/** 所有 POST 请求都会带的公共头(含登录前的二维码接口)。 */
export function buildCommonHeaders(): Record<string, string> {
return {
'Content-Type': 'application/json',
'iLink-App-Id': ILINK_APP_ID,
'iLink-App-ClientVersion': ILINK_APP_CLIENT_VERSION
}
}
/**
* 二维码状态轮询使用未鉴权的最小头集合(官方 2.4.6 不再带 Content-Type)。
* 注意:不自造 SKRouteTag,那是官方内部的路由/调试开关。
*/
export function buildQrStatusHeaders(): Record<string, string> {
return {
'iLink-App-Id': ILINK_APP_ID,
'iLink-App-ClientVersion': ILINK_APP_CLIENT_VERSION
}
}
/** 登录后的业务请求头;未登录时不得携带 Authorization。 */
export function buildAuthorizedHeaders(botToken: string): Record<string, string> {
if (!botToken) throw new Error('buildAuthorizedHeaders 需要有效的 bot token')
return {
...buildCommonHeaders(),
AuthorizationType: 'ilink_bot_token',
Authorization: `Bearer ${botToken}`,
'X-WECHAT-UIN': generateWechatUin()
}
}
/** 每个业务请求体都要带 base_info。 */
export function buildBaseInfo(options: ILinkHeaderOptions = {}): ILinkBaseInfo {
return {
channel_version: ILINK_CHANNEL_VERSION,
bot_agent: sanitizeBotAgent(options.botAgent ?? ILINK_DEFAULT_BOT_AGENT)
}
}
+507
View File
@@ -0,0 +1,507 @@
import { ILinkClient, type FetchLike } from './client'
import { ILinkPoller } from './poller'
import { normalizeInboundMessage } from './messages'
import { sendText, type SendTextResult } from './sender'
import {
sendMediaFromPath,
sendMediaFromSource,
sendMediaFromUrl,
type SendMediaOptions
} from './media'
import { runQrLogin, type QrEncoder } from './auth'
import { assertBusinessOk, ILinkError, describeError } from './errors'
import { TypingCoordinator, type TypingLease, type TypingBeginInput } from './typing'
import {
findCredentials,
loadAllCredentials,
loadContextToken,
loadCursor,
normalizeAccountId,
resolveAccountDirectory,
saveContextToken,
saveCursor,
type HomeDirectoryResolver
} from './account-store'
import type {
ILinkCredentials,
WechatConnectorAccount,
WechatConnectorPhase,
WechatInboundMessage,
WechatLoginEvent
} from './types'
export type ConnectorLogLevel = 'info' | 'warn' | 'error'
export interface WechatConnectorHost {
onLog?: (level: ConnectorLogLevel, message: string) => void
/** 一批入站消息;全部成功 resolve 后才推进游标。 */
onMessages?: (messages: WechatInboundMessage[]) => Promise<void>
onPhaseChange?: (phase: WechatConnectorPhase, error?: string) => void
onLoginEvent?: (event: WechatLoginEvent) => void
}
export interface WechatConnectorServiceOptions {
home?: HomeDirectoryResolver
fetchImpl?: FetchLike
qrEncoder?: QrEncoder
now?: () => number
sleep?: (milliseconds: number) => Promise<void>
botAgent?: string
/** 「正在输入」心跳间隔;默认 5 秒(测试可缩短)。 */
typingKeepaliveMs?: number
/** typing_ticket 缓存有效期。 */
typingTicketTtlMs?: number
/** 定时器注入点,便于测试确定性触发 keepalive。 */
typingSchedule?: (tick: () => void, intervalMs: number) => () => void
/** 便于测试注入更短的长轮询超时/退避。 */
pollOverrides?: {
normalFailureDelayMs?: number
repeatedFailureDelayMs?: number
defaultTimeoutMs?: number
}
}
export interface ConnectorSendTextInput {
to: string
text: string
accountId?: string
contextToken?: string
signal?: AbortSignal
}
/**
* TraceMemo 的微信 iLink 连接器,直接跑在 Electron main process 内。
*
* inbound 通过 onMessages 回调交给 Agent Hub,outbound 通过 sendText / sendMedia 直接调用;
* 不依赖子进程,也不开本地 HTTP 端口。
*/
export class WechatConnectorService {
private readonly options: WechatConnectorServiceOptions
private readonly typing: TypingCoordinator
private host: WechatConnectorHost = {}
private phase: WechatConnectorPhase = 'stopped'
private activeAccountId?: string
private client: ILinkClient | null = null
private pollerAbort: AbortController | null = null
private pollerPromise: Promise<void> | null = null
private loginAbort: AbortController | null = null
private loginPromise: Promise<void> | null = null
private pendingVerifyCode: ((code: string | undefined) => void) | null = null
constructor(options: WechatConnectorServiceOptions = {}) {
this.options = options
this.typing = new TypingCoordinator({
// 取票与下发都绑定"当前账号"的 client,切账号后自然走新 client。
fetchTicket: async ({ ilinkUserId, contextToken }) => {
const client = this.client
if (!client) return undefined
const response = await client.getConfig(ilinkUserId, contextToken ?? '')
assertBusinessOk(response, 'getconfig')
return response.typing_ticket
},
sendTyping: async ({ ilinkUserId, ticket, status }) => {
const client = this.client
if (!client) return false
const response = await client.sendTyping(ilinkUserId, ticket, status)
return Number(response.ret ?? 0) === 0 && Number(response.errcode ?? 0) === 0
},
log: (level, message) => this.log(level, message),
...(options.now ? { now: options.now } : {}),
...(options.typingKeepaliveMs !== undefined
? { keepaliveMs: options.typingKeepaliveMs }
: {}),
...(options.typingTicketTtlMs !== undefined
? { ticketTtlMs: options.typingTicketTtlMs }
: {}),
...(options.typingSchedule ? { schedule: options.typingSchedule } : {})
})
}
/**
* 开始「正在输入」。永不抛异常:拿不到 ticket 或服务端失败时返回空实现,
* 调用方照常执行业务,但**仍然要**在 finally 里 stop(幂等且安全)。
*/
beginTyping(input: TypingBeginInput): Promise<TypingLease> {
return this.typing.begin({
...input,
...(input.accountId ? {} : this.activeAccountId ? { accountId: this.activeAccountId } : {})
})
}
setHost(host: WechatConnectorHost): void {
this.host = host
}
getPhase(): WechatConnectorPhase {
return this.phase
}
getActiveAccountId(): string | undefined {
return this.activeAccountId
}
isRunning(): boolean {
return this.phase === 'polling'
}
listAccounts(): WechatConnectorAccount[] {
return loadAllCredentials(this.options.home).map((credentials) => ({
accountId: credentials.ilink_bot_id,
wechatUserId: credentials.ilink_user_id
}))
}
/** 主动发送时取该会话最近一次有效的 context_token。 */
resolveContextToken(accountId: string | undefined, toUserId: string): string | undefined {
const normalized = normalizeAccountId(accountId || this.activeAccountId || '')
if (!normalized) return undefined
return loadContextToken(normalized, toUserId, this.options.home)
}
/** 当前账号凭据所在目录(凭据 / 游标 / 会话令牌同目录)。 */
getAccountDirectory(accountId: string): { directory: string; legacy: boolean } {
return resolveAccountDirectory(normalizeAccountId(accountId), this.options.home)
}
/* ------------------------------ 登录 ------------------------------ */
/**
* 启动扫码登录。立即返回,进度通过 onLoginEvent 上报。
* 重复调用会先取消进行中的登录。
*/
startLogin(): void {
if (this.loginPromise) return
const controller = new AbortController()
this.loginAbort = controller
this.loginPromise = this.runLogin(controller).finally(() => {
if (this.loginAbort === controller) this.loginAbort = null
this.loginPromise = null
})
}
isLoginInProgress(): boolean {
return this.loginPromise !== null
}
cancelLogin(): void {
this.pendingVerifyCode?.(undefined)
this.pendingVerifyCode = null
this.loginAbort?.abort()
this.loginAbort = null
}
/** 手机端要求数字配对码时由宿主回填。 */
submitVerifyCode(code: string): void {
const resolver = this.pendingVerifyCode
this.pendingVerifyCode = null
resolver?.(code)
}
private async runLogin(controller: AbortController): Promise<void> {
this.setPhase('starting')
try {
await runQrLogin({
...(this.options.home ? { home: this.options.home } : {}),
...(this.options.fetchImpl ? { fetchImpl: this.options.fetchImpl } : {}),
...(this.options.qrEncoder ? { qrEncoder: this.options.qrEncoder } : {}),
...(this.options.now ? { now: this.options.now } : {}),
...(this.options.sleep ? { sleep: this.options.sleep } : {}),
signal: controller.signal,
onEvent: (event) => this.host.onLoginEvent?.(event),
verifyCodeProvider: () =>
new Promise<string | undefined>((resolve) => {
this.pendingVerifyCode = resolve
})
})
this.log('info', '扫码登录成功')
// 登录成功后的连接由宿主在收到 active 事件后调用 start()。
this.setPhase('stopped')
} catch (error) {
if (error instanceof ILinkError && error.kind === 'aborted') {
this.log('info', '扫码登录已取消')
this.setPhase('stopped')
return
}
const message = describeError(error)
this.log('error', `扫码登录失败:${message}`)
this.setPhase('error', message)
}
}
/* --------------------------- 连接与轮询 --------------------------- */
/** 启动某个账号的长轮询。重复调用会先停止当前账号。 */
async start(accountId: string): Promise<void> {
await this.stop()
const credentials = findCredentials(normalizeAccountId(accountId), this.options.home)
if (!credentials) {
throw new ILinkError({ kind: 'protocol', message: `未找到账号 ${accountId} 的登录凭据` })
}
const normalizedId = normalizeAccountId(credentials.ilink_bot_id)
const client = new ILinkClient({
baseUrl: credentials.baseurl,
botToken: credentials.bot_token,
...(this.options.fetchImpl ? { fetchImpl: this.options.fetchImpl } : {}),
...(this.options.botAgent ? { headers: { botAgent: this.options.botAgent } } : {})
})
this.client = client
this.activeAccountId = normalizedId
// 账号或 token 变了:旧 ticket 一律作废。
this.typing.invalidateTickets()
this.setPhase('starting')
// 生命周期通知是尽力而为:失败只告警,绝不阻断消息循环。
await this.notifyLifecycle('start')
const controller = new AbortController()
this.pollerAbort = controller
const poller = new ILinkPoller({
fetchUpdates: (getUpdatesBuf, timeoutMs) =>
client.getUpdates(getUpdatesBuf, { timeoutMs, signal: controller.signal }),
onMessages: async (messages) => {
this.rememberContextTokens(normalizedId, messages)
await this.host.onMessages?.(messages)
},
normalize: (raw) => normalizeInboundMessage(normalizedId, raw),
loadCursor: () => loadCursor(normalizedId, this.options.home),
saveCursor: (getUpdatesBuf) => saveCursor(normalizedId, getUpdatesBuf, this.options.home),
signal: controller.signal,
log: (level, message) => this.log(level, message),
onStaleToken: (message) => {
// 凭证已失效:缓存的 typing_ticket 不再可用。
this.typing.invalidateTickets()
this.setPhase('stale_token', message)
},
...(this.options.now ? { now: this.options.now } : {}),
...(this.options.sleep ? { sleep: this.options.sleep } : {}),
...(this.options.pollOverrides?.normalFailureDelayMs !== undefined
? { normalFailureDelayMs: this.options.pollOverrides.normalFailureDelayMs }
: {}),
...(this.options.pollOverrides?.repeatedFailureDelayMs !== undefined
? { repeatedFailureDelayMs: this.options.pollOverrides.repeatedFailureDelayMs }
: {}),
...(this.options.pollOverrides?.defaultTimeoutMs !== undefined
? { defaultTimeoutMs: this.options.pollOverrides.defaultTimeoutMs }
: {})
})
const pollPromise = poller
.run()
.catch((error) => {
if (controller.signal.aborted) return
const message = describeError(error)
this.log('error', `长轮询异常退出:${message}`)
this.setPhase('error', message)
})
.finally(() => {
if (this.pollerAbort === controller) {
this.pollerAbort = null
this.pollerPromise = null
}
})
this.pollerPromise = pollPromise
if (this.phase !== 'stale_token' && this.phase !== 'error') this.setPhase('polling')
this.log('info', `微信连接器已启动(账号 ${normalizedId})`)
}
async stop(): Promise<void> {
// 先收掉输入状态,避免连接器停了微信端还显示"对方正在输入"。
this.typing.clear()
const controller = this.pollerAbort
const running = this.pollerPromise
this.pollerAbort = null
if (controller && !controller.signal.aborted) controller.abort()
if (running) await running.catch(() => undefined)
if (this.client) {
// 用独立短超时发送停止通知,避免被长轮询的取消信号一起取消。
await this.notifyLifecycle('stop')
}
this.client = null
this.activeAccountId = undefined
if (this.phase !== 'error') this.setPhase('stopped')
}
private async notifyLifecycle(action: 'start' | 'stop'): Promise<void> {
const client = this.client
if (!client || !client.hasBotToken) return
try {
await client.notifyLifecycle(action)
} catch (error) {
this.log('warn', `notify${action} 未成功(不影响消息循环):${describeError(error)}`)
}
}
private rememberContextTokens(accountId: string, messages: WechatInboundMessage[]): void {
for (const message of messages) {
if (!message.contextToken) continue
try {
saveContextToken(
accountId,
message.fromUserId,
message.contextToken,
this.options.home,
this.options.now
)
} catch (error) {
this.log('warn', `会话上下文令牌保存失败:${describeError(error)}`)
}
}
}
/* ------------------------------ 发送 ------------------------------ */
/**
* 主动发送时的 context_token 解析:
* 显式传入优先,其次按「账号 + 用户」取最近一次有效值。
*/
resolveOutgoingContextToken(input: {
accountId?: string
to: string
contextToken?: string
}): string | undefined {
const explicit = String(input.contextToken ?? '').trim()
if (explicit) return explicit
return this.resolveContextToken(input.accountId, input.to)
}
private requireClient(accountId?: string): ILinkClient {
if (this.phase === 'stale_token') {
throw new ILinkError({
kind: 'stale_token',
message: '微信登录凭证已失效,请重新扫码登录'
})
}
if (!this.client) {
throw new ILinkError({ kind: 'protocol', message: '微信连接器尚未启动' })
}
if (accountId) {
const normalized = normalizeAccountId(accountId)
if (this.activeAccountId && normalized !== this.activeAccountId) {
throw new ILinkError({
kind: 'protocol',
message: `账号 ${accountId} 当前未连接`
})
}
}
if (!this.client.hasBotToken) {
throw new ILinkError({ kind: 'stale_token', message: '微信登录凭证不可用' })
}
return this.client
}
async sendText(input: ConnectorSendTextInput): Promise<SendTextResult> {
const client = this.requireClient(input.accountId)
const contextToken = this.resolveOutgoingContextToken({
...(input.accountId ? { accountId: input.accountId } : {}),
to: input.to,
...(input.contextToken ? { contextToken: input.contextToken } : {})
})
return sendText(client, {
to: input.to,
text: input.text,
...(contextToken ? { contextToken } : {}),
...(input.signal ? { signal: input.signal } : {})
})
}
async sendMedia(
input: Omit<SendMediaOptions, 'fetchImpl'> & { accountId?: string }
): Promise<{ clientId: string; itemType: number }> {
const client = this.requireClient(input.accountId)
const contextToken = this.resolveOutgoingContextToken({
...(input.accountId ? { accountId: input.accountId } : {}),
to: input.to,
...(input.contextToken ? { contextToken: input.contextToken } : {})
})
return sendMediaFromSource(client, {
to: input.to,
source: input.source,
mediaKind: 'file',
...(contextToken ? { contextToken } : {}),
...(this.options.fetchImpl ? { fetchImpl: this.options.fetchImpl } : {}),
...(input.signal ? { signal: input.signal } : {})
})
}
async sendMediaPath(input: {
accountId?: string
to: string
filePath: string
contextToken?: string
signal?: AbortSignal
}): Promise<{ clientId: string; itemType: number }> {
const client = this.requireClient(input.accountId)
const contextToken = this.resolveOutgoingContextToken({
...(input.accountId ? { accountId: input.accountId } : {}),
to: input.to,
...(input.contextToken ? { contextToken: input.contextToken } : {})
})
return sendMediaFromPath(client, {
to: input.to,
filePath: input.filePath,
...(contextToken ? { contextToken } : {}),
...(this.options.fetchImpl ? { fetchImpl: this.options.fetchImpl } : {}),
...(input.signal ? { signal: input.signal } : {})
})
}
async sendMediaUrl(input: {
accountId?: string
to: string
mediaUrl: string
contextToken?: string
signal?: AbortSignal
}): Promise<{ clientId: string; itemType: number }> {
const client = this.requireClient(input.accountId)
const contextToken = this.resolveOutgoingContextToken({
...(input.accountId ? { accountId: input.accountId } : {}),
to: input.to,
...(input.contextToken ? { contextToken: input.contextToken } : {})
})
return sendMediaFromUrl(client, {
to: input.to,
mediaUrl: input.mediaUrl,
...(contextToken ? { contextToken } : {}),
...(this.options.fetchImpl ? { fetchImpl: this.options.fetchImpl } : {}),
...(input.signal ? { signal: input.signal } : {})
})
}
/** 会话凭据(含 bot_token)只读暴露给需要的内部调用方;不用于日志。 */
getCredentials(accountId?: string): ILinkCredentials | undefined {
const id = normalizeAccountId(accountId || this.activeAccountId || '')
if (!id) return undefined
return findCredentials(id, this.options.home)
}
private setPhase(phase: WechatConnectorPhase, error?: string): void {
this.phase = phase
this.host.onPhaseChange?.(phase, error)
}
private log(level: ConnectorLogLevel, message: string): void {
this.host.onLog?.(level, message)
}
}
export { ILinkClient } from './client'
export { ILinkPoller } from './poller'
export * from './types'
export { normalizeInboundMessage, extractInboundText } from './messages'
export { markdownToPlainText, extractMarkdownImageUrls } from './markdown'
export { ILinkError, describeError, isILinkError } from './errors'
export {
loadAllCredentials,
normalizeAccountId,
saveCredentials,
accountsDirectory,
legacyAccountsDirectory,
loadCursor,
saveCursor,
loadContextToken,
saveContextToken
} from './account-store'
export { sendText, createClientId } from './sender'
export { runQrLogin } from './auth'
@@ -0,0 +1,58 @@
/**
* 微信纯文本渲染。
*
* 微信气泡不渲染 Markdown,所以外发前把常见 Markdown 语法降级成可读纯文本。
* 刻意不处理斜体(`*text*`):微信里 `*` 常作为普通字符出现,转换会误伤正文。
*/
const RE_CODE_BLOCK = /```[^\n]*\n?([\s\S]*?)```/g
const RE_INLINE_CODE = /`([^`]+)`/g
const RE_IMAGE = /!\[[^\]]*\]\([^)]*\)/g
const RE_LINK = /\[([^\]]+)\]\([^)]*\)/g
const RE_TABLE_SEPARATOR = /^\|[\s:|-]+\|$/gm
const RE_TABLE_ROW = /^\|(.+)\|$/gm
const RE_HEADER = /^#{1,6}\s+/gm
const RE_BOLD = /\*\*(.+?)\*\*|__(.+?)__/g
const RE_STRIKE = /~~(.+?)~~/g
const RE_BLOCKQUOTE = /^>\s?/gm
const RE_HORIZONTAL_RULE = /^[-*_]{3,}\s*$/gm
const RE_UNORDERED_LIST = /^(\s*)[-*+]\s+/gm
const RE_EXCESS_BLANK_LINES = /\n{3,}/g
const RE_MARKDOWN_IMAGE_URL = /!\[[^\]]*\]\(([^)]+)\)/g
export function markdownToPlainText(text: string): string {
let result = String(text ?? '')
result = result.replace(RE_CODE_BLOCK, (_match, body: string) => String(body ?? '').trim())
result = result.replace(RE_IMAGE, '')
result = result.replace(RE_LINK, '$1')
result = result.replace(RE_TABLE_SEPARATOR, '')
result = result.replace(RE_TABLE_ROW, (_match, body: string) =>
String(body ?? '')
.split('|')
.map((cell) => cell.trim())
.join(' ')
)
result = result.replace(RE_HEADER, '')
result = result.replace(RE_BOLD, (_match, strong: string, alternative: string) =>
strong !== undefined && strong !== '' ? strong : String(alternative ?? '')
)
result = result.replace(RE_STRIKE, '$1')
result = result.replace(RE_BLOCKQUOTE, '')
result = result.replace(RE_HORIZONTAL_RULE, '')
result = result.replace(RE_UNORDERED_LIST, '$1• ')
result = result.replace(RE_INLINE_CODE, '$1')
result = result.replace(RE_EXCESS_BLANK_LINES, '\n\n')
return result.trim()
}
/** 提取 Markdown 中内嵌的 http(s) 图片地址,用于"文字 + 随后补发图片"。 */
export function extractMarkdownImageUrls(text: string): string[] {
const urls: string[] = []
for (const match of String(text ?? '').matchAll(RE_MARKDOWN_IMAGE_URL)) {
const url = String(match[1] ?? '').trim()
if (url.startsWith('http://') || url.startsWith('https://')) urls.push(url)
}
return urls
}
+391
View File
@@ -0,0 +1,391 @@
import { createCipheriv, createDecipheriv, createHash, randomBytes } from 'node:crypto'
import { readFileSync } from 'node:fs'
import { basename, extname } from 'node:path'
import type { FetchLike, ILinkClient } from './client'
import { assertBusinessOk, excerptBody, ILinkError } from './errors'
import { buildSendMessageBody, createClientId } from './sender'
import {
ILINK_CDN_BASE_URL,
ILINK_CDN_MEDIA_TYPE_FILE,
ILINK_CDN_MEDIA_TYPE_IMAGE,
ILINK_CDN_MEDIA_TYPE_VIDEO,
ILINK_ITEM_TYPE_FILE,
ILINK_ITEM_TYPE_IMAGE,
ILINK_ITEM_TYPE_VIDEO,
type ILinkMessageItem
} from './types'
/**
* 媒体消息(图片 / 视频 / 文件)。
*
* 协议要求端到端加密:随机 16 字节 AES-128-ECB 密钥 → 加密后 PUT 到 CDN →
* sendmessage 里带 CDN 引用与 base64(hex key)。
*
* 本项目不生成缩略图:官方文档提到 IMAGE/VIDEO 可携带缩略图字段,
* 这里显式声明 `no_need_thumb: true`,避免服务端等待一个永远不会上传的缩略图。
*/
const CDN_REQUEST_TIMEOUT_MS = 60_000
const AES_BLOCK_SIZE = 16
const MIME_BY_EXTENSION: Readonly<Record<string, string>> = {
'.png': 'image/png',
'.jpg': 'image/jpeg',
'.jpeg': 'image/jpeg',
'.gif': 'image/gif',
'.webp': 'image/webp',
'.bmp': 'image/bmp',
'.mp4': 'video/mp4',
'.mov': 'video/quicktime',
'.webm': 'video/webm',
'.mkv': 'video/x-matroska',
'.avi': 'video/x-msvideo',
'.pdf': 'application/pdf',
'.txt': 'text/plain',
'.zip': 'application/zip'
}
const IMAGE_EXTENSIONS = new Set(['.png', '.jpg', '.jpeg', '.gif', '.webp', '.bmp'])
const VIDEO_EXTENSIONS = new Set(['.mp4', '.mov', '.webm', '.mkv', '.avi'])
export function aesEcbPaddedSize(plaintextSize: number): number {
return (Math.floor(plaintextSize / AES_BLOCK_SIZE) + 1) * AES_BLOCK_SIZE
}
export function encryptAesEcb(plaintext: Buffer, key: Buffer): Buffer {
const cipher = createCipheriv('aes-128-ecb', key, null)
return Buffer.concat([cipher.update(plaintext), cipher.final()])
}
export function decryptAesEcb(ciphertext: Buffer, key: Buffer): Buffer {
if (ciphertext.length === 0 || ciphertext.length % AES_BLOCK_SIZE !== 0) {
throw new ILinkError({ kind: 'protocol', message: '密文长度不是 AES 块大小的整数倍' })
}
const decipher = createDecipheriv('aes-128-ecb', key, null)
return Buffer.concat([decipher.update(ciphertext), decipher.final()])
}
/** 协议要求把 hex 字符串再做 base64 放进 item.media.aes_key。 */
export function aesKeyToBase64(hexKey: string): string {
return Buffer.from(hexKey, 'utf8').toString('base64')
}
/** 反向解析 item.media.aes_key:base64 → hex 字符串 → 原始密钥。 */
export function aesKeyFromBase64(base64Key: string): Buffer {
return Buffer.from(Buffer.from(base64Key, 'base64').toString('utf8'), 'hex')
}
export function stripQuery(rawUrl: string): string {
const index = rawUrl.indexOf('?')
return index >= 0 ? rawUrl.slice(0, index) : rawUrl
}
export function inferContentType(source: string): string {
return MIME_BY_EXTENSION[extname(stripQuery(source)).toLowerCase()] || 'application/octet-stream'
}
export function classifyMedia(
contentType: string,
source: string
): { cdnMediaType: number; itemType: number } {
const normalized = String(contentType || '').toLowerCase()
const extension = extname(stripQuery(source)).toLowerCase()
if (normalized.startsWith('image/') || IMAGE_EXTENSIONS.has(extension)) {
return { cdnMediaType: ILINK_CDN_MEDIA_TYPE_IMAGE, itemType: ILINK_ITEM_TYPE_IMAGE }
}
if (normalized.startsWith('video/') || VIDEO_EXTENSIONS.has(extension)) {
return { cdnMediaType: ILINK_CDN_MEDIA_TYPE_VIDEO, itemType: ILINK_ITEM_TYPE_VIDEO }
}
return { cdnMediaType: ILINK_CDN_MEDIA_TYPE_FILE, itemType: ILINK_ITEM_TYPE_FILE }
}
export interface UploadedMedia {
downloadParam: string
aesKeyHex: string
fileSize: number
cipherSize: number
}
async function fetchWithTimeout(
fetchImpl: FetchLike,
url: string,
init: RequestInit,
context: string
): Promise<Response> {
const controller = new AbortController()
const timer = setTimeout(() => controller.abort(new Error('cdn timeout')), CDN_REQUEST_TIMEOUT_MS)
try {
const response = await fetchImpl(url, { ...init, signal: controller.signal })
if (!response.ok) {
const body = await response.text().catch(() => '')
throw new ILinkError({
kind: 'http',
message: `${context}失败:HTTP ${response.status} ${excerptBody(body)}`,
httpStatus: response.status
})
}
return response
} catch (error) {
if (error instanceof ILinkError) throw error
throw new ILinkError({
kind: 'network',
message: `${context}失败:${error instanceof Error ? error.message : String(error)}`,
cause: error
})
} finally {
clearTimeout(timer)
}
}
/** 加密并上传到微信 CDN,返回 sendmessage 需要的媒体引用。 */
export async function uploadMediaToCdn(
client: ILinkClient,
input: {
data: Buffer
toUserId: string
mediaType: number
fetchImpl?: FetchLike
signal?: AbortSignal
}
): Promise<UploadedMedia> {
const fetchImpl = input.fetchImpl ?? globalThis.fetch
const fileKey = randomBytes(16)
const aesKey = randomBytes(16)
const fileKeyHex = fileKey.toString('hex')
const aesKeyHex = aesKey.toString('hex')
const rawFileMd5 = createHash('md5').update(input.data).digest('hex')
const cipherSize = aesEcbPaddedSize(input.data.length)
const uploadResponse = await client.getUploadUrl(
{
filekey: fileKeyHex,
media_type: input.mediaType,
to_user_id: input.toUserId,
rawsize: input.data.length,
rawfilemd5: rawFileMd5,
filesize: cipherSize,
no_need_thumb: true,
aeskey: aesKeyHex
},
input.signal
)
assertBusinessOk(uploadResponse, '获取 CDN 上传地址')
const encrypted = encryptAesEcb(input.data, aesKey)
const explicitUrl = String(uploadResponse.upload_full_url ?? '').trim()
const uploadParam = String(uploadResponse.upload_param ?? '').trim()
if (!explicitUrl && !uploadParam) {
throw new ILinkError({
kind: 'protocol',
message: '服务端未返回可用的 CDN 上传地址'
})
}
const cdnUrl =
explicitUrl ||
`${ILINK_CDN_BASE_URL}/upload?encrypted_query_param=${encodeURIComponent(uploadParam)}&filekey=${encodeURIComponent(fileKeyHex)}`
const uploadResult = await fetchWithTimeout(
fetchImpl,
cdnUrl,
{
method: 'POST',
headers: { 'Content-Type': 'application/octet-stream' },
body: new Uint8Array(encrypted)
},
'CDN 上传'
)
const downloadParam = uploadResult.headers.get('X-Encrypted-Param') ?? ''
if (!downloadParam) {
throw new ILinkError({
kind: 'protocol',
message: 'CDN 上传成功但缺少 X-Encrypted-Param 响应头'
})
}
return { downloadParam, aesKeyHex, fileSize: input.data.length, cipherSize }
}
async function downloadFromCdn(
fetchImpl: FetchLike,
encryptQueryParam: string,
aesKeyBase64: string
): Promise<Buffer> {
const url = `${ILINK_CDN_BASE_URL}/download?encrypted_query_param=${encodeURIComponent(encryptQueryParam)}`
const response = await fetchWithTimeout(fetchImpl, url, { method: 'GET' }, 'CDN 下载')
const ciphertext = Buffer.from(await response.arrayBuffer())
return decryptAesEcb(ciphertext, aesKeyFromBase64(aesKeyBase64))
}
function buildMediaItem(
itemType: number,
media: { encrypt_query_param: string; aes_key: string; encrypt_type: number },
uploaded: UploadedMedia,
fileName: string
): ILinkMessageItem {
if (itemType === ILINK_ITEM_TYPE_IMAGE) {
return { type: ILINK_ITEM_TYPE_IMAGE, image_item: { media, mid_size: uploaded.cipherSize } }
}
if (itemType === ILINK_ITEM_TYPE_VIDEO) {
return { type: ILINK_ITEM_TYPE_VIDEO, video_item: { media, video_size: uploaded.cipherSize } }
}
return {
type: ILINK_ITEM_TYPE_FILE,
file_item: {
media,
file_name: fileName || 'file',
len: String(uploaded.fileSize)
}
}
}
export interface SendMediaOptions {
to: string
data: Buffer
fileName: string
contentType: string
source: string
contextToken?: string
fetchImpl?: FetchLike
signal?: AbortSignal
}
/** 上传并发送一条媒体消息。 */
export async function sendMedia(
client: ILinkClient,
options: SendMediaOptions
): Promise<{ clientId: string; itemType: number }> {
const to = String(options.to ?? '').trim()
if (!to) throw new ILinkError({ kind: 'protocol', message: '发送媒体需要有效的接收者' })
const { cdnMediaType, itemType } = classifyMedia(options.contentType, options.source)
const uploaded = await uploadMediaToCdn(client, {
data: options.data,
toUserId: to,
mediaType: cdnMediaType,
...(options.fetchImpl ? { fetchImpl: options.fetchImpl } : {}),
...(options.signal ? { signal: options.signal } : {})
})
const clientId = createClientId()
const response = await client.sendMessage(
buildSendMessageBody({
item: buildMediaItem(
itemType,
{
encrypt_query_param: uploaded.downloadParam,
aes_key: aesKeyToBase64(uploaded.aesKeyHex),
encrypt_type: 1
},
uploaded,
options.fileName
),
to,
clientId,
...(options.contextToken ? { contextToken: options.contextToken } : {})
}),
options.signal
)
assertBusinessOk(response, '发送媒体')
return { clientId, itemType }
}
/** 发送本地文件。 */
export async function sendMediaFromPath(
client: ILinkClient,
input: {
to: string
filePath: string
contextToken?: string
fetchImpl?: FetchLike
signal?: AbortSignal
}
): Promise<{ clientId: string; itemType: number }> {
let data: Buffer
try {
data = readFileSync(input.filePath)
} catch (error) {
throw new ILinkError({
kind: 'protocol',
message: `读取媒体文件失败:${error instanceof Error ? error.message : String(error)}`
})
}
return sendMedia(client, {
to: input.to,
data,
fileName: basename(input.filePath),
contentType: inferContentType(input.filePath),
source: input.filePath,
...(input.contextToken ? { contextToken: input.contextToken } : {}),
...(input.fetchImpl ? { fetchImpl: input.fetchImpl } : {}),
...(input.signal ? { signal: input.signal } : {})
})
}
/** 发送远程媒体:下载后走同一条加密上传链路。 */
export async function sendMediaFromUrl(
client: ILinkClient,
input: {
to: string
mediaUrl: string
contextToken?: string
fetchImpl?: FetchLike
signal?: AbortSignal
}
): Promise<{ clientId: string; itemType: number }> {
if (!/^https?:\/\//i.test(input.mediaUrl)) {
throw new ILinkError({ kind: 'protocol', message: '远程媒体必须是 http(s) 地址' })
}
const fetchImpl = input.fetchImpl ?? globalThis.fetch
const response = await fetchWithTimeout(
fetchImpl,
input.mediaUrl,
{ method: 'GET' },
'下载远程媒体'
)
const data = Buffer.from(await response.arrayBuffer())
const headerType = response.headers.get('content-type') ?? ''
const name = basename(stripQuery(input.mediaUrl)) || 'file'
return sendMedia(client, {
to: input.to,
data,
fileName: name,
contentType: headerType || inferContentType(input.mediaUrl),
source: input.mediaUrl,
...(input.contextToken ? { contextToken: input.contextToken } : {}),
...(input.fetchImpl ? { fetchImpl: input.fetchImpl } : {}),
...(input.signal ? { signal: input.signal } : {})
})
}
/** 发送媒体来源(本地路径优先,其次 http(s) URL)。供统一发送入口复用。 */
export async function sendMediaFromSource(
client: ILinkClient,
input: {
to: string
source: string
mediaKind: 'image' | 'file'
contextToken?: string
fetchImpl?: FetchLike
signal?: AbortSignal
}
): Promise<{ clientId: string; itemType: number }> {
const isLocal = !/^https?:\/\//i.test(input.source)
if (isLocal) {
return sendMediaFromPath(client, {
to: input.to,
filePath: input.source,
...(input.contextToken ? { contextToken: input.contextToken } : {}),
...(input.fetchImpl ? { fetchImpl: input.fetchImpl } : {}),
...(input.signal ? { signal: input.signal } : {})
})
}
return sendMediaFromUrl(client, {
to: input.to,
mediaUrl: input.source,
...(input.contextToken ? { contextToken: input.contextToken } : {}),
...(input.fetchImpl ? { fetchImpl: input.fetchImpl } : {}),
...(input.signal ? { signal: input.signal } : {})
})
}
export { downloadFromCdn }
@@ -0,0 +1,61 @@
import {
ILINK_ITEM_TYPE_TEXT,
type ILinkMessageItem,
type ILinkWeixinMessage,
type WechatInboundItem,
type WechatInboundMessage
} from './types'
/**
* 把协议原始消息归一化成 Agent Hub 消费的形状。
*
* 关键点是 `context_token` **必须完整透传**:它属于会话上下文,
* 回复同一个会话时要原样回传,缺失会导致回复落到错误的会话线程。
*/
export function normalizeInboundMessage(
accountId: string,
raw: ILinkWeixinMessage
): WechatInboundMessage | undefined {
const fromUserId = String(raw.from_user_id ?? '').trim()
if (!fromUserId) return undefined
const items: WechatInboundItem[] = (raw.item_list ?? []).map((item) => {
const normalized: WechatInboundItem = { type: Number(item.type ?? 0) }
const text = itemText(item)
if (text) normalized.text = text
return normalized
})
const message: WechatInboundMessage = {
accountId,
fromUserId,
messageId:
raw.message_id !== undefined && raw.message_id !== null ? String(raw.message_id) : '',
messageType: Number(raw.message_type ?? 0),
items,
receivedAt: Date.now()
}
if (raw.seq !== undefined && raw.seq !== null) message.seq = Number(raw.seq)
const sessionId = String(raw.session_id ?? '').trim()
if (sessionId) message.sessionId = sessionId
const groupId = String(raw.group_id ?? '').trim()
if (groupId) message.groupId = groupId
const contextToken = String(raw.context_token ?? '').trim()
if (contextToken) message.contextToken = contextToken
return message
}
function itemText(item: ILinkMessageItem): string {
const direct = String(item.text_item?.text ?? '').trim()
if (direct) return direct
// 语音条目的 text 是微信侧语音转文字结果,保留以便上层按需使用。
return String(item.voice_item?.text ?? '').trim()
}
/** 拼接入站消息中的文本片段:只有类型为「文本」的条目参与拼接。 */
export function extractInboundText(items: WechatInboundItem[] | undefined): string {
return (items ?? [])
.filter((item) => item.type === ILINK_ITEM_TYPE_TEXT && item.text?.trim())
.map((item) => item.text!.trim())
.join(' ')
}
+187
View File
@@ -0,0 +1,187 @@
import { describeError, ILinkError } from './errors'
import {
ILINK_LONG_POLL_MAX_TIMEOUT_MS,
ILINK_LONG_POLL_TIMEOUT_MS,
type ILinkGetUpdatesResponse,
type ILinkWeixinMessage,
type WechatInboundMessage
} from './types'
export type PollLogLevel = 'info' | 'warn' | 'error'
export type PollLog = (level: PollLogLevel, message: string) => void
export interface ILinkPollerOptions {
/** 拉取一批消息;timeoutMs 来自服务端上一次响应里的建议值。 */
fetchUpdates: (getUpdatesBuf: string, timeoutMs: number) => Promise<ILinkGetUpdatesResponse>
/**
* 处理一批入站消息。
* **只有全部消息被成功接收后才会推进游标**,因此这里抛错会让整批重投。
*/
onMessages: (messages: WechatInboundMessage[]) => Promise<void>
normalize: (raw: ILinkWeixinMessage) => WechatInboundMessage | undefined
loadCursor: () => string
saveCursor: (getUpdatesBuf: string) => void
signal: AbortSignal
log: PollLog
/** bot token 失效(-14):停止轮询,交由上层通知用户重新登录。 */
onStaleToken?: (message: string) => void
now?: () => number
sleep?: (milliseconds: number) => Promise<void>
normalFailureDelayMs?: number
repeatedFailureDelayMs?: number
repeatedFailureThreshold?: number
defaultTimeoutMs?: number
maxTimeoutMs?: number
}
const DEFAULT_NORMAL_DELAY_MS = 2_000
const DEFAULT_REPEATED_DELAY_MS = 30_000
const DEFAULT_REPEATED_THRESHOLD = 3
/**
* 长轮询循环。
*
* 1. **先 dispatch 成功,再持久化新游标**。先推进游标再投递的话,进程在两步之间
* 崩溃就会静默丢消息;这里换取 at-least-once(允许重复,不允许丢失)。
* 2. **客户端自身超时不算失败**:保留游标直接进入下一轮,不计退避。
* 3. **ret/errcode = -14 表示 bot token 已失效**,必须停止业务请求并让用户重新登录;
* 重置游标后紧密重试只会打满接口。
* 4. 采用服务端 `longpolling_timeout_ms` 建议值,而不是写死超时。
*/
export class ILinkPoller {
private readonly options: ILinkPollerOptions
private cursor: string
private consecutiveFailures = 0
/** 是否正处于"连不上"的状态;用于在恢复时补一条日志,否则故障窗口在日志里看不出边界。 */
private degraded = false
private nextTimeoutMs: number
constructor(options: ILinkPollerOptions) {
this.options = options
this.cursor = options.loadCursor()
this.nextTimeoutMs = options.defaultTimeoutMs ?? ILINK_LONG_POLL_TIMEOUT_MS
}
get getUpdatesBuf(): string {
return this.cursor
}
async run(): Promise<void> {
const {
signal,
log,
sleep = (milliseconds: number) => new Promise<void>((r) => setTimeout(r, milliseconds))
} = this.options
const normalDelay = this.options.normalFailureDelayMs ?? DEFAULT_NORMAL_DELAY_MS
const repeatedDelay = this.options.repeatedFailureDelayMs ?? DEFAULT_REPEATED_DELAY_MS
const threshold = this.options.repeatedFailureThreshold ?? DEFAULT_REPEATED_THRESHOLD
const maxTimeout = this.options.maxTimeoutMs ?? ILINK_LONG_POLL_MAX_TIMEOUT_MS
log('info', this.cursor ? '长轮询已启动(恢复上次游标)' : '长轮询已启动')
while (!signal.aborted) {
let response: ILinkGetUpdatesResponse
try {
response = await this.options.fetchUpdates(this.cursor, this.nextTimeoutMs)
} catch (error) {
if (signal.aborted) return
const kind = error instanceof ILinkError ? error.kind : 'network'
// 长轮询客户端超时属于正常控制流:保留游标,立即进入下一轮。
if (kind === 'timeout') continue
// 调用方主动取消(例如停止连接器)
if (kind === 'aborted') return
this.consecutiveFailures += 1
this.degraded = true
const delay = this.failureDelay(normalDelay, repeatedDelay, threshold)
log('warn', `获取更新失败,${Math.round(delay / 1000)} 秒后重试:${describeError(error)}`)
await sleep(delay)
continue
}
if (signal.aborted) return
const ret = Number(response.ret ?? 0)
const errcode = Number(response.errcode ?? 0)
if (ret === -14 || errcode === -14) {
const message = '当前微信机器人登录凭证已失效,需要重新扫码登录'
log('error', message)
// 刻意不清理游标:重新登录后若账号相同,仍可续上原有进度。
this.options.onStaleToken?.(message)
return
}
if (ret !== 0 || errcode !== 0) {
this.consecutiveFailures += 1
this.degraded = true
const delay = this.failureDelay(normalDelay, repeatedDelay, threshold)
log(
'warn',
`服务端返回错误(ret=${ret} errcode=${errcode}${
response.errmsg ? ` errmsg=${response.errmsg}` : ''
}),${Math.round(delay / 1000)} 秒后重试`
)
await sleep(delay)
continue
}
this.consecutiveFailures = 0
if (this.degraded) {
this.degraded = false
log('info', '与微信服务器的连接已恢复')
}
const messages = this.collectMessages(response.msgs)
if (messages.length > 0) {
try {
await this.options.onMessages(messages)
} catch (error) {
// 接收失败:绝不推进游标,让服务端重新投递这一批。
this.consecutiveFailures += 1
this.degraded = true
const delay = this.failureDelay(normalDelay, repeatedDelay, threshold)
log('error', `入站消息处理失败,游标保持不变以便重投:${describeError(error)}`)
await sleep(delay)
continue
}
}
const nextBuf = String(response.get_updates_buf ?? '')
if (nextBuf && nextBuf !== this.cursor) {
this.cursor = nextBuf
try {
this.options.saveCursor(this.cursor)
} catch (error) {
log('warn', `游标持久化失败,下次启动可能重复投递:${describeError(error)}`)
}
}
const suggested = Number(response.longpolling_timeout_ms ?? 0)
if (Number.isFinite(suggested) && suggested > 0) {
this.nextTimeoutMs = Math.min(suggested, maxTimeout)
}
}
}
private collectMessages(raw: ILinkWeixinMessage[] | undefined): WechatInboundMessage[] {
if (!Array.isArray(raw) || raw.length === 0) return []
const result: WechatInboundMessage[] = []
for (const item of raw) {
const normalized = this.options.normalize(item)
if (!normalized) {
this.options.log('warn', '收到一条无法识别的入站消息,已跳过')
continue
}
result.push(normalized)
}
return result
}
private failureDelay(normalDelay: number, repeatedDelay: number, threshold: number): number {
if (this.consecutiveFailures >= threshold) {
this.consecutiveFailures = 0
return repeatedDelay
}
return normalDelay
}
}
+85
View File
@@ -0,0 +1,85 @@
import { randomBytes } from 'node:crypto'
import { assertBusinessOk, ILinkError } from './errors'
import { markdownToPlainText } from './markdown'
import type { ILinkClient } from './client'
import {
ILINK_ITEM_TYPE_TEXT,
ILINK_MESSAGE_STATE_FINISH,
ILINK_MESSAGE_TYPE_BOT,
type ILinkMessageItem,
type ILinkSendMessageRequest
} from './types'
/**
* Bot 外发消息的 `from_user_id`。
*
* 官方客户端(2.4.6)对 Bot 主动外发固定传空字符串;传 bot id 也能被服务端接受。
* 这里遵循官方实现。
*/
const BOT_OUTBOUND_FROM_USER_ID = ''
export function createClientId(): string {
return `tracememo-${randomBytes(16).toString('hex')}`
}
/** 组装 sendmessage 请求体;文本与媒体共用,每个请求只放一个 item。 */
export function buildSendMessageBody(input: {
item: ILinkMessageItem
to: string
contextToken?: string
clientId: string
runId?: string
}): ILinkSendMessageRequest['msg'] {
return {
from_user_id: BOT_OUTBOUND_FROM_USER_ID,
to_user_id: input.to,
client_id: input.clientId,
message_type: ILINK_MESSAGE_TYPE_BOT,
message_state: ILINK_MESSAGE_STATE_FINISH,
item_list: [input.item],
// 缺失时传空字符串而不是省略字段:服务端按字段存在性判断会话。
context_token: String(input.contextToken ?? ''),
...(input.runId ? { run_id: input.runId } : {})
}
}
export interface SendTextOptions {
to: string
text: string
/** 会话上下文令牌:回复当前会话时必须原样回传。 */
contextToken?: string
clientId?: string
runId?: string
signal?: AbortSignal
}
export interface SendTextResult {
clientId: string
/** 实际投递的纯文本内容(Markdown 已降级)。 */
plainText: string
}
export async function sendText(
client: ILinkClient,
options: SendTextOptions
): Promise<SendTextResult> {
const to = String(options.to ?? '').trim()
if (!to) throw new ILinkError({ kind: 'protocol', message: '发送文本需要有效的接收者' })
const plainText = markdownToPlainText(options.text)
if (!plainText) throw new ILinkError({ kind: 'protocol', message: '发送文本内容为空' })
const clientId = options.clientId || createClientId()
const response = await client.sendMessage(
buildSendMessageBody({
item: { type: ILINK_ITEM_TYPE_TEXT, text_item: { text: plainText } },
to,
clientId,
...(options.contextToken ? { contextToken: options.contextToken } : {}),
...(options.runId ? { runId: options.runId } : {})
}),
options.signal
)
assertBusinessOk(response, '发送文本')
return { clientId, plainText }
}
+279
View File
@@ -0,0 +1,279 @@
/**
* WeChat iLink 协议类型与常量。
*
* 与官方客户端(channel_version 2.4.6 基线)对齐,字段名以官方协议为准。
* 这里只描述协议本身,不引入任何 Electron 概念,便于将来被 Tauri 或其他宿主复用。
*/
/** 业务入口固定地址;登录与业务 API 都从这里开始。 */
export const ILINK_DEFAULT_BASE_URL = 'https://ilinkai.weixin.qq.com'
/** CDN 上传/下载基地址。 */
export const ILINK_CDN_BASE_URL = 'https://novac2c.cdn.weixin.qq.com/c2c'
/** base_info.channel_version 上报值。 */
export const ILINK_CHANNEL_VERSION = '2.4.6'
/** iLink-App-Id 固定值。 */
export const ILINK_APP_ID = 'bot'
/** iLink-App-ClientVersion 编码为 (major << 16) | (minor << 8) | patch,2.4.6 => 132102。 */
export const ILINK_APP_CLIENT_VERSION = String((2 << 16) | (4 << 8) | 6)
/** base_info.bot_agent 默认值;仅用于服务端观测聚合,不参与鉴权。 */
export const ILINK_DEFAULT_BOT_AGENT = 'TraceMemo/1.0.0'
/** 长轮询默认与上限。 */
export const ILINK_LONG_POLL_TIMEOUT_MS = 35_000
export const ILINK_LONG_POLL_MAX_TIMEOUT_MS = 120_000
export const ILINK_QR_STATUS_TIMEOUT_MS = 35_000
export const ILINK_SEND_TIMEOUT_MS = 15_000
export const ILINK_CONFIG_TIMEOUT_MS = 10_000
/** bot token 失效(官方 2.4.5 起把内部命名从 session expired 改为 stale token)。 */
export const ILINK_STALE_TOKEN_CODE = -14
/** message_type */
export const ILINK_MESSAGE_TYPE_BOT = 2
/** message_state */
export const ILINK_MESSAGE_STATE_FINISH = 2
/** item type */
export const ILINK_ITEM_TYPE_TEXT = 1
export const ILINK_ITEM_TYPE_IMAGE = 2
export const ILINK_ITEM_TYPE_VOICE = 3
export const ILINK_ITEM_TYPE_FILE = 4
export const ILINK_ITEM_TYPE_VIDEO = 5
/** CDN media_type */
export const ILINK_CDN_MEDIA_TYPE_IMAGE = 1
export const ILINK_CDN_MEDIA_TYPE_VIDEO = 2
export const ILINK_CDN_MEDIA_TYPE_FILE = 3
/** sendtyping 的输入状态值。 */
export const ILINK_TYPING_STATUS_TYPING = 1
export const ILINK_TYPING_STATUS_CANCEL = 2
/**
* 长任务期间维持"正在输入"心跳的间隔。
*
* 协议文档只定义了 status=1/2 两个状态、没有规定间隔;但微信客户端的输入指示
* 会自行消失,所以长任务必须周期性重发 status=1 才能一直亮着。
* 官方客户端按约 5 秒维持,这里沿用同一节奏。
*/
export const ILINK_TYPING_KEEPALIVE_MS = 5_000
/**
* typing_ticket 的缓存有效期。
* 官方按「账号 + 对端用户」缓存、首次取一次;这里用 24 小时兜底,
* 遇到服务端报错会立即失效并在下次需要时重取。
*/
export const ILINK_TYPING_TICKET_TTL_MS = 24 * 60 * 60 * 1000
/** 二维码登录状态机。 */
export type ILinkQrStatus =
| 'wait'
| 'scaned'
| 'confirmed'
| 'expired'
| 'need_verifycode'
| 'verify_code_blocked'
| 'scaned_but_redirect'
| 'binded_redirect'
| (string & {})
export interface ILinkBaseInfo {
channel_version: string
bot_agent: string
}
export interface ILinkQrCodeResponse {
qrcode: string
qrcode_img_content: string
ret?: number
errmsg?: string
}
export interface ILinkQrStatusResponse {
status: ILinkQrStatus
bot_token?: string
ilink_bot_id?: string
ilink_user_id?: string
baseurl?: string
redirect_host?: string
ret?: number
errcode?: number
errmsg?: string
}
/** 持久化到 ~/.tracememo/wechat-connector/accounts/<id>.json 的凭据。 */
export interface ILinkCredentials {
bot_token: string
ilink_bot_id: string
baseurl: string
ilink_user_id: string
}
export interface ILinkTextItem {
text: string
}
export interface ILinkMediaInfo {
encrypt_query_param: string
aes_key: string
encrypt_type: number
}
export interface ILinkImageItem {
url?: string
media?: ILinkMediaInfo
mid_size?: number
}
export interface ILinkVideoItem {
media?: ILinkMediaInfo
video_size?: number
}
export interface ILinkFileItem {
media?: ILinkMediaInfo
file_name?: string
len?: string
}
export interface ILinkVoiceItem {
media?: ILinkMediaInfo
voice_size?: number
encode_type?: number
playtime?: number
text?: string
}
export interface ILinkMessageItem {
type: number
text_item?: ILinkTextItem
image_item?: ILinkImageItem
voice_item?: ILinkVoiceItem
video_item?: ILinkVideoItem
file_item?: ILinkFileItem
}
export interface ILinkWeixinMessage {
seq?: number
message_id?: number
from_user_id?: string
to_user_id?: string
message_type?: number
message_state?: number
item_list?: ILinkMessageItem[]
context_token?: string
session_id?: string
group_id?: string
}
export interface ILinkGetUpdatesResponse {
ret?: number
errcode?: number
errmsg?: string
msgs?: ILinkWeixinMessage[]
get_updates_buf?: string
longpolling_timeout_ms?: number
}
export interface ILinkSendMessageRequest {
msg: {
from_user_id: string
to_user_id: string
client_id: string
message_type: number
message_state: number
item_list: ILinkMessageItem[]
context_token: string
run_id?: string
}
base_info: ILinkBaseInfo
}
export interface ILinkSendMessageResponse {
ret?: number
errmsg?: string
}
export interface ILinkGetUploadUrlRequest {
filekey: string
media_type: number
to_user_id: string
rawsize: number
rawfilemd5: string
filesize: number
no_need_thumb: boolean
aeskey: string
base_info: ILinkBaseInfo
}
export interface ILinkGetUploadUrlResponse {
ret?: number
errmsg?: string
upload_param?: string
upload_full_url?: string
}
export interface ILinkGetConfigResponse {
ret?: number
errcode?: number
errmsg?: string
typing_ticket?: string
}
export interface ILinkSendTypingRequest {
ilink_user_id: string
typing_ticket: string
status: number
base_info: ILinkBaseInfo
}
export interface ILinkSendTypingResponse {
ret?: number
errcode?: number
errmsg?: string
}
/**
* 归一化后的入站消息。Agent Hub 只消费这个形状,
* 不直接依赖协议原始字段,方便将来替换 transport。
*/
export interface WechatInboundItem {
type: number
text?: string
}
export interface WechatInboundMessage {
accountId: string
fromUserId: string
messageId: string
seq?: number
sessionId?: string
groupId?: string
messageType: number
/** 会话上下文令牌:回复必须原样回传,不得用于其他会话,也不得写入普通日志。 */
contextToken?: string
items: WechatInboundItem[]
receivedAt: number
}
/** 账号摘要(供 UI 账号列表使用)。 */
export interface WechatConnectorAccount {
accountId: string
wechatUserId: string
}
/** 登录过程中向宿主上报的事件。 */
export type WechatLoginEvent =
| { status: 'qrcode'; qrCodeDataUrl: string }
| { status: 'wait' | 'scaned' | 'need_verifycode' | 'verify_code_blocked' | 'expired' }
| { status: 'confirmed' }
| { status: 'active'; accountId: string; wechatUserId: string }
/** 连接器对外暴露的运行态。 */
export type WechatConnectorPhase = 'stopped' | 'starting' | 'polling' | 'stale_token' | 'error'
+338
View File
@@ -0,0 +1,338 @@
import { secretFingerprint } from '../log-redaction'
import {
ILINK_TYPING_KEEPALIVE_MS,
ILINK_TYPING_STATUS_CANCEL,
ILINK_TYPING_STATUS_TYPING,
ILINK_TYPING_TICKET_TTL_MS
} from './types'
/**
* 微信原生「正在输入」状态。
*
* 协议:`getconfig` 取 `typing_ticket`,再用 `sendtyping` 下发 status=1 / status=2。
* `typing_ticket` 只用于输入状态,**不是** sendmessage 的鉴权凭据。
*
* 这个模块要解决四件事:
* 1. **非致命**:所有失败都只记日志,绝不影响 Query Agent / 日报 / 最终消息发送。
* 2. **必须收尾**:调用方在 finally 里 stop,任何异常路径都不会留下"对方正在输入"。
* 3. **引用计数**:同一用户并发两个任务时,先结束的那个不能把另一个的 typing 一起取消。
* 4. **少打接口**:typing_ticket 按「账号 + 对端用户」缓存,并发取票去重。
*/
export interface TypingLease {
/** 幂等;可安全地在 finally 里调用。永不抛异常。 */
stop(): Promise<void>
}
export interface TypingBeginInput {
accountId?: string
to: string
contextToken?: string
}
export interface TypingCoordinatorDependencies {
/** 取 typing_ticket;失败/无票返回 undefined 即可(上层已 catch)。 */
fetchTicket: (input: {
ilinkUserId: string
contextToken?: string
}) => Promise<string | undefined>
/** 下发 status=1/2;返回是否成功。 */
sendTyping: (input: { ilinkUserId: string; ticket: string; status: number }) => Promise<boolean>
log: (level: 'info' | 'warn' | 'error', message: string) => void
now?: () => number
keepaliveMs?: number
ticketTtlMs?: number
maxPeers?: number
/**
* 定时器注入点:返回取消函数。
* 测试用可控实现,生产用 setInterval。
*/
schedule?: (tick: () => void, intervalMs: number) => () => void
}
interface PeerTypingState {
key: string
accountId: string
to: string
sessionId: string
refCount: number
/** 当前微信端是否处于「正在输入」。 */
active: boolean
startedAt: number
cancelKeepalive: (() => void) | null
ticket?: string
ticketFetchedAt: number
/** 并发取票去重。 */
ticketFetch: Promise<string | undefined> | null
contextToken?: string
/** 串行化 activate / deactivate,避免本次 TYPING 被上一次的 CANCEL 吃掉。 */
queue: Promise<void>
keepaliveFailureLogged: boolean
lastUseAt: number
}
const DEFAULT_MAX_PEERS = 50
const NOOP_LEASE: TypingLease = { stop: async () => undefined }
export class TypingCoordinator {
private readonly deps: TypingCoordinatorDependencies
private readonly keepaliveMs: number
private readonly ticketTtlMs: number
private readonly maxPeers: number
private readonly states = new Map<string, PeerTypingState>()
constructor(dependencies: TypingCoordinatorDependencies) {
this.deps = dependencies
this.keepaliveMs = dependencies.keepaliveMs ?? ILINK_TYPING_KEEPALIVE_MS
this.ticketTtlMs = dependencies.ticketTtlMs ?? ILINK_TYPING_TICKET_TTL_MS
this.maxPeers = dependencies.maxPeers ?? DEFAULT_MAX_PEERS
}
/** 当前处于「正在输入」的对端数量;用于测试与观测。 */
get activePeerCount(): number {
let count = 0
for (const state of this.states.values()) if (state.active) count += 1
return count
}
/**
* 开始一次输入状态。**永不抛异常**:拿不到 ticket 或服务端失败时返回一个空实现,
* 业务侧照常执行。
*/
async begin(input: TypingBeginInput): Promise<TypingLease> {
const to = String(input.to ?? '').trim()
if (!to) return NOOP_LEASE
const accountId = String(input.accountId ?? '').trim()
const key = `${accountId}::${to}`
try {
this.pruneIfNeeded()
const state = this.stateFor(key, accountId, to)
state.refCount += 1
state.lastUseAt = this.now()
if (input.contextToken) state.contextToken = input.contextToken
const shouldActivate = state.refCount === 1
if (shouldActivate) {
// 串行化:等上一次 deactivate 落地,避免 TYPING 紧接着被 CANCEL 掉。
state.queue = state.queue.then(() => this.activate(state))
await state.queue
}
return this.createLease(state)
} catch (error) {
// 兜底:任何意外都退化为空实现,绝不让 typing 影响业务。
this.deps.log('warn', `typing.begin.failed error=${describeTypingError(error)}`)
return NOOP_LEASE
}
}
/** 账号切换 / 重新登录 / bot token 失效后调用:清空 ticket 缓存。 */
invalidateTickets(): void {
for (const state of this.states.values()) {
state.ticket = undefined
state.ticketFetchedAt = 0
state.ticketFetch = null
}
}
/** 连接器停止时调用:清掉全部状态与定时器,避免留下悬挂的 keepalive。 */
clear(): void {
for (const state of this.states.values()) {
if (state.cancelKeepalive) {
state.cancelKeepalive()
state.cancelKeepalive = null
}
state.refCount = 0
state.active = false
}
this.states.clear()
}
private createLease(state: PeerTypingState): TypingLease {
let stopped = false
return {
stop: async (): Promise<void> => {
if (stopped) return
stopped = true
try {
state.refCount = Math.max(0, state.refCount - 1)
if (state.refCount > 0) return
state.queue = state.queue.then(() => this.deactivate(state))
await state.queue
} catch (error) {
this.deps.log(
'warn',
`typing.stop.leaked session=${state.sessionId} error=${describeTypingError(error)}`
)
}
}
}
}
private stateFor(key: string, accountId: string, to: string): PeerTypingState {
const existing = this.states.get(key)
if (existing) return existing
const state: PeerTypingState = {
key,
accountId,
to,
// 日志里只出现不可逆短指纹,不出现 openid 原文。
sessionId: secretFingerprint(key),
refCount: 0,
active: false,
startedAt: 0,
cancelKeepalive: null,
ticketFetchedAt: 0,
ticketFetch: null,
queue: Promise.resolve(),
keepaliveFailureLogged: false,
lastUseAt: this.now()
}
this.states.set(key, state)
return state
}
private async activate(state: PeerTypingState): Promise<void> {
if (state.active) return
const ticket = await this.ensureTicket(state)
if (!ticket) {
// 无票(getconfig 失败 / 服务端没给)时静默降级,业务照常。
return
}
const ok = await this.trySendTyping(state, ticket, ILINK_TYPING_STATUS_TYPING)
if (!ok) {
// ticket 可能已失效:丢掉缓存,下次需要时重取。
state.ticket = undefined
state.ticketFetchedAt = 0
this.deps.log('warn', `typing.start.failed session=${state.sessionId} ticketPresent=true`)
return
}
state.active = true
state.startedAt = this.now()
state.keepaliveFailureLogged = false
const schedule = this.deps.schedule ?? defaultSchedule
state.cancelKeepalive = schedule(() => {
void this.keepalive(state)
}, this.keepaliveMs)
this.deps.log('info', `typing.start session=${state.sessionId} ticketPresent=true`)
}
private async deactivate(state: PeerTypingState): Promise<void> {
if (state.cancelKeepalive) {
state.cancelKeepalive()
state.cancelKeepalive = null
}
if (!state.active) return
state.active = false
const durationMs = Math.max(0, this.now() - state.startedAt)
const ticket = state.ticket
if (!ticket) return
const ok = await this.trySendTyping(state, ticket, ILINK_TYPING_STATUS_CANCEL)
if (ok) {
this.deps.log(
'info',
`typing.stop session=${state.sessionId} duration=${durationMs}ms success=true`
)
} else {
this.deps.log(
'warn',
`typing.stop.failed session=${state.sessionId} duration=${durationMs}ms success=false`
)
}
}
/**
* 长任务的输入状态会自己消失,必须周期性重发 status=1。
* 失败只在同一会话里记一次日志,避免每 5 秒刷屏。
*/
private async keepalive(state: PeerTypingState): Promise<void> {
if (!state.active || !state.ticket) return
const ok = await this.trySendTyping(state, state.ticket, ILINK_TYPING_STATUS_TYPING)
if (ok) return
if (state.keepaliveFailureLogged) return
state.keepaliveFailureLogged = true
this.deps.log('warn', `typing.keepalive.failed session=${state.sessionId}`)
}
private async ensureTicket(state: PeerTypingState): Promise<string | undefined> {
const cached = String(state.ticket ?? '')
if (cached && this.now() - state.ticketFetchedAt < this.ticketTtlMs) return cached
if (state.ticketFetch) return state.ticketFetch
const pending = (async (): Promise<string | undefined> => {
try {
const ticket = await this.deps.fetchTicket({
ilinkUserId: state.to,
...(state.contextToken ? { contextToken: state.contextToken } : {})
})
const normalized = String(ticket ?? '').trim()
if (!normalized) {
this.deps.log(
'warn',
`typing.ticket.missing session=${state.sessionId} ticketPresent=false`
)
return undefined
}
state.ticket = normalized
state.ticketFetchedAt = this.now()
return normalized
} catch (error) {
this.deps.log(
'warn',
`typing.ticket.failed session=${state.sessionId} ticketPresent=false error=${describeTypingError(error)}`
)
return undefined
} finally {
state.ticketFetch = null
}
})()
state.ticketFetch = pending
return pending
}
private async trySendTyping(
state: PeerTypingState,
ticket: string,
status: number
): Promise<boolean> {
try {
return await this.deps.sendTyping({ ilinkUserId: state.to, ticket, status })
} catch (error) {
// 由调用方决定记哪条日志,这里只吞掉异常保证非致命。
void error
return false
}
}
private pruneIfNeeded(): void {
if (this.states.size <= this.maxPeers) return
const idle = [...this.states.values()]
.filter((state) => state.refCount === 0 && !state.active)
.sort((left, right) => left.lastUseAt - right.lastUseAt)
for (const state of idle) {
if (this.states.size <= this.maxPeers) break
this.states.delete(state.key)
}
}
private now(): number {
return this.deps.now?.() ?? Date.now()
}
}
function defaultSchedule(tick: () => void, intervalMs: number): () => void {
const timer = setInterval(tick, intervalMs)
// keepalive 不该阻止进程退出。
if (typeof timer.unref === 'function') timer.unref()
return () => clearInterval(timer)
}
function describeTypingError(error: unknown): string {
return error instanceof Error ? error.message : String(error)
}
+189
View File
@@ -0,0 +1,189 @@
import { chmodSync, mkdirSync, renameSync, rmSync, readFileSync, writeFileSync } from 'node:fs'
import { dirname } from 'node:path'
import type { WechatInboundItem, WechatInboundMessage } from './wechat-ilink/types'
/**
* Agent Hub 入站收件箱。
*
* 存在意义只有一个:**拿到消息就立刻落盘,然后才允许长轮询推进游标**。
* 先推进游标再异步投递的话,进程在两步之间崩溃就会静默丢消息;
* 因此这里的语义是 at-least-once:允许重复,不允许丢失。
*
* 代价:极端情况下(处理完但删除前崩溃)会重复处理同一条消息。
* Agent Hub 有 message_id 去重 + 业务侧幂等,重复可以接受,丢消息不行。
*
* 文件含聊天文本与 context_token,因此固定 0600 权限、处理完即删除,且从不写入日志。
*/
export interface WechatInboundInboxEntry {
key: string
accountId: string
fromUserId: string
messageId: string
contextToken?: string
items: WechatInboundItem[]
receivedAt: number
attempts: number
}
interface InboxFile {
entries: WechatInboundInboxEntry[]
}
export interface WechatInboundInboxOptions {
filePath: () => string
maxAttempts?: number
maxEntries?: number
now?: () => number
}
const DEFAULT_MAX_ATTEMPTS = 3
const DEFAULT_MAX_ENTRIES = 200
export function inboxKeyFor(
message: WechatInboundMessage,
fallbackIndex: number,
now: number
): string {
if (message.messageId) return `${message.accountId}::${message.messageId}`
return `${message.accountId}::${message.fromUserId}::${now}::${fallbackIndex}`
}
export class WechatInboundInbox {
private readonly options: WechatInboundInboxOptions
private readonly maxAttempts: number
private readonly maxEntries: number
private entries: WechatInboundInboxEntry[] | null = null
constructor(options: WechatInboundInboxOptions) {
this.options = options
this.maxAttempts = options.maxAttempts ?? DEFAULT_MAX_ATTEMPTS
this.maxEntries = options.maxEntries ?? DEFAULT_MAX_ENTRIES
}
/**
* 持久化接收一批消息,返回**本次新增**的条目。
* 已存在(重复投递)的条目不会重复返回,也不会重复处理。
*/
accept(messages: WechatInboundMessage[]): WechatInboundInboxEntry[] {
const current = this.load()
const known = new Set(current.map((entry) => entry.key))
const accepted: WechatInboundInboxEntry[] = []
const now = this.options.now?.() ?? Date.now()
messages.forEach((message, index) => {
const key = inboxKeyFor(message, index, now)
if (known.has(key)) return
known.add(key)
accepted.push({
key,
accountId: message.accountId,
fromUserId: message.fromUserId,
messageId: message.messageId,
...(message.contextToken ? { contextToken: message.contextToken } : {}),
items: message.items.map((item) => ({ ...item })),
receivedAt: message.receivedAt || now,
attempts: 0
})
})
if (accepted.length === 0) return []
const next = [...current, ...accepted].slice(-this.maxEntries)
this.persist(next)
this.entries = next
return accepted
}
/** 尚未处理完成的条目,按接收顺序返回。 */
pending(): WechatInboundInboxEntry[] {
return this.load().map((entry) => ({ ...entry }))
}
contains(key: string): boolean {
return this.load().some((entry) => entry.key === key)
}
size(): number {
return this.load().length
}
/** 处理成功,从收件箱移除。 */
complete(key: string): void {
const current = this.load()
const next = current.filter((entry) => entry.key !== key)
if (next.length === current.length) return
this.persist(next)
this.entries = next
}
/**
* 处理失败:累加尝试次数。
* 达到上限后放弃,返回 abandoned=true,由调用方明确记一条 error 日志——
* 静默丢弃是不允许的,但无限重试同一条毒消息同样不允许。
*/
recordFailure(key: string): { attempts: number; abandoned: boolean } {
const current = this.load()
let attempts = 0
let abandoned = false
const next = current
.map((entry) => {
if (entry.key !== key) return entry
attempts = entry.attempts + 1
abandoned = attempts >= this.maxAttempts
return { ...entry, attempts }
})
.filter((entry) => !(entry.key === key && abandoned))
this.persist(next)
this.entries = next
return { attempts, abandoned }
}
clear(): void {
this.persist([])
this.entries = []
}
private load(): WechatInboundInboxEntry[] {
if (this.entries) return this.entries
let parsed: InboxFile | null = null
try {
parsed = JSON.parse(readFileSync(this.options.filePath(), 'utf8')) as InboxFile
} catch {
parsed = null
}
const entries = Array.isArray(parsed?.entries)
? parsed!.entries.filter((entry): entry is WechatInboundInboxEntry => {
if (!entry || typeof entry !== 'object') return false
const candidate = entry as Partial<WechatInboundInboxEntry>
return Boolean(
String(candidate.key || '').trim() && String(candidate.fromUserId || '').trim()
)
})
: []
this.entries = entries
return entries
}
private persist(entries: WechatInboundInboxEntry[]): void {
const path = this.options.filePath()
try {
mkdirSync(dirname(path), { recursive: true, mode: 0o700 })
const tempPath = `${path}.tmp-${process.pid}-${Date.now()}`
writeFileSync(tempPath, JSON.stringify({ entries } satisfies InboxFile, null, 2), {
encoding: 'utf8',
mode: 0o600
})
chmodSync(tempPath, 0o600)
renameSync(tempPath, path)
chmodSync(path, 0o600)
} catch (error) {
// 落盘失败必须向上抛:调用方要放弃推进游标,让服务端重新投递。
try {
rmSync(`${path}.tmp-${process.pid}`, { force: true })
} catch {
// 忽略临时文件清理失败。
}
throw error
}
}
}
+357
View File
@@ -0,0 +1,357 @@
import { randomUUID } from 'node:crypto'
import type {
PersonalWechatSendRequest,
PersonalWechatSendResult
} from '../../shared/personal-wechat'
import {
normalizeWechatSendRequest,
resolveSendTransport,
type WechatSendErrorCode,
type WechatSendLogEntry,
type WechatSendRequest,
type WechatSendResult,
type WechatSendTransport
} from '../../shared/wechat-send'
import { isILinkError } from './wechat-ilink/errors'
import { wechatSendLogService, type WechatSendLogService } from './wechat-send-log-service'
/** iLink 发送适配器的注入点;由主进程在启动时接到 WechatConnectorService 上。 */
export type IlinkSendAdapter = (request: WechatSendRequest) => Promise<void>
export interface WechatSendGatewayDependencies {
now?: () => number
createRequestId?: () => string
/** 个人微信(注入式发送)适配器。 */
sendPersonal?: (request: PersonalWechatSendRequest) => Promise<PersonalWechatSendResult>
/** iLink 适配器;未注入时 iLink 发送记为 TRANSPORT_UNAVAILABLE。 */
sendIlink?: IlinkSendAdapter
log?: WechatSendLogService
}
class UnsupportedSendTypeError extends Error {}
function errorMessage(error: unknown): string {
return error instanceof Error ? error.message : String(error)
}
function classifyIlinkError(error: unknown): WechatSendErrorCode {
if (isILinkError(error) && error.isStaleToken) return 'STALE_TOKEN'
return 'SEND_FAILED'
}
/**
* 统一微信发送入口。
*
* ```text
* 业务层
* │
* ▼
* WechatSendGateway
* ├── Send Log
* ├── iLink adapter → WechatConnectorService
* └── Personal adapter → PersonalWechatSendService(Windows / macOS 注入式)
* ```
*
* 统一的是 TM 上层发送模型,不强行统一底层协议:
* 个人微信仍然是 `{toWxid,type,msg}` 心智模型,iLink 仍然是 sendmessage。
*/
export class WechatSendGateway {
private readonly deps: Required<Pick<WechatSendGatewayDependencies, 'now' | 'createRequestId'>> &
WechatSendGatewayDependencies
private ilinkAdapter: IlinkSendAdapter | null
constructor(dependencies: WechatSendGatewayDependencies = {}) {
this.deps = {
now: dependencies.now ?? (() => Date.now()),
createRequestId: dependencies.createRequestId ?? (() => randomUUID()),
log: dependencies.log ?? wechatSendLogService,
...(dependencies.sendPersonal ? { sendPersonal: dependencies.sendPersonal } : {}),
...(dependencies.sendIlink ? { sendIlink: dependencies.sendIlink } : {})
}
this.ilinkAdapter = dependencies.sendIlink ?? null
}
/** 主进程启动时注入 iLink 通道(WechatConnectorService)。 */
configureIlinkSender(adapter: IlinkSendAdapter): void {
this.ilinkAdapter = adapter
}
hasIlinkSender(): boolean {
return this.ilinkAdapter !== null
}
listSendLog(): WechatSendLogEntry[] {
return this.deps.log?.list() ?? []
}
/**
* 统一发送入口。
* 刻意不抛异常:调用方永远拿到结构化结果,失败也会留下 Send Log。
*/
async send(input: unknown): Promise<WechatSendResult> {
const startedAt = this.deps.now()
const fallbackRequestId =
input &&
typeof input === 'object' &&
typeof (input as { request_id?: unknown }).request_id === 'string'
? String((input as { request_id: string }).request_id).trim()
: ''
const requestId = fallbackRequestId || this.deps.createRequestId()
const request = normalizeWechatSendRequest(input, { createRequestId: () => requestId })
if (!request) {
const raw = (input ?? {}) as Partial<WechatSendRequest>
const transport = resolveSendTransport({
...(raw.transport === 'ilink' || raw.transport === 'personal'
? { transport: raw.transport }
: {}),
...(typeof raw.context_token === 'string' ? { context_token: raw.context_token } : {})
})
return this.finish({
requestId,
transport,
type: typeof raw.type === 'string' ? (raw.type as WechatSendRequest['type']) : 'text',
to: typeof raw.to === 'string' ? raw.to : '',
msg: typeof raw.msg === 'string' ? raw.msg : '',
startedAt,
errorCode: 'INVALID_REQUEST',
error: '发送请求不合法:缺少接收者、类型或内容'
})
}
const transport = resolveSendTransport(request)
try {
if (transport === 'ilink') {
if (!this.ilinkAdapter) {
return this.finish({
requestId: request.request_id,
transport,
type: request.type,
to: request.to,
msg: request.msg,
startedAt,
...(request.account_id ? { accountId: request.account_id } : {}),
errorCode: 'TRANSPORT_UNAVAILABLE',
error: 'iLink 发送通道尚未初始化'
})
}
await this.ilinkAdapter(request)
} else {
const personalResult = await this.sendPersonalRequest(unifiedToPersonalRequest(request))
if (!personalResult.success) {
return this.finish({
requestId: request.request_id,
transport,
type: request.type,
to: request.to,
msg: request.msg,
startedAt,
...(request.account_id ? { accountId: request.account_id } : {}),
errorCode: 'SEND_FAILED',
error: personalResult.error || '个人微信发送失败'
})
}
}
return this.finish({
requestId: request.request_id,
transport,
type: request.type,
to: request.to,
msg: request.msg,
startedAt,
...(request.account_id ? { accountId: request.account_id } : {}),
status: 'sent'
})
} catch (error) {
const errorCode: WechatSendErrorCode =
error instanceof UnsupportedSendTypeError
? 'UNSUPPORTED_TYPE'
: transport === 'ilink'
? classifyIlinkError(error)
: 'SEND_FAILED'
return this.finish({
requestId: request.request_id,
transport,
type: request.type,
to: request.to,
msg: request.msg,
startedAt,
...(request.account_id ? { accountId: request.account_id } : {}),
errorCode,
error: errorMessage(error)
})
}
}
/**
* 兼容入口:既有个人微信调用方直接给 `PersonalWechatSendRequest`。
* 走同一条 Send Log,但保持原有返回类型,避免打断现有业务与测试。
*/
async sendPersonal(request: PersonalWechatSendRequest): Promise<PersonalWechatSendResult> {
const requestId = this.deps.createRequestId()
const startedAt = this.deps.now()
const preview = personalToPreview(request)
try {
const result = await this.sendPersonalRequest(request)
this.record({
requestId,
transport: 'personal',
type: preview.type,
to: preview.to,
msg: preview.msg,
startedAt,
status: result.success ? 'sent' : 'failed',
...(result.success ? {} : { errorCode: 'SEND_FAILED' as WechatSendErrorCode })
})
return result
} catch (error) {
this.record({
requestId,
transport: 'personal',
type: preview.type,
to: preview.to,
msg: preview.msg,
startedAt,
status: 'failed',
errorCode: 'SEND_FAILED'
})
throw error
}
}
private async sendPersonalRequest(
request: PersonalWechatSendRequest
): Promise<PersonalWechatSendResult> {
const sender = this.deps.sendPersonal ?? (await defaultPersonalSender())
return sender(request)
}
private finish(input: {
requestId: string
transport: WechatSendTransport
type: WechatSendRequest['type']
to: string
msg: string
startedAt: number
accountId?: string
status?: 'sent' | 'failed' | 'blocked'
errorCode?: WechatSendErrorCode
error?: string
}): WechatSendResult {
const durationMs = Math.max(0, this.deps.now() - input.startedAt)
const status = input.status ?? 'failed'
this.record({
requestId: input.requestId,
transport: input.transport,
type: input.type,
to: input.to,
msg: input.msg,
startedAt: input.startedAt,
status,
durationMs,
...(input.accountId ? { accountId: input.accountId } : {}),
...(input.errorCode ? { errorCode: input.errorCode } : {})
})
return {
request_id: input.requestId,
success: status === 'sent',
status,
transport: input.transport,
duration_ms: durationMs,
...(input.errorCode ? { error_code: input.errorCode } : {}),
...(input.error ? { error: input.error } : {})
}
}
private record(input: {
requestId: string
transport: WechatSendTransport
type: WechatSendRequest['type']
to: string
msg: string
startedAt: number
status: 'sent' | 'failed' | 'blocked'
durationMs?: number
accountId?: string
errorCode?: WechatSendErrorCode
}): void {
const log = this.deps.log
if (!log) return
try {
log.record(
log.buildEntry({
request_id: input.requestId,
transport: input.transport,
to: input.to,
type: input.type,
msg: input.msg,
status: input.status,
timestamp: this.deps.now(),
...(input.durationMs !== undefined ? { duration_ms: input.durationMs } : {}),
...(input.accountId ? { account_id: input.accountId } : {}),
...(input.errorCode ? { error_code: input.errorCode } : {})
})
)
} catch (error) {
console.warn('[WechatSendGateway] 发送日志记录失败:', error)
}
}
}
/** 统一模型 → 个人微信模型。文件类型个人通道不支持,显式报错而不是静默降级。 */
export function unifiedToPersonalRequest(request: WechatSendRequest): PersonalWechatSendRequest {
const base = { to: request.to, isGroup: request.is_group === true }
if (request.type === 'text') {
return { ...base, type: 'text', text: request.msg }
}
if (request.type === 'image') {
return { ...base, type: 'image', filePath: request.msg }
}
if (request.type === 'voice') {
const metadata = request.metadata ?? {}
const fromId = typeof metadata.fromId === 'string' ? metadata.fromId.trim() : ''
const durationMs =
typeof metadata.durationMs === 'number' && Number.isFinite(metadata.durationMs)
? metadata.durationMs
: undefined
return {
...base,
type: 'voice',
filePath: request.msg,
...(fromId ? { fromId } : {}),
...(durationMs !== undefined ? { durationMs } : {})
}
}
throw new UnsupportedSendTypeError('个人微信通道暂不支持发送文件')
}
function personalToPreview(request: PersonalWechatSendRequest): {
type: WechatSendRequest['type']
to: string
msg: string
} {
if (request.type === 'text') return { type: 'text', to: request.to, msg: request.text }
return {
type: request.type === 'image' ? 'image' : 'voice',
to: request.to,
msg: request.filePath
}
}
let cachedPersonalSender:
| ((request: PersonalWechatSendRequest) => Promise<PersonalWechatSendResult>)
| null = null
async function defaultPersonalSender(): Promise<
(request: PersonalWechatSendRequest) => Promise<PersonalWechatSendResult>
> {
if (!cachedPersonalSender) {
const module = await import('./personal-wechat-send-service')
cachedPersonalSender = (request) => module.personalWechatSendService.send(request)
}
return cachedPersonalSender
}
/** 兼容性名称。 */
export const wechatSendGateway = new WechatSendGateway()
@@ -0,0 +1,147 @@
import { app } from 'electron'
import { createHash } from 'node:crypto'
import fs from 'fs-extra'
import path from 'path'
import {
buildSendPreview,
type WechatSendLogEntry,
type WechatSendStatus,
type WechatSendTransport,
type WechatSendType
} from '../../shared/wechat-send'
const MAX_LOG_ENTRIES = 500
/**
* 只允许白名单字段落盘。
* 即使调用方不小心把 context_token / bot_token 之类的字段混进条目,
* 也绝不会被写进 Send Log——隐私边界放在这里兜底,而不是依赖调用方自觉。
*/
function sanitizeEntry(entry: WechatSendLogEntry): WechatSendLogEntry {
return {
request_id: String(entry.request_id ?? ''),
timestamp: Number(entry.timestamp) || 0,
transport: entry.transport,
to: String(entry.to ?? ''),
type: entry.type,
status: entry.status,
...(entry.msg_preview ? { msg_preview: entry.msg_preview } : {}),
...(entry.msg_hash ? { msg_hash: entry.msg_hash } : {}),
...(entry.account_id ? { account_id: entry.account_id } : {}),
...(entry.duration_ms !== undefined ? { duration_ms: entry.duration_ms } : {}),
...(entry.error_code ? { error_code: entry.error_code } : {})
}
}
export interface WechatSendLogServiceOptions {
getUserDataPath?: () => string
maxEntries?: number
}
/**
* 发送日志(Send Log)。
*
* 与 WechatActionGateway 的 Action 审计刻意分成两层:
* - Send Log:**每一次**经过 WechatSendGateway 的发送都记录,包括普通 Agent 问答回复;
* - Action 审计:只记录需要业务审计的高层动作(定时日报、退群通知、用户主动 TTS)。
*
* 记录内容:request_id / 时间 / transport / 目标 / 类型 / 截断预览 / sha256 / 状态 / 耗时。
* **不记录**:context_token、bot_token、完整聊天文本、完整本地路径、Authorization 头。
*/
export class WechatSendLogService {
private readonly getUserDataPath: () => string
private readonly maxEntries: number
constructor(options: WechatSendLogServiceOptions = {}) {
this.getUserDataPath = options.getUserDataPath || (() => app.getPath('userData'))
this.maxEntries = options.maxEntries ?? MAX_LOG_ENTRIES
}
/** 对消息原文计算稳定哈希;不做任何截断,用于后续比对是否同一条内容。 */
hashMessage(msg: string): string {
return `sha256:${createHash('sha256')
.update(String(msg ?? ''))
.digest('hex')}`
}
/** 组装一条日志条目;预览会自动截断,媒体只保留文件名。 */
buildEntry(input: {
request_id: string
transport: WechatSendTransport
to: string
type: WechatSendType
msg: string
status: WechatSendStatus
timestamp: number
duration_ms?: number
account_id?: string
error_code?: WechatSendLogEntry['error_code']
}): WechatSendLogEntry {
const preview = buildSendPreview(input.msg, input.type)
return {
request_id: input.request_id,
timestamp: input.timestamp,
transport: input.transport,
to: input.to,
type: input.type,
status: input.status,
...(preview ? { msg_preview: preview } : {}),
msg_hash: this.hashMessage(input.msg),
...(input.account_id ? { account_id: input.account_id } : {}),
...(input.duration_ms !== undefined ? { duration_ms: input.duration_ms } : {}),
...(input.error_code ? { error_code: input.error_code } : {})
}
}
list(): WechatSendLogEntry[] {
return this.readAll().map((entry) => ({ ...entry }))
}
record(entry: WechatSendLogEntry): void {
const records = this.readAll()
const sanitized = sanitizeEntry(entry)
const withoutSameRequest = records.filter((item) => item.request_id !== sanitized.request_id)
const next = [sanitized, ...withoutSameRequest].slice(0, this.maxEntries)
try {
fs.ensureDirSync(path.dirname(this.filePath()))
fs.writeJsonSync(this.filePath(), next, { spaces: 2 })
} catch (error) {
// 发送日志写失败不能影响真实发送结果。
console.warn('[WechatSendLog] 发送日志写入失败:', error)
}
}
clear(): void {
try {
fs.writeJsonSync(this.filePath(), [], { spaces: 2 })
} catch {
// 忽略:清空失败不影响后续发送。
}
}
private filePath(): string {
return path.join(this.getUserDataPath(), 'actions', 'wechat-send-log.json')
}
private readAll(): WechatSendLogEntry[] {
try {
const value = fs.readJsonSync(this.filePath()) as unknown
if (!Array.isArray(value)) return []
return value.filter((item): item is WechatSendLogEntry => {
if (!item || typeof item !== 'object') return false
const record = item as Partial<WechatSendLogEntry>
// `to` 允许为空:非法请求同样要留痕,此时没有可用的接收者。
return Boolean(
String(record.request_id || '').trim() &&
String(record.type || '').trim() &&
String(record.status || '').trim() &&
Number.isFinite(Number(record.timestamp))
)
})
} catch {
return []
}
}
}
export const wechatSendLogService = new WechatSendLogService()
+25 -3
View File
@@ -5,7 +5,10 @@ import {
GroupReportExportResult,
GroupReportRenderSnapshotExportRequest
} from '../shared/group-report'
import type { InstalledReportTemplate, ReportTemplateOperationResult } from '../shared/report-template-package'
import type {
InstalledReportTemplate,
ReportTemplateOperationResult
} from '../shared/report-template-package'
import type {
ReportTemplateCatalogInstallResult,
ReportTemplateCatalogResult
@@ -65,6 +68,11 @@ import type {
ImageTextIndexStatus
} from '../shared/image-text-index'
import type { AgentHubActionResult, AgentHubLogEntry, AgentHubStatus } from '../shared/agent-hub'
import type {
AgentHubConversation,
AgentHubConversationMessage,
AgentHubConversationSummary
} from '../shared/agent-hub-conversation'
import type {
PersonalWechatGeneratedTtsVoiceRequest,
PersonalWechatGeneratedTtsVoiceResult,
@@ -672,9 +680,13 @@ declare global {
recognizeLocalImageText: (request: SystemOcrRequest) => Promise<SystemOcrResult>
getImageTextIndexStatus: () => Promise<ImageTextIndexStatus>
countImageMessages: (sinceMs?: number) => Promise<ImageTextIndexCountResult>
startImageTextIndex: (options?: ImageTextIndexStartOptions) => Promise<{ started: boolean; state: string }>
startImageTextIndex: (
options?: ImageTextIndexStartOptions
) => Promise<{ started: boolean; state: string }>
pauseImageTextIndex: () => Promise<{ paused: boolean; state: string }>
resumeImageTextIndex: (options?: ImageTextIndexStartOptions) => Promise<{ started: boolean; state: string }>
resumeImageTextIndex: (
options?: ImageTextIndexStartOptions
) => Promise<{ started: boolean; state: string }>
cancelImageTextIndex: () => Promise<{ cancellable: boolean; cancelled: boolean }>
clearImageTextIndex: () => Promise<{ removed: boolean; removedBytes: number }>
resetImageTextIndexFailures: () => Promise<{ reset: number }>
@@ -742,6 +754,16 @@ declare global {
reconnectAgentHub: () => Promise<AgentHubActionResult>
disconnectAgentHub: () => Promise<AgentHubActionResult>
selectAgentHubTestImage: () => Promise<{ canceled: boolean; path?: string }>
getAgentHubConversations: () => Promise<AgentHubConversationSummary[]>
getAgentHubConversation: (userId: string) => Promise<AgentHubConversation | null>
clearAgentHubConversations: () => Promise<{ success: boolean }>
onAgentHubConversation: (
callback: (payload: {
summary: AgentHubConversationSummary
message: AgentHubConversationMessage
}) => void
) => () => void
onAgentHubConversationsCleared: (callback: () => void) => () => void
onAgentHubStatus: (callback: (status: AgentHubStatus) => void) => () => void
onAgentHubLog: (callback: (entry: AgentHubLogEntry) => void) => () => void
}
+33 -5
View File
@@ -38,6 +38,10 @@ import type {
ImageTextIndexStatus
} from '../shared/image-text-index'
import type { AgentHubLogEntry, AgentHubStatus } from '../shared/agent-hub'
import type {
AgentHubConversationMessage,
AgentHubConversationSummary
} from '../shared/agent-hub-conversation'
import type {
PersonalWechatGeneratedTtsVoiceRequest,
PersonalWechatGeneratedTtsVoiceResult,
@@ -213,9 +217,7 @@ const api = {
*
* 事件带 requestId:UI 必须只认自己那一次请求,否则用户连问两次时阶段文案会串台。
*/
onAskWechatProgress: (
callback: (requestId: string, event: QueryAgentProgressEvent) => void
) => {
onAskWechatProgress: (callback: (requestId: string, event: QueryAgentProgressEvent) => void) => {
const listener = (
_event: Electron.IpcRendererEvent,
requestId: string,
@@ -481,11 +483,15 @@ const api = {
/** 点击索引前的快速统计(SQL COUNT,不解密图片)。 */
countImageMessages: (sinceMs?: number): Promise<ImageTextIndexCountResult> =>
ipcRenderer.invoke('image-text-index:count', sinceMs),
startImageTextIndex: (options?: ImageTextIndexStartOptions): Promise<{ started: boolean; state: string }> =>
startImageTextIndex: (
options?: ImageTextIndexStartOptions
): Promise<{ started: boolean; state: string }> =>
ipcRenderer.invoke('image-text-index:start', options),
pauseImageTextIndex: (): Promise<{ paused: boolean; state: string }> =>
ipcRenderer.invoke('image-text-index:pause'),
resumeImageTextIndex: (options?: ImageTextIndexStartOptions): Promise<{ started: boolean; state: string }> =>
resumeImageTextIndex: (
options?: ImageTextIndexStartOptions
): Promise<{ started: boolean; state: string }> =>
ipcRenderer.invoke('image-text-index:resume', options),
cancelImageTextIndex: (): Promise<{ cancellable: boolean; cancelled: boolean }> =>
ipcRenderer.invoke('image-text-index:cancel'),
@@ -604,6 +610,28 @@ const api = {
reconnectAgentHub: () => ipcRenderer.invoke('agent-hub:reconnect'),
disconnectAgentHub: () => ipcRenderer.invoke('agent-hub:disconnect'),
selectAgentHubTestImage: () => ipcRenderer.invoke('agent-hub:selectTestImage'),
getAgentHubConversations: () => ipcRenderer.invoke('agent-hub:getConversations'),
getAgentHubConversation: (userId: string) =>
ipcRenderer.invoke('agent-hub:getConversation', userId),
clearAgentHubConversations: () => ipcRenderer.invoke('agent-hub:clearConversations'),
onAgentHubConversation: (
callback: (payload: {
summary: AgentHubConversationSummary
message: AgentHubConversationMessage
}) => void
) => {
const listener = (
_event: Electron.IpcRendererEvent,
payload: { summary: AgentHubConversationSummary; message: AgentHubConversationMessage }
): void => callback(payload)
ipcRenderer.on('agent-hub:conversation', listener)
return () => ipcRenderer.removeListener('agent-hub:conversation', listener)
},
onAgentHubConversationsCleared: (callback: () => void) => {
const listener = (): void => callback()
ipcRenderer.on('agent-hub:conversationsCleared', listener)
return () => ipcRenderer.removeListener('agent-hub:conversationsCleared', listener)
},
onAgentHubStatus: (callback: (status: AgentHubStatus) => void) => {
const listener = (_event: Electron.IpcRendererEvent, status: AgentHubStatus): void =>
callback(status)
+1 -1
View File
@@ -2087,7 +2087,7 @@ function App(): React.ReactElement {
/>
)
case 'agent-hub':
return <AgentHubWorkspace />
return <AgentHubWorkspace selfInfo={selfInfo} />
case 'api':
return (
<ApiWorkspace
@@ -0,0 +1,331 @@
import React from 'react'
import type {
AgentHubConversationMessage,
AgentHubConversationSummary
} from '../../../../shared/agent-hub-conversation'
import { agentHubKindPlaceholder } from '../../../../shared/agent-hub-conversation'
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
Button
} from '../../components/ui'
export interface AgentHubConversationPanelSelfInfo {
wxid: string
nickname: string
avatar?: string
}
interface AgentHubConversationPanelProps {
selfInfo: AgentHubConversationPanelSelfInfo | null
/** 机器人账号对应的微信用户标识;与入站 from_user_id 相同即为"本机自己"。 */
selfUserId?: string
}
/**
* 机器人头像。
*
* iLink 只给 openid,拿不到真实头像,所以这里用固定的品牌色矢量头像,
* 让"右侧是机器人"一眼可辨,且不依赖任何外部资源。
*/
function AgentHubBotAvatar({ size = 32 }: { size?: number }): React.ReactElement {
return (
<span className="agent-hub-bot-avatar" style={{ width: size, height: size }} aria-hidden>
<svg viewBox="0 0 24 24" width={size * 0.62} height={size * 0.62} role="presentation">
<rect x="4.5" y="8" width="15" height="11" rx="3" fill="currentColor" />
<rect x="11" y="3.6" width="2" height="3.4" rx="1" fill="currentColor" />
<circle cx="12" cy="3.2" r="1.4" fill="currentColor" />
<circle cx="9.2" cy="12.6" r="1.5" fill="var(--wxex-bg-elevated)" />
<circle cx="14.8" cy="12.6" r="1.5" fill="var(--wxex-bg-elevated)" />
<rect x="9.4" y="16" width="5.2" height="1.4" rx="0.7" fill="var(--wxex-bg-elevated)" />
</svg>
</span>
)
}
function pad(value: number): string {
return String(value).padStart(2, '0')
}
function formatClock(timestamp: number): string {
const date = new Date(timestamp)
return `${pad(date.getHours())}:${pad(date.getMinutes())}`
}
function formatConversationTime(timestamp: number): string {
if (!Number.isFinite(timestamp) || timestamp <= 0) return ''
const date = new Date(timestamp)
const now = new Date()
const startOfToday = new Date(now.getFullYear(), now.getMonth(), now.getDate()).getTime()
if (timestamp >= startOfToday) return `今天 ${formatClock(timestamp)}`
if (timestamp >= startOfToday - 24 * 60 * 60 * 1000) return `昨天 ${formatClock(timestamp)}`
return `${date.getMonth() + 1}月${date.getDate()}日 ${formatClock(timestamp)}`
}
/** 去掉 @im.wechat 之类的后缀并截断,避免把完整 openid 铺在界面上。 */
function shortenUserId(userId: string): string {
const bare = userId.split('@')[0]
if (bare.length <= 10) return bare
return `${bare.slice(0, 4)}…${bare.slice(-4)}`
}
interface ConversationIdentity {
name: string
avatar?: string
initial: string
isSelf: boolean
}
function resolveIdentity(
userId: string,
selfInfo: AgentHubConversationPanelSelfInfo | null,
selfUserId?: string
): ConversationIdentity {
// 扫码登录的那个人 == 跟机器人聊天的这个人:直接复用左下角账号的头像与昵称。
if (selfUserId && userId === selfUserId) {
const name = selfInfo?.nickname?.trim() || '我自己'
return {
name,
...(selfInfo?.avatar ? { avatar: selfInfo.avatar } : {}),
initial: name.charAt(0),
isSelf: true
}
}
return { name: `微信用户 ${shortenUserId(userId)}`, initial: '微', isSelf: false }
}
function ConversationAvatar({
identity,
size = 36
}: {
identity: ConversationIdentity
size?: number
}): React.ReactElement {
if (identity.avatar) {
return (
<img
className="agent-hub-conversation-avatar"
style={{ width: size, height: size }}
src={identity.avatar}
alt=""
referrerPolicy="no-referrer"
/>
)
}
return (
<span
className={`agent-hub-conversation-avatar is-fallback ${identity.isSelf ? 'is-self' : ''}`}
style={{ width: size, height: size }}
aria-hidden
>
{identity.initial}
</span>
)
}
function messageBody(message: AgentHubConversationMessage): string {
return message.text.trim() || agentHubKindPlaceholder(message.kind)
}
/**
* Agent Hub 对话记录。
*
* 左侧是会话列表(默认就是本机账号自己),右侧是仿微信的收发气泡:
* 对方在左、机器人在右。数据来自本机 `conversations.json`,不经过任何外部服务。
*/
export function AgentHubConversationPanel({
selfInfo,
selfUserId
}: AgentHubConversationPanelProps): React.ReactElement {
const [summaries, setSummaries] = React.useState<AgentHubConversationSummary[]>([])
const [selectedUserId, setSelectedUserId] = React.useState<string | null>(null)
const [messages, setMessages] = React.useState<AgentHubConversationMessage[]>([])
const [confirmingClear, setConfirmingClear] = React.useState(false)
const bodyRef = React.useRef<HTMLDivElement>(null)
// 用 ref 跟踪当前选中会话:推送回调里不能依赖闭包里的 selectedUserId,
// 也不能在 setState 的 updater 里塞副作用(StrictMode 下会执行两次)。
const selectedRef = React.useRef<string | null>(null)
React.useEffect(() => {
let mounted = true
void window.api.getAgentHubConversations().then((items) => {
if (!mounted) return
setSummaries(items)
setSelectedUserId((current) => current ?? items[0]?.userId ?? null)
})
const unsubscribe = window.api.onAgentHubConversation(({ summary, message }) => {
if (!mounted) return
setSummaries((current) => {
const withoutThis = current.filter((item) => item.userId !== summary.userId)
return [summary, ...withoutThis].sort((left, right) => right.lastAt - left.lastAt)
})
if (selectedRef.current === null) setSelectedUserId(summary.userId)
else if (selectedRef.current === summary.userId) setMessages((list) => [...list, message])
})
const unsubscribeCleared = window.api.onAgentHubConversationsCleared(() => {
if (!mounted) return
setSummaries([])
setMessages([])
setSelectedUserId(null)
})
return () => {
mounted = false
unsubscribe()
unsubscribeCleared()
}
}, [])
React.useEffect(() => {
selectedRef.current = selectedUserId
if (!selectedUserId) {
setMessages([])
return
}
let mounted = true
void window.api.getAgentHubConversation(selectedUserId).then((conversation) => {
if (mounted) setMessages(conversation?.messages ?? [])
})
return () => {
mounted = false
}
}, [selectedUserId])
React.useEffect(() => {
const body = bodyRef.current
if (body) body.scrollTop = body.scrollHeight
}, [messages.length, selectedUserId])
const totalCount = summaries.reduce((sum, item) => sum + item.messageCount, 0)
const selectedIdentity = selectedUserId
? resolveIdentity(selectedUserId, selfInfo, selfUserId)
: null
const clearConversations = async (): Promise<void> => {
await window.api.clearAgentHubConversations()
setSummaries([])
setMessages([])
setSelectedUserId(null)
}
return (
<section className="agent-hub-card agent-hub-conversation-card">
<div className="agent-hub-conversation-heading">
<div>
<span className="agent-hub-card-kicker">收发记录</span>
<h2>对话记录</h2>
</div>
<div className="agent-hub-conversation-actions">
<span className="agent-hub-conversation-count">
{summaries.length} 个会话 · {totalCount} 条
</span>
<Button
variant="ghost"
size="sm"
disabled={totalCount === 0}
onClick={() => setConfirmingClear(true)}
>
清空记录
</Button>
</div>
</div>
<div className="agent-hub-conversation-body">
<div className="agent-hub-conversation-list">
{summaries.length === 0 ? (
<div className="agent-hub-conversation-list-empty">
还没有收发记录。机器人收到或发出消息后,这里会显示完整内容。
</div>
) : (
summaries.map((summary) => {
const identity = resolveIdentity(summary.userId, selfInfo, selfUserId)
return (
<button
type="button"
key={summary.userId}
className={`agent-hub-conversation-item ${
summary.userId === selectedUserId ? 'is-active' : ''
}`}
onClick={() => setSelectedUserId(summary.userId)}
>
<ConversationAvatar identity={identity} />
<span className="agent-hub-conversation-item-copy">
<span className="agent-hub-conversation-item-name">{identity.name}</span>
<span className="agent-hub-conversation-item-preview">
{summary.lastDirection === 'out' ? '机器人:' : ''}
{summary.lastPreview || '(空消息)'}
</span>
</span>
<span className="agent-hub-conversation-item-time">
{formatConversationTime(summary.lastAt)}
</span>
</button>
)
})
)}
</div>
<div className="agent-hub-conversation-thread" ref={bodyRef}>
{!selectedIdentity || messages.length === 0 ? (
<div className="agent-hub-conversation-thread-empty">
{summaries.length === 0
? '左侧出现会话后,这里会显示完整对话。'
: '这个会话还没有消息。'}
</div>
) : (
messages.map((message) => (
<div
className={`agent-hub-message ${message.direction === 'out' ? 'is-out' : 'is-in'}`}
key={message.id}
>
{message.direction === 'out' ? (
<AgentHubBotAvatar />
) : (
<ConversationAvatar identity={selectedIdentity} size={32} />
)}
<div className="agent-hub-message-main">
<div className="agent-hub-message-bubble">{messageBody(message)}</div>
<div className="agent-hub-message-meta">
<span>{formatConversationTime(message.createdAt)}</span>
{message.direction === 'out' && (
<span className={message.status === 'failed' ? 'is-failed' : ''}>
{message.status === 'failed'
? `发送失败${message.errorCode ? `(${message.errorCode})` : ''}`
: '已发送'}
</span>
)}
</div>
</div>
</div>
))
)}
</div>
</div>
<AlertDialog open={confirmingClear} onOpenChange={setConfirmingClear}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>清空对话记录?</AlertDialogTitle>
<AlertDialogDescription>
将删除本机保存的全部机器人收发记录。这不影响微信里的原始消息,也不影响运行日志。
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>取消</AlertDialogCancel>
<AlertDialogAction
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
onClick={() => void clearConversations()}
>
确认清空
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</section>
)
}
@@ -13,6 +13,7 @@ import {
SelectTrigger,
SelectValue
} from '../../components/ui'
import { AgentHubConversationPanel } from './AgentHubConversationPanel'
const STATUS_LABELS: Record<WechatConnectorStatus, string> = {
checking: '正在检查',
@@ -30,7 +31,14 @@ const LOG_SOURCE_LABELS: Record<AgentHubLogSource, string> = {
'wechat-connector': '微信连接器'
}
export function AgentHubWorkspace(): React.ReactElement {
export interface AgentHubWorkspaceProps {
/** 软件左下角那个账号:用于在对话记录左侧显示"对方是谁"。 */
selfInfo?: { wxid: string; nickname: string; avatar?: string } | null
}
export function AgentHubWorkspace({
selfInfo = null
}: AgentHubWorkspaceProps = {}): React.ReactElement {
const [status, setStatus] = React.useState<AgentHubStatus>({
hub: 'offline',
connector: 'checking',
@@ -283,6 +291,8 @@ export function AgentHubWorkspace(): React.ReactElement {
</div>
<p className="agent-hub-log-note">日志会隐藏 Token 和二维码数据,不记录你的微信密码。</p>
</section>
<AgentHubConversationPanel selfInfo={selfInfo} selfUserId={status.wechatUserId} />
</div>
)
}
+203
View File
@@ -1849,3 +1849,206 @@
overflow-y: auto;
padding: 0 10px 12px;
}
/* ------------------------------------------------------------------ */
/* Agent Hub 对话记录(收发回看) */
/* 与运行日志明确分工:日志只记流程与故障,这里保存完整收发内容。 */
/* 全部使用主题 token,因此浅色 / 深色自动适配,不需要单独覆写。 */
/* ------------------------------------------------------------------ */
.agent-hub-conversation-card {
max-width: 1080px;
margin: 16px auto 0;
padding: 20px 22px;
}
.agent-hub-conversation-heading {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
margin-bottom: 14px;
}
.agent-hub-conversation-heading h2 {
margin-top: 3px;
}
.agent-hub-conversation-actions {
display: flex;
align-items: center;
gap: 8px;
}
.agent-hub-conversation-count {
color: var(--wxex-text-muted);
font: 500 12px/18px var(--wxex-font);
}
.agent-hub-conversation-body {
display: grid;
grid-template-columns: minmax(0, 220px) minmax(0, 1fr);
min-height: 340px;
border: 1px solid hsl(var(--tm-border-subtle));
border-radius: var(--wxex-radius-md);
overflow: hidden;
}
.agent-hub-conversation-list {
min-width: 0;
max-height: 420px;
overflow: auto;
padding: 8px;
border-right: 1px solid hsl(var(--tm-border-subtle));
}
.agent-hub-conversation-list-empty {
padding: 12px 8px;
color: var(--wxex-text-muted);
font: 13px/20px var(--wxex-font);
}
.agent-hub-conversation-item {
display: grid;
grid-template-columns: 36px minmax(0, 1fr);
grid-template-areas: 'avatar copy' 'avatar time';
gap: 2px 10px;
width: 100%;
padding: 8px;
border: 0;
border-radius: var(--wxex-radius-md);
background: transparent;
text-align: left;
cursor: pointer;
font-family: var(--wxex-font);
}
.agent-hub-conversation-item:hover {
background: var(--wxex-bg-sidebar);
}
.agent-hub-conversation-item.is-active {
background: var(--wxex-brand-soft);
}
.agent-hub-conversation-avatar {
grid-area: avatar;
align-self: center;
border-radius: 50%;
object-fit: cover;
flex: none;
}
.agent-hub-conversation-avatar.is-fallback {
display: inline-flex;
align-items: center;
justify-content: center;
background: var(--wxex-bg-sidebar);
color: var(--wxex-text-secondary);
font: 600 13px/1 var(--wxex-font);
}
.agent-hub-conversation-avatar.is-fallback.is-self {
background: var(--wxex-brand-soft);
color: var(--wxex-brand);
}
.agent-hub-conversation-item-copy {
grid-area: copy;
min-width: 0;
display: flex;
flex-direction: column;
}
.agent-hub-conversation-item-name {
min-width: 0;
overflow: hidden;
color: var(--wxex-text-primary);
font: 700 13px/18px var(--wxex-font);
text-overflow: ellipsis;
white-space: nowrap;
}
.agent-hub-conversation-item-preview {
min-width: 0;
overflow: hidden;
color: var(--wxex-text-muted);
font: 12px/17px var(--wxex-font);
text-overflow: ellipsis;
white-space: nowrap;
}
.agent-hub-conversation-item-time {
grid-area: time;
min-width: 0;
overflow: hidden;
color: var(--wxex-text-muted);
font: 11px/16px var(--wxex-font);
text-overflow: ellipsis;
white-space: nowrap;
}
.agent-hub-conversation-note {
margin: 10px 4px 0;
color: var(--wxex-text-muted);
font: 11px/16px var(--wxex-font);
}
.agent-hub-conversation-thread {
display: flex;
flex-direction: column;
gap: 12px;
min-width: 0;
max-height: 420px;
overflow: auto;
padding: 14px 16px;
background: var(--wxex-bg-main);
}
.agent-hub-conversation-thread-empty {
margin: auto;
color: var(--wxex-text-muted);
font: 13px/20px var(--wxex-font);
text-align: center;
}
.agent-hub-message {
display: flex;
gap: 8px;
min-width: 0;
}
.agent-hub-message.is-out {
flex-direction: row-reverse;
}
.agent-hub-message-main {
display: flex;
flex-direction: column;
gap: 3px;
min-width: 0;
max-width: min(78%, 560px);
}
.agent-hub-message.is-out .agent-hub-message-main {
align-items: flex-end;
}
.agent-hub-message-bubble {
width: fit-content;
max-width: 100%;
padding: 8px 12px;
border: 1px solid hsl(var(--tm-border-subtle));
border-radius: var(--wxex-radius-md);
background: var(--wxex-bg-elevated);
color: var(--wxex-text-primary);
font: 13px/20px var(--wxex-font);
overflow-wrap: anywhere;
white-space: pre-wrap;
}
.agent-hub-message.is-out .agent-hub-message-bubble {
border-color: transparent;
background: var(--wxex-brand-soft);
}
.agent-hub-message-meta {
display: flex;
gap: 6px;
color: var(--wxex-text-muted);
font: 11px/16px var(--wxex-font);
}
.agent-hub-message-meta .is-failed {
color: var(--wxex-danger);
}
.agent-hub-bot-avatar {
display: inline-flex;
align-items: center;
justify-content: center;
flex: none;
border-radius: 50%;
background: var(--wxex-ai-soft);
color: var(--wxex-ai);
}
@media (max-width: 900px) {
.agent-hub-conversation-body {
grid-template-columns: minmax(0, 1fr);
}
.agent-hub-conversation-list {
max-height: 220px;
border-right: 0;
border-bottom: 1px solid hsl(var(--tm-border-subtle));
}
}
+66
View File
@@ -0,0 +1,66 @@
/**
* Agent Hub 对话记录(收发记录)。
*
* 这是**产品数据**,与「运行日志」明确分工:
* - 运行日志(`agent-hub.log`)只记流程与故障,会脱敏,不保留完整正文;
* - 对话记录保存完整收发内容,用于在应用内回看"机器人到底和谁说了什么"。
*
* 隐私边界:仅本机保存(`<userData>/agent-hub/conversations.json`,权限 0600),
* 不上传任何服务商,也不写入日志。
*/
export type AgentHubMessageDirection = 'in' | 'out'
export type AgentHubMessageKind = 'text' | 'image' | 'voice' | 'file' | 'video' | 'system'
export type AgentHubMessageStatus = 'sent' | 'failed'
export interface AgentHubConversationMessage {
id: string
direction: AgentHubMessageDirection
kind: AgentHubMessageKind
/** 文本内容;媒体消息记录文件名或 URL,不记录二进制。 */
text: string
createdAt: number
/** 仅出站消息有发送状态。 */
status?: AgentHubMessageStatus
/** 出站失败时的错误码(如 STALE_TOKEN / SEND_FAILED)。 */
errorCode?: string
/** 服务端 message_id:仅入站消息有,用于和日志对照。 */
messageId?: string
}
export interface AgentHubConversation {
/** 会话标识:入站为 from_user_id,出站为接收者 id。 */
userId: string
accountId?: string
firstAt: number
lastAt: number
messages: AgentHubConversationMessage[]
}
export interface AgentHubConversationSummary {
userId: string
accountId?: string
firstAt: number
lastAt: number
messageCount: number
/** 列表里显示的最后一句话(已按 kind 处理,媒体显示成中文占位)。 */
lastPreview: string
lastDirection: AgentHubMessageDirection
}
export const AGENT_HUB_KIND_LABELS: Record<AgentHubMessageKind, string> = {
text: '文字',
image: '图片',
voice: '语音',
file: '文件',
video: '视频',
system: '系统消息'
}
/** 媒体类消息在列表 / 气泡里的占位文案。 */
export function agentHubKindPlaceholder(kind: AgentHubMessageKind): string {
if (kind === 'text' || kind === 'system') return ''
return `[${AGENT_HUB_KIND_LABELS[kind]}]`
}
+162
View File
@@ -0,0 +1,162 @@
/**
* TraceMemo 统一微信发送模型。
*
* 上层业务只说"发什么给谁",不关心底下是 iLink 还是注入式个人微信。
* 这个文件刻意不引入任何 Electron / Node 专属对象,将来换宿主(例如 Tauri)
* 只需要替换 transport 实现,业务层与协议类型都不用动。
*/
export type WechatSendTransport = 'personal' | 'ilink'
export type WechatSendType = 'text' | 'image' | 'voice' | 'file'
/**
* 统一发送请求。
*
* - `msg` 是消息主体:text 时就是文本;image / voice / file 时是本地路径或 URL。
* - `context_token` 是 iLink 会话上下文,只在回复对应会话时传递。
* - `transport` 缺省时按 `context_token` 推断(有 token 即 iLink 会话)。
*/
export interface WechatSendRequest {
request_id: string
account_id?: string
to: string
type: WechatSendType
msg: string
context_token?: string
transport?: WechatSendTransport
/** 个人微信需要区分群聊 / 联系人。 */
is_group?: boolean
/** 传输层附加元数据(例如语音的 fromId / durationMs),不参与审计摘要。 */
metadata?: Record<string, unknown>
}
export type WechatSendStatus = 'sent' | 'failed' | 'blocked'
export type WechatSendErrorCode =
| 'INVALID_REQUEST'
| 'UNSUPPORTED_TYPE'
| 'TRANSPORT_UNAVAILABLE'
| 'STALE_TOKEN'
| 'SEND_FAILED'
| 'UNKNOWN'
export interface WechatSendResult {
request_id: string
success: boolean
status: WechatSendStatus
transport: WechatSendTransport
duration_ms: number
error_code?: WechatSendErrorCode
error?: string
}
/** Send Log 条目:可追溯,但不复制完整聊天内容。 */
export interface WechatSendLogEntry {
request_id: string
timestamp: number
transport: WechatSendTransport
account_id?: string
to: string
type: WechatSendType
/** 截断后的内容预览(本地文件只记录文件名)。 */
msg_preview?: string
/** 对 msg 原文计算的 sha256。 */
msg_hash?: string
status: WechatSendStatus
duration_ms?: number
error_code?: WechatSendErrorCode
}
export const MAX_SEND_PREVIEW_LENGTH = 200
const SUPPORTED_TYPES: ReadonlySet<string> = new Set(['text', 'image', 'voice', 'file'])
function asTrimmedString(value: unknown): string {
return typeof value === 'string' ? value.trim() : ''
}
/**
* 生成 Send Log 用的内容预览。
* 本地路径只保留文件名,避免把用户目录结构写进日志。
*/
export function buildSendPreview(msg: string, type: WechatSendType): string {
const raw = String(msg ?? '')
let preview = raw
if (type !== 'text') {
preview = filenameOf(raw)
}
const normalized = preview.replace(/\s+/g, ' ').trim()
return normalized.length > MAX_SEND_PREVIEW_LENGTH
? normalized.slice(0, MAX_SEND_PREVIEW_LENGTH)
: normalized
}
function filenameOf(value: string): string {
const withoutQuery = value.split('?')[0].split('#')[0]
const segments = withoutQuery.split(/[/\\]/)
return segments[segments.length - 1] || withoutQuery
}
export interface NormalizeSendRequestOptions {
/** 缺省 request_id 生成器;便于测试注入确定性值。 */
createRequestId?: () => string
}
/**
* 校验并规范化发送请求。
* 返回 null 表示请求不合法,调用方应当以 INVALID_REQUEST 记账,而不是静默丢弃。
*/
export function normalizeWechatSendRequest(
value: unknown,
options: NormalizeSendRequestOptions = {}
): WechatSendRequest | null {
if (!value || typeof value !== 'object') return null
const input = value as Partial<WechatSendRequest> & Record<string, unknown>
const to = asTrimmedString(input.to)
if (!to) return null
const type = asTrimmedString(input.type) as WechatSendType
if (!SUPPORTED_TYPES.has(type)) return null
const msg = typeof input.msg === 'string' ? input.msg.trim() : ''
// 文本必须非空;媒体允许空 msg 但会被适配器拒绝,这里提前挡掉以免产生空发送。
if (!msg) return null
const requestId = asTrimmedString(input.request_id) || options.createRequestId?.() || ''
if (!requestId) return null
const transport = asTrimmedString(input.transport)
const contextToken = asTrimmedString(input.context_token)
const accountId = asTrimmedString(input.account_id)
const normalized: WechatSendRequest = {
request_id: requestId,
to,
type,
msg,
...(accountId ? { account_id: accountId } : {}),
...(contextToken ? { context_token: contextToken } : {}),
...(transport === 'ilink' || transport === 'personal' ? { transport } : {}),
...(typeof input.is_group === 'boolean' ? { is_group: input.is_group } : {}),
...(input.metadata && typeof input.metadata === 'object'
? { metadata: input.metadata as Record<string, unknown> }
: {})
}
return normalized
}
/**
* 传输通道解析规则:
* 1. 显式声明优先;
* 2. 带 context_token 说明是 iLink 会话,走 iLink;
* 3. 其余走个人微信注入通道。
*/
export function resolveSendTransport(request: {
transport?: WechatSendTransport
context_token?: string
}): WechatSendTransport {
if (request.transport) return request.transport
return request.context_token ? 'ilink' : 'personal'
}