feat(discord): faster reaction status state machine (watchdog + debounce) (#18248)

* fix(discord): avoid unnecessary message fetches in reaction notifications

* style(discord): format reaction listener for CI

* feat(discord): add reaction status machine and fix tool/final wiring

* fix(discord): harden reaction status transitions and cleanup

* revert(discord): restore status-machine flow from 0a5a72204

* fix(auto-reply): restore lifecycle callback forwarding for channels

* chore(ci): add daily upstream sync workflow for custom branch

* fix(discord): non-blocking reactions and robust cleanup

* chore: remove unrelated workflow from Discord-only PR

* Discord: streamline reaction handling

* Docs: add Discord reaction changelog

---------

Co-authored-by: Shadow <hi@shadowing.dev>
This commit is contained in:
victor-wu.eth
2026-02-17 04:38:39 +09:00
committed by GitHub
parent c953cfdee7
commit 7c240a2b58
8 changed files with 790 additions and 135 deletions

View File

@@ -774,11 +774,22 @@ function makeReactionEvent(overrides?: {
messageId?: string;
emojiName?: string;
botAsAuthor?: boolean;
guild?: { name?: string };
messageAuthorId?: string;
messageFetch?: ReturnType<typeof vi.fn>;
guild?: { name?: string; id?: string };
}) {
const userId = overrides?.userId ?? "user-1";
const messageId = overrides?.messageId ?? "msg-1";
const channelId = overrides?.channelId ?? "channel-1";
const messageFetch =
overrides?.messageFetch ??
vi.fn(async () => ({
author: {
id: overrides?.messageAuthorId ?? (overrides?.botAsAuthor ? "bot-1" : "other-user"),
username: overrides?.botAsAuthor ? "bot" : "otheruser",
discriminator: "0",
},
}));
return {
guild_id: overrides?.guildId,
channel_id: channelId,
@@ -792,23 +803,30 @@ function makeReactionEvent(overrides?: {
discriminator: "0",
},
message: {
fetch: vi.fn(async () => ({
author: {
id: overrides?.botAsAuthor ? "bot-1" : "other-user",
username: overrides?.botAsAuthor ? "bot" : "otheruser",
discriminator: "0",
},
})),
fetch: messageFetch,
},
} as unknown as Parameters<DiscordReactionListener["handle"]>[0];
}
function makeReactionClient(channelType: ChannelType = ChannelType.DM) {
function makeReactionClient(options?: {
channelType?: ChannelType;
channelName?: string;
parentId?: string;
parentName?: string;
}) {
const channelType = options?.channelType ?? ChannelType.DM;
const channelName =
options?.channelName ?? (channelType === ChannelType.DM ? undefined : "test-channel");
const parentId = options?.parentId;
const parentName = options?.parentName ?? "parent-channel";
return {
fetchChannel: vi.fn(async () => ({
type: channelType,
name: channelType === ChannelType.DM ? undefined : "test-channel",
})),
fetchChannel: vi.fn(async (channelId: string) => {
if (parentId && channelId === parentId) {
return { type: ChannelType.GuildText, name: parentName, parentId: undefined };
}
return { type: channelType, name: channelName, parentId };
}),
} as unknown as Parameters<DiscordReactionListener["handle"]>[1];
}
@@ -837,7 +855,7 @@ describe("discord DM reaction handling", () => {
resolveAgentRouteMock.mockClear();
const data = makeReactionEvent({ botAsAuthor: true });
const client = makeReactionClient(ChannelType.DM);
const client = makeReactionClient({ channelType: ChannelType.DM });
const listener = new DiscordReactionListener(makeReactionListenerParams());
await listener.handle(data, client);
@@ -854,7 +872,7 @@ describe("discord DM reaction handling", () => {
resolveAgentRouteMock.mockClear();
const data = makeReactionEvent({ botAsAuthor: true });
const client = makeReactionClient(ChannelType.DM);
const client = makeReactionClient({ channelType: ChannelType.DM });
const guildEntries = makeEntries({
"guild-123": { slug: "guild-123" },
});
@@ -880,7 +898,7 @@ describe("discord DM reaction handling", () => {
botAsAuthor: true,
guild: { name: "Test Guild" },
});
const client = makeReactionClient(ChannelType.GuildText);
const client = makeReactionClient({ channelType: ChannelType.GuildText });
const listener = new DiscordReactionListener(makeReactionListenerParams());
await listener.handle(data, client);
@@ -895,7 +913,7 @@ describe("discord DM reaction handling", () => {
resolveAgentRouteMock.mockClear();
const data = makeReactionEvent({ botAsAuthor: true });
const client = makeReactionClient(ChannelType.DM);
const client = makeReactionClient({ channelType: ChannelType.DM });
const listener = new DiscordReactionListener(makeReactionListenerParams());
await listener.handle(data, client);
@@ -911,7 +929,7 @@ describe("discord DM reaction handling", () => {
resolveAgentRouteMock.mockClear();
const data = makeReactionEvent({ userId: "user-42", botAsAuthor: true });
const client = makeReactionClient(ChannelType.DM);
const client = makeReactionClient({ channelType: ChannelType.DM });
const listener = new DiscordReactionListener(makeReactionListenerParams());
await listener.handle(data, client);
@@ -926,7 +944,7 @@ describe("discord DM reaction handling", () => {
resolveAgentRouteMock.mockClear();
const data = makeReactionEvent({ botAsAuthor: true });
const client = makeReactionClient(ChannelType.GroupDM);
const client = makeReactionClient({ channelType: ChannelType.GroupDM });
const listener = new DiscordReactionListener(makeReactionListenerParams());
await listener.handle(data, client);
@@ -936,3 +954,116 @@ describe("discord DM reaction handling", () => {
expect(routeArgs.peer).toEqual({ kind: "group", id: "channel-1" });
});
});
describe("discord reaction notification modes", () => {
const guildId = "guild-900";
const guild = fakeGuild(guildId, "Mode Guild");
it("skips message fetch when mode is off", async () => {
enqueueSystemEventSpy.mockClear();
resolveAgentRouteMock.mockClear();
const messageFetch = vi.fn(async () => ({
author: { id: "bot-1", username: "bot", discriminator: "0" },
}));
const data = makeReactionEvent({ guildId, guild, messageFetch });
const client = makeReactionClient({ channelType: ChannelType.GuildText });
const guildEntries = makeEntries({
[guildId]: { reactionNotifications: "off" },
});
const listener = new DiscordReactionListener(makeReactionListenerParams({ guildEntries }));
await listener.handle(data, client);
expect(messageFetch).not.toHaveBeenCalled();
expect(enqueueSystemEventSpy).not.toHaveBeenCalled();
});
it("skips message fetch when mode is all", async () => {
enqueueSystemEventSpy.mockClear();
resolveAgentRouteMock.mockClear();
const messageFetch = vi.fn(async () => ({
author: { id: "other-user", username: "other", discriminator: "0" },
}));
const data = makeReactionEvent({ guildId, guild, messageFetch });
const client = makeReactionClient({ channelType: ChannelType.GuildText });
const guildEntries = makeEntries({
[guildId]: { reactionNotifications: "all" },
});
const listener = new DiscordReactionListener(makeReactionListenerParams({ guildEntries }));
await listener.handle(data, client);
expect(messageFetch).not.toHaveBeenCalled();
expect(enqueueSystemEventSpy).toHaveBeenCalledOnce();
});
it("skips message fetch when mode is allowlist", async () => {
enqueueSystemEventSpy.mockClear();
resolveAgentRouteMock.mockClear();
const messageFetch = vi.fn(async () => ({
author: { id: "other-user", username: "other", discriminator: "0" },
}));
const data = makeReactionEvent({ guildId, guild, userId: "123", messageFetch });
const client = makeReactionClient({ channelType: ChannelType.GuildText });
const guildEntries = makeEntries({
[guildId]: { reactionNotifications: "allowlist", users: ["123"] },
});
const listener = new DiscordReactionListener(makeReactionListenerParams({ guildEntries }));
await listener.handle(data, client);
expect(messageFetch).not.toHaveBeenCalled();
expect(enqueueSystemEventSpy).toHaveBeenCalledOnce();
});
it("fetches message when mode is own", async () => {
enqueueSystemEventSpy.mockClear();
resolveAgentRouteMock.mockClear();
const messageFetch = vi.fn(async () => ({
author: { id: "bot-1", username: "bot", discriminator: "0" },
}));
const data = makeReactionEvent({ guildId, guild, messageFetch });
const client = makeReactionClient({ channelType: ChannelType.GuildText });
const guildEntries = makeEntries({
[guildId]: { reactionNotifications: "own" },
});
const listener = new DiscordReactionListener(makeReactionListenerParams({ guildEntries }));
await listener.handle(data, client);
expect(messageFetch).toHaveBeenCalledOnce();
expect(enqueueSystemEventSpy).toHaveBeenCalledOnce();
});
it("skips message fetch for thread channels in all mode", async () => {
enqueueSystemEventSpy.mockClear();
resolveAgentRouteMock.mockClear();
const messageFetch = vi.fn(async () => ({
author: { id: "other-user", username: "other", discriminator: "0" },
}));
const data = makeReactionEvent({
guildId,
guild,
channelId: "thread-1",
messageFetch,
});
const client = makeReactionClient({
channelType: ChannelType.PublicThread,
parentId: "parent-1",
});
const guildEntries = makeEntries({
[guildId]: { reactionNotifications: "all" },
});
const listener = new DiscordReactionListener(makeReactionListenerParams({ guildEntries }));
await listener.handle(data, client);
expect(messageFetch).not.toHaveBeenCalled();
expect(enqueueSystemEventSpy).toHaveBeenCalledOnce();
});
});