feat: add remote channels and richer notes
This commit is contained in:
@@ -74,7 +74,10 @@ export interface Outbox {
|
||||
enqueue(message: ChannelResultMessage): OutboxEntry | Promise<OutboxEntry>
|
||||
markDelivered(id: string): void | Promise<void>
|
||||
markFailed(id: string): void | Promise<void>
|
||||
listUndelivered(): readonly OutboxEntry[] | Promise<readonly OutboxEntry[]>
|
||||
listUndelivered(
|
||||
channel?: string,
|
||||
limit?: number
|
||||
): readonly OutboxEntry[] | Promise<readonly OutboxEntry[]>
|
||||
}
|
||||
|
||||
export class MemoryOutbox implements Outbox {
|
||||
@@ -117,9 +120,22 @@ export class MemoryOutbox implements Outbox {
|
||||
entry.attempts += 1
|
||||
}
|
||||
|
||||
listUndelivered(): readonly OutboxEntry[] {
|
||||
listUndelivered(
|
||||
channel?: string,
|
||||
limit = this.maximumEntries
|
||||
): readonly OutboxEntry[] {
|
||||
return [...this.entries.values()]
|
||||
.filter((entry) => entry.state !== 'delivered')
|
||||
.filter(
|
||||
(entry) =>
|
||||
entry.state !== 'delivered' &&
|
||||
(channel === undefined || entry.message.channel === channel)
|
||||
)
|
||||
.sort(
|
||||
(left, right) =>
|
||||
left.attempts - right.attempts ||
|
||||
left.createdAt - right.createdAt
|
||||
)
|
||||
.slice(0, limit)
|
||||
.map((entry) => this.clone(entry))
|
||||
}
|
||||
|
||||
@@ -146,7 +162,12 @@ export class MemoryOutbox implements Outbox {
|
||||
|
||||
export type ChannelExecutor = (
|
||||
message: ChannelInboundText,
|
||||
signal: AbortSignal
|
||||
signal: AbortSignal,
|
||||
reportProgress: (result: {
|
||||
status: string
|
||||
output?: string
|
||||
error?: string
|
||||
}) => Promise<void>
|
||||
) => Promise<{
|
||||
status: string
|
||||
output?: string
|
||||
|
||||
@@ -82,9 +82,13 @@ function managerHarness(
|
||||
const record: ServiceRecord = {
|
||||
settings,
|
||||
start: vi.fn(async () => {
|
||||
if (settings.secret === failSecret) {
|
||||
const secret =
|
||||
settings.channel === 'weixin'
|
||||
? settings.token
|
||||
: settings.secret
|
||||
if (secret === failSecret) {
|
||||
throw new Error(
|
||||
`Authorization secret=${settings.secret} connection failed`
|
||||
`Authorization secret=${secret} connection failed`
|
||||
)
|
||||
}
|
||||
}),
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
type ChannelRuntimeStatus,
|
||||
type ChannelSettingsApply,
|
||||
type ChannelSettingsSnapshot,
|
||||
type CredentialChannel,
|
||||
type DingTalkChannelSettingsInput,
|
||||
type ManagedChannel,
|
||||
type WeComChannelSettingsInput
|
||||
@@ -15,7 +16,10 @@ import type {
|
||||
ChannelDriver,
|
||||
ChannelExecutor
|
||||
} from './channel-driver'
|
||||
import { ChannelService } from './channel-service'
|
||||
import {
|
||||
ChannelService,
|
||||
type ChannelServiceOptions
|
||||
} from './channel-service'
|
||||
import { redactChannelError } from './channel-service'
|
||||
import {
|
||||
ChannelSettingsStore,
|
||||
@@ -23,6 +27,8 @@ import {
|
||||
} from './channel-settings-store'
|
||||
import { DingTalkChannelDriver } from './dingtalk-channel-driver'
|
||||
import { WeComChannelDriver } from './wecom-channel-driver'
|
||||
import { WechatChannelDriver } from './wechat-channel-driver'
|
||||
import type { WechatSidecarLauncher } from './wechat-sidecar-client'
|
||||
|
||||
export type ManagedChannelService = Pick<
|
||||
ChannelService,
|
||||
@@ -39,12 +45,19 @@ export type ChannelServiceFactory = (
|
||||
options: {
|
||||
allowedSenderIds: readonly string[]
|
||||
allowGroupMessages: boolean
|
||||
dedupStore?: ChannelServiceOptions['dedupStore']
|
||||
outbox?: ChannelServiceOptions['outbox']
|
||||
onDeliveryFailure?: ChannelServiceOptions['onDeliveryFailure']
|
||||
onDeliverySuccess?: ChannelServiceOptions['onDeliverySuccess']
|
||||
}
|
||||
) => ManagedChannelService | Promise<ManagedChannelService>
|
||||
|
||||
export type ChannelManagerOptions = {
|
||||
createDriver?: ChannelDriverFactory
|
||||
createService?: ChannelServiceFactory
|
||||
launchWechatSidecar?: WechatSidecarLauncher
|
||||
dedupStore?: ChannelServiceOptions['dedupStore']
|
||||
outbox?: ChannelServiceOptions['outbox']
|
||||
}
|
||||
|
||||
type TestSettingsInput =
|
||||
@@ -58,8 +71,15 @@ type TestSettingsInput =
|
||||
}
|
||||
|
||||
function defaultDriverFactory(
|
||||
settings: ResolvedChannelSettings
|
||||
settings: ResolvedChannelSettings,
|
||||
launchWechatSidecar?: WechatSidecarLauncher
|
||||
): ChannelDriver {
|
||||
if (settings.channel === 'weixin') {
|
||||
if (!launchWechatSidecar) {
|
||||
throw new Error('微信 Sidecar 启动器不可用')
|
||||
}
|
||||
return new WechatChannelDriver(settings, launchWechatSidecar)
|
||||
}
|
||||
if (settings.secret === undefined) {
|
||||
throw new Error('通道 Secret 尚未配置')
|
||||
}
|
||||
@@ -116,6 +136,17 @@ function sanitizedManagerFailure(message: string): Error {
|
||||
}
|
||||
|
||||
function validateResolved(settings: ResolvedChannelSettings): void {
|
||||
if (settings.channel === 'weixin') {
|
||||
if (
|
||||
settings.accountId.length === 0 ||
|
||||
settings.userId.length === 0 ||
|
||||
settings.baseUrl.length === 0 ||
|
||||
settings.token === undefined
|
||||
) {
|
||||
throw new Error('微信 ClawBot 需要先完成扫码绑定')
|
||||
}
|
||||
return
|
||||
}
|
||||
const identifier =
|
||||
settings.channel === 'wecom' ? settings.botId : settings.clientId
|
||||
if (
|
||||
@@ -142,6 +173,8 @@ export class ChannelManager {
|
||||
>()
|
||||
private readonly createDriver: ChannelDriverFactory
|
||||
private readonly createService: ChannelServiceFactory
|
||||
private readonly dedupStore?: ChannelServiceOptions['dedupStore']
|
||||
private readonly outbox?: ChannelServiceOptions['outbox']
|
||||
private operationQueue: Promise<void> = Promise.resolve()
|
||||
|
||||
constructor(
|
||||
@@ -149,8 +182,13 @@ export class ChannelManager {
|
||||
private readonly executor: ChannelExecutor,
|
||||
options: ChannelManagerOptions = {}
|
||||
) {
|
||||
this.createDriver = options.createDriver ?? defaultDriverFactory
|
||||
this.createDriver =
|
||||
options.createDriver ??
|
||||
((settings) =>
|
||||
defaultDriverFactory(settings, options.launchWechatSidecar))
|
||||
this.createService = options.createService ?? defaultServiceFactory
|
||||
this.dedupStore = options.dedupStore
|
||||
this.outbox = options.outbox
|
||||
}
|
||||
|
||||
snapshot(): Promise<ChannelSettingsSnapshot> {
|
||||
@@ -185,6 +223,7 @@ export class ChannelManager {
|
||||
return this.enqueue(async () => {
|
||||
await this.store.apply(input)
|
||||
const channels: ManagedChannel[] = [
|
||||
...(input.weixin === undefined ? [] : (['weixin'] as const)),
|
||||
...(input.wecom === undefined ? [] : (['wecom'] as const)),
|
||||
...(input.dingtalk === undefined ? [] : (['dingtalk'] as const))
|
||||
]
|
||||
@@ -209,7 +248,7 @@ export class ChannelManager {
|
||||
settings?: DingTalkChannelSettingsInput
|
||||
): Promise<ChannelConnectionTestResult>
|
||||
async test(
|
||||
channel: ManagedChannel,
|
||||
channel: CredentialChannel,
|
||||
settings?: WeComChannelSettingsInput | DingTalkChannelSettingsInput
|
||||
): Promise<ChannelConnectionTestResult> {
|
||||
let resolved: ResolvedChannelSettings | undefined
|
||||
@@ -234,7 +273,9 @@ export class ChannelManager {
|
||||
channel,
|
||||
ok: false,
|
||||
error: redactManagerError(error, [
|
||||
resolved?.secret,
|
||||
resolved && resolved.channel !== 'weixin'
|
||||
? resolved.secret
|
||||
: undefined,
|
||||
settings?.secret.action === 'replace'
|
||||
? settings.secret.value
|
||||
: undefined
|
||||
@@ -252,7 +293,7 @@ export class ChannelManager {
|
||||
settings?: DingTalkChannelSettingsInput
|
||||
): Promise<ChannelConnectionTestResult>
|
||||
testConnection(
|
||||
channel: ManagedChannel,
|
||||
channel: CredentialChannel,
|
||||
settings?: WeComChannelSettingsInput | DingTalkChannelSettingsInput
|
||||
): Promise<ChannelConnectionTestResult> {
|
||||
return channel === 'wecom'
|
||||
@@ -286,6 +327,18 @@ export class ChannelManager {
|
||||
})
|
||||
}
|
||||
|
||||
reload(channel: ManagedChannel): Promise<ChannelSettingsSnapshot> {
|
||||
return this.enqueue(async () => {
|
||||
const settings = await this.store.resolve(channel)
|
||||
if (!settings.enabled) {
|
||||
await this.disableService(channel)
|
||||
} else {
|
||||
await this.replaceService(settings)
|
||||
}
|
||||
return this.snapshot()
|
||||
})
|
||||
}
|
||||
|
||||
private async replaceService(
|
||||
settings: ResolvedChannelSettings
|
||||
): Promise<void> {
|
||||
@@ -310,7 +363,11 @@ export class ChannelManager {
|
||||
this.services.delete(channel)
|
||||
await Promise.resolve(previous.stop()).catch(() => undefined)
|
||||
}
|
||||
const redacted = redactManagerError(error, [settings.secret])
|
||||
const redacted = redactManagerError(error, [
|
||||
settings.channel === 'weixin'
|
||||
? settings.token
|
||||
: settings.secret
|
||||
])
|
||||
this.statuses.set(channel, {
|
||||
state: 'error',
|
||||
lastError: redacted
|
||||
@@ -337,22 +394,36 @@ export class ChannelManager {
|
||||
const driver = await this.createDriver(settings)
|
||||
return this.createService(driver, this.executor, {
|
||||
allowedSenderIds: settings.allowedSenderIds,
|
||||
allowGroupMessages: settings.allowGroupMessages
|
||||
allowGroupMessages: settings.allowGroupMessages,
|
||||
dedupStore: this.dedupStore,
|
||||
outbox: this.outbox,
|
||||
onDeliveryFailure: (error) => {
|
||||
this.statuses.set(settings.channel, {
|
||||
state: 'error',
|
||||
lastError: redactManagerError(error, [
|
||||
settings.channel === 'weixin'
|
||||
? settings.token
|
||||
: settings.secret
|
||||
])
|
||||
})
|
||||
},
|
||||
onDeliverySuccess: () => {
|
||||
this.statuses.set(settings.channel, { state: 'running' })
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
private async settingsForTest(
|
||||
input: TestSettingsInput
|
||||
): Promise<ResolvedChannelSettings> {
|
||||
const current = await this.store.resolve(input.channel)
|
||||
if (input.settings === undefined) {
|
||||
return current
|
||||
}
|
||||
if (current.readOnly) {
|
||||
throw new Error('环境变量通道配置为只读,不能使用临时设置')
|
||||
}
|
||||
|
||||
if (input.channel === 'wecom') {
|
||||
const current = await this.store.resolve('wecom')
|
||||
if (input.settings === undefined) {
|
||||
return current
|
||||
}
|
||||
if (current.readOnly) {
|
||||
throw new Error('环境变量通道配置为只读,不能使用临时设置')
|
||||
}
|
||||
const parsed = weComChannelSettingsInputSchema.parse(input.settings)
|
||||
return {
|
||||
channel: 'wecom',
|
||||
@@ -361,6 +432,13 @@ export class ChannelManager {
|
||||
...this.testCommonSettings(current.secret, parsed)
|
||||
}
|
||||
}
|
||||
const current = await this.store.resolve('dingtalk')
|
||||
if (input.settings === undefined) {
|
||||
return current
|
||||
}
|
||||
if (current.readOnly) {
|
||||
throw new Error('环境变量通道配置为只读,不能使用临时设置')
|
||||
}
|
||||
const parsed = dingTalkChannelSettingsInputSchema.parse(input.settings)
|
||||
return {
|
||||
channel: 'dingtalk',
|
||||
|
||||
@@ -151,7 +151,8 @@ describe('ChannelService', () => {
|
||||
text: '帮我分析',
|
||||
workMode: 'ask'
|
||||
}),
|
||||
expect.any(AbortSignal)
|
||||
expect.any(AbortSignal),
|
||||
expect.any(Function)
|
||||
)
|
||||
expect(driver.sent).toEqual([])
|
||||
|
||||
@@ -166,6 +167,42 @@ describe('ChannelService', () => {
|
||||
await service.stop()
|
||||
})
|
||||
|
||||
it('delivers a bounded waiting message before the final result', async () => {
|
||||
const driver = new FakeChannelDriver()
|
||||
const executor = vi.fn(
|
||||
async (
|
||||
_message: unknown,
|
||||
_signal: AbortSignal,
|
||||
reportProgress: (
|
||||
result: { status: string; output: string }
|
||||
) => Promise<void>
|
||||
) => {
|
||||
await reportProgress({
|
||||
status: 'waiting_approval',
|
||||
output: '等待电脑端确认'
|
||||
})
|
||||
return { status: 'completed', output: '执行完成' }
|
||||
}
|
||||
)
|
||||
const service = new ChannelService(driver, executor, {
|
||||
allowedSenderIds: ['allowed-user']
|
||||
})
|
||||
await service.start()
|
||||
await driver.emit(
|
||||
inbound({
|
||||
eventId: 'progress-event',
|
||||
senderId: 'allowed-user'
|
||||
})
|
||||
)
|
||||
|
||||
await waitForSent(driver, 2)
|
||||
expect(driver.sent.map((message) => message.status)).toEqual([
|
||||
'waiting_approval',
|
||||
'completed'
|
||||
])
|
||||
await service.stop()
|
||||
})
|
||||
|
||||
it('requires both explicit group enablement and an @ mention', async () => {
|
||||
const blockedDriver = new FakeChannelDriver()
|
||||
const blockedExecutor = vi.fn(async () => ({ status: 'completed' }))
|
||||
|
||||
@@ -25,6 +25,8 @@ export type ChannelServiceOptions = {
|
||||
maximumResultLength?: number
|
||||
dedupStore?: DedupStore
|
||||
outbox?: Outbox
|
||||
onDeliveryFailure?: (error: unknown) => void
|
||||
onDeliverySuccess?: () => void
|
||||
}
|
||||
|
||||
type ServiceState = 'idle' | 'running' | 'stopped'
|
||||
@@ -85,6 +87,8 @@ export class ChannelService {
|
||||
private readonly maximumResultLength: number
|
||||
private readonly dedupStore: DedupStore
|
||||
private readonly outbox: Outbox
|
||||
private readonly onDeliveryFailure?: (error: unknown) => void
|
||||
private readonly onDeliverySuccess?: () => void
|
||||
private readonly tasks = new Set<Promise<void>>()
|
||||
private readonly active = new Map<string, AbortController>()
|
||||
private state: ServiceState = 'idle'
|
||||
@@ -130,6 +134,8 @@ export class ChannelService {
|
||||
)
|
||||
this.dedupStore = options.dedupStore ?? new MemoryDedupStore()
|
||||
this.outbox = options.outbox ?? new MemoryOutbox()
|
||||
this.onDeliveryFailure = options.onDeliveryFailure
|
||||
this.onDeliverySuccess = options.onDeliverySuccess
|
||||
}
|
||||
|
||||
async start(): Promise<void> {
|
||||
@@ -156,6 +162,7 @@ export class ChannelService {
|
||||
this.tasks.delete(task)
|
||||
})
|
||||
})
|
||||
await this.retryUndelivered()
|
||||
} catch (error) {
|
||||
this.state = 'idle'
|
||||
throw error
|
||||
@@ -202,6 +209,38 @@ export class ChannelService {
|
||||
}
|
||||
}
|
||||
|
||||
private async retryUndelivered(): Promise<void> {
|
||||
const entries = await this.outbox.listUndelivered(
|
||||
this.driver.channel,
|
||||
100
|
||||
)
|
||||
let consecutiveFailures = 0
|
||||
for (const entry of entries) {
|
||||
if (this.state !== 'running') {
|
||||
return
|
||||
}
|
||||
if (entry.attempts >= 5) {
|
||||
continue
|
||||
}
|
||||
try {
|
||||
await this.driver.send(
|
||||
entry.message,
|
||||
new AbortController().signal
|
||||
)
|
||||
await this.outbox.markDelivered(entry.id)
|
||||
this.onDeliverySuccess?.()
|
||||
consecutiveFailures = 0
|
||||
} catch (error) {
|
||||
await this.outbox.markFailed(entry.id)
|
||||
this.onDeliveryFailure?.(error)
|
||||
consecutiveFailures += 1
|
||||
if (consecutiveFailures >= 3) {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async process(rawMessage: unknown): Promise<void> {
|
||||
const parsed = channelInboundTextSchema.safeParse(rawMessage)
|
||||
if (!parsed.success) {
|
||||
@@ -316,8 +355,27 @@ export class ChannelService {
|
||||
}
|
||||
|
||||
signal.addEventListener('abort', abort, { once: true })
|
||||
let progressCount = 0
|
||||
const reportProgress = async (rawResult: {
|
||||
status: string
|
||||
output?: string
|
||||
error?: string
|
||||
}): Promise<void> => {
|
||||
if (signal.aborted) {
|
||||
throw signal.reason
|
||||
}
|
||||
if (progressCount >= 3) {
|
||||
throw new Error('远程进度消息超过限制')
|
||||
}
|
||||
const result = channelExecutorResultSchema.parse(rawResult)
|
||||
progressCount += 1
|
||||
await this.deliver(
|
||||
this.result(message, result),
|
||||
signal
|
||||
)
|
||||
}
|
||||
void Promise.resolve()
|
||||
.then(() => this.executor(message, signal))
|
||||
.then(() => this.executor(message, signal, reportProgress))
|
||||
.then(
|
||||
(result) => finish(resolve, result),
|
||||
(error: unknown) => finish(reject, error)
|
||||
@@ -363,8 +421,10 @@ export class ChannelService {
|
||||
try {
|
||||
await this.driver.send(message, signal)
|
||||
await this.outbox.markDelivered(entry.id)
|
||||
this.onDeliverySuccess?.()
|
||||
} catch (error) {
|
||||
await this.outbox.markFailed(entry.id)
|
||||
this.onDeliveryFailure?.(error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
@@ -210,10 +210,45 @@ describe('ChannelSettingsStore', () => {
|
||||
version: number
|
||||
dingtalk: { allowedSenderIds: string[] }
|
||||
}
|
||||
expect(persisted.version).toBe(1)
|
||||
expect(persisted.version).toBe(3)
|
||||
expect(persisted.dingtalk.allowedSenderIds).toEqual(['staff-a'])
|
||||
expect((await readdir(join(filePath, '..'))).some(
|
||||
(name) => name.endsWith('.tmp')
|
||||
)).toBe(false)
|
||||
})
|
||||
|
||||
it('encrypts Weixin binding credentials and removes them on disconnect', async () => {
|
||||
const filePath = await settingsPath()
|
||||
const store = new ChannelSettingsStore(filePath, createCipher(), {})
|
||||
|
||||
const bound = await store.saveWeixinBinding({
|
||||
accountId: 'account-123456',
|
||||
userId: 'user-654321',
|
||||
baseUrl: 'https://ilinkai.weixin.qq.com',
|
||||
token: 'weixin-private-token'
|
||||
})
|
||||
expect(bound.weixin).toMatchObject({
|
||||
enabled: true,
|
||||
bindingConfigured: true,
|
||||
source: 'encrypted',
|
||||
accountDisplay: '微信用户 ****4321'
|
||||
})
|
||||
const raw = await readFile(filePath, 'utf8')
|
||||
expect(raw).not.toContain('weixin-private-token')
|
||||
expect(raw).not.toContain('user-654321')
|
||||
expect(await store.resolve('weixin')).toMatchObject({
|
||||
enabled: true,
|
||||
accountId: 'account-123456',
|
||||
userId: 'user-654321',
|
||||
token: 'weixin-private-token'
|
||||
})
|
||||
|
||||
const disconnected = await store.clearWeixinBinding()
|
||||
expect(disconnected.weixin).toMatchObject({
|
||||
enabled: false,
|
||||
bindingConfigured: false,
|
||||
source: 'none'
|
||||
})
|
||||
expect((await store.resolve('weixin')).token).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -15,10 +15,12 @@ import {
|
||||
type ChannelRuntimeStatus,
|
||||
type ChannelSettingsApply,
|
||||
type ChannelSettingsSnapshot,
|
||||
type CredentialChannel,
|
||||
type DingTalkChannelSettingsInput,
|
||||
type ManagedChannel,
|
||||
type WeComChannelSettingsInput
|
||||
} from '../../shared/channel-settings-contracts'
|
||||
import { weixinAccountDisplay } from '../../shared/weixin-channel-contracts'
|
||||
|
||||
export interface ChannelCredentialCipher {
|
||||
isAvailable(): boolean
|
||||
@@ -45,7 +47,7 @@ const storedChannelFields = {
|
||||
allowGroupMessages: z.boolean()
|
||||
} as const
|
||||
|
||||
const storedSettingsSchema = z
|
||||
const legacyStoredSettingsSchema = z
|
||||
.object({
|
||||
version: z.literal(1),
|
||||
wecom: z
|
||||
@@ -69,13 +71,51 @@ const storedSettingsSchema = z
|
||||
})
|
||||
.strict()
|
||||
|
||||
const legacyWeixinStoredChannelSchema = z
|
||||
.object({
|
||||
enabled: z.boolean(),
|
||||
credential: encryptedCredentialSchema.optional(),
|
||||
accountId: z
|
||||
.string()
|
||||
.trim()
|
||||
.max(CHANNEL_SETTINGS_LIMITS.maximumIdentifierLength),
|
||||
userId: z
|
||||
.string()
|
||||
.trim()
|
||||
.max(CHANNEL_SETTINGS_LIMITS.maximumIdentifierLength),
|
||||
baseUrl: z.union([
|
||||
z.literal(''),
|
||||
z.string().url().max(2_048)
|
||||
])
|
||||
})
|
||||
.strict()
|
||||
|
||||
const storedSettingsSchema = z
|
||||
.object({
|
||||
version: z.literal(3),
|
||||
weixin: z
|
||||
.object({
|
||||
enabled: z.boolean(),
|
||||
credential: encryptedCredentialSchema.optional()
|
||||
})
|
||||
.strict(),
|
||||
wecom: legacyStoredSettingsSchema.shape.wecom,
|
||||
dingtalk: legacyStoredSettingsSchema.shape.dingtalk
|
||||
})
|
||||
.strict()
|
||||
|
||||
type StoredSettings = z.infer<typeof storedSettingsSchema>
|
||||
type StoredChannel = StoredSettings['wecom'] | StoredSettings['dingtalk']
|
||||
type StoredCredentialChannel =
|
||||
| StoredSettings['wecom']
|
||||
| StoredSettings['dingtalk']
|
||||
type StoredEncryptedCredential = z.infer<
|
||||
typeof encryptedCredentialSchema
|
||||
>
|
||||
|
||||
const credentialPayloadSchema = z
|
||||
.object({
|
||||
version: z.literal(1),
|
||||
channel: z.enum(['wecom', 'dingtalk']),
|
||||
channel: z.enum(['weixin', 'wecom', 'dingtalk']),
|
||||
secret: z
|
||||
.string()
|
||||
.min(1)
|
||||
@@ -83,6 +123,37 @@ const credentialPayloadSchema = z
|
||||
})
|
||||
.strict()
|
||||
|
||||
const weixinCredentialPayloadSchema = z
|
||||
.object({
|
||||
version: z.literal(2),
|
||||
channel: z.literal('weixin'),
|
||||
accountId: z
|
||||
.string()
|
||||
.trim()
|
||||
.min(1)
|
||||
.max(CHANNEL_SETTINGS_LIMITS.maximumIdentifierLength),
|
||||
userId: z
|
||||
.string()
|
||||
.trim()
|
||||
.min(1)
|
||||
.max(CHANNEL_SETTINGS_LIMITS.maximumIdentifierLength),
|
||||
baseUrl: z.string().url().max(2_048),
|
||||
token: z
|
||||
.string()
|
||||
.min(1)
|
||||
.max(CHANNEL_SETTINGS_LIMITS.maximumSecretLength)
|
||||
})
|
||||
.strict()
|
||||
|
||||
const versionTwoStoredSettingsSchema = z
|
||||
.object({
|
||||
version: z.literal(2),
|
||||
weixin: legacyWeixinStoredChannelSchema,
|
||||
wecom: legacyStoredSettingsSchema.shape.wecom,
|
||||
dingtalk: legacyStoredSettingsSchema.shape.dingtalk
|
||||
})
|
||||
.strict()
|
||||
|
||||
type EnvironmentChannel = {
|
||||
owned: boolean
|
||||
enabled: boolean
|
||||
@@ -94,6 +165,18 @@ type EnvironmentChannel = {
|
||||
}
|
||||
|
||||
export type ResolvedChannelSettings =
|
||||
| {
|
||||
channel: 'weixin'
|
||||
enabled: boolean
|
||||
accountId: string
|
||||
userId: string
|
||||
baseUrl: string
|
||||
token?: string
|
||||
allowedSenderIds: readonly string[]
|
||||
allowGroupMessages: false
|
||||
source: 'none' | 'encrypted'
|
||||
readOnly: false
|
||||
}
|
||||
| {
|
||||
channel: 'wecom'
|
||||
enabled: boolean
|
||||
@@ -116,7 +199,10 @@ export type ResolvedChannelSettings =
|
||||
}
|
||||
|
||||
const defaultStoredSettings: StoredSettings = {
|
||||
version: 1,
|
||||
version: 3,
|
||||
weixin: {
|
||||
enabled: false
|
||||
},
|
||||
wecom: {
|
||||
enabled: false,
|
||||
botId: '',
|
||||
@@ -202,6 +288,35 @@ function cloneStored(settings: StoredSettings): StoredSettings {
|
||||
return structuredClone(settings)
|
||||
}
|
||||
|
||||
const weixinBindingSchema = z
|
||||
.object({
|
||||
accountId: z
|
||||
.string()
|
||||
.trim()
|
||||
.min(1)
|
||||
.max(CHANNEL_SETTINGS_LIMITS.maximumIdentifierLength),
|
||||
userId: z
|
||||
.string()
|
||||
.trim()
|
||||
.min(1)
|
||||
.max(CHANNEL_SETTINGS_LIMITS.maximumIdentifierLength),
|
||||
baseUrl: z
|
||||
.string()
|
||||
.url()
|
||||
.max(2_048)
|
||||
.refine((value) => new URL(value).protocol === 'https:', {
|
||||
message: '微信服务地址必须使用 HTTPS'
|
||||
}),
|
||||
token: z
|
||||
.string()
|
||||
.trim()
|
||||
.min(1)
|
||||
.max(CHANNEL_SETTINGS_LIMITS.maximumSecretLength)
|
||||
})
|
||||
.strict()
|
||||
|
||||
export type WeixinBinding = z.infer<typeof weixinBindingSchema>
|
||||
|
||||
export class ChannelSettingsStore {
|
||||
private settings?: StoredSettings
|
||||
private warning?: string
|
||||
@@ -217,7 +332,8 @@ export class ChannelSettingsStore {
|
||||
async snapshot(
|
||||
statuses: Partial<Record<ManagedChannel, ChannelRuntimeStatus>> = {}
|
||||
): Promise<ChannelSettingsSnapshot> {
|
||||
const [wecom, dingtalk] = await Promise.all([
|
||||
const [weixin, wecom, dingtalk] = await Promise.all([
|
||||
this.resolve('weixin'),
|
||||
this.resolve('wecom'),
|
||||
this.resolve('dingtalk')
|
||||
])
|
||||
@@ -227,6 +343,13 @@ export class ChannelSettingsStore {
|
||||
weComEnvironment.error ?? dingTalkEnvironment.error
|
||||
const warning = this.warning ?? environmentWarning
|
||||
return {
|
||||
weixin: {
|
||||
enabled: weixin.enabled,
|
||||
bindingConfigured: weixin.token !== undefined,
|
||||
source: weixin.source,
|
||||
accountDisplay: weixinAccountDisplay(weixin.userId),
|
||||
status: statuses.weixin ?? defaultStatus(weixin.enabled)
|
||||
},
|
||||
wecom: {
|
||||
enabled: wecom.enabled,
|
||||
botId: wecom.botId,
|
||||
@@ -277,8 +400,28 @@ export class ChannelSettingsStore {
|
||||
resolve(channel: 'dingtalk'): Promise<Extract<ResolvedChannelSettings, {
|
||||
channel: 'dingtalk'
|
||||
}>>
|
||||
resolve(channel: 'weixin'): Promise<Extract<ResolvedChannelSettings, {
|
||||
channel: 'weixin'
|
||||
}>>
|
||||
resolve(channel: ManagedChannel): Promise<ResolvedChannelSettings>
|
||||
async resolve(channel: ManagedChannel): Promise<ResolvedChannelSettings> {
|
||||
if (channel === 'weixin') {
|
||||
const settings = await this.load()
|
||||
const stored = settings.weixin
|
||||
const binding = this.decryptWeixinBinding(stored)
|
||||
return {
|
||||
channel,
|
||||
enabled: stored.enabled,
|
||||
accountId: binding?.accountId ?? '',
|
||||
userId: binding?.userId ?? '',
|
||||
baseUrl: binding?.baseUrl ?? '',
|
||||
...(binding === undefined ? {} : { token: binding.token }),
|
||||
allowedSenderIds: binding ? [binding.userId] : [],
|
||||
allowGroupMessages: false,
|
||||
source: binding === undefined ? 'none' : 'encrypted',
|
||||
readOnly: false
|
||||
}
|
||||
}
|
||||
const environment = this.environmentChannel(channel)
|
||||
if (environment.owned) {
|
||||
const common = {
|
||||
@@ -319,10 +462,57 @@ export class ChannelSettingsStore {
|
||||
}
|
||||
|
||||
resolveAll(): Promise<readonly [
|
||||
Extract<ResolvedChannelSettings, { channel: 'weixin' }>,
|
||||
Extract<ResolvedChannelSettings, { channel: 'wecom' }>,
|
||||
Extract<ResolvedChannelSettings, { channel: 'dingtalk' }>
|
||||
]> {
|
||||
return Promise.all([this.resolve('wecom'), this.resolve('dingtalk')])
|
||||
return Promise.all([
|
||||
this.resolve('weixin'),
|
||||
this.resolve('wecom'),
|
||||
this.resolve('dingtalk')
|
||||
])
|
||||
}
|
||||
|
||||
async saveWeixinBinding(input: WeixinBinding): Promise<ChannelSettingsSnapshot> {
|
||||
const parsed = weixinBindingSchema.parse(input)
|
||||
let snapshot!: ChannelSettingsSnapshot
|
||||
const update = async (): Promise<void> => {
|
||||
const current = cloneStored(await this.load())
|
||||
current.weixin = {
|
||||
enabled: true,
|
||||
credential: this.encryptWeixinBinding(parsed)
|
||||
}
|
||||
await this.persist(current)
|
||||
this.settings = current
|
||||
this.warning = undefined
|
||||
snapshot = await this.snapshot()
|
||||
}
|
||||
const operation = this.updateQueue.then(update, update)
|
||||
this.updateQueue = operation.then(
|
||||
() => undefined,
|
||||
() => undefined
|
||||
)
|
||||
return operation.then(() => snapshot)
|
||||
}
|
||||
|
||||
async clearWeixinBinding(): Promise<ChannelSettingsSnapshot> {
|
||||
let snapshot!: ChannelSettingsSnapshot
|
||||
const update = async (): Promise<void> => {
|
||||
const current = cloneStored(await this.load())
|
||||
current.weixin = {
|
||||
enabled: false
|
||||
}
|
||||
await this.persist(current)
|
||||
this.settings = current
|
||||
this.warning = undefined
|
||||
snapshot = await this.snapshot()
|
||||
}
|
||||
const operation = this.updateQueue.then(update, update)
|
||||
this.updateQueue = operation.then(
|
||||
() => undefined,
|
||||
() => undefined
|
||||
)
|
||||
return operation.then(() => snapshot)
|
||||
}
|
||||
|
||||
apply(input: ChannelSettingsApply): Promise<ChannelSettingsSnapshot> {
|
||||
@@ -343,6 +533,9 @@ export class ChannelSettingsStore {
|
||||
input: ChannelSettingsApply
|
||||
): Promise<ChannelSettingsSnapshot> {
|
||||
const current = cloneStored(await this.load())
|
||||
if (input.weixin !== undefined) {
|
||||
current.weixin.enabled = input.weixin.enabled
|
||||
}
|
||||
if (input.wecom !== undefined) {
|
||||
if (this.environmentChannel('wecom').owned) {
|
||||
throw new Error('企业微信由环境变量配置,不能在设置中修改')
|
||||
@@ -364,8 +557,9 @@ export class ChannelSettingsStore {
|
||||
)
|
||||
}
|
||||
|
||||
this.validateEnabledChannel('wecom', current.wecom)
|
||||
this.validateEnabledChannel('dingtalk', current.dingtalk)
|
||||
this.validateEnabledWeixin(current.weixin)
|
||||
this.validateEnabledCredentialChannel('wecom', current.wecom)
|
||||
this.validateEnabledCredentialChannel('dingtalk', current.dingtalk)
|
||||
await this.persist(current)
|
||||
this.settings = current
|
||||
this.warning = undefined
|
||||
@@ -383,10 +577,10 @@ export class ChannelSettingsStore {
|
||||
input: DingTalkChannelSettingsInput
|
||||
): StoredSettings['dingtalk']
|
||||
private updateStoredChannel(
|
||||
channel: ManagedChannel,
|
||||
current: StoredChannel,
|
||||
channel: CredentialChannel,
|
||||
current: StoredCredentialChannel,
|
||||
input: WeComChannelSettingsInput | DingTalkChannelSettingsInput
|
||||
): StoredChannel {
|
||||
): StoredCredentialChannel {
|
||||
const credential =
|
||||
input.secret.action === 'keep'
|
||||
? current.credential
|
||||
@@ -414,9 +608,22 @@ export class ChannelSettingsStore {
|
||||
}
|
||||
}
|
||||
|
||||
private validateEnabledChannel(
|
||||
channel: ManagedChannel,
|
||||
stored: StoredChannel
|
||||
private validateEnabledWeixin(
|
||||
stored: StoredSettings['weixin']
|
||||
): void {
|
||||
if (!stored.enabled) {
|
||||
return
|
||||
}
|
||||
if (
|
||||
this.decryptWeixinBinding(stored) === undefined
|
||||
) {
|
||||
throw new Error('启用微信 ClawBot 前需要先完成扫码绑定')
|
||||
}
|
||||
}
|
||||
|
||||
private validateEnabledCredentialChannel(
|
||||
channel: CredentialChannel,
|
||||
stored: StoredCredentialChannel
|
||||
): void {
|
||||
if (!stored.enabled) {
|
||||
return
|
||||
@@ -439,9 +646,9 @@ export class ChannelSettingsStore {
|
||||
}
|
||||
|
||||
private encryptCredential(
|
||||
channel: ManagedChannel,
|
||||
channel: CredentialChannel,
|
||||
secret: string
|
||||
): StoredChannel['credential'] {
|
||||
): StoredEncryptedCredential {
|
||||
if (!this.cipher.isAvailable()) {
|
||||
throw new Error('系统安全存储不可用,无法保存通道 Secret')
|
||||
}
|
||||
@@ -456,8 +663,8 @@ export class ChannelSettingsStore {
|
||||
}
|
||||
|
||||
private decryptCredential(
|
||||
channel: ManagedChannel,
|
||||
stored: StoredChannel
|
||||
channel: CredentialChannel,
|
||||
stored: StoredCredentialChannel
|
||||
): string | undefined {
|
||||
if (stored.credential === undefined || !this.cipher.isAvailable()) {
|
||||
return undefined
|
||||
@@ -476,14 +683,125 @@ export class ChannelSettingsStore {
|
||||
}
|
||||
}
|
||||
|
||||
private encryptWeixinBinding(
|
||||
binding: WeixinBinding
|
||||
): StoredEncryptedCredential {
|
||||
if (!this.cipher.isAvailable()) {
|
||||
throw new Error('系统安全存储不可用,无法保存微信绑定')
|
||||
}
|
||||
const encrypted = this.cipher.encrypt(
|
||||
JSON.stringify({
|
||||
version: 2,
|
||||
channel: 'weixin',
|
||||
accountId: binding.accountId,
|
||||
userId: binding.userId,
|
||||
baseUrl: binding.baseUrl,
|
||||
token: binding.token
|
||||
})
|
||||
)
|
||||
return {
|
||||
formatVersion: 1,
|
||||
scheme: 'electron-safe-storage',
|
||||
ciphertextBase64: encrypted.toString('base64')
|
||||
}
|
||||
}
|
||||
|
||||
private decryptWeixinBinding(
|
||||
stored: StoredSettings['weixin']
|
||||
): WeixinBinding | undefined {
|
||||
if (stored.credential === undefined || !this.cipher.isAvailable()) {
|
||||
return undefined
|
||||
}
|
||||
try {
|
||||
return weixinCredentialPayloadSchema.parse(
|
||||
JSON.parse(
|
||||
this.cipher.decrypt(
|
||||
Buffer.from(stored.credential.ciphertextBase64, 'base64')
|
||||
)
|
||||
)
|
||||
)
|
||||
} catch {
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
private async load(): Promise<StoredSettings> {
|
||||
if (this.settings !== undefined) {
|
||||
return this.settings
|
||||
}
|
||||
try {
|
||||
this.settings = storedSettingsSchema.parse(
|
||||
JSON.parse(await readFile(this.filePath, 'utf8'))
|
||||
)
|
||||
const raw: unknown = JSON.parse(await readFile(this.filePath, 'utf8'))
|
||||
const current = storedSettingsSchema.safeParse(raw)
|
||||
if (current.success) {
|
||||
this.settings = current.data
|
||||
} else {
|
||||
const versionTwo = versionTwoStoredSettingsSchema.safeParse(raw)
|
||||
if (versionTwo.success) {
|
||||
const legacyWeixin = versionTwo.data.weixin
|
||||
let token: string | undefined
|
||||
if (
|
||||
legacyWeixin.credential &&
|
||||
this.cipher.isAvailable()
|
||||
) {
|
||||
try {
|
||||
const payload = credentialPayloadSchema.parse(
|
||||
JSON.parse(
|
||||
this.cipher.decrypt(
|
||||
Buffer.from(
|
||||
legacyWeixin.credential.ciphertextBase64,
|
||||
'base64'
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
token =
|
||||
payload.channel === 'weixin'
|
||||
? payload.secret
|
||||
: undefined
|
||||
} catch {
|
||||
token = undefined
|
||||
}
|
||||
}
|
||||
const binding =
|
||||
token &&
|
||||
legacyWeixin.accountId &&
|
||||
legacyWeixin.userId &&
|
||||
legacyWeixin.baseUrl
|
||||
? {
|
||||
accountId: legacyWeixin.accountId,
|
||||
userId: legacyWeixin.userId,
|
||||
baseUrl: legacyWeixin.baseUrl,
|
||||
token
|
||||
}
|
||||
: undefined
|
||||
this.settings = {
|
||||
version: 3,
|
||||
weixin: {
|
||||
enabled: binding ? legacyWeixin.enabled : false,
|
||||
...(binding
|
||||
? { credential: this.encryptWeixinBinding(binding) }
|
||||
: {})
|
||||
},
|
||||
wecom: versionTwo.data.wecom,
|
||||
dingtalk: versionTwo.data.dingtalk
|
||||
}
|
||||
if (legacyWeixin.enabled && !binding) {
|
||||
this.warning =
|
||||
'旧版微信绑定无法安全迁移,请重新扫码绑定'
|
||||
}
|
||||
} else {
|
||||
const legacy = legacyStoredSettingsSchema.parse(raw)
|
||||
this.settings = {
|
||||
version: 3,
|
||||
weixin: {
|
||||
enabled: false
|
||||
},
|
||||
wecom: legacy.wecom,
|
||||
dingtalk: legacy.dingtalk
|
||||
}
|
||||
}
|
||||
await this.persist(this.settings)
|
||||
}
|
||||
} catch (error) {
|
||||
if (!isMissingFile(error)) {
|
||||
this.warning = '通道设置文件已损坏,已隔离原文件并恢复默认设置'
|
||||
@@ -516,7 +834,7 @@ export class ChannelSettingsStore {
|
||||
}
|
||||
}
|
||||
|
||||
private environmentChannel(channel: ManagedChannel): EnvironmentChannel {
|
||||
private environmentChannel(channel: CredentialChannel): EnvironmentChannel {
|
||||
const prefix =
|
||||
channel === 'wecom' ? 'GOODBUDDY_WECOM' : 'GOODBUDDY_DINGTALK'
|
||||
const idName =
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { RemoteChannelApprovalBroker } from './remote-channel-approval-broker'
|
||||
|
||||
const request = {
|
||||
requestId: '00000000-0000-4000-8000-000000000001',
|
||||
kind: 'request' as const,
|
||||
channel: 'weixin' as const,
|
||||
channelLabel: '微信 ClawBot',
|
||||
senderDisplay: '发送者 ****1234',
|
||||
projectName: '微信 ClawBot',
|
||||
rootPath: 'C:\\Users\\tester',
|
||||
title: '请求执行任务',
|
||||
description: '创建一份报告'
|
||||
}
|
||||
|
||||
describe('RemoteChannelApprovalBroker', () => {
|
||||
it('accepts only a local one-time response for the matching request', async () => {
|
||||
const published: Array<{ approvalId: string }> = []
|
||||
const broker = new RemoteChannelApprovalBroker(
|
||||
(approval) => published.push(approval),
|
||||
10_000
|
||||
)
|
||||
const controller = new AbortController()
|
||||
const result = broker.request(request, controller.signal)
|
||||
|
||||
expect(published).toHaveLength(1)
|
||||
expect(broker.listPending()).toEqual([
|
||||
expect.objectContaining({
|
||||
approvalId: published[0]!.approvalId,
|
||||
channel: 'weixin'
|
||||
})
|
||||
])
|
||||
expect(
|
||||
broker.respond(published[0]!.approvalId, 'once')
|
||||
).toBe(true)
|
||||
await expect(result).resolves.toBe('once')
|
||||
expect(broker.listPending()).toEqual([])
|
||||
expect(
|
||||
broker.respond(published[0]!.approvalId, 'deny')
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it('denies pending approvals when aborted or cleared', async () => {
|
||||
const published: Array<{ approvalId: string }> = []
|
||||
const broker = new RemoteChannelApprovalBroker(
|
||||
(approval) => published.push(approval),
|
||||
10_000
|
||||
)
|
||||
const firstController = new AbortController()
|
||||
const first = broker.request(request, firstController.signal)
|
||||
firstController.abort()
|
||||
await expect(first).resolves.toBe('deny')
|
||||
|
||||
const second = broker.request(
|
||||
{ ...request, requestId: crypto.randomUUID() },
|
||||
new AbortController().signal
|
||||
)
|
||||
broker.clear()
|
||||
await expect(second).resolves.toBe('deny')
|
||||
})
|
||||
|
||||
it('denies an approval after its bounded timeout', async () => {
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
const broker = new RemoteChannelApprovalBroker(() => undefined, 500)
|
||||
const result = broker.request(
|
||||
request,
|
||||
new AbortController().signal
|
||||
)
|
||||
await vi.advanceTimersByTimeAsync(500)
|
||||
await expect(result).resolves.toBe('deny')
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,81 @@
|
||||
import type {
|
||||
RemoteChannelApproval,
|
||||
RemoteChannelApprovalDecision
|
||||
} from '../../shared/remote-channel-contracts'
|
||||
|
||||
type PendingApproval = {
|
||||
approval: RemoteChannelApproval
|
||||
resolve: (decision: RemoteChannelApprovalDecision) => void
|
||||
timeout: ReturnType<typeof setTimeout>
|
||||
abort: () => void
|
||||
}
|
||||
|
||||
export class RemoteChannelApprovalBroker {
|
||||
private readonly pending = new Map<string, PendingApproval>()
|
||||
|
||||
constructor(
|
||||
private readonly publish: (approval: RemoteChannelApproval) => void,
|
||||
private readonly timeoutMs = 120_000
|
||||
) {}
|
||||
|
||||
request(
|
||||
input: Omit<RemoteChannelApproval, 'approvalId' | 'expiresAt'>,
|
||||
signal: AbortSignal
|
||||
): Promise<RemoteChannelApprovalDecision> {
|
||||
if (signal.aborted) {
|
||||
return Promise.resolve('deny')
|
||||
}
|
||||
const approvalId = crypto.randomUUID()
|
||||
const approval: RemoteChannelApproval = {
|
||||
...input,
|
||||
approvalId,
|
||||
expiresAt: new Date(Date.now() + this.timeoutMs).toISOString()
|
||||
}
|
||||
return new Promise((resolve) => {
|
||||
const finish = (
|
||||
decision: RemoteChannelApprovalDecision
|
||||
): void => {
|
||||
signal.removeEventListener('abort', abort)
|
||||
resolve(decision)
|
||||
}
|
||||
const abort = (): void => {
|
||||
this.respond(approvalId, 'deny')
|
||||
}
|
||||
const timeout = setTimeout(abort, this.timeoutMs)
|
||||
this.pending.set(approvalId, {
|
||||
approval,
|
||||
resolve: finish,
|
||||
timeout,
|
||||
abort
|
||||
})
|
||||
signal.addEventListener('abort', abort, { once: true })
|
||||
this.publish(approval)
|
||||
})
|
||||
}
|
||||
|
||||
respond(
|
||||
approvalId: string,
|
||||
decision: RemoteChannelApprovalDecision
|
||||
): boolean {
|
||||
const pending = this.pending.get(approvalId)
|
||||
if (!pending) {
|
||||
return false
|
||||
}
|
||||
clearTimeout(pending.timeout)
|
||||
this.pending.delete(approvalId)
|
||||
pending.resolve(decision)
|
||||
return true
|
||||
}
|
||||
|
||||
clear(): void {
|
||||
for (const approvalId of [...this.pending.keys()]) {
|
||||
this.respond(approvalId, 'deny')
|
||||
}
|
||||
}
|
||||
|
||||
listPending(): RemoteChannelApproval[] {
|
||||
return [...this.pending.values()].map((pending) =>
|
||||
structuredClone(pending.approval)
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
parseRemoteChannelPrompt
|
||||
} from './remote-channel-routing'
|
||||
import { projectChannelLabels } from '../../shared/assistant-contracts'
|
||||
|
||||
describe('parseRemoteChannelPrompt', () => {
|
||||
it('uses the channel project default mode without changing the prompt', () => {
|
||||
expect(
|
||||
parseRemoteChannelPrompt(' 请整理下载目录 ', 'execute')
|
||||
).toEqual({
|
||||
workMode: 'execute',
|
||||
prompt: '请整理下载目录'
|
||||
})
|
||||
expect(parseRemoteChannelPrompt('总结进展', 'plan')).toEqual({
|
||||
workMode: 'plan',
|
||||
prompt: '总结进展'
|
||||
})
|
||||
})
|
||||
|
||||
it.each([
|
||||
['/ask 请只读分析', 'ask', '请只读分析'],
|
||||
['/execute: 创建文件', 'execute', '创建文件'],
|
||||
['/exec 执行测试', 'execute', '执行测试'],
|
||||
['对话:解释错误', 'ask', '解释错误'],
|
||||
['执行: 更新依赖', 'execute', '更新依赖']
|
||||
] as const)(
|
||||
'parses explicit mode prefix %s',
|
||||
(text, workMode, prompt) => {
|
||||
expect(parseRemoteChannelPrompt(text, 'ask')).toEqual({
|
||||
workMode,
|
||||
prompt
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
it('rejects a prefix without a request body', () => {
|
||||
expect(() => parseRemoteChannelPrompt('/execute', 'ask')).toThrow(
|
||||
'远程请求内容不能为空'
|
||||
)
|
||||
})
|
||||
|
||||
it('defines a stable product label for every managed channel', () => {
|
||||
expect(projectChannelLabels).toEqual({
|
||||
weixin: '微信 ClawBot',
|
||||
wecom: '企业微信',
|
||||
dingtalk: '钉钉'
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,37 @@
|
||||
import type { WorkMode } from '../../shared/assistant-contracts'
|
||||
|
||||
const COMMAND_PATTERN =
|
||||
/^\/(?<command>ask|execute|exec)(?=$|[\s::])[\s::]*/iu
|
||||
const CHINESE_PATTERN =
|
||||
/^(?<command>对话|问答|执行)(?=$|[\s::])[\s::]*/u
|
||||
|
||||
export function parseRemoteChannelPrompt(
|
||||
text: string,
|
||||
defaultWorkMode: WorkMode
|
||||
): {
|
||||
workMode: WorkMode
|
||||
prompt: string
|
||||
} {
|
||||
const value = text.trim()
|
||||
const commandMatch = COMMAND_PATTERN.exec(value)
|
||||
const chineseMatch = commandMatch ? undefined : CHINESE_PATTERN.exec(value)
|
||||
const match = commandMatch ?? chineseMatch
|
||||
const command = (
|
||||
match?.groups?.command ?? ''
|
||||
).toLocaleLowerCase()
|
||||
const workMode =
|
||||
command === 'execute' ||
|
||||
command === 'exec' ||
|
||||
command === '执行'
|
||||
? 'execute'
|
||||
: command === 'ask' ||
|
||||
command === '对话' ||
|
||||
command === '问答'
|
||||
? 'ask'
|
||||
: defaultWorkMode
|
||||
const prompt = match ? value.slice(match[0].length).trim() : value
|
||||
if (!prompt) {
|
||||
throw new Error('远程请求内容不能为空')
|
||||
}
|
||||
return { workMode, prompt }
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import type { AssistantDatabase } from '../assistant/assistant-database'
|
||||
import type {
|
||||
DedupStore,
|
||||
Outbox,
|
||||
OutboxEntry
|
||||
} from './channel-driver'
|
||||
import type { ChannelResultMessage } from '../../shared/channel-contracts'
|
||||
|
||||
export class SqliteChannelDedupStore implements DedupStore {
|
||||
constructor(private readonly database: AssistantDatabase) {}
|
||||
|
||||
claim(channel: string, eventId: string): boolean {
|
||||
return this.database.claimChannelEvent(channel, eventId)
|
||||
}
|
||||
|
||||
release(channel: string, eventId: string): void {
|
||||
this.database.releaseChannelEvent(channel, eventId)
|
||||
}
|
||||
}
|
||||
|
||||
export class SqliteChannelOutbox implements Outbox {
|
||||
constructor(private readonly database: AssistantDatabase) {}
|
||||
|
||||
enqueue(message: ChannelResultMessage): OutboxEntry {
|
||||
return this.database.enqueueChannelResult(message)
|
||||
}
|
||||
|
||||
markDelivered(id: string): void {
|
||||
this.database.markChannelResult(id, 'delivered')
|
||||
}
|
||||
|
||||
markFailed(id: string): void {
|
||||
this.database.markChannelResult(id, 'failed')
|
||||
}
|
||||
|
||||
listUndelivered(
|
||||
channel?: string,
|
||||
limit?: number
|
||||
): readonly OutboxEntry[] {
|
||||
return this.database.listUndeliveredChannelResults(
|
||||
channel,
|
||||
limit
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
import {
|
||||
weixinAccountDisplay,
|
||||
type WeixinBindingSnapshot
|
||||
} from '../../shared/weixin-channel-contracts'
|
||||
import { ChannelSettingsStore } from './channel-settings-store'
|
||||
import {
|
||||
WechatSidecarClient,
|
||||
type WechatSidecarLauncher
|
||||
} from './wechat-sidecar-client'
|
||||
import type {
|
||||
WechatSidecarCredentialMessage,
|
||||
WechatSidecarMessage
|
||||
} from './wechat-sidecar-protocol'
|
||||
|
||||
export class WechatBindingController {
|
||||
private client?: WechatSidecarClient
|
||||
private unsubscribe?: () => void
|
||||
private snapshotValue: WeixinBindingSnapshot = {
|
||||
status: 'stopped'
|
||||
}
|
||||
private credentialSave: Promise<void> = Promise.resolve()
|
||||
private generation = 0
|
||||
private savingCredential = false
|
||||
|
||||
constructor(
|
||||
private readonly store: ChannelSettingsStore,
|
||||
private readonly launcher: WechatSidecarLauncher,
|
||||
private readonly onChanged: () => Promise<void>,
|
||||
private readonly publish: (snapshot: WeixinBindingSnapshot) => void
|
||||
) {}
|
||||
|
||||
snapshot(): WeixinBindingSnapshot {
|
||||
return structuredClone(this.snapshotValue)
|
||||
}
|
||||
|
||||
start(): WeixinBindingSnapshot {
|
||||
if (this.savingCredential) {
|
||||
throw new Error('微信绑定凭据正在保存,请稍后重试')
|
||||
}
|
||||
this.stopClient()
|
||||
const generation = ++this.generation
|
||||
const client = new WechatSidecarClient(this.launcher)
|
||||
this.client = client
|
||||
this.unsubscribe = client.subscribe((message) => {
|
||||
this.handleMessage(message, generation)
|
||||
})
|
||||
this.setSnapshot({ status: 'starting' })
|
||||
client.start()
|
||||
client.send({ type: 'start_login' })
|
||||
return this.snapshot()
|
||||
}
|
||||
|
||||
submitVerification(code: string): WeixinBindingSnapshot {
|
||||
if (!this.client) {
|
||||
throw new Error('当前没有进行中的微信绑定')
|
||||
}
|
||||
this.client.send({ type: 'submit_verification', code })
|
||||
this.setSnapshot({ status: 'scanned' })
|
||||
return this.snapshot()
|
||||
}
|
||||
|
||||
async disconnect(): Promise<WeixinBindingSnapshot> {
|
||||
this.generation += 1
|
||||
this.stopClient()
|
||||
await this.credentialSave
|
||||
await this.store.clearWeixinBinding()
|
||||
await this.onChanged()
|
||||
this.setSnapshot({ status: 'stopped' })
|
||||
return this.snapshot()
|
||||
}
|
||||
|
||||
stop(): void {
|
||||
this.generation += 1
|
||||
this.stopClient()
|
||||
this.snapshotValue = { status: 'stopped' }
|
||||
}
|
||||
|
||||
private handleMessage(
|
||||
message: WechatSidecarMessage | WechatSidecarCredentialMessage,
|
||||
generation: number
|
||||
): void {
|
||||
if (generation !== this.generation) {
|
||||
return
|
||||
}
|
||||
if (message.type === 'credential') {
|
||||
this.savingCredential = true
|
||||
this.credentialSave = this.credentialSave
|
||||
.then(async () => {
|
||||
if (generation !== this.generation) {
|
||||
return
|
||||
}
|
||||
this.stopClient()
|
||||
await this.store.saveWeixinBinding({
|
||||
accountId: message.accountId,
|
||||
userId: message.userId,
|
||||
baseUrl: message.baseUrl,
|
||||
token: message.token
|
||||
})
|
||||
if (generation !== this.generation) {
|
||||
return
|
||||
}
|
||||
await this.onChanged()
|
||||
if (generation !== this.generation) {
|
||||
return
|
||||
}
|
||||
this.setSnapshot({
|
||||
status: 'connected',
|
||||
accountDisplay: weixinAccountDisplay(message.userId)
|
||||
})
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
if (generation !== this.generation) {
|
||||
return
|
||||
}
|
||||
this.setSnapshot({
|
||||
status: 'failed',
|
||||
detail:
|
||||
error instanceof Error
|
||||
? error.message.slice(0, 512)
|
||||
: '微信绑定保存失败'
|
||||
})
|
||||
})
|
||||
.finally(() => {
|
||||
this.savingCredential = false
|
||||
})
|
||||
return
|
||||
}
|
||||
if (message.type === 'qr') {
|
||||
this.setSnapshot({
|
||||
status: 'pending',
|
||||
qrPayload: message.payload,
|
||||
qrExpiresAt: message.expiresAt
|
||||
})
|
||||
return
|
||||
}
|
||||
if (message.type === 'verification_required') {
|
||||
this.setSnapshot({
|
||||
...this.snapshotValue,
|
||||
status: 'verification_required',
|
||||
detail: message.prompt
|
||||
})
|
||||
return
|
||||
}
|
||||
if (message.type === 'connected') {
|
||||
return
|
||||
}
|
||||
if (message.type === 'status') {
|
||||
this.setSnapshot({
|
||||
...this.snapshotValue,
|
||||
status: message.status,
|
||||
...(message.detail ? { detail: message.detail } : {})
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
private setSnapshot(snapshot: WeixinBindingSnapshot): void {
|
||||
this.snapshotValue = structuredClone(snapshot)
|
||||
this.publish(this.snapshot())
|
||||
}
|
||||
|
||||
private stopClient(): void {
|
||||
this.unsubscribe?.()
|
||||
this.unsubscribe = undefined
|
||||
this.client?.stop()
|
||||
this.client = undefined
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
import { EventEmitter } from 'node:events'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import type { ResolvedChannelSettings } from './channel-settings-store'
|
||||
import { WechatChannelDriver } from './wechat-channel-driver'
|
||||
import type { WechatSidecarChild } from './wechat-sidecar-client'
|
||||
|
||||
class FakeSidecar extends EventEmitter {
|
||||
readonly posted: unknown[] = []
|
||||
|
||||
postMessage(message: unknown): void {
|
||||
this.posted.push(message)
|
||||
}
|
||||
|
||||
kill(): boolean {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
const settings: Extract<
|
||||
ResolvedChannelSettings,
|
||||
{ channel: 'weixin' }
|
||||
> = {
|
||||
channel: 'weixin',
|
||||
enabled: true,
|
||||
accountId: 'bot-account',
|
||||
userId: 'bound-user',
|
||||
baseUrl: 'https://ilinkai.weixin.qq.com',
|
||||
token: 'private-token',
|
||||
allowedSenderIds: ['bound-user'],
|
||||
allowGroupMessages: false,
|
||||
source: 'encrypted',
|
||||
readOnly: false
|
||||
}
|
||||
|
||||
describe('WechatChannelDriver', () => {
|
||||
it('starts an isolated account, forwards text, and correlates replies', async () => {
|
||||
const child = new FakeSidecar()
|
||||
const handler = vi.fn()
|
||||
const driver = new WechatChannelDriver(
|
||||
settings,
|
||||
() => child as unknown as WechatSidecarChild
|
||||
)
|
||||
|
||||
const starting = driver.start(handler)
|
||||
await vi.waitFor(() =>
|
||||
expect(child.posted).toContainEqual(
|
||||
expect.objectContaining({
|
||||
type: 'start_account',
|
||||
accountId: 'bot-account',
|
||||
token: 'private-token'
|
||||
})
|
||||
)
|
||||
)
|
||||
child.emit('message', {
|
||||
type: 'status',
|
||||
status: 'connected'
|
||||
})
|
||||
await starting
|
||||
|
||||
child.emit('message', {
|
||||
type: 'inbound_text',
|
||||
eventId: 'event-1',
|
||||
senderId: 'sender-1',
|
||||
conversationId: 'sender-1',
|
||||
text: '你好'
|
||||
})
|
||||
await vi.waitFor(() => expect(handler).toHaveBeenCalledOnce())
|
||||
expect(handler).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
channel: 'weixin',
|
||||
eventId: 'event-1',
|
||||
senderId: 'sender-1',
|
||||
workMode: 'ask'
|
||||
}),
|
||||
expect.any(Function)
|
||||
)
|
||||
|
||||
const sending = driver.send(
|
||||
{
|
||||
channel: 'weixin',
|
||||
eventId: 'event-1',
|
||||
conversationId: 'sender-1',
|
||||
recipientId: 'sender-1',
|
||||
status: 'completed',
|
||||
output: '收到'
|
||||
},
|
||||
new AbortController().signal
|
||||
)
|
||||
const reply = child.posted.find(
|
||||
(
|
||||
message
|
||||
): message is {
|
||||
type: 'reply'
|
||||
replyId: string
|
||||
} =>
|
||||
typeof message === 'object' &&
|
||||
message !== null &&
|
||||
'type' in message &&
|
||||
message.type === 'reply'
|
||||
)
|
||||
expect(reply).toBeDefined()
|
||||
child.emit('message', {
|
||||
type: 'reply_result',
|
||||
replyId: reply!.replyId,
|
||||
ok: true
|
||||
})
|
||||
await expect(sending).resolves.toBeUndefined()
|
||||
driver.stop()
|
||||
})
|
||||
|
||||
it('rejects incomplete persisted bindings before launching', async () => {
|
||||
const launch = vi.fn(
|
||||
() => new FakeSidecar() as unknown as WechatSidecarChild
|
||||
)
|
||||
const driver = new WechatChannelDriver(
|
||||
{ ...settings, token: undefined },
|
||||
launch
|
||||
)
|
||||
await expect(driver.start(vi.fn())).rejects.toThrow(
|
||||
'尚未完成扫码绑定'
|
||||
)
|
||||
expect(launch).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('rejects in-flight replies when the sidecar fails', async () => {
|
||||
const child = new FakeSidecar()
|
||||
const driver = new WechatChannelDriver(
|
||||
settings,
|
||||
() => child as unknown as WechatSidecarChild
|
||||
)
|
||||
const starting = driver.start(vi.fn())
|
||||
await vi.waitFor(() =>
|
||||
expect(child.posted).toContainEqual(
|
||||
expect.objectContaining({ type: 'start_account' })
|
||||
)
|
||||
)
|
||||
child.emit('message', {
|
||||
type: 'status',
|
||||
status: 'connected'
|
||||
})
|
||||
await starting
|
||||
|
||||
const sending = driver.send(
|
||||
{
|
||||
channel: 'weixin',
|
||||
eventId: 'event-failed',
|
||||
conversationId: 'sender-1',
|
||||
recipientId: 'sender-1',
|
||||
status: 'completed',
|
||||
output: '结果'
|
||||
},
|
||||
new AbortController().signal
|
||||
)
|
||||
child.emit('message', {
|
||||
type: 'status',
|
||||
status: 'failed',
|
||||
detail: 'Sidecar 已退出'
|
||||
})
|
||||
await expect(sending).rejects.toThrow('Sidecar 已退出')
|
||||
driver.stop()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,224 @@
|
||||
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 = 20_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 timeout = setTimeout(() => {
|
||||
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 => {
|
||||
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
|
||||
})
|
||||
} 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_text') {
|
||||
void Promise.resolve(
|
||||
this.handler?.(
|
||||
{
|
||||
channel: this.channel,
|
||||
eventId: message.eventId,
|
||||
senderId: message.senderId,
|
||||
conversationId: message.conversationId,
|
||||
conversationType: 'direct',
|
||||
text: message.text,
|
||||
mentioned: false,
|
||||
workMode: 'ask',
|
||||
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()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
import {
|
||||
wechatSidecarCredentialMessageSchema,
|
||||
wechatSidecarMessageSchema,
|
||||
type WechatSidecarCommand,
|
||||
type WechatSidecarCredentialMessage,
|
||||
type WechatSidecarMessage,
|
||||
type WechatSidecarStartAccountCommand
|
||||
} from './wechat-sidecar-protocol'
|
||||
|
||||
export interface WechatSidecarChild {
|
||||
postMessage(message: unknown): void
|
||||
kill(): boolean
|
||||
on(event: 'message', listener: (message: unknown) => void): this
|
||||
once(
|
||||
event: 'exit',
|
||||
listener: (code: number | null) => void
|
||||
): this
|
||||
once(
|
||||
event: 'error',
|
||||
listener: (error: Error) => void
|
||||
): this
|
||||
}
|
||||
|
||||
export type WechatSidecarLauncher = () => WechatSidecarChild
|
||||
|
||||
type SidecarListener = (
|
||||
message: WechatSidecarMessage | WechatSidecarCredentialMessage
|
||||
) => void
|
||||
|
||||
export class WechatSidecarClient {
|
||||
private child?: WechatSidecarChild
|
||||
private readonly listeners = new Set<SidecarListener>()
|
||||
private exitError?: Error
|
||||
|
||||
constructor(private readonly launch: WechatSidecarLauncher) {}
|
||||
|
||||
start(): void {
|
||||
if (this.child) {
|
||||
return
|
||||
}
|
||||
this.exitError = undefined
|
||||
const child = this.launch()
|
||||
this.child = child
|
||||
child.on('message', (raw) => {
|
||||
const payload =
|
||||
raw !== null &&
|
||||
typeof raw === 'object' &&
|
||||
'data' in raw
|
||||
? raw.data
|
||||
: raw
|
||||
const publicMessage = wechatSidecarMessageSchema.safeParse(payload)
|
||||
if (publicMessage.success) {
|
||||
this.publish(publicMessage.data)
|
||||
return
|
||||
}
|
||||
const credential =
|
||||
wechatSidecarCredentialMessageSchema.safeParse(payload)
|
||||
if (credential.success) {
|
||||
this.publish(credential.data)
|
||||
}
|
||||
})
|
||||
child.once('error', (error) => {
|
||||
this.exitError = new Error(
|
||||
`微信 Sidecar 异常:${error.message.slice(0, 300)}`
|
||||
)
|
||||
this.publish({
|
||||
type: 'status',
|
||||
status: 'failed',
|
||||
detail: this.exitError.message
|
||||
})
|
||||
})
|
||||
child.once('exit', (code) => {
|
||||
if (this.child !== child) {
|
||||
return
|
||||
}
|
||||
this.child = undefined
|
||||
if (code !== 0 && this.exitError === undefined) {
|
||||
this.publish({
|
||||
type: 'status',
|
||||
status: 'failed',
|
||||
detail: `微信 Sidecar 已退出(${code ?? '未知状态'})`
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
send(
|
||||
command: WechatSidecarCommand | WechatSidecarStartAccountCommand
|
||||
): void {
|
||||
if (!this.child) {
|
||||
throw this.exitError ?? new Error('微信 Sidecar 尚未启动')
|
||||
}
|
||||
this.child.postMessage(command)
|
||||
}
|
||||
|
||||
subscribe(listener: SidecarListener): () => void {
|
||||
this.listeners.add(listener)
|
||||
return () => this.listeners.delete(listener)
|
||||
}
|
||||
|
||||
stop(): void {
|
||||
const child = this.child
|
||||
this.child = undefined
|
||||
if (!child) {
|
||||
return
|
||||
}
|
||||
try {
|
||||
child.postMessage({ type: 'shutdown' })
|
||||
} finally {
|
||||
setTimeout(() => child.kill(), 2_000).unref()
|
||||
}
|
||||
}
|
||||
|
||||
private publish(
|
||||
message: WechatSidecarMessage | WechatSidecarCredentialMessage
|
||||
): void {
|
||||
for (const listener of this.listeners) {
|
||||
listener(message)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import { EventEmitter } from 'node:events'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
class FakeParentPort extends EventEmitter {
|
||||
readonly messages: unknown[] = []
|
||||
|
||||
postMessage(message: unknown): void {
|
||||
this.messages.push(message)
|
||||
}
|
||||
}
|
||||
|
||||
const originalParentPort = Object.getOwnPropertyDescriptor(
|
||||
process,
|
||||
'parentPort'
|
||||
)
|
||||
|
||||
afterEach(() => {
|
||||
if (originalParentPort) {
|
||||
Object.defineProperty(process, 'parentPort', originalParentPort)
|
||||
} else {
|
||||
delete (process as Partial<NodeJS.Process>).parentPort
|
||||
}
|
||||
vi.resetModules()
|
||||
})
|
||||
|
||||
describe('Weixin utility-process entry', () => {
|
||||
it('uses process.parentPort for utility-process messaging', async () => {
|
||||
const parentPort = new FakeParentPort()
|
||||
Object.defineProperty(process, 'parentPort', {
|
||||
configurable: true,
|
||||
value: parentPort
|
||||
})
|
||||
|
||||
await import('./wechat-sidecar')
|
||||
|
||||
expect(parentPort.listenerCount('message')).toBe(1)
|
||||
expect(parentPort.messages).toEqual([
|
||||
{ type: 'status', status: 'stopped' }
|
||||
])
|
||||
})
|
||||
})
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
WECHAT_SIDECAR_MAX_QR_PAYLOAD_LENGTH,
|
||||
WECHAT_SIDECAR_MAX_TEXT_LENGTH,
|
||||
WechatQrStateMachine,
|
||||
wechatSidecarCommandSchema,
|
||||
wechatSidecarMessageSchema
|
||||
} from './wechat-sidecar-protocol'
|
||||
|
||||
@@ -42,7 +43,7 @@ describe('wechatSidecarMessageSchema', () => {
|
||||
).toMatchObject({ eventId: 'event-1', text: '你好' })
|
||||
|
||||
expect(
|
||||
wechatSidecarMessageSchema.parse({
|
||||
wechatSidecarCommandSchema.parse({
|
||||
type: 'reply',
|
||||
replyId: 'reply-1',
|
||||
inReplyToEventId: 'event-1',
|
||||
@@ -53,6 +54,18 @@ describe('wechatSidecarMessageSchema', () => {
|
||||
replyId: 'reply-1',
|
||||
inReplyToEventId: 'event-1'
|
||||
})
|
||||
|
||||
expect(
|
||||
wechatSidecarMessageSchema.parse({
|
||||
type: 'reply_result',
|
||||
replyId: 'reply-1',
|
||||
ok: true
|
||||
})
|
||||
).toEqual({
|
||||
type: 'reply_result',
|
||||
replyId: 'reply-1',
|
||||
ok: true
|
||||
})
|
||||
})
|
||||
|
||||
it.each(['session', 'cookie', 'token'])(
|
||||
|
||||
@@ -1,8 +1,13 @@
|
||||
import { z } from 'zod'
|
||||
import {
|
||||
weixinBindingStatusSchema,
|
||||
weixinVerificationInputSchema
|
||||
} from '../../shared/weixin-channel-contracts'
|
||||
|
||||
export const WECHAT_SIDECAR_MAX_TEXT_LENGTH = 8_000
|
||||
export const WECHAT_SIDECAR_MAX_QR_PAYLOAD_LENGTH = 4_096
|
||||
export const WECHAT_SIDECAR_MAX_QR_TTL_MS = 5 * 60 * 1_000
|
||||
export const WECHAT_SIDECAR_PROTOCOL_VERSION = 1
|
||||
|
||||
function containsControlCharacter(value: string): boolean {
|
||||
for (const character of value) {
|
||||
@@ -37,15 +42,7 @@ const textSchema = z
|
||||
.min(1)
|
||||
.max(WECHAT_SIDECAR_MAX_TEXT_LENGTH)
|
||||
|
||||
export const wechatSidecarStatusSchema = z.enum([
|
||||
'stopped',
|
||||
'starting',
|
||||
'pending',
|
||||
'scanned',
|
||||
'connected',
|
||||
'expired',
|
||||
'failed'
|
||||
])
|
||||
export const wechatSidecarStatusSchema = weixinBindingStatusSchema
|
||||
|
||||
export type WechatSidecarStatus = z.infer<
|
||||
typeof wechatSidecarStatusSchema
|
||||
@@ -82,7 +79,42 @@ export const wechatSidecarInboundTextMessageSchema = z
|
||||
})
|
||||
.strict()
|
||||
|
||||
export const wechatSidecarReplyMessageSchema = z
|
||||
export const wechatSidecarVerificationRequiredMessageSchema = z
|
||||
.object({
|
||||
type: z.literal('verification_required'),
|
||||
prompt: z.string().trim().min(1).max(256)
|
||||
})
|
||||
.strict()
|
||||
|
||||
export const wechatSidecarConnectedMessageSchema = z
|
||||
.object({
|
||||
type: z.literal('connected'),
|
||||
accountId: identifierSchema,
|
||||
userId: identifierSchema
|
||||
})
|
||||
.strict()
|
||||
|
||||
export const wechatSidecarReplyResultMessageSchema = z
|
||||
.object({
|
||||
type: z.literal('reply_result'),
|
||||
replyId: identifierSchema,
|
||||
ok: z.boolean(),
|
||||
error: z.string().trim().min(1).max(512).optional()
|
||||
})
|
||||
.strict()
|
||||
.superRefine((result, context) => {
|
||||
if (result.ok === (result.error !== undefined)) {
|
||||
context.addIssue({
|
||||
code: 'custom',
|
||||
path: ['error'],
|
||||
message: result.ok
|
||||
? '成功回复不能包含错误'
|
||||
: '失败回复必须包含错误'
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
export const wechatSidecarReplyCommandSchema = z
|
||||
.object({
|
||||
type: z.literal('reply'),
|
||||
replyId: identifierSchema,
|
||||
@@ -96,7 +128,9 @@ export const wechatSidecarMessageSchema = z.discriminatedUnion('type', [
|
||||
wechatSidecarStatusMessageSchema,
|
||||
wechatSidecarQrMessageSchema,
|
||||
wechatSidecarInboundTextMessageSchema,
|
||||
wechatSidecarReplyMessageSchema
|
||||
wechatSidecarVerificationRequiredMessageSchema,
|
||||
wechatSidecarConnectedMessageSchema,
|
||||
wechatSidecarReplyResultMessageSchema
|
||||
])
|
||||
|
||||
export type WechatSidecarMessage = z.infer<
|
||||
@@ -106,6 +140,69 @@ export type WechatSidecarQrMessage = z.infer<
|
||||
typeof wechatSidecarQrMessageSchema
|
||||
>
|
||||
|
||||
export const wechatSidecarStartLoginCommandSchema = z
|
||||
.object({
|
||||
type: z.literal('start_login')
|
||||
})
|
||||
.strict()
|
||||
|
||||
export const wechatSidecarSubmitVerificationCommandSchema = z
|
||||
.object({
|
||||
type: z.literal('submit_verification'),
|
||||
code: weixinVerificationInputSchema.shape.code
|
||||
})
|
||||
.strict()
|
||||
|
||||
export const wechatSidecarDisconnectCommandSchema = z
|
||||
.object({
|
||||
type: z.literal('disconnect')
|
||||
})
|
||||
.strict()
|
||||
|
||||
export const wechatSidecarShutdownCommandSchema = z
|
||||
.object({
|
||||
type: z.literal('shutdown')
|
||||
})
|
||||
.strict()
|
||||
|
||||
export const wechatSidecarCommandSchema = z.discriminatedUnion('type', [
|
||||
wechatSidecarStartLoginCommandSchema,
|
||||
wechatSidecarSubmitVerificationCommandSchema,
|
||||
wechatSidecarReplyCommandSchema,
|
||||
wechatSidecarDisconnectCommandSchema,
|
||||
wechatSidecarShutdownCommandSchema
|
||||
])
|
||||
export type WechatSidecarCommand = z.infer<
|
||||
typeof wechatSidecarCommandSchema
|
||||
>
|
||||
|
||||
export const wechatSidecarStartAccountCommandSchema = z
|
||||
.object({
|
||||
type: z.literal('start_account'),
|
||||
accountId: identifierSchema,
|
||||
userId: identifierSchema,
|
||||
baseUrl: z.string().url().max(2_048),
|
||||
token: z.string().trim().min(1).max(4_096)
|
||||
})
|
||||
.strict()
|
||||
|
||||
export const wechatSidecarCredentialMessageSchema = z
|
||||
.object({
|
||||
type: z.literal('credential'),
|
||||
accountId: identifierSchema,
|
||||
userId: identifierSchema,
|
||||
baseUrl: z.string().url().max(2_048),
|
||||
token: z.string().trim().min(1).max(4_096)
|
||||
})
|
||||
.strict()
|
||||
|
||||
export type WechatSidecarStartAccountCommand = z.infer<
|
||||
typeof wechatSidecarStartAccountCommandSchema
|
||||
>
|
||||
export type WechatSidecarCredentialMessage = z.infer<
|
||||
typeof wechatSidecarCredentialMessageSchema
|
||||
>
|
||||
|
||||
const allowedTransitions: Readonly<
|
||||
Record<WechatSidecarStatus, ReadonlySet<WechatSidecarStatus>>
|
||||
> = {
|
||||
@@ -120,11 +217,19 @@ const allowedTransitions: Readonly<
|
||||
]),
|
||||
scanned: new Set([
|
||||
'scanned',
|
||||
'verification_required',
|
||||
'connected',
|
||||
'expired',
|
||||
'failed',
|
||||
'stopped'
|
||||
]),
|
||||
verification_required: new Set([
|
||||
'verification_required',
|
||||
'scanned',
|
||||
'expired',
|
||||
'failed',
|
||||
'stopped'
|
||||
]),
|
||||
connected: new Set(['connected', 'failed', 'stopped']),
|
||||
expired: new Set(['expired', 'starting', 'stopped']),
|
||||
failed: new Set(['failed', 'starting', 'stopped'])
|
||||
@@ -201,7 +306,9 @@ export class WechatQrStateMachine {
|
||||
expire(now = Date.now()): boolean {
|
||||
this.assertTimestamp(now)
|
||||
if (
|
||||
(this.status === 'pending' || this.status === 'scanned') &&
|
||||
(this.status === 'pending' ||
|
||||
this.status === 'scanned' ||
|
||||
this.status === 'verification_required') &&
|
||||
this.qr &&
|
||||
Date.parse(this.qr.expiresAt) <= now
|
||||
) {
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
isAllowedWechatUrl,
|
||||
redactWechatSidecarError
|
||||
} from './wechat-sidecar-security'
|
||||
|
||||
describe('Weixin sidecar network boundary', () => {
|
||||
it.each([
|
||||
'https://weixin.qq.com',
|
||||
'https://ilinkai.weixin.qq.com',
|
||||
'https://sub.domain.weixin.qq.com/api'
|
||||
])('allows Tencent Weixin HTTPS host %s', (url) => {
|
||||
expect(isAllowedWechatUrl(url)).toBe(true)
|
||||
})
|
||||
|
||||
it.each([
|
||||
'http://ilinkai.weixin.qq.com',
|
||||
'https://weixin.qq.com.example.com',
|
||||
'https://evilweixin.qq.com',
|
||||
'https://user:password@ilinkai.weixin.qq.com',
|
||||
'file:///etc/passwd',
|
||||
'not-a-url'
|
||||
])('rejects untrusted or credentialed URL %s', (url) => {
|
||||
expect(isAllowedWechatUrl(url)).toBe(false)
|
||||
})
|
||||
|
||||
it('redacts credentials and full service paths from errors', () => {
|
||||
const result = redactWechatSidecarError(
|
||||
new Error(
|
||||
'token=secret-value https://ilinkai.weixin.qq.com/ilink/bot/getupdates'
|
||||
)
|
||||
)
|
||||
expect(result).not.toContain('secret-value')
|
||||
expect(result).not.toContain('/ilink/bot/getupdates')
|
||||
expect(result).toContain('[已隐藏]')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,32 @@
|
||||
const ALLOWED_WECHAT_HOST_SUFFIX = '.weixin.qq.com'
|
||||
|
||||
export function isAllowedWechatUrl(value: string): boolean {
|
||||
try {
|
||||
const parsed = new URL(value)
|
||||
const host = parsed.hostname.toLocaleLowerCase()
|
||||
return (
|
||||
parsed.protocol === 'https:' &&
|
||||
(host === 'weixin.qq.com' ||
|
||||
host.endsWith(ALLOWED_WECHAT_HOST_SUFFIX)) &&
|
||||
parsed.username === '' &&
|
||||
parsed.password === ''
|
||||
)
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
export function redactWechatSidecarError(error: unknown): string {
|
||||
const message =
|
||||
error instanceof Error ? error.message : '微信通信发生未知错误'
|
||||
return message
|
||||
.replace(
|
||||
/\b(token|authorization|password|secret)\b(\s*[:=]\s*)([^\s,;]+)/giu,
|
||||
'$1$2[已隐藏]'
|
||||
)
|
||||
.replace(
|
||||
/\bhttps?:\/\/[^\s/]+\/[^\s]*/giu,
|
||||
'[微信服务地址已隐藏]'
|
||||
)
|
||||
.slice(0, 512)
|
||||
}
|
||||
@@ -0,0 +1,624 @@
|
||||
import { createHash, randomBytes, randomUUID } from 'node:crypto'
|
||||
import {
|
||||
wechatSidecarCommandSchema,
|
||||
wechatSidecarStartAccountCommandSchema,
|
||||
type WechatSidecarCommand,
|
||||
type WechatSidecarCredentialMessage,
|
||||
type WechatSidecarMessage,
|
||||
type WechatSidecarStartAccountCommand
|
||||
} from './wechat-sidecar-protocol'
|
||||
import {
|
||||
isAllowedWechatUrl,
|
||||
redactWechatSidecarError
|
||||
} from './wechat-sidecar-security'
|
||||
|
||||
const QR_BASE_URL = 'https://ilinkai.weixin.qq.com'
|
||||
const DEFAULT_API_BASE_URL = QR_BASE_URL
|
||||
const BOT_TYPE = '3'
|
||||
const LONG_POLL_TIMEOUT_MS = 35_000
|
||||
const API_TIMEOUT_MS = 15_000
|
||||
const MAX_REPLY_CONTEXTS = 1_000
|
||||
const ILINK_CHANNEL_VERSION = '2.4.6'
|
||||
const ILINK_CLIENT_VERSION = '132102'
|
||||
const parentPort = process.parentPort
|
||||
|
||||
class RequestTimeoutError extends Error {}
|
||||
|
||||
type QrResponse = {
|
||||
qrcode?: string
|
||||
qrcode_img_content?: string
|
||||
}
|
||||
|
||||
type QrStatusResponse = {
|
||||
status?:
|
||||
| 'wait'
|
||||
| 'scaned'
|
||||
| 'confirmed'
|
||||
| 'expired'
|
||||
| 'scaned_but_redirect'
|
||||
| 'need_verifycode'
|
||||
| 'verify_code_blocked'
|
||||
| 'binded_redirect'
|
||||
bot_token?: string
|
||||
ilink_bot_id?: string
|
||||
ilink_user_id?: string
|
||||
baseurl?: string
|
||||
redirect_host?: string
|
||||
}
|
||||
|
||||
type WeixinMessageItem = {
|
||||
type?: number
|
||||
text_item?: { text?: string }
|
||||
}
|
||||
|
||||
type WeixinMessage = {
|
||||
seq?: number
|
||||
message_id?: number
|
||||
from_user_id?: string
|
||||
create_time_ms?: number
|
||||
message_type?: number
|
||||
item_list?: WeixinMessageItem[]
|
||||
context_token?: string
|
||||
}
|
||||
|
||||
type UpdatesResponse = {
|
||||
ret?: number
|
||||
errcode?: number
|
||||
errmsg?: string
|
||||
msgs?: WeixinMessage[]
|
||||
get_updates_buf?: string
|
||||
longpolling_timeout_ms?: number
|
||||
}
|
||||
|
||||
type ReplyContext = {
|
||||
recipientId: string
|
||||
contextToken?: string
|
||||
}
|
||||
|
||||
const replyContexts = new Map<string, ReplyContext>()
|
||||
let activeQr:
|
||||
| {
|
||||
qrcode: string
|
||||
pollingBaseUrl: string
|
||||
expiresAt: number
|
||||
verifyCode?: string
|
||||
}
|
||||
| undefined
|
||||
let account: WechatSidecarStartAccountCommand | undefined
|
||||
let lifecycleController = new AbortController()
|
||||
|
||||
function post(message: WechatSidecarMessage | WechatSidecarCredentialMessage): void {
|
||||
parentPort.postMessage(message)
|
||||
}
|
||||
|
||||
function safeDetail(error: unknown): string {
|
||||
return redactWechatSidecarError(error)
|
||||
}
|
||||
|
||||
function assertTencentUrl(raw: string): URL {
|
||||
if (!isAllowedWechatUrl(raw)) {
|
||||
throw new Error('微信服务返回了不受信任的地址')
|
||||
}
|
||||
const url = new URL(raw)
|
||||
return url
|
||||
}
|
||||
|
||||
function normalizeBaseUrl(raw: string | undefined): string {
|
||||
return assertTencentUrl(raw?.trim() || DEFAULT_API_BASE_URL)
|
||||
.toString()
|
||||
.replace(/\/$/u, '')
|
||||
}
|
||||
|
||||
function randomWechatUin(): string {
|
||||
const value = randomBytes(4).readUInt32BE(0)
|
||||
return Buffer.from(String(value), 'utf8').toString('base64')
|
||||
}
|
||||
|
||||
function commonHeaders(token?: string): Record<string, string> {
|
||||
return {
|
||||
'Content-Type': 'application/json',
|
||||
'iLink-App-Id': 'bot',
|
||||
'iLink-App-ClientVersion': ILINK_CLIENT_VERSION,
|
||||
AuthorizationType: 'ilink_bot_token',
|
||||
'X-WECHAT-UIN': randomWechatUin(),
|
||||
...(token ? { Authorization: `Bearer ${token}` } : {})
|
||||
}
|
||||
}
|
||||
|
||||
function baseInfo(): { channel_version: string; bot_agent: string } {
|
||||
return {
|
||||
channel_version: ILINK_CHANNEL_VERSION,
|
||||
bot_agent: 'GoodBuddy/0.8.6'
|
||||
}
|
||||
}
|
||||
|
||||
async function requestJson<T>(input: {
|
||||
baseUrl: string
|
||||
endpoint: string
|
||||
method: 'GET' | 'POST'
|
||||
token?: string
|
||||
body?: unknown
|
||||
timeoutMs: number
|
||||
signal?: AbortSignal
|
||||
}): Promise<T> {
|
||||
const baseUrl = normalizeBaseUrl(input.baseUrl)
|
||||
const url = new URL(input.endpoint, `${baseUrl}/`)
|
||||
assertTencentUrl(url.toString())
|
||||
const timeoutController = new AbortController()
|
||||
let timedOut = false
|
||||
const timeout = setTimeout(() => {
|
||||
timedOut = true
|
||||
timeoutController.abort()
|
||||
}, input.timeoutMs)
|
||||
const abort = (): void => timeoutController.abort(input.signal?.reason)
|
||||
input.signal?.addEventListener('abort', abort, { once: true })
|
||||
try {
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
method: input.method,
|
||||
headers: commonHeaders(input.token),
|
||||
...(input.body === undefined
|
||||
? {}
|
||||
: { body: JSON.stringify(input.body) }),
|
||||
signal: timeoutController.signal
|
||||
})
|
||||
const text = await response.text()
|
||||
if (!response.ok) {
|
||||
throw new Error(`微信服务请求失败(${response.status})`)
|
||||
}
|
||||
return JSON.parse(text) as T
|
||||
} catch (error) {
|
||||
if (timedOut) {
|
||||
throw new RequestTimeoutError('微信请求等待超时')
|
||||
}
|
||||
throw error
|
||||
}
|
||||
} finally {
|
||||
clearTimeout(timeout)
|
||||
input.signal?.removeEventListener('abort', abort)
|
||||
}
|
||||
}
|
||||
|
||||
async function startLogin(): Promise<void> {
|
||||
lifecycleController.abort()
|
||||
lifecycleController = new AbortController()
|
||||
activeQr = undefined
|
||||
post({ type: 'status', status: 'starting' })
|
||||
try {
|
||||
const result = await requestJson<QrResponse>({
|
||||
baseUrl: QR_BASE_URL,
|
||||
endpoint: `ilink/bot/get_bot_qrcode?bot_type=${BOT_TYPE}`,
|
||||
method: 'POST',
|
||||
body: { local_token_list: [] },
|
||||
timeoutMs: API_TIMEOUT_MS,
|
||||
signal: lifecycleController.signal
|
||||
})
|
||||
if (!result.qrcode || !result.qrcode_img_content) {
|
||||
throw new Error('微信服务未返回有效二维码')
|
||||
}
|
||||
activeQr = {
|
||||
qrcode: result.qrcode,
|
||||
pollingBaseUrl: QR_BASE_URL,
|
||||
expiresAt: Date.now() + 5 * 60_000
|
||||
}
|
||||
const expiresAt = new Date(activeQr.expiresAt).toISOString()
|
||||
post({ type: 'status', status: 'pending' })
|
||||
post({
|
||||
type: 'qr',
|
||||
qrId: randomUUID(),
|
||||
payload: result.qrcode_img_content,
|
||||
expiresAt
|
||||
})
|
||||
void pollQr(lifecycleController.signal)
|
||||
} catch (error) {
|
||||
if (!lifecycleController.signal.aborted) {
|
||||
post({
|
||||
type: 'status',
|
||||
status: 'failed',
|
||||
detail: safeDetail(error)
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function pollQr(signal: AbortSignal): Promise<void> {
|
||||
while (!signal.aborted && activeQr) {
|
||||
const current = activeQr
|
||||
if (Date.now() >= current.expiresAt) {
|
||||
post({ type: 'status', status: 'expired' })
|
||||
activeQr = undefined
|
||||
return
|
||||
}
|
||||
try {
|
||||
let endpoint =
|
||||
`ilink/bot/get_qrcode_status?qrcode=${encodeURIComponent(current.qrcode)}`
|
||||
if (current.verifyCode) {
|
||||
endpoint += `&verify_code=${encodeURIComponent(current.verifyCode)}`
|
||||
}
|
||||
const status = await requestJson<QrStatusResponse>({
|
||||
baseUrl: current.pollingBaseUrl,
|
||||
endpoint,
|
||||
method: 'GET',
|
||||
timeoutMs: LONG_POLL_TIMEOUT_MS,
|
||||
signal
|
||||
})
|
||||
if (signal.aborted || !activeQr) {
|
||||
return
|
||||
}
|
||||
switch (status.status) {
|
||||
case 'wait':
|
||||
case undefined:
|
||||
break
|
||||
case 'scaned':
|
||||
activeQr.verifyCode = undefined
|
||||
post({ type: 'status', status: 'scanned' })
|
||||
break
|
||||
case 'need_verifycode':
|
||||
post({ type: 'status', status: 'verification_required' })
|
||||
post({
|
||||
type: 'verification_required',
|
||||
prompt: activeQr.verifyCode
|
||||
? '数字不匹配,请重新输入手机微信显示的数字'
|
||||
: '请输入手机微信显示的数字'
|
||||
})
|
||||
return
|
||||
case 'verify_code_blocked':
|
||||
post({
|
||||
type: 'status',
|
||||
status: 'failed',
|
||||
detail: '验证码错误次数过多,请重新扫码'
|
||||
})
|
||||
activeQr = undefined
|
||||
return
|
||||
case 'expired':
|
||||
post({ type: 'status', status: 'expired' })
|
||||
activeQr = undefined
|
||||
return
|
||||
case 'scaned_but_redirect':
|
||||
if (!status.redirect_host) {
|
||||
throw new Error('微信扫码重定向地址缺失')
|
||||
}
|
||||
activeQr.pollingBaseUrl = normalizeBaseUrl(
|
||||
`https://${status.redirect_host}`
|
||||
)
|
||||
break
|
||||
case 'binded_redirect':
|
||||
post({
|
||||
type: 'status',
|
||||
status: 'failed',
|
||||
detail: '此微信已绑定,但本地凭据不可用,请先在微信中解除旧连接'
|
||||
})
|
||||
activeQr = undefined
|
||||
return
|
||||
case 'confirmed': {
|
||||
if (
|
||||
!status.bot_token ||
|
||||
!status.ilink_bot_id ||
|
||||
!status.ilink_user_id
|
||||
) {
|
||||
throw new Error('微信确认结果缺少账号凭据')
|
||||
}
|
||||
const baseUrl = normalizeBaseUrl(status.baseurl)
|
||||
const credential: WechatSidecarCredentialMessage = {
|
||||
type: 'credential',
|
||||
accountId: status.ilink_bot_id,
|
||||
userId: status.ilink_user_id,
|
||||
baseUrl,
|
||||
token: status.bot_token
|
||||
}
|
||||
post(credential)
|
||||
post({
|
||||
type: 'connected',
|
||||
accountId: credential.accountId,
|
||||
userId: credential.userId
|
||||
})
|
||||
post({ type: 'status', status: 'connected' })
|
||||
activeQr = undefined
|
||||
return
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
if (signal.aborted) {
|
||||
return
|
||||
}
|
||||
if (error instanceof RequestTimeoutError) {
|
||||
continue
|
||||
}
|
||||
await sleep(2_000, signal)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function submitVerification(code: string): void {
|
||||
if (!activeQr) {
|
||||
post({
|
||||
type: 'status',
|
||||
status: 'failed',
|
||||
detail: '当前没有等待验证的微信扫码'
|
||||
})
|
||||
return
|
||||
}
|
||||
activeQr.verifyCode = code
|
||||
post({ type: 'status', status: 'scanned' })
|
||||
void pollQr(lifecycleController.signal)
|
||||
}
|
||||
|
||||
async function startAccount(
|
||||
command: WechatSidecarStartAccountCommand
|
||||
): Promise<void> {
|
||||
lifecycleController.abort()
|
||||
lifecycleController = new AbortController()
|
||||
account = {
|
||||
...command,
|
||||
baseUrl: normalizeBaseUrl(command.baseUrl)
|
||||
}
|
||||
post({ type: 'status', status: 'starting' })
|
||||
try {
|
||||
await notifyLifecycle('notifystart')
|
||||
} catch {
|
||||
// Connection notification is advisory; polling remains authoritative.
|
||||
}
|
||||
post({
|
||||
type: 'connected',
|
||||
accountId: account.accountId,
|
||||
userId: account.userId
|
||||
})
|
||||
post({ type: 'status', status: 'connected' })
|
||||
void pollMessages(lifecycleController.signal)
|
||||
}
|
||||
|
||||
async function pollMessages(signal: AbortSignal): Promise<void> {
|
||||
let cursor = ''
|
||||
let timeoutMs = LONG_POLL_TIMEOUT_MS
|
||||
let failures = 0
|
||||
while (!signal.aborted && account) {
|
||||
try {
|
||||
const result = await requestJson<UpdatesResponse>({
|
||||
baseUrl: account.baseUrl,
|
||||
endpoint: 'ilink/bot/getupdates',
|
||||
method: 'POST',
|
||||
token: account.token,
|
||||
body: {
|
||||
get_updates_buf: cursor,
|
||||
base_info: baseInfo()
|
||||
},
|
||||
timeoutMs,
|
||||
signal
|
||||
})
|
||||
if (signal.aborted) {
|
||||
return
|
||||
}
|
||||
if (
|
||||
(result.ret !== undefined && result.ret !== 0) ||
|
||||
(result.errcode !== undefined && result.errcode !== 0)
|
||||
) {
|
||||
throw new Error('微信消息轮询失败')
|
||||
}
|
||||
failures = 0
|
||||
if (result.get_updates_buf) {
|
||||
cursor = result.get_updates_buf
|
||||
}
|
||||
if (
|
||||
result.longpolling_timeout_ms &&
|
||||
result.longpolling_timeout_ms > 0
|
||||
) {
|
||||
timeoutMs = Math.min(result.longpolling_timeout_ms, 60_000)
|
||||
}
|
||||
for (const message of result.msgs ?? []) {
|
||||
handleInboundMessage(message)
|
||||
}
|
||||
} catch (error) {
|
||||
if (signal.aborted) {
|
||||
return
|
||||
}
|
||||
if (error instanceof RequestTimeoutError) {
|
||||
continue
|
||||
}
|
||||
failures += 1
|
||||
if (failures >= 3) {
|
||||
post({
|
||||
type: 'status',
|
||||
status: 'failed',
|
||||
detail: safeDetail(error)
|
||||
})
|
||||
failures = 0
|
||||
await sleep(30_000, signal)
|
||||
continue
|
||||
}
|
||||
await sleep(2_000, signal)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function handleInboundMessage(message: WeixinMessage): void {
|
||||
if (message.message_type !== undefined && message.message_type !== 1) {
|
||||
return
|
||||
}
|
||||
const senderId = message.from_user_id?.trim()
|
||||
const text = message.item_list
|
||||
?.find((item) => item.type === 1)
|
||||
?.text_item?.text?.trim()
|
||||
if (!senderId || !text) {
|
||||
return
|
||||
}
|
||||
const eventId = stableEventId(message, senderId, text)
|
||||
replyContexts.set(eventId, {
|
||||
recipientId: senderId,
|
||||
...(message.context_token
|
||||
? { contextToken: message.context_token }
|
||||
: {})
|
||||
})
|
||||
while (replyContexts.size > MAX_REPLY_CONTEXTS) {
|
||||
const oldest = replyContexts.keys().next().value
|
||||
if (oldest === undefined) {
|
||||
break
|
||||
}
|
||||
replyContexts.delete(oldest)
|
||||
}
|
||||
post({
|
||||
type: 'inbound_text',
|
||||
eventId,
|
||||
senderId,
|
||||
conversationId: senderId,
|
||||
text
|
||||
})
|
||||
}
|
||||
|
||||
function stableEventId(
|
||||
message: WeixinMessage,
|
||||
senderId: string,
|
||||
text: string
|
||||
): string {
|
||||
if (message.message_id !== undefined) {
|
||||
return `message-${message.message_id}`
|
||||
}
|
||||
if (message.seq !== undefined) {
|
||||
return `sequence-${message.seq}`
|
||||
}
|
||||
return `digest-${createHash('sha256')
|
||||
.update(
|
||||
`${senderId}\u0000${message.create_time_ms ?? 0}\u0000${text}`,
|
||||
'utf8'
|
||||
)
|
||||
.digest('hex')}`
|
||||
}
|
||||
|
||||
async function sendReply(
|
||||
command: Extract<WechatSidecarCommand, { type: 'reply' }>
|
||||
): Promise<void> {
|
||||
const currentAccount = account
|
||||
const context = replyContexts.get(command.inReplyToEventId)
|
||||
if (!currentAccount || !context) {
|
||||
post({
|
||||
type: 'reply_result',
|
||||
replyId: command.replyId,
|
||||
ok: false,
|
||||
error: '微信回复上下文已失效'
|
||||
})
|
||||
return
|
||||
}
|
||||
try {
|
||||
const response = await requestJson<{ ret?: number; errmsg?: string }>({
|
||||
baseUrl: currentAccount.baseUrl,
|
||||
endpoint: 'ilink/bot/sendmessage',
|
||||
method: 'POST',
|
||||
token: currentAccount.token,
|
||||
body: {
|
||||
msg: {
|
||||
from_user_id: '',
|
||||
to_user_id: context.recipientId,
|
||||
client_id: `goodbuddy-${randomUUID()}`,
|
||||
context_token: context.contextToken,
|
||||
message_type: 2,
|
||||
message_state: 2,
|
||||
item_list: [
|
||||
{
|
||||
type: 1,
|
||||
text_item: { text: command.text }
|
||||
}
|
||||
]
|
||||
},
|
||||
base_info: baseInfo()
|
||||
},
|
||||
timeoutMs: API_TIMEOUT_MS,
|
||||
signal: lifecycleController.signal
|
||||
})
|
||||
if (response.ret !== undefined && response.ret !== 0) {
|
||||
throw new Error(response.errmsg || '微信消息发送失败')
|
||||
}
|
||||
post({ type: 'reply_result', replyId: command.replyId, ok: true })
|
||||
} catch (error) {
|
||||
post({
|
||||
type: 'reply_result',
|
||||
replyId: command.replyId,
|
||||
ok: false,
|
||||
error: safeDetail(error)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
async function notifyLifecycle(
|
||||
endpoint: 'notifystart' | 'notifystop'
|
||||
): Promise<void> {
|
||||
if (!account) {
|
||||
return
|
||||
}
|
||||
await requestJson({
|
||||
baseUrl: account.baseUrl,
|
||||
endpoint: `ilink/bot/msg/${endpoint}`,
|
||||
method: 'POST',
|
||||
token: account.token,
|
||||
body: { base_info: baseInfo() },
|
||||
timeoutMs: 10_000
|
||||
})
|
||||
}
|
||||
|
||||
async function disconnect(): Promise<void> {
|
||||
lifecycleController.abort()
|
||||
try {
|
||||
await notifyLifecycle('notifystop')
|
||||
} catch {
|
||||
// Best effort during local disconnect and shutdown.
|
||||
}
|
||||
activeQr = undefined
|
||||
account = undefined
|
||||
replyContexts.clear()
|
||||
post({ type: 'status', status: 'stopped' })
|
||||
}
|
||||
|
||||
function sleep(ms: number, signal: AbortSignal): Promise<void> {
|
||||
return new Promise((resolve) => {
|
||||
let settled = false
|
||||
const finish = (): void => {
|
||||
if (settled) {
|
||||
return
|
||||
}
|
||||
settled = true
|
||||
clearTimeout(timeout)
|
||||
signal.removeEventListener('abort', finish)
|
||||
resolve()
|
||||
}
|
||||
const timeout = setTimeout(finish, ms)
|
||||
signal.addEventListener('abort', finish, { once: true })
|
||||
if (signal.aborted) {
|
||||
finish()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
parentPort.on('message', (event) => {
|
||||
const startAccountCommand =
|
||||
wechatSidecarStartAccountCommandSchema.safeParse(event.data)
|
||||
if (startAccountCommand.success) {
|
||||
void startAccount(startAccountCommand.data)
|
||||
return
|
||||
}
|
||||
const command = wechatSidecarCommandSchema.safeParse(event.data)
|
||||
if (!command.success) {
|
||||
post({
|
||||
type: 'status',
|
||||
status: 'failed',
|
||||
detail: '微信 Sidecar 收到无效命令'
|
||||
})
|
||||
return
|
||||
}
|
||||
switch (command.data.type) {
|
||||
case 'start_login':
|
||||
void startLogin()
|
||||
break
|
||||
case 'submit_verification':
|
||||
submitVerification(command.data.code)
|
||||
break
|
||||
case 'reply':
|
||||
void sendReply(command.data)
|
||||
break
|
||||
case 'disconnect':
|
||||
void disconnect()
|
||||
break
|
||||
case 'shutdown':
|
||||
void disconnect().finally(() => process.exit(0))
|
||||
break
|
||||
}
|
||||
})
|
||||
|
||||
post({ type: 'status', status: 'stopped' })
|
||||
Reference in New Issue
Block a user