fix: preserve channel message delivery

This commit is contained in:
lofyer
2026-08-13 02:09:09 +08:00
parent 67cb69f07d
commit 980f3a0c8f
15 changed files with 586 additions and 130 deletions
+64 -9
View File
@@ -98,7 +98,7 @@ describe('AssistantDatabase', () => {
database.close() database.close()
}) })
it('migrates existing databases to schema version 18', async () => { it('migrates existing databases to schema version 19', async () => {
const directory = await mkdtemp( const directory = await mkdtemp(
join(tmpdir(), 'goodbuddy-assistant-migration-') join(tmpdir(), 'goodbuddy-assistant-migration-')
) )
@@ -127,7 +127,7 @@ describe('AssistantDatabase', () => {
user_version: number user_version: number
} }
).user_version ).user_version
).toBe(18) ).toBe(19)
expect( expect(
current current
.prepare( .prepare(
@@ -231,7 +231,7 @@ describe('AssistantDatabase', () => {
user_version: number user_version: number
} }
).user_version ).user_version
).toBe(18) ).toBe(19)
expect( expect(
current current
.prepare( .prepare(
@@ -630,9 +630,18 @@ describe('AssistantDatabase', () => {
const databasePath = join(directory, 'assistant.sqlite') const databasePath = join(directory, 'assistant.sqlite')
const database = new AssistantDatabase(databasePath) const database = new AssistantDatabase(databasePath)
database.initialize('C:\\Workspace') database.initialize('C:\\Workspace')
expect(database.claimChannelEvent('weixin', 'event-1')).toBe(true) expect(
expect(database.claimChannelEvent('weixin', 'event-1')).toBe(false) database.claimChannelEvent('weixin', 'account-1', 'event-1')
expect(database.claimChannelEvent('dingtalk', 'event-1')).toBe(true) ).toBe(true)
expect(
database.claimChannelEvent('weixin', 'account-1', 'event-1')
).toBe(false)
expect(
database.claimChannelEvent('weixin', 'account-2', 'event-1')
).toBe(true)
expect(
database.claimChannelEvent('dingtalk', 'account-1', 'event-1')
).toBe(true)
const entry = database.enqueueChannelResult({ const entry = database.enqueueChannelResult({
channel: 'weixin', channel: 'weixin',
@@ -656,12 +665,58 @@ describe('AssistantDatabase', () => {
const reopened = new AssistantDatabase(databasePath) const reopened = new AssistantDatabase(databasePath)
reopened.initialize('C:\\Workspace') reopened.initialize('C:\\Workspace')
expect(reopened.claimChannelEvent('weixin', 'event-1')).toBe( expect(
false reopened.claimChannelEvent('weixin', 'account-1', 'event-1')
) ).toBe(false)
reopened.close() reopened.close()
}) })
it('preserves legacy channel event claims while adding account identity', async () => {
const directory = await mkdtemp(
join(tmpdir(), 'goodbuddy-channel-event-migration-')
)
temporaryDirectories.push(directory)
const databasePath = join(directory, 'assistant.sqlite')
const initial = new AssistantDatabase(databasePath)
initial.initialize('C:\\Workspace')
initial.close()
const legacy = new DatabaseSync(databasePath)
legacy.exec(`
DROP TABLE channel_events;
CREATE TABLE channel_events (
channel TEXT NOT NULL,
event_id TEXT NOT NULL,
claimed_at INTEGER NOT NULL,
PRIMARY KEY(channel, event_id)
);
CREATE INDEX channel_events_claimed_at
ON channel_events(claimed_at);
INSERT INTO channel_events(channel, event_id, claimed_at)
VALUES ('weixin', 'legacy-event', 1);
PRAGMA user_version = 18;
`)
legacy.close()
const migrated = new AssistantDatabase(databasePath)
migrated.initialize('C:\\Workspace')
expect(
migrated.claimChannelEvent(
'weixin',
'default',
'legacy-event'
)
).toBe(false)
expect(
migrated.claimChannelEvent(
'weixin',
'new-account',
'legacy-event'
)
).toBe(true)
migrated.close()
})
it('safely deletes a confirmed project and its scoped data', async () => { it('safely deletes a confirmed project and its scoped data', async () => {
const database = await createDatabase() const database = await createDatabase()
const project = database.createProject({ const project = database.createProject({
+56 -10
View File
@@ -1636,15 +1636,19 @@ export class AssistantDatabase {
} }
} }
claimChannelEvent(channel: string, eventId: string): boolean { claimChannelEvent(
channel: string,
accountId: string,
eventId: string
): boolean {
const database = this.requireDatabase() const database = this.requireDatabase()
const result = database const result = database
.prepare( .prepare(
`INSERT OR IGNORE INTO channel_events `INSERT OR IGNORE INTO channel_events
(channel, event_id, claimed_at) (channel, account_id, event_id, claimed_at)
VALUES (?, ?, ?)` VALUES (?, ?, ?, ?)`
) )
.run(channel, eventId, Date.now()) .run(channel, accountId, eventId, Date.now())
if (result.changes === 1) { if (result.changes === 1) {
this.channelEventWrites += 1 this.channelEventWrites += 1
if (this.channelEventWrites % 128 === 0) { if (this.channelEventWrites % 128 === 0) {
@@ -1664,12 +1668,17 @@ export class AssistantDatabase {
return result.changes === 1 return result.changes === 1
} }
releaseChannelEvent(channel: string, eventId: string): void { releaseChannelEvent(
channel: string,
accountId: string,
eventId: string
): void {
this.requireDatabase() this.requireDatabase()
.prepare( .prepare(
'DELETE FROM channel_events WHERE channel = ? AND event_id = ?' `DELETE FROM channel_events
WHERE channel = ? AND account_id = ? AND event_id = ?`
) )
.run(channel, eventId) .run(channel, accountId, eventId)
} }
enqueueChannelResult(message: ChannelResultMessage): { enqueueChannelResult(message: ChannelResultMessage): {
@@ -4475,12 +4484,12 @@ export class AssistantDatabase {
const version = database const version = database
.prepare('PRAGMA user_version') .prepare('PRAGMA user_version')
.get() as { user_version: number } .get() as { user_version: number }
if (version.user_version > 18) { if (version.user_version > 19) {
throw new Error( throw new Error(
` GoodBuddy ${version.user_version}` ` GoodBuddy ${version.user_version}`
) )
} }
if (version.user_version === 18) { if (version.user_version === 19) {
return return
} }
if (version.user_version < 1) { if (version.user_version < 1) {
@@ -5162,9 +5171,10 @@ export class AssistantDatabase {
database.exec(` database.exec(`
CREATE TABLE IF NOT EXISTS channel_events ( CREATE TABLE IF NOT EXISTS channel_events (
channel TEXT NOT NULL, channel TEXT NOT NULL,
account_id TEXT NOT NULL DEFAULT 'default',
event_id TEXT NOT NULL, event_id TEXT NOT NULL,
claimed_at INTEGER NOT NULL, claimed_at INTEGER NOT NULL,
PRIMARY KEY(channel, event_id) PRIMARY KEY(channel, account_id, event_id)
); );
CREATE INDEX IF NOT EXISTS channel_events_claimed_at CREATE INDEX IF NOT EXISTS channel_events_claimed_at
ON channel_events(claimed_at); ON channel_events(claimed_at);
@@ -5353,6 +5363,42 @@ export class AssistantDatabase {
throw error throw error
} }
} }
if (version.user_version < 19) {
database.exec('BEGIN IMMEDIATE')
try {
const eventColumns = new Set(
(
database
.prepare('PRAGMA table_info(channel_events)')
.all() as Array<{ name: string }>
).map((column) => column.name)
)
if (!eventColumns.has('account_id')) {
database.exec(`
ALTER TABLE channel_events RENAME TO channel_events_legacy;
DROP INDEX IF EXISTS channel_events_claimed_at;
CREATE TABLE channel_events (
channel TEXT NOT NULL,
account_id TEXT NOT NULL DEFAULT 'default',
event_id TEXT NOT NULL,
claimed_at INTEGER NOT NULL,
PRIMARY KEY(channel, account_id, event_id)
);
INSERT INTO channel_events
(channel, account_id, event_id, claimed_at)
SELECT channel, 'default', event_id, claimed_at
FROM channel_events_legacy;
DROP TABLE channel_events_legacy;
CREATE INDEX channel_events_claimed_at
ON channel_events(claimed_at);
`)
}
database.exec('PRAGMA user_version = 19; COMMIT;')
} catch (error) {
database.exec('ROLLBACK')
throw error
}
}
} }
private requireDatabase(): DatabaseSync { private requireDatabase(): DatabaseSync {
@@ -100,7 +100,7 @@ describe('AssistantDatabase heartbeat persistence', () => {
).count ).count
check.close() check.close()
migrated.close() migrated.close()
expect(version).toBe(18) expect(version).toBe(19)
expect(heartbeatTableCount).toBe(3) expect(heartbeatTableCount).toBe(3)
}) })
+16 -8
View File
@@ -20,8 +20,16 @@ export interface ChannelDriver {
} }
export interface DedupStore { export interface DedupStore {
claim(channel: string, eventId: string): boolean | Promise<boolean> claim(
release(channel: string, eventId: string): void | Promise<void> channel: string,
accountId: string,
eventId: string
): boolean | Promise<boolean>
release(
channel: string,
accountId: string,
eventId: string
): void | Promise<void>
} }
export class MemoryDedupStore implements DedupStore { export class MemoryDedupStore implements DedupStore {
@@ -33,8 +41,8 @@ export class MemoryDedupStore implements DedupStore {
} }
} }
claim(channel: string, eventId: string): boolean { claim(channel: string, accountId: string, eventId: string): boolean {
const key = this.key(channel, eventId) const key = this.key(channel, accountId, eventId)
if (this.claimed.has(key)) { if (this.claimed.has(key)) {
return false return false
} }
@@ -50,16 +58,16 @@ export class MemoryDedupStore implements DedupStore {
return true return true
} }
release(channel: string, eventId: string): void { release(channel: string, accountId: string, eventId: string): void {
this.claimed.delete(this.key(channel, eventId)) this.claimed.delete(this.key(channel, accountId, eventId))
} }
clear(): void { clear(): void {
this.claimed.clear() this.claimed.clear()
} }
private key(channel: string, eventId: string): string { private key(channel: string, accountId: string, eventId: string): string {
return `${channel}\u0000${eventId}` return `${channel}\u0000${accountId}\u0000${eventId}`
} }
} }
+183 -6
View File
@@ -50,6 +50,7 @@ function inbound(
): ChannelInboundText { ): ChannelInboundText {
return { return {
channel: 'fake', channel: 'fake',
accountId: 'default',
eventId: 'event-1', eventId: 'event-1',
senderId: 'allowed-user', senderId: 'allowed-user',
conversationId: 'conversation-1', conversationId: 'conversation-1',
@@ -83,6 +84,7 @@ describe('channel contracts', () => {
}) })
).toEqual({ ).toEqual({
channel: 'fake', channel: 'fake',
accountId: 'default',
eventId: 'event-1', eventId: 'event-1',
senderId: 'user-1', senderId: 'user-1',
conversationId: 'direct-1', conversationId: 'direct-1',
@@ -135,7 +137,7 @@ describe('channel contracts', () => {
}) })
describe('ChannelService', () => { describe('ChannelService', () => {
it('acknowledges first and denies all senders when no allowlist is configured', async () => { it('acknowledges after accepting input and denies all senders when no allowlist is configured', async () => {
const driver = new FakeChannelDriver() const driver = new FakeChannelDriver()
const executor = vi.fn() const executor = vi.fn()
const service = new ChannelService(driver, executor) const service = new ChannelService(driver, executor)
@@ -149,6 +151,17 @@ describe('ChannelService', () => {
await service.stop() await service.stop()
}) })
it('does not acknowledge malformed input', async () => {
const driver = new FakeChannelDriver()
const service = new ChannelService(driver, vi.fn())
await service.start()
await driver.emit({ channel: 'fake' })
expect(driver.acknowledgements).toBe(0)
await service.stop()
})
it('executes an allowed request asynchronously with the normalized ask mode', async () => { it('executes an allowed request asynchronously with the normalized ask mode', async () => {
const driver = new FakeChannelDriver() const driver = new FakeChannelDriver()
let finish: ((value: { status: string; output: string }) => void) | undefined let finish: ((value: { status: string; output: string }) => void) | undefined
@@ -173,6 +186,9 @@ describe('ChannelService', () => {
}) })
expect(driver.acknowledgements).toBe(1) expect(driver.acknowledgements).toBe(1)
await vi.waitFor(() => {
expect(executor).toHaveBeenCalledOnce()
})
expect(executor).toHaveBeenCalledWith( expect(executor).toHaveBeenCalledWith(
expect.objectContaining({ expect.objectContaining({
text: '帮我分析', text: '帮我分析',
@@ -280,9 +296,10 @@ describe('ChannelService', () => {
it('deduplicates by channel and event id', async () => { it('deduplicates by channel and event id', async () => {
const store = new MemoryDedupStore() const store = new MemoryDedupStore()
expect(store.claim('first', 'same-id')).toBe(true) expect(store.claim('first', 'account-1', 'same-id')).toBe(true)
expect(store.claim('first', 'same-id')).toBe(false) expect(store.claim('first', 'account-1', 'same-id')).toBe(false)
expect(store.claim('second', 'same-id')).toBe(true) expect(store.claim('first', 'account-2', 'same-id')).toBe(true)
expect(store.claim('second', 'account-1', 'same-id')).toBe(true)
const driver = new FakeChannelDriver() const driver = new FakeChannelDriver()
const executor = vi.fn(async () => ({ const executor = vi.fn(async () => ({
@@ -303,6 +320,154 @@ describe('ChannelService', () => {
await service.stop() await service.stop()
}) })
it('does not deduplicate matching event ids from different accounts', async () => {
const driver = new FakeChannelDriver()
const executor = vi.fn(async () => ({
status: 'completed',
output: 'done'
}))
const service = new ChannelService(driver, executor, {
allowedSenderIds: ['allowed-user']
})
await service.start()
await driver.emit(
inbound({
accountId: 'account-1',
eventId: 'shared-event',
conversationId: 'shared-conversation'
})
)
await driver.emit(
inbound({
accountId: 'account-2',
eventId: 'shared-event',
conversationId: 'shared-conversation'
})
)
await waitForSent(driver, 2)
expect(executor).toHaveBeenCalledTimes(2)
await service.stop()
})
it('serializes requests from the same conversation', async () => {
const driver = new FakeChannelDriver()
const finishes: Array<() => void> = []
const executor = vi.fn(
(message: ChannelInboundText) =>
new Promise<{ status: string; output: string }>((resolve) => {
finishes.push(() =>
resolve({
status: 'completed',
output: message.eventId
})
)
})
)
const service = new ChannelService(driver, executor, {
allowedSenderIds: ['allowed-user'],
maximumConcurrency: 2
})
await service.start()
await driver.emit(inbound({ eventId: 'first' }))
await driver.emit(inbound({ eventId: 'second' }))
expect(executor).toHaveBeenCalledOnce()
finishes[0]?.()
await vi.waitFor(() => {
expect(executor).toHaveBeenCalledTimes(2)
})
finishes[1]?.()
await waitForSent(driver, 2)
expect(driver.sent.map((message) => message.output)).toEqual([
'first',
'second'
])
await service.stop()
})
it('keeps failed deliveries in the outbox without sending a second result', async () => {
class FailingDriver extends FakeChannelDriver {
attempts = 0
override async send(
message: ChannelResultMessage,
signal: AbortSignal
): Promise<void> {
void message
void signal
this.attempts += 1
throw new Error('offline')
}
}
const driver = new FailingDriver()
const outbox = new MemoryOutbox()
const service = new ChannelService(
driver,
async () => ({ status: 'completed', output: '完成' }),
{
allowedSenderIds: ['allowed-user'],
outbox
}
)
await service.start()
await driver.emit(inbound({ eventId: 'delivery-failure' }))
await vi.waitFor(() => {
expect(driver.attempts).toBe(1)
})
expect(await outbox.listUndelivered()).toEqual([
expect.objectContaining({
state: 'failed',
attempts: 1,
message: expect.objectContaining({
eventId: 'delivery-failure',
status: 'completed'
})
})
])
await service.stop()
})
it('releases the event claim when no durable result can be queued', async () => {
const driver = new FakeChannelDriver()
const store = new MemoryDedupStore()
const outbox = {
enqueue: vi.fn(() => {
throw new Error('database unavailable')
}),
markDelivered: vi.fn(),
markFailed: vi.fn(),
listUndelivered: vi.fn(() => [])
}
const deliveryFailure = vi.fn()
const executor = vi.fn(async () => ({
status: 'completed',
output: '完成'
}))
const service = new ChannelService(driver, executor, {
allowedSenderIds: ['allowed-user'],
dedupStore: store,
outbox,
onDeliveryFailure: deliveryFailure
})
await service.start()
await driver.emit(inbound({ eventId: 'retryable' }))
await vi.waitFor(() => {
expect(outbox.enqueue).toHaveBeenCalledOnce()
})
await driver.emit(inbound({ eventId: 'retryable' }))
await vi.waitFor(() => {
expect(outbox.enqueue).toHaveBeenCalledTimes(2)
})
expect(executor).toHaveBeenCalledTimes(2)
expect(deliveryFailure).toHaveBeenCalled()
await service.stop()
})
it('enforces concurrency and input length limits', async () => { it('enforces concurrency and input length limits', async () => {
const driver = new FakeChannelDriver() const driver = new FakeChannelDriver()
let finish: (() => void) | undefined let finish: (() => void) | undefined
@@ -320,8 +485,20 @@ describe('ChannelService', () => {
await service.start() await service.start()
await driver.emit(inbound({ eventId: 'active', text: '12345' })) await driver.emit(inbound({ eventId: 'active', text: '12345' }))
await driver.emit(inbound({ eventId: 'busy', text: '12345' })) await driver.emit(
await driver.emit(inbound({ eventId: 'too-long', text: '123456' })) inbound({
eventId: 'busy',
conversationId: 'conversation-2',
text: '12345'
})
)
await driver.emit(
inbound({
eventId: 'too-long',
conversationId: 'conversation-3',
text: '123456'
})
)
await waitForSent(driver, 2) await waitForSent(driver, 2)
expect(driver.sent).toEqual( expect(driver.sent).toEqual(
+137 -63
View File
@@ -89,8 +89,8 @@ export class ChannelService {
private readonly outbox: Outbox private readonly outbox: Outbox
private readonly onDeliveryFailure?: (error: unknown) => void private readonly onDeliveryFailure?: (error: unknown) => void
private readonly onDeliverySuccess?: () => void private readonly onDeliverySuccess?: () => void
private readonly tasks = new Set<Promise<void>>()
private readonly active = new Map<string, AbortController>() private readonly active = new Map<string, AbortController>()
private readonly conversationTails = new Map<string, Promise<void>>()
private state: ServiceState = 'idle' private state: ServiceState = 'idle'
private stopPromise?: Promise<void> private stopPromise?: Promise<void>
@@ -149,18 +149,17 @@ export class ChannelService {
this.state = 'running' this.state = 'running'
try { try {
await this.driver.start(async (rawMessage, acknowledge) => { await this.driver.start(async (rawMessage, acknowledge) => {
await acknowledge()
if (this.state !== 'running') { if (this.state !== 'running') {
await acknowledge()
return return
} }
const task = this.process(rawMessage).catch(() => { try {
// Processing failures are converted to bounded channel results. this.enqueue(rawMessage)
}) await acknowledge()
this.tasks.add(task) } catch (error) {
void task.finally(() => { this.onDeliveryFailure?.(error)
this.tasks.delete(task) }
})
}) })
await this.retryUndelivered() await this.retryUndelivered()
} catch (error) { } catch (error) {
@@ -170,9 +169,12 @@ export class ChannelService {
} }
cancel(eventId: string): boolean { cancel(eventId: string): boolean {
const controller = this.active.get( const suffix = `\u0000${eventId}`
this.activeKey(this.driver.channel, eventId) const controller = [...this.active.entries()].find(
) ([key]) =>
key.startsWith(`${this.driver.channel}\u0000`) &&
key.endsWith(suffix)
)?.[1]
if (!controller) { if (!controller) {
return false return false
} }
@@ -201,7 +203,7 @@ export class ChannelService {
const driverStop = Promise.resolve().then(() => this.driver.stop()) const driverStop = Promise.resolve().then(() => this.driver.stop())
const results = await Promise.allSettled([ const results = await Promise.allSettled([
driverStop, driverStop,
...this.tasks ...this.conversationTails.values()
]) ])
const driverResult = results[0] const driverResult = results[0]
if (driverResult?.status === 'rejected') { if (driverResult?.status === 'rejected') {
@@ -259,73 +261,96 @@ export class ChannelService {
const claimed = await this.dedupStore.claim( const claimed = await this.dedupStore.claim(
message.channel, message.channel,
message.accountId,
message.eventId message.eventId
) )
if (!claimed) { if (!claimed) {
return return
} }
if (message.text.length > this.maximumInputLength) { let durableResult = false
await this.deliver(
this.result(message, {
status: 'rejected',
error: `消息过长,最多允许 ${this.maximumInputLength} 个字符`
}),
new AbortController().signal
)
return
}
if (this.active.size >= this.maximumConcurrency) {
await this.deliver(
this.result(message, {
status: 'busy',
error: '当前请求较多,请稍后重试'
}),
new AbortController().signal
)
return
}
const key = this.activeKey(message.channel, message.eventId)
const controller = new AbortController()
this.active.set(key, controller)
try { try {
const rawResult = await this.execute(message, controller.signal) if (message.text.length > this.maximumInputLength) {
if (controller.signal.aborted) { durableResult = await this.tryDeliver(
await this.deliver(
this.result(message, { this.result(message, {
status: 'cancelled', status: 'rejected',
error: '请求已取消' error: `消息过长,最多允许 ${this.maximumInputLength} 个字符`
}), }),
new AbortController().signal new AbortController().signal
) )
return return
} }
const result = channelExecutorResultSchema.safeParse(rawResult) if (this.active.size >= this.maximumConcurrency) {
if (!result.success) { durableResult = await this.tryDeliver(
await this.deliver(
this.result(message, { this.result(message, {
status: 'failed', status: 'busy',
error: '请求返回了无效结果' error: '当前请求较多,请稍后重试'
}), }),
controller.signal new AbortController().signal
) )
return return
} }
await this.deliver(this.result(message, result.data), controller.signal)
} catch { const key = this.activeKey(
const cancelled = controller.signal.aborted message.channel,
await this.deliver( message.accountId,
this.result(message, { message.eventId
status: cancelled ? 'cancelled' : 'failed',
error: cancelled ? '请求已取消' : '请求处理失败'
}),
new AbortController().signal
) )
const controller = new AbortController()
this.active.set(key, controller)
try {
let rawResult: Awaited<ReturnType<ChannelExecutor>>
try {
rawResult = await this.execute(message, controller.signal)
} catch {
const cancelled = controller.signal.aborted
durableResult = await this.tryDeliver(
this.result(message, {
status: cancelled ? 'cancelled' : 'failed',
error: cancelled ? '请求已取消' : '请求处理失败'
}),
new AbortController().signal
)
return
}
if (controller.signal.aborted) {
durableResult = await this.tryDeliver(
this.result(message, {
status: 'cancelled',
error: '请求已取消'
}),
new AbortController().signal
)
return
}
const result = channelExecutorResultSchema.safeParse(rawResult)
if (!result.success) {
durableResult = await this.tryDeliver(
this.result(message, {
status: 'failed',
error: '请求返回了无效结果'
}),
controller.signal
)
return
}
durableResult = await this.tryDeliver(
this.result(message, result.data),
controller.signal
)
} finally {
this.active.delete(key)
}
} finally { } finally {
this.active.delete(key) if (!durableResult) {
await this.dedupStore.release(
message.channel,
message.accountId,
message.eventId
)
}
} }
} }
@@ -420,7 +445,7 @@ export class ChannelService {
private async deliver( private async deliver(
message: ChannelResultMessage, message: ChannelResultMessage,
signal: AbortSignal signal: AbortSignal
): Promise<void> { ): Promise<boolean> {
const entry = await this.outbox.enqueue(message) const entry = await this.outbox.enqueue(message)
try { try {
await this.driver.send(message, signal) await this.driver.send(message, signal)
@@ -429,11 +454,60 @@ export class ChannelService {
} catch (error) { } catch (error) {
await this.outbox.markFailed(entry.id) await this.outbox.markFailed(entry.id)
this.onDeliveryFailure?.(error) this.onDeliveryFailure?.(error)
throw error }
return true
}
private async tryDeliver(
message: ChannelResultMessage,
signal: AbortSignal
): Promise<boolean> {
try {
return await this.deliver(message, signal)
} catch (error) {
this.onDeliveryFailure?.(error)
return false
} }
} }
private activeKey(channel: string, eventId: string): string { private activeKey(
return `${channel}\u0000${eventId}` channel: string,
accountId: string,
eventId: string
): string {
return `${channel}\u0000${accountId}\u0000${eventId}`
}
private enqueue(rawMessage: unknown): void {
const parsed = channelInboundTextSchema.safeParse(rawMessage)
if (!parsed.success) {
throw new Error('通道消息格式无效')
}
if (parsed.data.channel !== this.driver.channel) {
throw new Error('通道消息来源不匹配')
}
const key =
`${parsed.data.channel}\u0000${parsed.data.accountId}` +
`\u0000${parsed.data.conversationId}`
const previous = this.conversationTails.get(key) ?? Promise.resolve()
const task =
this.conversationTails.has(key)
? previous
.catch(() => undefined)
.then(() => this.process(parsed.data))
: this.process(parsed.data)
const tail = task.then(
() => undefined,
() => undefined
)
this.conversationTails.set(key, tail)
void tail.finally(() => {
if (this.conversationTails.get(key) === tail) {
this.conversationTails.delete(key)
}
})
void task.catch(() => {
// The event claim is released when no durable result could be recorded.
})
} }
} }
@@ -66,6 +66,7 @@ describe('DingTalkChannelDriver', () => {
expect(messages).toEqual([ expect(messages).toEqual([
{ {
channel: 'dingtalk', channel: 'dingtalk',
accountId: 'client-id',
eventId: 'event-1', eventId: 'event-1',
senderId: 'user-1', senderId: 'user-1',
conversationId: 'conversation-1', conversationId: 'conversation-1',
@@ -177,4 +178,45 @@ describe('DingTalkChannelDriver', () => {
{ status: 'SUCCESS' } { status: 'SUCCESS' }
) )
}) })
it('rejects unsupported attachments without consuming the reply context', async () => {
const transport = new FakeTransport()
const driver = new DingTalkChannelDriver({
clientId: 'client-id',
clientSecret: 'client-secret',
allowedSenderIds: ['user-1'],
transportFactory: {
create: async () => transport
}
})
await driver.start(() => undefined)
await transport.listener?.(envelope('media-event'))
const message = {
channel: 'dingtalk' as const,
eventId: 'media-event',
conversationId: 'conversation-1',
recipientId: 'user-1',
status: 'completed',
output: '文件已生成',
attachments: [
{
name: 'result.txt',
mimeType: 'text/plain',
size: 2,
kind: 'file' as const,
dataBase64: 'b2s='
}
]
}
await expect(
driver.send(message, new AbortController().signal)
).rejects.toThrow('暂不支持发送附件')
await driver.send(
{ ...message, attachments: undefined },
new AbortController().signal
)
expect(transport.replyText).toHaveBeenCalledOnce()
await driver.stop()
})
}) })
+8 -1
View File
@@ -194,6 +194,7 @@ function resultText(message: ChannelResultMessage): string {
export class DingTalkChannelDriver implements ChannelDriver { export class DingTalkChannelDriver implements ChannelDriver {
readonly channel = 'dingtalk' readonly channel = 'dingtalk'
private readonly accountId: string
private readonly driver: DingTalkDriver private readonly driver: DingTalkDriver
private readonly maximumContexts: number private readonly maximumContexts: number
@@ -201,6 +202,7 @@ export class DingTalkChannelDriver implements ChannelDriver {
private handler?: ChannelInboundHandler private handler?: ChannelInboundHandler
constructor(options: DingTalkChannelDriverOptions) { constructor(options: DingTalkChannelDriverOptions) {
this.accountId = options.clientId
this.maximumContexts = maximumReplyContexts( this.maximumContexts = maximumReplyContexts(
options.maximumReplyContexts options.maximumReplyContexts
) )
@@ -230,6 +232,9 @@ export class DingTalkChannelDriver implements ChannelDriver {
message: ChannelResultMessage, message: ChannelResultMessage,
signal: AbortSignal signal: AbortSignal
): Promise<void> { ): Promise<void> {
if (message.attachments?.length) {
throw new Error('钉钉通道暂不支持发送附件')
}
const record = this.replyContexts.get(message.eventId) const record = this.replyContexts.get(message.eventId)
if ( if (
!record || !record ||
@@ -245,7 +250,8 @@ export class DingTalkChannelDriver implements ChannelDriver {
await this.driver.reply(record.context, resultText(message)) await this.driver.reply(record.context, resultText(message))
} catch { } catch {
throw new Error('钉钉消息回复失败') throw new Error('钉钉消息回复失败')
} finally { }
if (!message.attachments?.length) {
this.replyContexts.delete(message.eventId) this.replyContexts.delete(message.eventId)
} }
} }
@@ -276,6 +282,7 @@ export class DingTalkChannelDriver implements ChannelDriver {
this.enforceContextLimit() this.enforceContextLimit()
const inbound: ChannelInboundText = { const inbound: ChannelInboundText = {
channel: this.channel, channel: this.channel,
accountId: this.accountId,
eventId: message.dedupeKey, eventId: message.dedupeKey,
senderId: message.senderId, senderId: message.senderId,
conversationId: message.conversationId, conversationId: message.conversationId,
+4 -4
View File
@@ -9,12 +9,12 @@ import type { ChannelResultMessage } from '../../shared/channel-contracts'
export class SqliteChannelDedupStore implements DedupStore { export class SqliteChannelDedupStore implements DedupStore {
constructor(private readonly database: AssistantDatabase) {} constructor(private readonly database: AssistantDatabase) {}
claim(channel: string, eventId: string): boolean { claim(channel: string, accountId: string, eventId: string): boolean {
return this.database.claimChannelEvent(channel, eventId) return this.database.claimChannelEvent(channel, accountId, eventId)
} }
release(channel: string, eventId: string): void { release(channel: string, accountId: string, eventId: string): void {
this.database.releaseChannelEvent(channel, eventId) this.database.releaseChannelEvent(channel, accountId, eventId)
} }
} }
@@ -77,6 +77,7 @@ describe('WechatChannelDriver', () => {
expect(handler).toHaveBeenCalledWith( expect(handler).toHaveBeenCalledWith(
expect.objectContaining({ expect.objectContaining({
channel: 'weixin', channel: 'weixin',
accountId: 'bot-account',
eventId: 'event-1', eventId: 'event-1',
senderId: 'sender-1', senderId: 'sender-1',
workMode: 'ask', workMode: 'ask',
@@ -186,6 +186,7 @@ export class WechatChannelDriver implements ChannelDriver {
this.handler?.( this.handler?.(
{ {
channel: this.channel, channel: this.channel,
accountId: this.settings.accountId,
eventId: message.eventId, eventId: message.eventId,
senderId: message.senderId, senderId: message.senderId,
conversationId: message.conversationId, conversationId: message.conversationId,
@@ -87,6 +87,7 @@ describe('WeComChannelDriver', () => {
transport.emit(groupFrame('event-2', 'request-2')) transport.emit(groupFrame('event-2', 'request-2'))
expect(messages[0]).toEqual({ expect(messages[0]).toEqual({
channel: 'wecom', channel: 'wecom',
accountId: 'bot-1',
eventId: 'event-1', eventId: 'event-1',
senderId: 'user-1', senderId: 'user-1',
conversationId: 'group-1', conversationId: 'group-1',
@@ -130,4 +131,42 @@ describe('WeComChannelDriver', () => {
await driver.stop() await driver.stop()
expect(transport.disconnect).toHaveBeenCalledOnce() expect(transport.disconnect).toHaveBeenCalledOnce()
}) })
it('rejects unsupported attachments without consuming the reply context', async () => {
const transport = new FakeTransport()
const driver = new WeComChannelDriver({
botId: 'bot-1',
secret: 'secret',
transportFactory: () => transport
})
await driver.start(() => undefined)
transport.emit(groupFrame('media-event', 'media-request'))
const message = {
channel: 'wecom' as const,
eventId: 'media-event',
conversationId: 'group-1',
recipientId: 'user-1',
status: 'completed',
output: '文件已生成',
attachments: [
{
name: 'result.txt',
mimeType: 'text/plain',
size: 2,
kind: 'file' as const,
dataBase64: 'b2s='
}
]
}
await expect(
driver.send(message, new AbortController().signal)
).rejects.toThrow('回复失败')
await driver.send(
{ ...message, attachments: undefined },
new AbortController().signal
)
expect(transport.replyStream).toHaveBeenCalledOnce()
await driver.stop()
})
}) })
+7 -2
View File
@@ -40,12 +40,14 @@ function resultText(message: ChannelResultMessage): string {
export class WeComChannelDriver implements ChannelDriver { export class WeComChannelDriver implements ChannelDriver {
readonly channel = 'wecom' readonly channel = 'wecom'
private readonly accountId: string
private readonly driver: WeComDriver private readonly driver: WeComDriver
private readonly maximumContexts: number private readonly maximumContexts: number
private readonly replyContexts = new Map<string, ReplyRecord>() private readonly replyContexts = new Map<string, ReplyRecord>()
private handler?: ChannelInboundHandler private handler?: ChannelInboundHandler
constructor(options: WeComChannelDriverOptions) { constructor(options: WeComChannelDriverOptions) {
this.accountId = options.botId
this.maximumContexts = maximumReplyContexts( this.maximumContexts = maximumReplyContexts(
options.maximumReplyContexts options.maximumReplyContexts
) )
@@ -86,11 +88,13 @@ export class WeComChannelDriver implements ChannelDriver {
try { try {
signal.throwIfAborted() signal.throwIfAborted()
await this.driver.reply(record.context, { await this.driver.reply(record.context, {
text: resultText(message) text: resultText(message),
attachments: message.attachments
}) })
} catch { } catch {
throw new Error('企业微信消息回复失败') throw new Error('企业微信消息回复失败')
} finally { }
if (!message.attachments?.length) {
this.replyContexts.delete(message.eventId) this.replyContexts.delete(message.eventId)
} }
} }
@@ -119,6 +123,7 @@ export class WeComChannelDriver implements ChannelDriver {
this.enforceContextLimit() this.enforceContextLimit()
const inbound: ChannelInboundText = { const inbound: ChannelInboundText = {
channel: this.channel, channel: this.channel,
accountId: this.accountId,
eventId: message.eventId, eventId: message.eventId,
senderId: message.userId, senderId: message.userId,
conversationId: message.conversationId, conversationId: message.conversationId,
+26 -26
View File
@@ -1571,30 +1571,30 @@ export function registerIpcHandlers(
contextManager.remove(contextId) contextManager.remove(contextId)
} }
} }
const remoteConversation = try {
assistantDatabase.getOrCreateRemoteConversation({ const remoteConversation =
projectId: project.id, assistantDatabase.getOrCreateRemoteConversation({
channel, projectId: project.id,
accountId: 'default', channel,
externalConversationId: message.conversationId, accountId: message.accountId,
conversationType: message.conversationType, externalConversationId: message.conversationId,
title: `${channelLabel} · ****${identitySuffix}`, conversationType: message.conversationType,
accountDisplay: senderDisplay, title: `${channelLabel} · ****${identitySuffix}`,
runtimeSelection accountDisplay: senderDisplay,
runtimeSelection
})
assistantDatabase.appendRemoteConversationMessage({
conversationId: remoteConversation.id,
role: 'user',
content: parsed.prompt,
attachments: publicAttachments,
status: `${channelLabel} · ${
parsed.workMode === 'execute'
? '执行'
: '对话'
}`
}) })
assistantDatabase.appendRemoteConversationMessage({ publishRemoteConversationChange()
conversationId: remoteConversation.id,
role: 'user',
content: parsed.prompt,
attachments: publicAttachments,
status: `${channelLabel} · ${
parsed.workMode === 'execute'
? '执行'
: '对话'
}`
})
publishRemoteConversationChange()
const remoteTaskId = randomUUID() const remoteTaskId = randomUUID()
assistantDatabase.createTask({ assistantDatabase.createTask({
id: remoteTaskId, id: remoteTaskId,
@@ -1658,7 +1658,6 @@ export function registerIpcHandlers(
detail: unavailable, detail: unavailable,
status: 'failed' status: 'failed'
}) })
releaseRemoteContexts()
return { status: 'failed', error: unavailable } return { status: 'failed', error: unavailable }
} }
if ( if (
@@ -1692,7 +1691,6 @@ export function registerIpcHandlers(
detail: unavailable, detail: unavailable,
status: 'failed' status: 'failed'
}) })
releaseRemoteContexts()
return { status: 'failed', error: unavailable } return { status: 'failed', error: unavailable }
} }
} }
@@ -1781,8 +1779,10 @@ export function registerIpcHandlers(
status: status:
result.status === 'completed' ? 'completed' : 'failed' result.status === 'completed' ? 'completed' : 'failed'
}) })
releaseRemoteContexts()
return result return result
} finally {
releaseRemoteContexts()
}
} }
const channelManager = channelSettingsStore const channelManager = channelSettingsStore
? new ChannelManager(channelSettingsStore, channelExecutor, { ? new ChannelManager(channelSettingsStore, channelExecutor, {
+1
View File
@@ -105,6 +105,7 @@ export const channelInboundTextSchema = z
.trim() .trim()
.min(1) .min(1)
.max(CHANNEL_LIMITS.maximumChannelLength), .max(CHANNEL_LIMITS.maximumChannelLength),
accountId: channelIdentifierSchema.default('default'),
eventId: z eventId: z
.string() .string()
.trim() .trim()