mirror of
https://wget.la/https://github.com/Wxw-Gu/WechatExplorer
synced 2026-10-07 23:03:51 +08:00
feat: 重写agent hub连接器,新增对话记录和微信输入状态
- 新增 Agent Hub 对话记录面板,按会话回看机器人与微信用户的完整收发内容 - 新增微信原生正在输入状态,长任务维持 typing,异常路径强制收尾
This commit is contained in:
@@ -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
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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 } : {})
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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(' ')
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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'
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
Vendored
+25
-3
@@ -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
@@ -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)
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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]}]`
|
||||
}
|
||||
@@ -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'
|
||||
}
|
||||
Reference in New Issue
Block a user