Files
goodbuddy/src/main/channels/wechat-channel-driver.ts
T
mesalogo 56ebd40bd8 feat: unify execution and add workspace file imports
Remove Ask/Execute modes and general product tool approvals so requests use the selected runtime, enabled capabilities, and execution account permissions. Migrate retired application metadata while preserving user content.

Retire the Results page while retaining conversation images and stored artifacts. Add local and remote workspace file imports, timestamps and sorting, improve image discovery and saving, and refine workspace navigation and supervision responsiveness. Include the accompanying web-search changes, tests, probes, and product documentation.

Remote imports require an updated Agent. Existing artifacts are not automatically copied into project directories. Validation evidence and outstanding failures remain documented in feature progress files; no full test suite or real-model calls were rerun for this commit.

Release note: Run requests without switching work modes, import files into a selected local or remote workspace folder, sort workspace files by date, and keep using conversation images after removal of the Results page.
2026-10-04 20:02:59 +08:00

237 lines
6.6 KiB
TypeScript

import type { ChannelResultMessage } from '../../shared/channel-contracts'
import type {
ChannelDriver,
ChannelInboundHandler
} from './channel-driver'
import type { ResolvedChannelSettings } from './channel-settings-store'
import {
WechatSidecarClient,
type WechatSidecarLauncher
} from './wechat-sidecar-client'
import type {
WechatSidecarCredentialMessage,
WechatSidecarMessage
} from './wechat-sidecar-protocol'
type ResolvedWeixinSettings = Extract<
ResolvedChannelSettings,
{ channel: 'weixin' }
>
type PendingReply = {
resolve: () => void
reject: (error: Error) => void
}
const REPLY_TIMEOUT_MS = 6 * 60_000
export class WechatChannelDriver implements ChannelDriver {
readonly channel = 'weixin'
private readonly client: WechatSidecarClient
private readonly pendingReplies = new Map<string, PendingReply>()
private handler?: ChannelInboundHandler
private unsubscribe?: () => void
private state: 'idle' | 'running' | 'stopped' = 'idle'
constructor(
private readonly settings: ResolvedWeixinSettings,
launcher: WechatSidecarLauncher
) {
this.client = new WechatSidecarClient(launcher)
}
async start(handler: ChannelInboundHandler): Promise<void> {
if (this.state === 'running') {
return
}
if (this.state === 'stopped') {
throw new Error('微信通道已停止')
}
if (
!this.settings.token ||
!this.settings.accountId ||
!this.settings.userId ||
!this.settings.baseUrl
) {
throw new Error('微信 ClawBot 尚未完成扫码绑定')
}
this.handler = handler
this.unsubscribe = this.client.subscribe((message) => {
this.handleMessage(message)
})
this.client.start()
const connected = this.waitUntilConnected()
try {
this.client.send({
type: 'start_account',
accountId: this.settings.accountId,
userId: this.settings.userId,
baseUrl: this.settings.baseUrl,
token: this.settings.token
})
} catch (error) {
void connected.catch(() => undefined)
this.stop()
throw error
}
await connected
this.state = 'running'
}
send(message: ChannelResultMessage, signal: AbortSignal): Promise<void> {
if (this.state !== 'running') {
return Promise.reject(new Error('微信通道尚未连接'))
}
if (signal.aborted) {
return Promise.reject(signal.reason)
}
const text =
message.output?.trim() ||
message.error?.trim() ||
`任务状态:${message.status}`
const replyId = crypto.randomUUID()
return new Promise<void>((resolve, reject) => {
const cancelSidecarReply = (): void => {
try {
this.client.send({ type: 'cancel_reply', replyId })
} catch {
// A dead sidecar no longer has in-flight network work.
}
}
const timeout = setTimeout(() => {
cancelSidecarReply()
finish(() => reject(new Error('微信回复超时')))
}, REPLY_TIMEOUT_MS)
const finish = (callback: () => void): void => {
clearTimeout(timeout)
signal.removeEventListener('abort', abort)
this.pendingReplies.delete(replyId)
callback()
}
const abort = (): void => {
cancelSidecarReply()
finish(() => reject(new Error('微信回复已取消')))
}
this.pendingReplies.set(replyId, {
resolve: () => finish(resolve),
reject: (error) => finish(() => reject(error))
})
signal.addEventListener('abort', abort, { once: true })
if (signal.aborted) {
abort()
return
}
try {
this.client.send({
type: 'reply',
replyId,
inReplyToEventId: message.eventId,
conversationId: message.conversationId,
text,
attachments: message.attachments
})
} catch (error) {
finish(() =>
reject(error instanceof Error ? error : new Error('微信回复失败'))
)
}
})
}
stop(): void {
if (this.state === 'stopped') {
return
}
this.state = 'stopped'
this.handler = undefined
this.unsubscribe?.()
this.unsubscribe = undefined
this.rejectPendingReplies(new Error('微信通道已停止'))
try {
this.client.send({ type: 'disconnect' })
} catch {
// A dead sidecar is already disconnected.
}
this.client.stop()
}
private waitUntilConnected(): Promise<void> {
return new Promise((resolve, reject) => {
const timeout = setTimeout(() => {
remove()
reject(new Error('微信通道连接超时'))
}, 15_000)
const remove = this.client.subscribe((message) => {
if (message.type === 'status' && message.status === 'connected') {
clearTimeout(timeout)
remove()
resolve()
} else if (
message.type === 'status' &&
message.status === 'failed'
) {
clearTimeout(timeout)
remove()
reject(new Error(message.detail ?? '微信通道连接失败'))
}
})
})
}
private handleMessage(
message: WechatSidecarMessage | WechatSidecarCredentialMessage
): void {
if (message.type === 'inbound_message') {
void Promise.resolve(
this.handler?.(
{
channel: this.channel,
accountId: this.settings.accountId,
eventId: message.eventId,
senderId: message.senderId,
conversationId: message.conversationId,
conversationType: 'direct',
text: message.text,
attachments: message.attachments,
attachmentError: message.attachmentError,
mentioned: false,
receivedAt: Date.now()
},
() => undefined
)
).catch(() => undefined)
return
}
if (message.type === 'reply_result') {
const pending = this.pendingReplies.get(message.replyId)
if (!pending) {
return
}
if (message.ok) {
pending.resolve()
} else {
pending.reject(new Error(message.error ?? '微信回复失败'))
}
return
}
if (
message.type === 'status' &&
(message.status === 'failed' || message.status === 'stopped')
) {
this.rejectPendingReplies(
new Error(message.detail ?? '微信 Sidecar 已断开')
)
if (message.status === 'stopped') {
this.state = 'stopped'
}
}
}
private rejectPendingReplies(error: Error): void {
for (const pending of [...this.pendingReplies.values()]) {
pending.reject(error)
}
this.pendingReplies.clear()
}
}