fix: preserve channel message delivery
This commit is contained in:
@@ -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({
|
||||||
|
|||||||
@@ -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)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -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}`
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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()
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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()
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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
@@ -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, {
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
Reference in New Issue
Block a user