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.
237 lines
6.6 KiB
TypeScript
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()
|
|
}
|
|
}
|