mirror of
https://github.com/hansjone/dsh-im-ops.git
synced 2026-10-09 05:20:46 +08:00
fix(discord): close thread recovery races
This commit is contained in:
parent
2609ae20e8
commit
5b3db16097
3 changed files with 299 additions and 95 deletions
|
|
@ -671,6 +671,95 @@ test('Discord recovers an already-created thread after an uncertain or duplicate
|
|||
assert.equal(route.conversationRoute.threadId, message.id);
|
||||
assert.equal(route.conversationRoute.managed, true);
|
||||
assert.deepEqual(route.replyTarget, { channelId: message.id });
|
||||
|
||||
const foreignMessage = {
|
||||
...message,
|
||||
id: '111111111111111151',
|
||||
content: `<@${botId}> recover foreign thread`,
|
||||
};
|
||||
const foreign = await resolveDiscordMessageRoute(foreignMessage, botId, {
|
||||
channel: { id: foreignMessage.channel_id, type: 0 },
|
||||
api: {
|
||||
async getChannel({ channelId }) {
|
||||
assert.equal(channelId, foreignMessage.id);
|
||||
return {
|
||||
id: foreignMessage.id,
|
||||
type: 11,
|
||||
parent_id: foreignMessage.channel_id,
|
||||
owner_id: '999999999999999999',
|
||||
};
|
||||
},
|
||||
async startThreadFromMessage() {
|
||||
const error = new Error('Thread already exists');
|
||||
error.status = 400;
|
||||
throw error;
|
||||
},
|
||||
},
|
||||
});
|
||||
assert.equal(foreign.addressed, true, 'the explicit source mention still starts this turn');
|
||||
assert.equal(foreign.requiresMention, true);
|
||||
assert.equal(foreign.conversationRoute.managed, false);
|
||||
assert.deepEqual(foreign.replyTarget, { channelId: foreignMessage.id });
|
||||
});
|
||||
|
||||
test('Discord converges a post-dispatch abort but sends no create request after a pre-dispatch abort', async () => {
|
||||
const botId = '1234567890123456789';
|
||||
const base = {
|
||||
channel_id: '222222222222222252',
|
||||
guild_id: '444444444444444444',
|
||||
author: { id: '333333333333333333', bot: false },
|
||||
mentions: [{ id: botId }],
|
||||
content: `<@${botId}> abort boundary`,
|
||||
};
|
||||
const before = new AbortController();
|
||||
const beforeReason = new DOMException('cancelled before dispatch', 'AbortError');
|
||||
before.abort(beforeReason);
|
||||
let preDispatchCalls = 0;
|
||||
await assert.rejects(() => resolveDiscordMessageRoute({
|
||||
...base,
|
||||
id: '111111111111111152',
|
||||
}, botId, {
|
||||
channel: { id: base.channel_id, type: 0 },
|
||||
signal: before.signal,
|
||||
api: {
|
||||
async getChannel() { preDispatchCalls += 1; },
|
||||
async startThreadFromMessage() { preDispatchCalls += 1; },
|
||||
},
|
||||
}), (error) => error === beforeReason);
|
||||
assert.equal(preDispatchCalls, 0);
|
||||
|
||||
const after = new AbortController();
|
||||
const afterReason = new DOMException('cancelled after dispatch', 'AbortError');
|
||||
const message = { ...base, id: '111111111111111153' };
|
||||
let starts = 0;
|
||||
let lookups = 0;
|
||||
const recovered = await resolveDiscordMessageRoute(message, botId, {
|
||||
channel: { id: message.channel_id, type: 0 },
|
||||
signal: after.signal,
|
||||
api: {
|
||||
async startThreadFromMessage() {
|
||||
starts += 1;
|
||||
after.abort(afterReason);
|
||||
throw afterReason;
|
||||
},
|
||||
async getChannel({ channelId, signal }) {
|
||||
lookups += 1;
|
||||
assert.equal(channelId, message.id);
|
||||
assert.notEqual(signal, after.signal);
|
||||
assert.equal(signal.aborted, false);
|
||||
return {
|
||||
id: message.id,
|
||||
type: 11,
|
||||
parent_id: message.channel_id,
|
||||
owner_id: botId,
|
||||
};
|
||||
},
|
||||
},
|
||||
});
|
||||
assert.equal(starts, 1);
|
||||
assert.equal(lookups, 1);
|
||||
assert.equal(recovered.conversationId, message.id);
|
||||
assert.equal(recovered.conversationRoute.managed, true);
|
||||
});
|
||||
|
||||
test('Discord falls back before routing when the parent is unsupported or thread creation is rejected', async () => {
|
||||
|
|
@ -794,6 +883,67 @@ test('Discord merges a deterministic Thread fallback notice into one delivered a
|
|||
assert.equal(edits[0].content, `${streamTarget.notice}\n\nstreamed answer`);
|
||||
});
|
||||
|
||||
test('Discord keeps streamed text and result files on the final created Thread target', async () => {
|
||||
const botId = '1234567890123456789';
|
||||
const message = {
|
||||
id: '111111111111111165',
|
||||
channel_id: '222222222222222265',
|
||||
guild_id: '444444444444444444',
|
||||
author: { id: '333333333333333333', bot: false },
|
||||
mentions: [{ id: botId }],
|
||||
content: `<@${botId}> deliver in thread`,
|
||||
};
|
||||
const operations = [];
|
||||
const api = {
|
||||
async getChannel() {
|
||||
assert.fail('the cached parent channel must be reused');
|
||||
},
|
||||
async startThreadFromMessage() {
|
||||
return {
|
||||
id: message.id,
|
||||
type: 11,
|
||||
parent_id: message.channel_id,
|
||||
owner_id: botId,
|
||||
};
|
||||
},
|
||||
async createMessage(options) {
|
||||
operations.push({ operation: 'create', ...options });
|
||||
return { id: '888888888888888881' };
|
||||
},
|
||||
async editMessage(options) {
|
||||
operations.push({ operation: 'edit', ...options });
|
||||
return { id: options.messageId };
|
||||
},
|
||||
async createFileMessage(options) {
|
||||
operations.push({ operation: 'file', ...options });
|
||||
return { id: '888888888888888882' };
|
||||
},
|
||||
async sendTyping(options) {
|
||||
operations.push({ operation: 'typing', ...options });
|
||||
},
|
||||
};
|
||||
const route = await resolveDiscordMessageRoute(message, botId, {
|
||||
channel: { id: message.channel_id, type: 0 },
|
||||
api,
|
||||
});
|
||||
const client = new DiscordBotClient({ api });
|
||||
await client.sendTyping(route.replyTarget);
|
||||
const stream = await client.openStream(route.replyTarget);
|
||||
await stream.finish('thread final answer');
|
||||
await client.sendFile(route.replyTarget, {
|
||||
fileName: 'result.txt',
|
||||
bytes: Buffer.from('thread result'),
|
||||
});
|
||||
|
||||
assert.deepEqual(operations.map(({ operation }) => operation), [
|
||||
'typing', 'create', 'edit', 'file',
|
||||
]);
|
||||
assert.equal(operations.every(({ channelId }) => channelId === message.id), true);
|
||||
assert.equal(operations.some(({ channelId }) => channelId === message.channel_id), false);
|
||||
assert.equal(operations.find(({ operation }) => operation === 'create').replyToMessageId, undefined);
|
||||
assert.equal(operations.find(({ operation }) => operation === 'file').replyToMessageId, undefined);
|
||||
});
|
||||
|
||||
test('Discord keeps image attachments in images and exposes ordinary attachments as files', async () => {
|
||||
const png = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
|
||||
const calls = [];
|
||||
|
|
@ -967,7 +1117,7 @@ test('Discord runtime keeps one isolated Session per managed thread and reuses i
|
|||
hasSeen: (messageId) => seen.has(messageId),
|
||||
async markSeen(messageId) { seen.add(messageId); },
|
||||
};
|
||||
const runtime = new DiscordRuntime({
|
||||
const createRuntime = () => new DiscordRuntime({
|
||||
config: { botId: 'discord_test', platformId: botId, name: 'Harness Discord' },
|
||||
token: TOKEN,
|
||||
harness,
|
||||
|
|
@ -983,6 +1133,7 @@ test('Discord runtime keeps one isolated Session per managed thread and reuses i
|
|||
random: () => 0.5,
|
||||
logger: { warn() {}, error(...args) { assert.fail(args.join(' ')); } },
|
||||
});
|
||||
let runtime = createRuntime();
|
||||
await runtime.start();
|
||||
socket.emit('message', {
|
||||
data: JSON.stringify({
|
||||
|
|
@ -1133,6 +1284,50 @@ test('Discord runtime keeps one isolated Session per managed thread and reuses i
|
|||
);
|
||||
assert.equal(sessions.has(`group:${parentChannelId}`), false);
|
||||
await runtime.stop();
|
||||
|
||||
runtime = createRuntime();
|
||||
await runtime.start();
|
||||
socket.emit('message', {
|
||||
data: JSON.stringify({
|
||||
op: 0,
|
||||
t: 'GUILD_CREATE',
|
||||
s: 20,
|
||||
d: {
|
||||
id: '444444444444444444',
|
||||
channels: [{ id: parentChannelId, type: 0 }],
|
||||
threads: [{
|
||||
id: firstMessage.id,
|
||||
type: 11,
|
||||
parent_id: parentChannelId,
|
||||
owner_id: botId,
|
||||
}],
|
||||
},
|
||||
}),
|
||||
});
|
||||
socket.emit('message', {
|
||||
data: JSON.stringify({
|
||||
op: 0,
|
||||
t: 'MESSAGE_CREATE',
|
||||
s: 21,
|
||||
d: {
|
||||
id: '111111111111111174',
|
||||
channel_id: firstMessage.id,
|
||||
guild_id: '444444444444444444',
|
||||
author: { id: '333333333333333330', bot: false },
|
||||
mentions: [],
|
||||
content: 'continue after restart',
|
||||
},
|
||||
}),
|
||||
});
|
||||
await eventually(() => runtime.status.messagesReplied === 1);
|
||||
assert.deepEqual(asks[5], {
|
||||
sessionId: 'session-1',
|
||||
text: 'continue after restart',
|
||||
files: 0,
|
||||
});
|
||||
assert.equal(starts.length, 3, 'restart must reuse the persisted bot-owned Thread');
|
||||
assert.equal(sessions.get(`group:${firstMessage.id}`), 'session-1');
|
||||
await runtime.stop();
|
||||
});
|
||||
|
||||
test('Discord runtime records one uncertain Thread result and suppresses Gateway replays', async () => {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue