refactor(src): split oversized modules
This commit is contained in:
BIN
src/discord/.DS_Store
vendored
Normal file
BIN
src/discord/.DS_Store
vendored
Normal file
Binary file not shown.
451
src/discord/monitor.tool-result.part-1.test.ts
Normal file
451
src/discord/monitor.tool-result.part-1.test.ts
Normal file
@@ -0,0 +1,451 @@
|
||||
import type { Client } from "@buape/carbon";
|
||||
import { ChannelType, MessageType } from "@buape/carbon";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const sendMock = vi.fn();
|
||||
const reactMock = vi.fn();
|
||||
const updateLastRouteMock = vi.fn();
|
||||
const dispatchMock = vi.fn();
|
||||
const readAllowFromStoreMock = vi.fn();
|
||||
const upsertPairingRequestMock = vi.fn();
|
||||
|
||||
vi.mock("./send.js", () => ({
|
||||
sendMessageDiscord: (...args: unknown[]) => sendMock(...args),
|
||||
reactMessageDiscord: async (...args: unknown[]) => {
|
||||
reactMock(...args);
|
||||
},
|
||||
}));
|
||||
vi.mock("../auto-reply/reply/dispatch-from-config.js", () => ({
|
||||
dispatchReplyFromConfig: (...args: unknown[]) => dispatchMock(...args),
|
||||
}));
|
||||
vi.mock("../pairing/pairing-store.js", () => ({
|
||||
readChannelAllowFromStore: (...args: unknown[]) =>
|
||||
readAllowFromStoreMock(...args),
|
||||
upsertChannelPairingRequest: (...args: unknown[]) =>
|
||||
upsertPairingRequestMock(...args),
|
||||
}));
|
||||
vi.mock("../config/sessions.js", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("../config/sessions.js")>();
|
||||
return {
|
||||
...actual,
|
||||
resolveStorePath: vi.fn(() => "/tmp/clawdbot-sessions.json"),
|
||||
updateLastRoute: (...args: unknown[]) => updateLastRouteMock(...args),
|
||||
resolveSessionKey: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
sendMock.mockReset().mockResolvedValue(undefined);
|
||||
updateLastRouteMock.mockReset();
|
||||
dispatchMock.mockReset().mockImplementation(async ({ dispatcher }) => {
|
||||
dispatcher.sendFinalReply({ text: "hi" });
|
||||
return { queuedFinal: true, counts: { final: 1 } };
|
||||
});
|
||||
readAllowFromStoreMock.mockReset().mockResolvedValue([]);
|
||||
upsertPairingRequestMock
|
||||
.mockReset()
|
||||
.mockResolvedValue({ code: "PAIRCODE", created: true });
|
||||
vi.resetModules();
|
||||
});
|
||||
|
||||
describe("discord tool result dispatch", () => {
|
||||
it("sends status replies with responsePrefix", async () => {
|
||||
const { createDiscordMessageHandler } = await import("./monitor.js");
|
||||
const cfg = {
|
||||
agents: {
|
||||
defaults: {
|
||||
model: "anthropic/claude-opus-4-5",
|
||||
workspace: "/tmp/clawd",
|
||||
},
|
||||
},
|
||||
session: { store: "/tmp/clawdbot-sessions.json" },
|
||||
messages: { responsePrefix: "PFX" },
|
||||
channels: { discord: { dm: { enabled: true, policy: "open" } } },
|
||||
} as ReturnType<typeof import("../config/config.js").loadConfig>;
|
||||
|
||||
const runtimeError = vi.fn();
|
||||
const handler = createDiscordMessageHandler({
|
||||
cfg,
|
||||
discordConfig: cfg.channels.discord,
|
||||
accountId: "default",
|
||||
token: "token",
|
||||
runtime: {
|
||||
log: vi.fn(),
|
||||
error: runtimeError,
|
||||
exit: (code: number): never => {
|
||||
throw new Error(`exit ${code}`);
|
||||
},
|
||||
},
|
||||
botUserId: "bot-id",
|
||||
guildHistories: new Map(),
|
||||
historyLimit: 0,
|
||||
mediaMaxBytes: 10_000,
|
||||
textLimit: 2000,
|
||||
replyToMode: "off",
|
||||
dmEnabled: true,
|
||||
groupDmEnabled: false,
|
||||
});
|
||||
|
||||
const client = {
|
||||
fetchChannel: vi.fn().mockResolvedValue({
|
||||
type: ChannelType.DM,
|
||||
name: "dm",
|
||||
}),
|
||||
} as unknown as Client;
|
||||
|
||||
await handler(
|
||||
{
|
||||
message: {
|
||||
id: "m1",
|
||||
content: "/status",
|
||||
channelId: "c1",
|
||||
timestamp: new Date().toISOString(),
|
||||
type: MessageType.Default,
|
||||
attachments: [],
|
||||
embeds: [],
|
||||
mentionedEveryone: false,
|
||||
mentionedUsers: [],
|
||||
mentionedRoles: [],
|
||||
author: { id: "u1", bot: false, username: "Ada" },
|
||||
},
|
||||
author: { id: "u1", bot: false, username: "Ada" },
|
||||
guild_id: null,
|
||||
},
|
||||
client,
|
||||
);
|
||||
|
||||
expect(runtimeError).not.toHaveBeenCalled();
|
||||
expect(sendMock).toHaveBeenCalledTimes(1);
|
||||
expect(sendMock.mock.calls[0]?.[1]).toMatch(/^PFX /);
|
||||
}, 30_000);
|
||||
|
||||
it("caches channel info lookups between messages", async () => {
|
||||
const { createDiscordMessageHandler } = await import("./monitor.js");
|
||||
const cfg = {
|
||||
agents: {
|
||||
defaults: {
|
||||
model: "anthropic/claude-opus-4-5",
|
||||
workspace: "/tmp/clawd",
|
||||
},
|
||||
},
|
||||
session: { store: "/tmp/clawdbot-sessions.json" },
|
||||
channels: { discord: { dm: { enabled: true, policy: "open" } } },
|
||||
} as ReturnType<typeof import("../config/config.js").loadConfig>;
|
||||
|
||||
const handler = createDiscordMessageHandler({
|
||||
cfg,
|
||||
discordConfig: cfg.channels.discord,
|
||||
accountId: "default",
|
||||
token: "token",
|
||||
runtime: {
|
||||
log: vi.fn(),
|
||||
error: vi.fn(),
|
||||
exit: (code: number): never => {
|
||||
throw new Error(`exit ${code}`);
|
||||
},
|
||||
},
|
||||
botUserId: "bot-id",
|
||||
guildHistories: new Map(),
|
||||
historyLimit: 0,
|
||||
mediaMaxBytes: 10_000,
|
||||
textLimit: 2000,
|
||||
replyToMode: "off",
|
||||
dmEnabled: true,
|
||||
groupDmEnabled: false,
|
||||
});
|
||||
|
||||
const fetchChannel = vi.fn().mockResolvedValue({
|
||||
type: ChannelType.DM,
|
||||
name: "dm",
|
||||
});
|
||||
const client = { fetchChannel } as unknown as Client;
|
||||
const baseMessage = {
|
||||
content: "hello",
|
||||
channelId: "cache-channel-1",
|
||||
timestamp: new Date().toISOString(),
|
||||
type: MessageType.Default,
|
||||
attachments: [],
|
||||
embeds: [],
|
||||
mentionedEveryone: false,
|
||||
mentionedUsers: [],
|
||||
mentionedRoles: [],
|
||||
author: { id: "u-cache", bot: false, username: "Ada" },
|
||||
};
|
||||
|
||||
await handler(
|
||||
{
|
||||
message: { ...baseMessage, id: "m-cache-1" },
|
||||
author: baseMessage.author,
|
||||
guild_id: null,
|
||||
},
|
||||
client,
|
||||
);
|
||||
await handler(
|
||||
{
|
||||
message: { ...baseMessage, id: "m-cache-2" },
|
||||
author: baseMessage.author,
|
||||
guild_id: null,
|
||||
},
|
||||
client,
|
||||
);
|
||||
|
||||
expect(fetchChannel).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("includes forwarded message snapshots in body", async () => {
|
||||
const { createDiscordMessageHandler } = await import("./monitor.js");
|
||||
let capturedBody = "";
|
||||
dispatchMock.mockImplementationOnce(async ({ ctx, dispatcher }) => {
|
||||
capturedBody = ctx.Body ?? "";
|
||||
dispatcher.sendFinalReply({ text: "ok" });
|
||||
return { queuedFinal: true, counts: { final: 1 } };
|
||||
});
|
||||
|
||||
const cfg = {
|
||||
agents: {
|
||||
defaults: {
|
||||
model: "anthropic/claude-opus-4-5",
|
||||
workspace: "/tmp/clawd",
|
||||
},
|
||||
},
|
||||
session: { store: "/tmp/clawdbot-sessions.json" },
|
||||
channels: { discord: { dm: { enabled: true, policy: "open" } } },
|
||||
} as ReturnType<typeof import("../config/config.js").loadConfig>;
|
||||
|
||||
const handler = createDiscordMessageHandler({
|
||||
cfg,
|
||||
discordConfig: cfg.channels.discord,
|
||||
accountId: "default",
|
||||
token: "token",
|
||||
runtime: {
|
||||
log: vi.fn(),
|
||||
error: vi.fn(),
|
||||
exit: (code: number): never => {
|
||||
throw new Error(`exit ${code}`);
|
||||
},
|
||||
},
|
||||
botUserId: "bot-id",
|
||||
guildHistories: new Map(),
|
||||
historyLimit: 0,
|
||||
mediaMaxBytes: 10_000,
|
||||
textLimit: 2000,
|
||||
replyToMode: "off",
|
||||
dmEnabled: true,
|
||||
groupDmEnabled: false,
|
||||
});
|
||||
|
||||
const client = {
|
||||
fetchChannel: vi.fn().mockResolvedValue({
|
||||
type: ChannelType.DM,
|
||||
name: "dm",
|
||||
}),
|
||||
} as unknown as Client;
|
||||
|
||||
await handler(
|
||||
{
|
||||
message: {
|
||||
id: "m-forward-1",
|
||||
content: "",
|
||||
channelId: "c-forward-1",
|
||||
timestamp: new Date().toISOString(),
|
||||
type: MessageType.Default,
|
||||
attachments: [],
|
||||
embeds: [],
|
||||
mentionedEveryone: false,
|
||||
mentionedUsers: [],
|
||||
mentionedRoles: [],
|
||||
author: { id: "u1", bot: false, username: "Ada" },
|
||||
rawData: {
|
||||
message_snapshots: [
|
||||
{
|
||||
message: {
|
||||
content: "forwarded hello",
|
||||
embeds: [],
|
||||
attachments: [],
|
||||
author: {
|
||||
id: "u2",
|
||||
username: "Bob",
|
||||
discriminator: "0",
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
author: { id: "u1", bot: false, username: "Ada" },
|
||||
guild_id: null,
|
||||
},
|
||||
client,
|
||||
);
|
||||
|
||||
expect(capturedBody).toContain("[Forwarded message from @Bob]");
|
||||
expect(capturedBody).toContain("forwarded hello");
|
||||
});
|
||||
|
||||
it("uses channel id allowlists for non-thread channels with categories", async () => {
|
||||
const { createDiscordMessageHandler } = await import("./monitor.js");
|
||||
let capturedCtx: { SessionKey?: string } | undefined;
|
||||
dispatchMock.mockImplementationOnce(async ({ ctx, dispatcher }) => {
|
||||
capturedCtx = ctx;
|
||||
dispatcher.sendFinalReply({ text: "hi" });
|
||||
return { queuedFinal: true, counts: { final: 1 } };
|
||||
});
|
||||
|
||||
const cfg = {
|
||||
agents: {
|
||||
defaults: {
|
||||
model: "anthropic/claude-opus-4-5",
|
||||
workspace: "/tmp/clawd",
|
||||
},
|
||||
},
|
||||
session: { store: "/tmp/clawdbot-sessions.json" },
|
||||
channels: {
|
||||
discord: {
|
||||
dm: { enabled: true, policy: "open" },
|
||||
guilds: {
|
||||
"*": {
|
||||
requireMention: false,
|
||||
channels: { c1: { allow: true } },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
routing: { allowFrom: [] },
|
||||
} as ReturnType<typeof import("../config/config.js").loadConfig>;
|
||||
|
||||
const handler = createDiscordMessageHandler({
|
||||
cfg,
|
||||
discordConfig: cfg.channels.discord,
|
||||
accountId: "default",
|
||||
token: "token",
|
||||
runtime: {
|
||||
log: vi.fn(),
|
||||
error: vi.fn(),
|
||||
exit: (code: number): never => {
|
||||
throw new Error(`exit ${code}`);
|
||||
},
|
||||
},
|
||||
botUserId: "bot-id",
|
||||
guildHistories: new Map(),
|
||||
historyLimit: 0,
|
||||
mediaMaxBytes: 10_000,
|
||||
textLimit: 2000,
|
||||
replyToMode: "off",
|
||||
dmEnabled: true,
|
||||
groupDmEnabled: false,
|
||||
guildEntries: {
|
||||
"*": { requireMention: false, channels: { c1: { allow: true } } },
|
||||
},
|
||||
});
|
||||
|
||||
const client = {
|
||||
fetchChannel: vi.fn().mockResolvedValue({
|
||||
type: ChannelType.GuildText,
|
||||
name: "general",
|
||||
parentId: "category-1",
|
||||
}),
|
||||
rest: { get: vi.fn() },
|
||||
} as unknown as Client;
|
||||
|
||||
await handler(
|
||||
{
|
||||
message: {
|
||||
id: "m-category",
|
||||
content: "hello",
|
||||
channelId: "c1",
|
||||
timestamp: new Date().toISOString(),
|
||||
type: MessageType.Default,
|
||||
attachments: [],
|
||||
embeds: [],
|
||||
mentionedEveryone: false,
|
||||
mentionedUsers: [],
|
||||
mentionedRoles: [],
|
||||
author: { id: "u1", bot: false, username: "Ada", tag: "Ada#1" },
|
||||
},
|
||||
author: { id: "u1", bot: false, username: "Ada", tag: "Ada#1" },
|
||||
member: { displayName: "Ada" },
|
||||
guild: { id: "g1", name: "Guild" },
|
||||
guild_id: "g1",
|
||||
},
|
||||
client,
|
||||
);
|
||||
|
||||
expect(capturedCtx?.SessionKey).toBe("agent:main:discord:channel:c1");
|
||||
});
|
||||
|
||||
it("replies with pairing code and sender id when dmPolicy is pairing", async () => {
|
||||
const { createDiscordMessageHandler } = await import("./monitor.js");
|
||||
const cfg = {
|
||||
agents: {
|
||||
defaults: {
|
||||
model: "anthropic/claude-opus-4-5",
|
||||
workspace: "/tmp/clawd",
|
||||
},
|
||||
},
|
||||
session: { store: "/tmp/clawdbot-sessions.json" },
|
||||
channels: {
|
||||
discord: { dm: { enabled: true, policy: "pairing", allowFrom: [] } },
|
||||
},
|
||||
} as ReturnType<typeof import("../config/config.js").loadConfig>;
|
||||
|
||||
const handler = createDiscordMessageHandler({
|
||||
cfg,
|
||||
discordConfig: cfg.channels.discord,
|
||||
accountId: "default",
|
||||
token: "token",
|
||||
runtime: {
|
||||
log: vi.fn(),
|
||||
error: vi.fn(),
|
||||
exit: (code: number): never => {
|
||||
throw new Error(`exit ${code}`);
|
||||
},
|
||||
},
|
||||
botUserId: "bot-id",
|
||||
guildHistories: new Map(),
|
||||
historyLimit: 0,
|
||||
mediaMaxBytes: 10_000,
|
||||
textLimit: 2000,
|
||||
replyToMode: "off",
|
||||
dmEnabled: true,
|
||||
groupDmEnabled: false,
|
||||
});
|
||||
|
||||
const client = {
|
||||
fetchChannel: vi.fn().mockResolvedValue({
|
||||
type: ChannelType.DM,
|
||||
name: "dm",
|
||||
}),
|
||||
} as unknown as Client;
|
||||
|
||||
await handler(
|
||||
{
|
||||
message: {
|
||||
id: "m1",
|
||||
content: "hello",
|
||||
channelId: "c1",
|
||||
timestamp: new Date().toISOString(),
|
||||
type: MessageType.Default,
|
||||
attachments: [],
|
||||
embeds: [],
|
||||
mentionedEveryone: false,
|
||||
mentionedUsers: [],
|
||||
mentionedRoles: [],
|
||||
author: { id: "u2", bot: false, username: "Ada" },
|
||||
},
|
||||
author: { id: "u2", bot: false, username: "Ada" },
|
||||
guild_id: null,
|
||||
},
|
||||
client,
|
||||
);
|
||||
|
||||
expect(dispatchMock).not.toHaveBeenCalled();
|
||||
expect(upsertPairingRequestMock).toHaveBeenCalled();
|
||||
expect(sendMock).toHaveBeenCalledTimes(1);
|
||||
expect(String(sendMock.mock.calls[0]?.[1] ?? "")).toContain(
|
||||
"Your Discord user id: u2",
|
||||
);
|
||||
expect(String(sendMock.mock.calls[0]?.[1] ?? "")).toContain(
|
||||
"Pairing code: PAIRCODE",
|
||||
);
|
||||
}, 10000);
|
||||
});
|
||||
@@ -50,406 +50,6 @@ beforeEach(() => {
|
||||
});
|
||||
|
||||
describe("discord tool result dispatch", () => {
|
||||
it("sends status replies with responsePrefix", async () => {
|
||||
const { createDiscordMessageHandler } = await import("./monitor.js");
|
||||
const cfg = {
|
||||
agents: {
|
||||
defaults: {
|
||||
model: "anthropic/claude-opus-4-5",
|
||||
workspace: "/tmp/clawd",
|
||||
},
|
||||
},
|
||||
session: { store: "/tmp/clawdbot-sessions.json" },
|
||||
messages: { responsePrefix: "PFX" },
|
||||
channels: { discord: { dm: { enabled: true, policy: "open" } } },
|
||||
} as ReturnType<typeof import("../config/config.js").loadConfig>;
|
||||
|
||||
const runtimeError = vi.fn();
|
||||
const handler = createDiscordMessageHandler({
|
||||
cfg,
|
||||
discordConfig: cfg.channels.discord,
|
||||
accountId: "default",
|
||||
token: "token",
|
||||
runtime: {
|
||||
log: vi.fn(),
|
||||
error: runtimeError,
|
||||
exit: (code: number): never => {
|
||||
throw new Error(`exit ${code}`);
|
||||
},
|
||||
},
|
||||
botUserId: "bot-id",
|
||||
guildHistories: new Map(),
|
||||
historyLimit: 0,
|
||||
mediaMaxBytes: 10_000,
|
||||
textLimit: 2000,
|
||||
replyToMode: "off",
|
||||
dmEnabled: true,
|
||||
groupDmEnabled: false,
|
||||
});
|
||||
|
||||
const client = {
|
||||
fetchChannel: vi.fn().mockResolvedValue({
|
||||
type: ChannelType.DM,
|
||||
name: "dm",
|
||||
}),
|
||||
} as unknown as Client;
|
||||
|
||||
await handler(
|
||||
{
|
||||
message: {
|
||||
id: "m1",
|
||||
content: "/status",
|
||||
channelId: "c1",
|
||||
timestamp: new Date().toISOString(),
|
||||
type: MessageType.Default,
|
||||
attachments: [],
|
||||
embeds: [],
|
||||
mentionedEveryone: false,
|
||||
mentionedUsers: [],
|
||||
mentionedRoles: [],
|
||||
author: { id: "u1", bot: false, username: "Ada" },
|
||||
},
|
||||
author: { id: "u1", bot: false, username: "Ada" },
|
||||
guild_id: null,
|
||||
},
|
||||
client,
|
||||
);
|
||||
|
||||
expect(runtimeError).not.toHaveBeenCalled();
|
||||
expect(sendMock).toHaveBeenCalledTimes(1);
|
||||
expect(sendMock.mock.calls[0]?.[1]).toMatch(/^PFX /);
|
||||
}, 30_000);
|
||||
|
||||
it("caches channel info lookups between messages", async () => {
|
||||
const { createDiscordMessageHandler } = await import("./monitor.js");
|
||||
const cfg = {
|
||||
agents: {
|
||||
defaults: {
|
||||
model: "anthropic/claude-opus-4-5",
|
||||
workspace: "/tmp/clawd",
|
||||
},
|
||||
},
|
||||
session: { store: "/tmp/clawdbot-sessions.json" },
|
||||
channels: { discord: { dm: { enabled: true, policy: "open" } } },
|
||||
} as ReturnType<typeof import("../config/config.js").loadConfig>;
|
||||
|
||||
const handler = createDiscordMessageHandler({
|
||||
cfg,
|
||||
discordConfig: cfg.channels.discord,
|
||||
accountId: "default",
|
||||
token: "token",
|
||||
runtime: {
|
||||
log: vi.fn(),
|
||||
error: vi.fn(),
|
||||
exit: (code: number): never => {
|
||||
throw new Error(`exit ${code}`);
|
||||
},
|
||||
},
|
||||
botUserId: "bot-id",
|
||||
guildHistories: new Map(),
|
||||
historyLimit: 0,
|
||||
mediaMaxBytes: 10_000,
|
||||
textLimit: 2000,
|
||||
replyToMode: "off",
|
||||
dmEnabled: true,
|
||||
groupDmEnabled: false,
|
||||
});
|
||||
|
||||
const fetchChannel = vi.fn().mockResolvedValue({
|
||||
type: ChannelType.DM,
|
||||
name: "dm",
|
||||
});
|
||||
const client = { fetchChannel } as unknown as Client;
|
||||
const baseMessage = {
|
||||
content: "hello",
|
||||
channelId: "cache-channel-1",
|
||||
timestamp: new Date().toISOString(),
|
||||
type: MessageType.Default,
|
||||
attachments: [],
|
||||
embeds: [],
|
||||
mentionedEveryone: false,
|
||||
mentionedUsers: [],
|
||||
mentionedRoles: [],
|
||||
author: { id: "u-cache", bot: false, username: "Ada" },
|
||||
};
|
||||
|
||||
await handler(
|
||||
{
|
||||
message: { ...baseMessage, id: "m-cache-1" },
|
||||
author: baseMessage.author,
|
||||
guild_id: null,
|
||||
},
|
||||
client,
|
||||
);
|
||||
await handler(
|
||||
{
|
||||
message: { ...baseMessage, id: "m-cache-2" },
|
||||
author: baseMessage.author,
|
||||
guild_id: null,
|
||||
},
|
||||
client,
|
||||
);
|
||||
|
||||
expect(fetchChannel).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("includes forwarded message snapshots in body", async () => {
|
||||
const { createDiscordMessageHandler } = await import("./monitor.js");
|
||||
let capturedBody = "";
|
||||
dispatchMock.mockImplementationOnce(async ({ ctx, dispatcher }) => {
|
||||
capturedBody = ctx.Body ?? "";
|
||||
dispatcher.sendFinalReply({ text: "ok" });
|
||||
return { queuedFinal: true, counts: { final: 1 } };
|
||||
});
|
||||
|
||||
const cfg = {
|
||||
agents: {
|
||||
defaults: {
|
||||
model: "anthropic/claude-opus-4-5",
|
||||
workspace: "/tmp/clawd",
|
||||
},
|
||||
},
|
||||
session: { store: "/tmp/clawdbot-sessions.json" },
|
||||
channels: { discord: { dm: { enabled: true, policy: "open" } } },
|
||||
} as ReturnType<typeof import("../config/config.js").loadConfig>;
|
||||
|
||||
const handler = createDiscordMessageHandler({
|
||||
cfg,
|
||||
discordConfig: cfg.channels.discord,
|
||||
accountId: "default",
|
||||
token: "token",
|
||||
runtime: {
|
||||
log: vi.fn(),
|
||||
error: vi.fn(),
|
||||
exit: (code: number): never => {
|
||||
throw new Error(`exit ${code}`);
|
||||
},
|
||||
},
|
||||
botUserId: "bot-id",
|
||||
guildHistories: new Map(),
|
||||
historyLimit: 0,
|
||||
mediaMaxBytes: 10_000,
|
||||
textLimit: 2000,
|
||||
replyToMode: "off",
|
||||
dmEnabled: true,
|
||||
groupDmEnabled: false,
|
||||
});
|
||||
|
||||
const client = {
|
||||
fetchChannel: vi.fn().mockResolvedValue({
|
||||
type: ChannelType.DM,
|
||||
name: "dm",
|
||||
}),
|
||||
} as unknown as Client;
|
||||
|
||||
await handler(
|
||||
{
|
||||
message: {
|
||||
id: "m-forward-1",
|
||||
content: "",
|
||||
channelId: "c-forward-1",
|
||||
timestamp: new Date().toISOString(),
|
||||
type: MessageType.Default,
|
||||
attachments: [],
|
||||
embeds: [],
|
||||
mentionedEveryone: false,
|
||||
mentionedUsers: [],
|
||||
mentionedRoles: [],
|
||||
author: { id: "u1", bot: false, username: "Ada" },
|
||||
rawData: {
|
||||
message_snapshots: [
|
||||
{
|
||||
message: {
|
||||
content: "forwarded hello",
|
||||
embeds: [],
|
||||
attachments: [],
|
||||
author: {
|
||||
id: "u2",
|
||||
username: "Bob",
|
||||
discriminator: "0",
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
author: { id: "u1", bot: false, username: "Ada" },
|
||||
guild_id: null,
|
||||
},
|
||||
client,
|
||||
);
|
||||
|
||||
expect(capturedBody).toContain("[Forwarded message from @Bob]");
|
||||
expect(capturedBody).toContain("forwarded hello");
|
||||
});
|
||||
|
||||
it("uses channel id allowlists for non-thread channels with categories", async () => {
|
||||
const { createDiscordMessageHandler } = await import("./monitor.js");
|
||||
let capturedCtx: { SessionKey?: string } | undefined;
|
||||
dispatchMock.mockImplementationOnce(async ({ ctx, dispatcher }) => {
|
||||
capturedCtx = ctx;
|
||||
dispatcher.sendFinalReply({ text: "hi" });
|
||||
return { queuedFinal: true, counts: { final: 1 } };
|
||||
});
|
||||
|
||||
const cfg = {
|
||||
agents: {
|
||||
defaults: {
|
||||
model: "anthropic/claude-opus-4-5",
|
||||
workspace: "/tmp/clawd",
|
||||
},
|
||||
},
|
||||
session: { store: "/tmp/clawdbot-sessions.json" },
|
||||
channels: {
|
||||
discord: {
|
||||
dm: { enabled: true, policy: "open" },
|
||||
guilds: {
|
||||
"*": {
|
||||
requireMention: false,
|
||||
channels: { c1: { allow: true } },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
routing: { allowFrom: [] },
|
||||
} as ReturnType<typeof import("../config/config.js").loadConfig>;
|
||||
|
||||
const handler = createDiscordMessageHandler({
|
||||
cfg,
|
||||
discordConfig: cfg.channels.discord,
|
||||
accountId: "default",
|
||||
token: "token",
|
||||
runtime: {
|
||||
log: vi.fn(),
|
||||
error: vi.fn(),
|
||||
exit: (code: number): never => {
|
||||
throw new Error(`exit ${code}`);
|
||||
},
|
||||
},
|
||||
botUserId: "bot-id",
|
||||
guildHistories: new Map(),
|
||||
historyLimit: 0,
|
||||
mediaMaxBytes: 10_000,
|
||||
textLimit: 2000,
|
||||
replyToMode: "off",
|
||||
dmEnabled: true,
|
||||
groupDmEnabled: false,
|
||||
guildEntries: {
|
||||
"*": { requireMention: false, channels: { c1: { allow: true } } },
|
||||
},
|
||||
});
|
||||
|
||||
const client = {
|
||||
fetchChannel: vi.fn().mockResolvedValue({
|
||||
type: ChannelType.GuildText,
|
||||
name: "general",
|
||||
parentId: "category-1",
|
||||
}),
|
||||
rest: { get: vi.fn() },
|
||||
} as unknown as Client;
|
||||
|
||||
await handler(
|
||||
{
|
||||
message: {
|
||||
id: "m-category",
|
||||
content: "hello",
|
||||
channelId: "c1",
|
||||
timestamp: new Date().toISOString(),
|
||||
type: MessageType.Default,
|
||||
attachments: [],
|
||||
embeds: [],
|
||||
mentionedEveryone: false,
|
||||
mentionedUsers: [],
|
||||
mentionedRoles: [],
|
||||
author: { id: "u1", bot: false, username: "Ada", tag: "Ada#1" },
|
||||
},
|
||||
author: { id: "u1", bot: false, username: "Ada", tag: "Ada#1" },
|
||||
member: { displayName: "Ada" },
|
||||
guild: { id: "g1", name: "Guild" },
|
||||
guild_id: "g1",
|
||||
},
|
||||
client,
|
||||
);
|
||||
|
||||
expect(capturedCtx?.SessionKey).toBe("agent:main:discord:channel:c1");
|
||||
});
|
||||
|
||||
it("replies with pairing code and sender id when dmPolicy is pairing", async () => {
|
||||
const { createDiscordMessageHandler } = await import("./monitor.js");
|
||||
const cfg = {
|
||||
agents: {
|
||||
defaults: {
|
||||
model: "anthropic/claude-opus-4-5",
|
||||
workspace: "/tmp/clawd",
|
||||
},
|
||||
},
|
||||
session: { store: "/tmp/clawdbot-sessions.json" },
|
||||
channels: {
|
||||
discord: { dm: { enabled: true, policy: "pairing", allowFrom: [] } },
|
||||
},
|
||||
} as ReturnType<typeof import("../config/config.js").loadConfig>;
|
||||
|
||||
const handler = createDiscordMessageHandler({
|
||||
cfg,
|
||||
discordConfig: cfg.channels.discord,
|
||||
accountId: "default",
|
||||
token: "token",
|
||||
runtime: {
|
||||
log: vi.fn(),
|
||||
error: vi.fn(),
|
||||
exit: (code: number): never => {
|
||||
throw new Error(`exit ${code}`);
|
||||
},
|
||||
},
|
||||
botUserId: "bot-id",
|
||||
guildHistories: new Map(),
|
||||
historyLimit: 0,
|
||||
mediaMaxBytes: 10_000,
|
||||
textLimit: 2000,
|
||||
replyToMode: "off",
|
||||
dmEnabled: true,
|
||||
groupDmEnabled: false,
|
||||
});
|
||||
|
||||
const client = {
|
||||
fetchChannel: vi.fn().mockResolvedValue({
|
||||
type: ChannelType.DM,
|
||||
name: "dm",
|
||||
}),
|
||||
} as unknown as Client;
|
||||
|
||||
await handler(
|
||||
{
|
||||
message: {
|
||||
id: "m1",
|
||||
content: "hello",
|
||||
channelId: "c1",
|
||||
timestamp: new Date().toISOString(),
|
||||
type: MessageType.Default,
|
||||
attachments: [],
|
||||
embeds: [],
|
||||
mentionedEveryone: false,
|
||||
mentionedUsers: [],
|
||||
mentionedRoles: [],
|
||||
author: { id: "u2", bot: false, username: "Ada" },
|
||||
},
|
||||
author: { id: "u2", bot: false, username: "Ada" },
|
||||
guild_id: null,
|
||||
},
|
||||
client,
|
||||
);
|
||||
|
||||
expect(dispatchMock).not.toHaveBeenCalled();
|
||||
expect(upsertPairingRequestMock).toHaveBeenCalled();
|
||||
expect(sendMock).toHaveBeenCalledTimes(1);
|
||||
expect(String(sendMock.mock.calls[0]?.[1] ?? "")).toContain(
|
||||
"Your Discord user id: u2",
|
||||
);
|
||||
expect(String(sendMock.mock.calls[0]?.[1] ?? "")).toContain(
|
||||
"Pairing code: PAIRCODE",
|
||||
);
|
||||
}, 10000);
|
||||
|
||||
it("accepts guild messages when mentionPatterns match", async () => {
|
||||
const { createDiscordMessageHandler } = await import("./monitor.js");
|
||||
const cfg = {
|
||||
File diff suppressed because it is too large
Load Diff
272
src/discord/monitor/allow-list.ts
Normal file
272
src/discord/monitor/allow-list.ts
Normal file
@@ -0,0 +1,272 @@
|
||||
import type { Guild, User } from "@buape/carbon";
|
||||
|
||||
import { formatDiscordUserTag } from "./format.js";
|
||||
|
||||
export type DiscordAllowList = {
|
||||
allowAll: boolean;
|
||||
ids: Set<string>;
|
||||
names: Set<string>;
|
||||
};
|
||||
|
||||
export type DiscordGuildEntryResolved = {
|
||||
id?: string;
|
||||
slug?: string;
|
||||
requireMention?: boolean;
|
||||
reactionNotifications?: "off" | "own" | "all" | "allowlist";
|
||||
users?: Array<string | number>;
|
||||
channels?: Record<
|
||||
string,
|
||||
{
|
||||
allow?: boolean;
|
||||
requireMention?: boolean;
|
||||
skills?: string[];
|
||||
enabled?: boolean;
|
||||
users?: Array<string | number>;
|
||||
systemPrompt?: string;
|
||||
autoThread?: boolean;
|
||||
}
|
||||
>;
|
||||
};
|
||||
|
||||
export type DiscordChannelConfigResolved = {
|
||||
allowed: boolean;
|
||||
requireMention?: boolean;
|
||||
skills?: string[];
|
||||
enabled?: boolean;
|
||||
users?: Array<string | number>;
|
||||
systemPrompt?: string;
|
||||
autoThread?: boolean;
|
||||
};
|
||||
|
||||
export function normalizeDiscordAllowList(
|
||||
raw: Array<string | number> | undefined,
|
||||
prefixes: string[],
|
||||
) {
|
||||
if (!raw || raw.length === 0) return null;
|
||||
const ids = new Set<string>();
|
||||
const names = new Set<string>();
|
||||
const allowAll = raw.some((entry) => String(entry).trim() === "*");
|
||||
for (const entry of raw) {
|
||||
const text = String(entry).trim();
|
||||
if (!text || text === "*") continue;
|
||||
const normalized = normalizeDiscordSlug(text);
|
||||
const maybeId = text.replace(/^<@!?/, "").replace(/>$/, "");
|
||||
if (/^\d+$/.test(maybeId)) {
|
||||
ids.add(maybeId);
|
||||
continue;
|
||||
}
|
||||
const prefix = prefixes.find((entry) => text.startsWith(entry));
|
||||
if (prefix) {
|
||||
const candidate = text.slice(prefix.length);
|
||||
if (candidate) ids.add(candidate);
|
||||
continue;
|
||||
}
|
||||
if (normalized) {
|
||||
names.add(normalized);
|
||||
}
|
||||
}
|
||||
return { allowAll, ids, names } satisfies DiscordAllowList;
|
||||
}
|
||||
|
||||
export function normalizeDiscordSlug(value: string) {
|
||||
return value
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/^#/, "")
|
||||
.replace(/[^a-z0-9]+/g, "-")
|
||||
.replace(/^-+|-+$/g, "");
|
||||
}
|
||||
|
||||
export function allowListMatches(
|
||||
list: DiscordAllowList,
|
||||
candidate: { id?: string; name?: string; tag?: string },
|
||||
) {
|
||||
if (list.allowAll) return true;
|
||||
if (candidate.id && list.ids.has(candidate.id)) return true;
|
||||
const slug = candidate.name ? normalizeDiscordSlug(candidate.name) : "";
|
||||
if (slug && list.names.has(slug)) return true;
|
||||
if (candidate.tag && list.names.has(normalizeDiscordSlug(candidate.tag)))
|
||||
return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
export function resolveDiscordUserAllowed(params: {
|
||||
allowList?: Array<string | number>;
|
||||
userId: string;
|
||||
userName?: string;
|
||||
userTag?: string;
|
||||
}) {
|
||||
const allowList = normalizeDiscordAllowList(params.allowList, [
|
||||
"discord:",
|
||||
"user:",
|
||||
]);
|
||||
if (!allowList) return true;
|
||||
return allowListMatches(allowList, {
|
||||
id: params.userId,
|
||||
name: params.userName,
|
||||
tag: params.userTag,
|
||||
});
|
||||
}
|
||||
|
||||
export function resolveDiscordCommandAuthorized(params: {
|
||||
isDirectMessage: boolean;
|
||||
allowFrom?: Array<string | number>;
|
||||
guildInfo?: DiscordGuildEntryResolved | null;
|
||||
author: User;
|
||||
}) {
|
||||
if (!params.isDirectMessage) return true;
|
||||
const allowList = normalizeDiscordAllowList(params.allowFrom, [
|
||||
"discord:",
|
||||
"user:",
|
||||
]);
|
||||
if (!allowList) return true;
|
||||
return allowListMatches(allowList, {
|
||||
id: params.author.id,
|
||||
name: params.author.username,
|
||||
tag: formatDiscordUserTag(params.author),
|
||||
});
|
||||
}
|
||||
|
||||
export function resolveDiscordGuildEntry(params: {
|
||||
guild?: Guild<true> | Guild<false> | null;
|
||||
guildEntries?: Record<string, DiscordGuildEntryResolved>;
|
||||
}): DiscordGuildEntryResolved | null {
|
||||
const guild = params.guild;
|
||||
const entries = params.guildEntries;
|
||||
if (!guild || !entries) return null;
|
||||
const byId = entries[guild.id];
|
||||
if (byId) return { ...byId, id: guild.id };
|
||||
const slug = normalizeDiscordSlug(guild.name ?? "");
|
||||
const bySlug = entries[slug];
|
||||
if (bySlug) return { ...bySlug, id: guild.id, slug: slug || bySlug.slug };
|
||||
const wildcard = entries["*"];
|
||||
if (wildcard)
|
||||
return { ...wildcard, id: guild.id, slug: slug || wildcard.slug };
|
||||
return null;
|
||||
}
|
||||
|
||||
export function resolveDiscordChannelConfig(params: {
|
||||
guildInfo?: DiscordGuildEntryResolved | null;
|
||||
channelId: string;
|
||||
channelName?: string;
|
||||
channelSlug: string;
|
||||
}): DiscordChannelConfigResolved | null {
|
||||
const { guildInfo, channelId, channelName, channelSlug } = params;
|
||||
const channels = guildInfo?.channels;
|
||||
if (!channels) return null;
|
||||
const byId = channels[channelId];
|
||||
if (byId)
|
||||
return {
|
||||
allowed: byId.allow !== false,
|
||||
requireMention: byId.requireMention,
|
||||
skills: byId.skills,
|
||||
enabled: byId.enabled,
|
||||
users: byId.users,
|
||||
systemPrompt: byId.systemPrompt,
|
||||
autoThread: byId.autoThread,
|
||||
};
|
||||
if (channelSlug && channels[channelSlug]) {
|
||||
const entry = channels[channelSlug];
|
||||
return {
|
||||
allowed: entry.allow !== false,
|
||||
requireMention: entry.requireMention,
|
||||
skills: entry.skills,
|
||||
enabled: entry.enabled,
|
||||
users: entry.users,
|
||||
systemPrompt: entry.systemPrompt,
|
||||
autoThread: entry.autoThread,
|
||||
};
|
||||
}
|
||||
if (channelName && channels[channelName]) {
|
||||
const entry = channels[channelName];
|
||||
return {
|
||||
allowed: entry.allow !== false,
|
||||
requireMention: entry.requireMention,
|
||||
skills: entry.skills,
|
||||
enabled: entry.enabled,
|
||||
users: entry.users,
|
||||
systemPrompt: entry.systemPrompt,
|
||||
autoThread: entry.autoThread,
|
||||
};
|
||||
}
|
||||
return { allowed: false };
|
||||
}
|
||||
|
||||
export function resolveDiscordShouldRequireMention(params: {
|
||||
isGuildMessage: boolean;
|
||||
isThread: boolean;
|
||||
channelConfig?: DiscordChannelConfigResolved | null;
|
||||
guildInfo?: DiscordGuildEntryResolved | null;
|
||||
}): boolean {
|
||||
if (!params.isGuildMessage) return false;
|
||||
if (params.isThread && params.channelConfig?.autoThread) return false;
|
||||
return (
|
||||
params.channelConfig?.requireMention ??
|
||||
params.guildInfo?.requireMention ??
|
||||
true
|
||||
);
|
||||
}
|
||||
|
||||
export function isDiscordGroupAllowedByPolicy(params: {
|
||||
groupPolicy: "open" | "disabled" | "allowlist";
|
||||
channelAllowlistConfigured: boolean;
|
||||
channelAllowed: boolean;
|
||||
}): boolean {
|
||||
const { groupPolicy, channelAllowlistConfigured, channelAllowed } = params;
|
||||
if (groupPolicy === "disabled") return false;
|
||||
if (groupPolicy === "open") return true;
|
||||
if (!channelAllowlistConfigured) return false;
|
||||
return channelAllowed;
|
||||
}
|
||||
|
||||
export function resolveGroupDmAllow(params: {
|
||||
channels?: Array<string | number>;
|
||||
channelId: string;
|
||||
channelName?: string;
|
||||
channelSlug: string;
|
||||
}) {
|
||||
const { channels, channelId, channelName, channelSlug } = params;
|
||||
if (!channels || channels.length === 0) return true;
|
||||
const allowList = channels.map((entry) =>
|
||||
normalizeDiscordSlug(String(entry)),
|
||||
);
|
||||
const candidates = [
|
||||
normalizeDiscordSlug(channelId),
|
||||
channelSlug,
|
||||
channelName ? normalizeDiscordSlug(channelName) : "",
|
||||
].filter(Boolean);
|
||||
return (
|
||||
allowList.includes("*") ||
|
||||
candidates.some((candidate) => allowList.includes(candidate))
|
||||
);
|
||||
}
|
||||
|
||||
export function shouldEmitDiscordReactionNotification(params: {
|
||||
mode?: "off" | "own" | "all" | "allowlist";
|
||||
botId?: string;
|
||||
messageAuthorId?: string;
|
||||
userId: string;
|
||||
userName?: string;
|
||||
userTag?: string;
|
||||
allowlist?: Array<string | number>;
|
||||
}) {
|
||||
const mode = params.mode ?? "own";
|
||||
if (mode === "off") return false;
|
||||
if (mode === "all") return true;
|
||||
if (mode === "own") {
|
||||
return Boolean(params.botId && params.messageAuthorId === params.botId);
|
||||
}
|
||||
if (mode === "allowlist") {
|
||||
const list = normalizeDiscordAllowList(params.allowlist, [
|
||||
"discord:",
|
||||
"user:",
|
||||
]);
|
||||
if (!list) return false;
|
||||
return allowListMatches(list, {
|
||||
id: params.userId,
|
||||
name: params.userName,
|
||||
tag: params.userTag,
|
||||
});
|
||||
}
|
||||
return false;
|
||||
}
|
||||
37
src/discord/monitor/format.ts
Normal file
37
src/discord/monitor/format.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import type { Guild, User } from "@buape/carbon";
|
||||
|
||||
export function resolveDiscordSystemLocation(params: {
|
||||
isDirectMessage: boolean;
|
||||
isGroupDm: boolean;
|
||||
guild?: Guild;
|
||||
channelName: string;
|
||||
}) {
|
||||
const { isDirectMessage, isGroupDm, guild, channelName } = params;
|
||||
if (isDirectMessage) return "DM";
|
||||
if (isGroupDm) return `Group DM #${channelName}`;
|
||||
return guild?.name ? `${guild.name} #${channelName}` : `#${channelName}`;
|
||||
}
|
||||
|
||||
export function formatDiscordReactionEmoji(emoji: {
|
||||
id?: string | null;
|
||||
name?: string | null;
|
||||
}) {
|
||||
if (emoji.id && emoji.name) {
|
||||
return `${emoji.name}:${emoji.id}`;
|
||||
}
|
||||
return emoji.name ?? "emoji";
|
||||
}
|
||||
|
||||
export function formatDiscordUserTag(user: User) {
|
||||
const discriminator = (user.discriminator ?? "").trim();
|
||||
if (discriminator && discriminator !== "0") {
|
||||
return `${user.username}#${discriminator}`;
|
||||
}
|
||||
return user.username ?? user.id;
|
||||
}
|
||||
|
||||
export function resolveTimestampMs(timestamp?: string | null) {
|
||||
if (!timestamp) return undefined;
|
||||
const parsed = Date.parse(timestamp);
|
||||
return Number.isNaN(parsed) ? undefined : parsed;
|
||||
}
|
||||
266
src/discord/monitor/listeners.ts
Normal file
266
src/discord/monitor/listeners.ts
Normal file
@@ -0,0 +1,266 @@
|
||||
import {
|
||||
type Client,
|
||||
MessageCreateListener,
|
||||
MessageReactionAddListener,
|
||||
MessageReactionRemoveListener,
|
||||
} from "@buape/carbon";
|
||||
|
||||
import { danger } from "../../globals.js";
|
||||
import { formatDurationSeconds } from "../../infra/format-duration.js";
|
||||
import { enqueueSystemEvent } from "../../infra/system-events.js";
|
||||
import { resolveAgentRoute } from "../../routing/resolve-route.js";
|
||||
import {
|
||||
normalizeDiscordSlug,
|
||||
resolveDiscordChannelConfig,
|
||||
resolveDiscordGuildEntry,
|
||||
shouldEmitDiscordReactionNotification,
|
||||
} from "./allow-list.js";
|
||||
import { formatDiscordReactionEmoji, formatDiscordUserTag } from "./format.js";
|
||||
|
||||
type LoadedConfig = ReturnType<
|
||||
typeof import("../../config/config.js").loadConfig
|
||||
>;
|
||||
type RuntimeEnv = import("../../runtime.js").RuntimeEnv;
|
||||
type Logger = ReturnType<typeof import("../../logging.js").getChildLogger>;
|
||||
|
||||
export type DiscordMessageEvent = Parameters<
|
||||
MessageCreateListener["handle"]
|
||||
>[0];
|
||||
|
||||
export type DiscordMessageHandler = (
|
||||
data: DiscordMessageEvent,
|
||||
client: Client,
|
||||
) => Promise<void>;
|
||||
|
||||
type DiscordReactionEvent = Parameters<MessageReactionAddListener["handle"]>[0];
|
||||
|
||||
const DISCORD_SLOW_LISTENER_THRESHOLD_MS = 1000;
|
||||
|
||||
function logSlowDiscordListener(params: {
|
||||
logger: Logger | undefined;
|
||||
listener: string;
|
||||
event: string;
|
||||
durationMs: number;
|
||||
}) {
|
||||
if (params.durationMs < DISCORD_SLOW_LISTENER_THRESHOLD_MS) return;
|
||||
const duration = formatDurationSeconds(params.durationMs, {
|
||||
decimals: 1,
|
||||
unit: "seconds",
|
||||
});
|
||||
const message = `[EventQueue] Slow listener detected: ${params.listener} took ${duration} for event ${params.event}`;
|
||||
if (params.logger?.warn) {
|
||||
params.logger.warn(message);
|
||||
} else {
|
||||
console.warn(message);
|
||||
}
|
||||
}
|
||||
|
||||
export function registerDiscordListener(
|
||||
listeners: Array<object>,
|
||||
listener: object,
|
||||
) {
|
||||
if (
|
||||
listeners.some((existing) => existing.constructor === listener.constructor)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
listeners.push(listener);
|
||||
return true;
|
||||
}
|
||||
|
||||
export class DiscordMessageListener extends MessageCreateListener {
|
||||
constructor(
|
||||
private handler: DiscordMessageHandler,
|
||||
private logger?: Logger,
|
||||
) {
|
||||
super();
|
||||
}
|
||||
|
||||
async handle(data: DiscordMessageEvent, client: Client) {
|
||||
const startedAt = Date.now();
|
||||
try {
|
||||
await this.handler(data, client);
|
||||
} finally {
|
||||
logSlowDiscordListener({
|
||||
logger: this.logger,
|
||||
listener: this.constructor.name,
|
||||
event: this.type,
|
||||
durationMs: Date.now() - startedAt,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export class DiscordReactionListener extends MessageReactionAddListener {
|
||||
constructor(
|
||||
private params: {
|
||||
cfg: LoadedConfig;
|
||||
accountId: string;
|
||||
runtime: RuntimeEnv;
|
||||
botUserId?: string;
|
||||
guildEntries?: Record<
|
||||
string,
|
||||
import("./allow-list.js").DiscordGuildEntryResolved
|
||||
>;
|
||||
logger: Logger;
|
||||
},
|
||||
) {
|
||||
super();
|
||||
}
|
||||
|
||||
async handle(data: DiscordReactionEvent, client: Client) {
|
||||
const startedAt = Date.now();
|
||||
try {
|
||||
await handleDiscordReactionEvent({
|
||||
data,
|
||||
client,
|
||||
action: "added",
|
||||
cfg: this.params.cfg,
|
||||
accountId: this.params.accountId,
|
||||
botUserId: this.params.botUserId,
|
||||
guildEntries: this.params.guildEntries,
|
||||
logger: this.params.logger,
|
||||
});
|
||||
} finally {
|
||||
logSlowDiscordListener({
|
||||
logger: this.params.logger,
|
||||
listener: this.constructor.name,
|
||||
event: this.type,
|
||||
durationMs: Date.now() - startedAt,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export class DiscordReactionRemoveListener extends MessageReactionRemoveListener {
|
||||
constructor(
|
||||
private params: {
|
||||
cfg: LoadedConfig;
|
||||
accountId: string;
|
||||
runtime: RuntimeEnv;
|
||||
botUserId?: string;
|
||||
guildEntries?: Record<
|
||||
string,
|
||||
import("./allow-list.js").DiscordGuildEntryResolved
|
||||
>;
|
||||
logger: Logger;
|
||||
},
|
||||
) {
|
||||
super();
|
||||
}
|
||||
|
||||
async handle(data: DiscordReactionEvent, client: Client) {
|
||||
const startedAt = Date.now();
|
||||
try {
|
||||
await handleDiscordReactionEvent({
|
||||
data,
|
||||
client,
|
||||
action: "removed",
|
||||
cfg: this.params.cfg,
|
||||
accountId: this.params.accountId,
|
||||
botUserId: this.params.botUserId,
|
||||
guildEntries: this.params.guildEntries,
|
||||
logger: this.params.logger,
|
||||
});
|
||||
} finally {
|
||||
logSlowDiscordListener({
|
||||
logger: this.params.logger,
|
||||
listener: this.constructor.name,
|
||||
event: this.type,
|
||||
durationMs: Date.now() - startedAt,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDiscordReactionEvent(params: {
|
||||
data: DiscordReactionEvent;
|
||||
client: Client;
|
||||
action: "added" | "removed";
|
||||
cfg: LoadedConfig;
|
||||
accountId: string;
|
||||
botUserId?: string;
|
||||
guildEntries?: Record<
|
||||
string,
|
||||
import("./allow-list.js").DiscordGuildEntryResolved
|
||||
>;
|
||||
logger: Logger;
|
||||
}) {
|
||||
try {
|
||||
const { data, client, action, botUserId, guildEntries } = params;
|
||||
if (!("user" in data)) return;
|
||||
const user = data.user;
|
||||
if (!user || user.bot) return;
|
||||
if (!data.guild_id) return;
|
||||
|
||||
const guildInfo = resolveDiscordGuildEntry({
|
||||
guild: data.guild ?? undefined,
|
||||
guildEntries,
|
||||
});
|
||||
if (guildEntries && Object.keys(guildEntries).length > 0 && !guildInfo) {
|
||||
return;
|
||||
}
|
||||
|
||||
const channel = await client.fetchChannel(data.channel_id);
|
||||
if (!channel) return;
|
||||
const channelName =
|
||||
"name" in channel ? (channel.name ?? undefined) : undefined;
|
||||
const channelSlug = channelName ? normalizeDiscordSlug(channelName) : "";
|
||||
const channelConfig = resolveDiscordChannelConfig({
|
||||
guildInfo,
|
||||
channelId: data.channel_id,
|
||||
channelName,
|
||||
channelSlug,
|
||||
});
|
||||
if (channelConfig?.allowed === false) return;
|
||||
|
||||
if (botUserId && user.id === botUserId) return;
|
||||
|
||||
const reactionMode = guildInfo?.reactionNotifications ?? "own";
|
||||
const message = await data.message.fetch().catch(() => null);
|
||||
const messageAuthorId = message?.author?.id ?? undefined;
|
||||
const shouldNotify = shouldEmitDiscordReactionNotification({
|
||||
mode: reactionMode,
|
||||
botId: botUserId,
|
||||
messageAuthorId,
|
||||
userId: user.id,
|
||||
userName: user.username,
|
||||
userTag: formatDiscordUserTag(user),
|
||||
allowlist: guildInfo?.users,
|
||||
});
|
||||
if (!shouldNotify) return;
|
||||
|
||||
const emojiLabel = formatDiscordReactionEmoji(data.emoji);
|
||||
const actorLabel = formatDiscordUserTag(user);
|
||||
const guildSlug =
|
||||
guildInfo?.slug ||
|
||||
(data.guild?.name
|
||||
? normalizeDiscordSlug(data.guild.name)
|
||||
: data.guild_id);
|
||||
const channelLabel = channelSlug
|
||||
? `#${channelSlug}`
|
||||
: channelName
|
||||
? `#${normalizeDiscordSlug(channelName)}`
|
||||
: `#${data.channel_id}`;
|
||||
const authorLabel = message?.author
|
||||
? formatDiscordUserTag(message.author)
|
||||
: undefined;
|
||||
const baseText = `Discord reaction ${action}: ${emojiLabel} by ${actorLabel} on ${guildSlug} ${channelLabel} msg ${data.message_id}`;
|
||||
const text = authorLabel ? `${baseText} from ${authorLabel}` : baseText;
|
||||
const route = resolveAgentRoute({
|
||||
cfg: params.cfg,
|
||||
channel: "discord",
|
||||
accountId: params.accountId,
|
||||
guildId: data.guild_id ?? undefined,
|
||||
peer: { kind: "channel", id: data.channel_id },
|
||||
});
|
||||
enqueueSystemEvent(text, {
|
||||
sessionKey: route.sessionKey,
|
||||
contextKey: `discord:reaction:${action}:${data.message_id}:${user.id}:${emojiLabel}`,
|
||||
});
|
||||
} catch (err) {
|
||||
params.logger.error(
|
||||
danger(`discord reaction handler failed: ${String(err)}`),
|
||||
);
|
||||
}
|
||||
}
|
||||
481
src/discord/monitor/message-handler.preflight.ts
Normal file
481
src/discord/monitor/message-handler.preflight.ts
Normal file
@@ -0,0 +1,481 @@
|
||||
import { ChannelType, MessageType, type User } from "@buape/carbon";
|
||||
|
||||
import { hasControlCommand } from "../../auto-reply/command-detection.js";
|
||||
import { shouldHandleTextCommands } from "../../auto-reply/commands-registry.js";
|
||||
import type { HistoryEntry } from "../../auto-reply/reply/history.js";
|
||||
import {
|
||||
buildMentionRegexes,
|
||||
matchesMentionPatterns,
|
||||
} from "../../auto-reply/reply/mentions.js";
|
||||
import { logVerbose, shouldLogVerbose } from "../../globals.js";
|
||||
import { recordChannelActivity } from "../../infra/channel-activity.js";
|
||||
import { enqueueSystemEvent } from "../../infra/system-events.js";
|
||||
import { getChildLogger } from "../../logging.js";
|
||||
import { buildPairingReply } from "../../pairing/pairing-messages.js";
|
||||
import {
|
||||
readChannelAllowFromStore,
|
||||
upsertChannelPairingRequest,
|
||||
} from "../../pairing/pairing-store.js";
|
||||
import { resolveAgentRoute } from "../../routing/resolve-route.js";
|
||||
import { sendMessageDiscord } from "../send.js";
|
||||
import {
|
||||
allowListMatches,
|
||||
isDiscordGroupAllowedByPolicy,
|
||||
normalizeDiscordAllowList,
|
||||
normalizeDiscordSlug,
|
||||
resolveDiscordChannelConfig,
|
||||
resolveDiscordCommandAuthorized,
|
||||
resolveDiscordGuildEntry,
|
||||
resolveDiscordShouldRequireMention,
|
||||
resolveDiscordUserAllowed,
|
||||
resolveGroupDmAllow,
|
||||
} from "./allow-list.js";
|
||||
import {
|
||||
formatDiscordUserTag,
|
||||
resolveDiscordSystemLocation,
|
||||
resolveTimestampMs,
|
||||
} from "./format.js";
|
||||
import type {
|
||||
DiscordMessagePreflightContext,
|
||||
DiscordMessagePreflightParams,
|
||||
} from "./message-handler.preflight.types.js";
|
||||
import {
|
||||
resolveDiscordChannelInfo,
|
||||
resolveDiscordMessageText,
|
||||
} from "./message-utils.js";
|
||||
import { resolveDiscordSystemEvent } from "./system-events.js";
|
||||
import {
|
||||
resolveDiscordThreadChannel,
|
||||
resolveDiscordThreadParentInfo,
|
||||
} from "./threading.js";
|
||||
|
||||
export type {
|
||||
DiscordMessagePreflightContext,
|
||||
DiscordMessagePreflightParams,
|
||||
} from "./message-handler.preflight.types.js";
|
||||
|
||||
export async function preflightDiscordMessage(
|
||||
params: DiscordMessagePreflightParams,
|
||||
): Promise<DiscordMessagePreflightContext | null> {
|
||||
const logger = getChildLogger({ module: "discord-auto-reply" });
|
||||
const message = params.data.message;
|
||||
const author = params.data.author;
|
||||
if (!author) return null;
|
||||
|
||||
const allowBots = params.discordConfig?.allowBots ?? false;
|
||||
if (author.bot) {
|
||||
// Always ignore own messages to prevent self-reply loops
|
||||
if (params.botUserId && author.id === params.botUserId) return null;
|
||||
if (!allowBots) {
|
||||
logVerbose("discord: drop bot message (allowBots=false)");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
const isGuildMessage = Boolean(params.data.guild_id);
|
||||
const channelInfo = await resolveDiscordChannelInfo(
|
||||
params.client,
|
||||
message.channelId,
|
||||
);
|
||||
const isDirectMessage = channelInfo?.type === ChannelType.DM;
|
||||
const isGroupDm = channelInfo?.type === ChannelType.GroupDM;
|
||||
|
||||
if (isGroupDm && !params.groupDmEnabled) {
|
||||
logVerbose("discord: drop group dm (group dms disabled)");
|
||||
return null;
|
||||
}
|
||||
if (isDirectMessage && !params.dmEnabled) {
|
||||
logVerbose("discord: drop dm (dms disabled)");
|
||||
return null;
|
||||
}
|
||||
|
||||
const dmPolicy = params.discordConfig?.dm?.policy ?? "pairing";
|
||||
let commandAuthorized = true;
|
||||
if (isDirectMessage) {
|
||||
if (dmPolicy === "disabled") {
|
||||
logVerbose("discord: drop dm (dmPolicy: disabled)");
|
||||
return null;
|
||||
}
|
||||
if (dmPolicy !== "open") {
|
||||
const storeAllowFrom = await readChannelAllowFromStore("discord").catch(
|
||||
() => [],
|
||||
);
|
||||
const effectiveAllowFrom = [
|
||||
...(params.allowFrom ?? []),
|
||||
...storeAllowFrom,
|
||||
];
|
||||
const allowList = normalizeDiscordAllowList(effectiveAllowFrom, [
|
||||
"discord:",
|
||||
"user:",
|
||||
]);
|
||||
const permitted = allowList
|
||||
? allowListMatches(allowList, {
|
||||
id: author.id,
|
||||
name: author.username,
|
||||
tag: formatDiscordUserTag(author),
|
||||
})
|
||||
: false;
|
||||
if (!permitted) {
|
||||
commandAuthorized = false;
|
||||
if (dmPolicy === "pairing") {
|
||||
const { code, created } = await upsertChannelPairingRequest({
|
||||
channel: "discord",
|
||||
id: author.id,
|
||||
meta: {
|
||||
tag: formatDiscordUserTag(author),
|
||||
name: author.username ?? undefined,
|
||||
},
|
||||
});
|
||||
if (created) {
|
||||
logVerbose(
|
||||
`discord pairing request sender=${author.id} tag=${formatDiscordUserTag(author)}`,
|
||||
);
|
||||
try {
|
||||
await sendMessageDiscord(
|
||||
`user:${author.id}`,
|
||||
buildPairingReply({
|
||||
channel: "discord",
|
||||
idLine: `Your Discord user id: ${author.id}`,
|
||||
code,
|
||||
}),
|
||||
{
|
||||
token: params.token,
|
||||
rest: params.client.rest,
|
||||
accountId: params.accountId,
|
||||
},
|
||||
);
|
||||
} catch (err) {
|
||||
logVerbose(
|
||||
`discord pairing reply failed for ${author.id}: ${String(err)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
logVerbose(
|
||||
`Blocked unauthorized discord sender ${author.id} (dmPolicy=${dmPolicy})`,
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
commandAuthorized = true;
|
||||
}
|
||||
}
|
||||
|
||||
const botId = params.botUserId;
|
||||
const baseText = resolveDiscordMessageText(message, {
|
||||
includeForwarded: false,
|
||||
});
|
||||
const messageText = resolveDiscordMessageText(message, {
|
||||
includeForwarded: true,
|
||||
});
|
||||
recordChannelActivity({
|
||||
channel: "discord",
|
||||
accountId: params.accountId,
|
||||
direction: "inbound",
|
||||
});
|
||||
const route = resolveAgentRoute({
|
||||
cfg: params.cfg,
|
||||
channel: "discord",
|
||||
accountId: params.accountId,
|
||||
guildId: params.data.guild_id ?? undefined,
|
||||
peer: {
|
||||
kind: isDirectMessage ? "dm" : isGroupDm ? "group" : "channel",
|
||||
id: isDirectMessage ? author.id : message.channelId,
|
||||
},
|
||||
});
|
||||
const mentionRegexes = buildMentionRegexes(params.cfg, route.agentId);
|
||||
const wasMentioned =
|
||||
!isDirectMessage &&
|
||||
(Boolean(
|
||||
botId && message.mentionedUsers?.some((user: User) => user.id === botId),
|
||||
) ||
|
||||
matchesMentionPatterns(baseText, mentionRegexes));
|
||||
if (shouldLogVerbose()) {
|
||||
logVerbose(
|
||||
`discord: inbound id=${message.id} guild=${message.guild?.id ?? "dm"} channel=${message.channelId} mention=${wasMentioned ? "yes" : "no"} type=${isDirectMessage ? "dm" : isGroupDm ? "group-dm" : "guild"} content=${messageText ? "yes" : "no"}`,
|
||||
);
|
||||
}
|
||||
|
||||
if (
|
||||
isGuildMessage &&
|
||||
(message.type === MessageType.ChatInputCommand ||
|
||||
message.type === MessageType.ContextMenuCommand)
|
||||
) {
|
||||
logVerbose("discord: drop channel command message");
|
||||
return null;
|
||||
}
|
||||
|
||||
const guildInfo = isGuildMessage
|
||||
? resolveDiscordGuildEntry({
|
||||
guild: params.data.guild ?? undefined,
|
||||
guildEntries: params.guildEntries,
|
||||
})
|
||||
: null;
|
||||
if (
|
||||
isGuildMessage &&
|
||||
params.guildEntries &&
|
||||
Object.keys(params.guildEntries).length > 0 &&
|
||||
!guildInfo
|
||||
) {
|
||||
logVerbose(
|
||||
`Blocked discord guild ${params.data.guild_id ?? "unknown"} (not in discord.guilds)`,
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
const channelName =
|
||||
channelInfo?.name ??
|
||||
((isGuildMessage || isGroupDm) &&
|
||||
message.channel &&
|
||||
"name" in message.channel
|
||||
? message.channel.name
|
||||
: undefined);
|
||||
const threadChannel = resolveDiscordThreadChannel({
|
||||
isGuildMessage,
|
||||
message,
|
||||
channelInfo,
|
||||
});
|
||||
let threadParentId: string | undefined;
|
||||
let threadParentName: string | undefined;
|
||||
let threadParentType: ChannelType | undefined;
|
||||
if (threadChannel) {
|
||||
const parentInfo = await resolveDiscordThreadParentInfo({
|
||||
client: params.client,
|
||||
threadChannel,
|
||||
channelInfo,
|
||||
});
|
||||
threadParentId = parentInfo.id;
|
||||
threadParentName = parentInfo.name;
|
||||
threadParentType = parentInfo.type;
|
||||
}
|
||||
const threadName = threadChannel?.name;
|
||||
const configChannelName = threadParentName ?? channelName;
|
||||
const configChannelSlug = configChannelName
|
||||
? normalizeDiscordSlug(configChannelName)
|
||||
: "";
|
||||
const displayChannelName = threadName ?? channelName;
|
||||
const displayChannelSlug = displayChannelName
|
||||
? normalizeDiscordSlug(displayChannelName)
|
||||
: "";
|
||||
const guildSlug =
|
||||
guildInfo?.slug ||
|
||||
(params.data.guild?.name
|
||||
? normalizeDiscordSlug(params.data.guild.name)
|
||||
: "");
|
||||
|
||||
const baseSessionKey = route.sessionKey;
|
||||
const channelConfig = isGuildMessage
|
||||
? resolveDiscordChannelConfig({
|
||||
guildInfo,
|
||||
channelId: threadParentId ?? message.channelId,
|
||||
channelName: configChannelName,
|
||||
channelSlug: configChannelSlug,
|
||||
})
|
||||
: null;
|
||||
if (isGuildMessage && channelConfig?.enabled === false) {
|
||||
logVerbose(
|
||||
`Blocked discord channel ${message.channelId} (channel disabled)`,
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
const groupDmAllowed =
|
||||
isGroupDm &&
|
||||
resolveGroupDmAllow({
|
||||
channels: params.groupDmChannels,
|
||||
channelId: message.channelId,
|
||||
channelName: displayChannelName,
|
||||
channelSlug: displayChannelSlug,
|
||||
});
|
||||
if (isGroupDm && !groupDmAllowed) return null;
|
||||
|
||||
const channelAllowlistConfigured =
|
||||
Boolean(guildInfo?.channels) &&
|
||||
Object.keys(guildInfo?.channels ?? {}).length > 0;
|
||||
const channelAllowed = channelConfig?.allowed !== false;
|
||||
if (
|
||||
isGuildMessage &&
|
||||
!isDiscordGroupAllowedByPolicy({
|
||||
groupPolicy: params.groupPolicy,
|
||||
channelAllowlistConfigured,
|
||||
channelAllowed,
|
||||
})
|
||||
) {
|
||||
if (params.groupPolicy === "disabled") {
|
||||
logVerbose("discord: drop guild message (groupPolicy: disabled)");
|
||||
} else if (!channelAllowlistConfigured) {
|
||||
logVerbose(
|
||||
"discord: drop guild message (groupPolicy: allowlist, no channel allowlist)",
|
||||
);
|
||||
} else {
|
||||
logVerbose(
|
||||
`Blocked discord channel ${message.channelId} not in guild channel allowlist (groupPolicy: allowlist)`,
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
if (isGuildMessage && channelConfig?.allowed === false) {
|
||||
logVerbose(
|
||||
`Blocked discord channel ${message.channelId} not in guild channel allowlist`,
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
const textForHistory = resolveDiscordMessageText(message, {
|
||||
includeForwarded: true,
|
||||
});
|
||||
const historyEntry =
|
||||
isGuildMessage && params.historyLimit > 0 && textForHistory
|
||||
? ({
|
||||
sender:
|
||||
params.data.member?.nickname ??
|
||||
author.globalName ??
|
||||
author.username ??
|
||||
author.id,
|
||||
body: textForHistory,
|
||||
timestamp: resolveTimestampMs(message.timestamp),
|
||||
messageId: message.id,
|
||||
} satisfies HistoryEntry)
|
||||
: undefined;
|
||||
|
||||
const shouldRequireMention = resolveDiscordShouldRequireMention({
|
||||
isGuildMessage,
|
||||
isThread: Boolean(threadChannel),
|
||||
channelConfig,
|
||||
guildInfo,
|
||||
});
|
||||
const hasAnyMention = Boolean(
|
||||
!isDirectMessage &&
|
||||
(message.mentionedEveryone ||
|
||||
(message.mentionedUsers?.length ?? 0) > 0 ||
|
||||
(message.mentionedRoles?.length ?? 0) > 0),
|
||||
);
|
||||
if (!isDirectMessage) {
|
||||
commandAuthorized = resolveDiscordCommandAuthorized({
|
||||
isDirectMessage,
|
||||
allowFrom: params.allowFrom,
|
||||
guildInfo,
|
||||
author,
|
||||
});
|
||||
}
|
||||
const allowTextCommands = shouldHandleTextCommands({
|
||||
cfg: params.cfg,
|
||||
surface: "discord",
|
||||
});
|
||||
const shouldBypassMention =
|
||||
allowTextCommands &&
|
||||
isGuildMessage &&
|
||||
shouldRequireMention &&
|
||||
!wasMentioned &&
|
||||
!hasAnyMention &&
|
||||
commandAuthorized &&
|
||||
hasControlCommand(baseText, params.cfg);
|
||||
const effectiveWasMentioned = wasMentioned || shouldBypassMention;
|
||||
const canDetectMention = Boolean(botId) || mentionRegexes.length > 0;
|
||||
if (isGuildMessage && shouldRequireMention) {
|
||||
if (botId && !wasMentioned && !shouldBypassMention) {
|
||||
logVerbose(
|
||||
`discord: drop guild message (mention required, botId=${botId})`,
|
||||
);
|
||||
logger.info(
|
||||
{
|
||||
channelId: message.channelId,
|
||||
reason: "no-mention",
|
||||
},
|
||||
"discord: skipping guild message",
|
||||
);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
if (isGuildMessage) {
|
||||
const channelUsers = channelConfig?.users ?? guildInfo?.users;
|
||||
if (Array.isArray(channelUsers) && channelUsers.length > 0) {
|
||||
const userOk = resolveDiscordUserAllowed({
|
||||
allowList: channelUsers,
|
||||
userId: author.id,
|
||||
userName: author.username,
|
||||
userTag: formatDiscordUserTag(author),
|
||||
});
|
||||
if (!userOk) {
|
||||
logVerbose(
|
||||
`Blocked discord guild sender ${author.id} (not in channel users allowlist)`,
|
||||
);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const systemLocation = resolveDiscordSystemLocation({
|
||||
isDirectMessage,
|
||||
isGroupDm,
|
||||
guild: params.data.guild ?? undefined,
|
||||
channelName: channelName ?? message.channelId,
|
||||
});
|
||||
const systemText = resolveDiscordSystemEvent(message, systemLocation);
|
||||
if (systemText) {
|
||||
enqueueSystemEvent(systemText, {
|
||||
sessionKey: route.sessionKey,
|
||||
contextKey: `discord:system:${message.channelId}:${message.id}`,
|
||||
});
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!messageText) {
|
||||
logVerbose(`discord: drop message ${message.id} (empty content)`);
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
cfg: params.cfg,
|
||||
discordConfig: params.discordConfig,
|
||||
accountId: params.accountId,
|
||||
token: params.token,
|
||||
runtime: params.runtime,
|
||||
botUserId: params.botUserId,
|
||||
guildHistories: params.guildHistories,
|
||||
historyLimit: params.historyLimit,
|
||||
mediaMaxBytes: params.mediaMaxBytes,
|
||||
textLimit: params.textLimit,
|
||||
replyToMode: params.replyToMode,
|
||||
ackReactionScope: params.ackReactionScope,
|
||||
groupPolicy: params.groupPolicy,
|
||||
data: params.data,
|
||||
client: params.client,
|
||||
message,
|
||||
author,
|
||||
channelInfo,
|
||||
channelName,
|
||||
isGuildMessage,
|
||||
isDirectMessage,
|
||||
isGroupDm,
|
||||
commandAuthorized,
|
||||
baseText,
|
||||
messageText,
|
||||
wasMentioned,
|
||||
route,
|
||||
guildInfo,
|
||||
guildSlug,
|
||||
threadChannel,
|
||||
threadParentId,
|
||||
threadParentName,
|
||||
threadParentType,
|
||||
threadName,
|
||||
configChannelName,
|
||||
configChannelSlug,
|
||||
displayChannelName,
|
||||
displayChannelSlug,
|
||||
baseSessionKey,
|
||||
channelConfig,
|
||||
channelAllowlistConfigured,
|
||||
channelAllowed,
|
||||
shouldRequireMention,
|
||||
hasAnyMention,
|
||||
allowTextCommands,
|
||||
shouldBypassMention,
|
||||
effectiveWasMentioned,
|
||||
canDetectMention,
|
||||
historyEntry,
|
||||
};
|
||||
}
|
||||
105
src/discord/monitor/message-handler.preflight.types.ts
Normal file
105
src/discord/monitor/message-handler.preflight.types.ts
Normal file
@@ -0,0 +1,105 @@
|
||||
import type { ChannelType, Client, User } from "@buape/carbon";
|
||||
import type { HistoryEntry } from "../../auto-reply/reply/history.js";
|
||||
import type { ReplyToMode } from "../../config/config.js";
|
||||
import type { resolveAgentRoute } from "../../routing/resolve-route.js";
|
||||
import type {
|
||||
DiscordChannelConfigResolved,
|
||||
DiscordGuildEntryResolved,
|
||||
} from "./allow-list.js";
|
||||
import type { DiscordChannelInfo } from "./message-utils.js";
|
||||
import type { DiscordThreadChannel } from "./threading.js";
|
||||
|
||||
export type LoadedConfig = ReturnType<
|
||||
typeof import("../../config/config.js").loadConfig
|
||||
>;
|
||||
export type RuntimeEnv = import("../../runtime.js").RuntimeEnv;
|
||||
|
||||
export type DiscordMessageEvent = import("./listeners.js").DiscordMessageEvent;
|
||||
|
||||
export type DiscordMessagePreflightContext = {
|
||||
cfg: LoadedConfig;
|
||||
discordConfig: NonNullable<
|
||||
import("../../config/config.js").ClawdbotConfig["channels"]
|
||||
>["discord"];
|
||||
accountId: string;
|
||||
token: string;
|
||||
runtime: RuntimeEnv;
|
||||
botUserId?: string;
|
||||
guildHistories: Map<string, HistoryEntry[]>;
|
||||
historyLimit: number;
|
||||
mediaMaxBytes: number;
|
||||
textLimit: number;
|
||||
replyToMode: ReplyToMode;
|
||||
ackReactionScope: "all" | "direct" | "group-all" | "group-mentions";
|
||||
groupPolicy: "open" | "disabled" | "allowlist";
|
||||
|
||||
data: DiscordMessageEvent;
|
||||
client: Client;
|
||||
message: DiscordMessageEvent["message"];
|
||||
author: User;
|
||||
|
||||
channelInfo: DiscordChannelInfo | null;
|
||||
channelName?: string;
|
||||
|
||||
isGuildMessage: boolean;
|
||||
isDirectMessage: boolean;
|
||||
isGroupDm: boolean;
|
||||
|
||||
commandAuthorized: boolean;
|
||||
baseText: string;
|
||||
messageText: string;
|
||||
wasMentioned: boolean;
|
||||
|
||||
route: ReturnType<typeof resolveAgentRoute>;
|
||||
|
||||
guildInfo: DiscordGuildEntryResolved | null;
|
||||
guildSlug: string;
|
||||
|
||||
threadChannel: DiscordThreadChannel | null;
|
||||
threadParentId?: string;
|
||||
threadParentName?: string;
|
||||
threadParentType?: ChannelType;
|
||||
threadName?: string | null;
|
||||
|
||||
configChannelName?: string;
|
||||
configChannelSlug: string;
|
||||
displayChannelName?: string;
|
||||
displayChannelSlug: string;
|
||||
|
||||
baseSessionKey: string;
|
||||
channelConfig: DiscordChannelConfigResolved | null;
|
||||
channelAllowlistConfigured: boolean;
|
||||
channelAllowed: boolean;
|
||||
|
||||
shouldRequireMention: boolean;
|
||||
hasAnyMention: boolean;
|
||||
allowTextCommands: boolean;
|
||||
shouldBypassMention: boolean;
|
||||
effectiveWasMentioned: boolean;
|
||||
canDetectMention: boolean;
|
||||
|
||||
historyEntry?: HistoryEntry;
|
||||
};
|
||||
|
||||
export type DiscordMessagePreflightParams = {
|
||||
cfg: LoadedConfig;
|
||||
discordConfig: DiscordMessagePreflightContext["discordConfig"];
|
||||
accountId: string;
|
||||
token: string;
|
||||
runtime: RuntimeEnv;
|
||||
botUserId?: string;
|
||||
guildHistories: Map<string, HistoryEntry[]>;
|
||||
historyLimit: number;
|
||||
mediaMaxBytes: number;
|
||||
textLimit: number;
|
||||
replyToMode: ReplyToMode;
|
||||
dmEnabled: boolean;
|
||||
groupDmEnabled: boolean;
|
||||
groupDmChannels?: Array<string | number>;
|
||||
allowFrom?: Array<string | number>;
|
||||
guildEntries?: Record<string, DiscordGuildEntryResolved>;
|
||||
ackReactionScope: DiscordMessagePreflightContext["ackReactionScope"];
|
||||
groupPolicy: DiscordMessagePreflightContext["groupPolicy"];
|
||||
data: DiscordMessageEvent;
|
||||
client: Client;
|
||||
};
|
||||
386
src/discord/monitor/message-handler.process.ts
Normal file
386
src/discord/monitor/message-handler.process.ts
Normal file
@@ -0,0 +1,386 @@
|
||||
import {
|
||||
resolveAckReaction,
|
||||
resolveEffectiveMessagesConfig,
|
||||
resolveHumanDelayConfig,
|
||||
} from "../../agents/identity.js";
|
||||
import {
|
||||
formatAgentEnvelope,
|
||||
formatThreadStarterEnvelope,
|
||||
} from "../../auto-reply/envelope.js";
|
||||
import { dispatchReplyFromConfig } from "../../auto-reply/reply/dispatch-from-config.js";
|
||||
import {
|
||||
buildHistoryContextFromMap,
|
||||
clearHistoryEntries,
|
||||
} from "../../auto-reply/reply/history.js";
|
||||
import { createReplyDispatcherWithTyping } from "../../auto-reply/reply/reply-dispatcher.js";
|
||||
import type { ReplyPayload } from "../../auto-reply/types.js";
|
||||
import { resolveStorePath, updateLastRoute } from "../../config/sessions.js";
|
||||
import { danger, logVerbose, shouldLogVerbose } from "../../globals.js";
|
||||
import { buildAgentSessionKey } from "../../routing/resolve-route.js";
|
||||
import { resolveThreadSessionKeys } from "../../routing/session-key.js";
|
||||
import { truncateUtf16Safe } from "../../utils.js";
|
||||
import { reactMessageDiscord, removeReactionDiscord } from "../send.js";
|
||||
import { normalizeDiscordSlug } from "./allow-list.js";
|
||||
import { formatDiscordUserTag, resolveTimestampMs } from "./format.js";
|
||||
import type { DiscordMessagePreflightContext } from "./message-handler.preflight.js";
|
||||
import {
|
||||
buildDiscordMediaPayload,
|
||||
resolveDiscordMessageText,
|
||||
resolveMediaList,
|
||||
} from "./message-utils.js";
|
||||
import {
|
||||
buildDirectLabel,
|
||||
buildGuildLabel,
|
||||
resolveReplyContext,
|
||||
} from "./reply-context.js";
|
||||
import { deliverDiscordReply } from "./reply-delivery.js";
|
||||
import {
|
||||
maybeCreateDiscordAutoThread,
|
||||
resolveDiscordReplyDeliveryPlan,
|
||||
resolveDiscordThreadStarter,
|
||||
} from "./threading.js";
|
||||
import { sendTyping } from "./typing.js";
|
||||
|
||||
export async function processDiscordMessage(
|
||||
ctx: DiscordMessagePreflightContext,
|
||||
) {
|
||||
const {
|
||||
cfg,
|
||||
discordConfig,
|
||||
accountId,
|
||||
token,
|
||||
runtime,
|
||||
guildHistories,
|
||||
historyLimit,
|
||||
mediaMaxBytes,
|
||||
textLimit,
|
||||
replyToMode,
|
||||
ackReactionScope,
|
||||
message,
|
||||
author,
|
||||
data,
|
||||
client,
|
||||
channelInfo,
|
||||
channelName,
|
||||
isGuildMessage,
|
||||
isDirectMessage,
|
||||
isGroupDm,
|
||||
baseText,
|
||||
messageText,
|
||||
wasMentioned,
|
||||
shouldRequireMention,
|
||||
canDetectMention,
|
||||
shouldBypassMention,
|
||||
effectiveWasMentioned,
|
||||
historyEntry,
|
||||
threadChannel,
|
||||
threadParentId,
|
||||
threadParentName,
|
||||
threadParentType,
|
||||
threadName,
|
||||
displayChannelSlug,
|
||||
guildInfo,
|
||||
guildSlug,
|
||||
channelConfig,
|
||||
baseSessionKey,
|
||||
route,
|
||||
commandAuthorized,
|
||||
} = ctx;
|
||||
|
||||
const mediaList = await resolveMediaList(message, mediaMaxBytes);
|
||||
const text = messageText;
|
||||
if (!text) {
|
||||
logVerbose(`discord: drop message ${message.id} (empty content)`);
|
||||
return;
|
||||
}
|
||||
const ackReaction = resolveAckReaction(cfg, route.agentId);
|
||||
const removeAckAfterReply = cfg.messages?.removeAckAfterReply ?? false;
|
||||
const shouldAckReaction = () => {
|
||||
if (!ackReaction) return false;
|
||||
if (ackReactionScope === "all") return true;
|
||||
if (ackReactionScope === "direct") return isDirectMessage;
|
||||
const isGroupChat = isGuildMessage || isGroupDm;
|
||||
if (ackReactionScope === "group-all") return isGroupChat;
|
||||
if (ackReactionScope === "group-mentions") {
|
||||
if (!isGuildMessage) return false;
|
||||
if (!shouldRequireMention) return false;
|
||||
if (!canDetectMention) return false;
|
||||
return wasMentioned || shouldBypassMention;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
const ackReactionPromise = shouldAckReaction()
|
||||
? reactMessageDiscord(message.channelId, message.id, ackReaction, {
|
||||
rest: client.rest,
|
||||
}).then(
|
||||
() => true,
|
||||
(err) => {
|
||||
logVerbose(
|
||||
`discord react failed for channel ${message.channelId}: ${String(err)}`,
|
||||
);
|
||||
return false;
|
||||
},
|
||||
)
|
||||
: null;
|
||||
|
||||
const fromLabel = isDirectMessage
|
||||
? buildDirectLabel(author)
|
||||
: buildGuildLabel({
|
||||
guild: data.guild ?? undefined,
|
||||
channelName: channelName ?? message.channelId,
|
||||
channelId: message.channelId,
|
||||
});
|
||||
const groupRoom =
|
||||
isGuildMessage && displayChannelSlug ? `#${displayChannelSlug}` : undefined;
|
||||
const groupSubject = isDirectMessage ? undefined : groupRoom;
|
||||
const channelDescription = channelInfo?.topic?.trim();
|
||||
const systemPromptParts = [
|
||||
channelDescription ? `Channel topic: ${channelDescription}` : null,
|
||||
channelConfig?.systemPrompt?.trim() || null,
|
||||
].filter((entry): entry is string => Boolean(entry));
|
||||
const groupSystemPrompt =
|
||||
systemPromptParts.length > 0 ? systemPromptParts.join("\n\n") : undefined;
|
||||
let combinedBody = formatAgentEnvelope({
|
||||
channel: "Discord",
|
||||
from: fromLabel,
|
||||
timestamp: resolveTimestampMs(message.timestamp),
|
||||
body: text,
|
||||
});
|
||||
let shouldClearHistory = false;
|
||||
if (!isDirectMessage) {
|
||||
combinedBody = buildHistoryContextFromMap({
|
||||
historyMap: guildHistories,
|
||||
historyKey: message.channelId,
|
||||
limit: historyLimit,
|
||||
entry: historyEntry,
|
||||
currentMessage: combinedBody,
|
||||
formatEntry: (entry) =>
|
||||
formatAgentEnvelope({
|
||||
channel: "Discord",
|
||||
from: fromLabel,
|
||||
timestamp: entry.timestamp,
|
||||
body: `${entry.sender}: ${entry.body} [id:${entry.messageId ?? "unknown"} channel:${message.channelId}]`,
|
||||
}),
|
||||
});
|
||||
const name = formatDiscordUserTag(author);
|
||||
const id = author.id;
|
||||
combinedBody = `${combinedBody}\n[from: ${name} user id:${id}]`;
|
||||
shouldClearHistory = true;
|
||||
}
|
||||
const replyContext = resolveReplyContext(message, resolveDiscordMessageText);
|
||||
if (replyContext) {
|
||||
combinedBody = `[Replied message - for context]\n${replyContext}\n\n${combinedBody}`;
|
||||
}
|
||||
|
||||
let threadStarterBody: string | undefined;
|
||||
let threadLabel: string | undefined;
|
||||
let parentSessionKey: string | undefined;
|
||||
if (threadChannel) {
|
||||
const starter = await resolveDiscordThreadStarter({
|
||||
channel: threadChannel,
|
||||
client,
|
||||
parentId: threadParentId,
|
||||
parentType: threadParentType,
|
||||
resolveTimestampMs,
|
||||
});
|
||||
if (starter?.text) {
|
||||
const starterEnvelope = formatThreadStarterEnvelope({
|
||||
channel: "Discord",
|
||||
author: starter.author,
|
||||
timestamp: starter.timestamp,
|
||||
body: starter.text,
|
||||
});
|
||||
threadStarterBody = starterEnvelope;
|
||||
}
|
||||
const parentName = threadParentName ?? "parent";
|
||||
threadLabel = threadName
|
||||
? `Discord thread #${normalizeDiscordSlug(parentName)} › ${threadName}`
|
||||
: `Discord thread #${normalizeDiscordSlug(parentName)}`;
|
||||
if (threadParentId) {
|
||||
parentSessionKey = buildAgentSessionKey({
|
||||
agentId: route.agentId,
|
||||
channel: route.channel,
|
||||
peer: { kind: "channel", id: threadParentId },
|
||||
});
|
||||
}
|
||||
}
|
||||
const mediaPayload = buildDiscordMediaPayload(mediaList);
|
||||
const discordTo = `channel:${message.channelId}`;
|
||||
const threadKeys = resolveThreadSessionKeys({
|
||||
baseSessionKey,
|
||||
threadId: threadChannel ? message.channelId : undefined,
|
||||
parentSessionKey,
|
||||
useSuffix: false,
|
||||
});
|
||||
const ctxPayload = {
|
||||
Body: combinedBody,
|
||||
RawBody: baseText,
|
||||
CommandBody: baseText,
|
||||
From: isDirectMessage
|
||||
? `discord:${author.id}`
|
||||
: `group:${message.channelId}`,
|
||||
To: discordTo,
|
||||
SessionKey: threadKeys.sessionKey,
|
||||
AccountId: route.accountId,
|
||||
ChatType: isDirectMessage ? "direct" : "group",
|
||||
SenderName: data.member?.nickname ?? author.globalName ?? author.username,
|
||||
SenderId: author.id,
|
||||
SenderUsername: author.username,
|
||||
SenderTag: formatDiscordUserTag(author),
|
||||
GroupSubject: groupSubject,
|
||||
GroupRoom: groupRoom,
|
||||
GroupSystemPrompt: isGuildMessage ? groupSystemPrompt : undefined,
|
||||
GroupSpace: isGuildMessage
|
||||
? (guildInfo?.id ?? guildSlug) || undefined
|
||||
: undefined,
|
||||
Provider: "discord" as const,
|
||||
Surface: "discord" as const,
|
||||
WasMentioned: effectiveWasMentioned,
|
||||
MessageSid: message.id,
|
||||
ParentSessionKey: threadKeys.parentSessionKey,
|
||||
ThreadStarterBody: threadStarterBody,
|
||||
ThreadLabel: threadLabel,
|
||||
Timestamp: resolveTimestampMs(message.timestamp),
|
||||
...mediaPayload,
|
||||
CommandAuthorized: commandAuthorized,
|
||||
CommandSource: "text" as const,
|
||||
// Originating channel for reply routing.
|
||||
OriginatingChannel: "discord" as const,
|
||||
OriginatingTo: discordTo,
|
||||
};
|
||||
let replyTarget = ctxPayload.To ?? undefined;
|
||||
if (!replyTarget) {
|
||||
runtime.error?.(danger("discord: missing reply target"));
|
||||
return;
|
||||
}
|
||||
const createdThreadId = await maybeCreateDiscordAutoThread({
|
||||
client,
|
||||
message,
|
||||
isGuildMessage,
|
||||
channelConfig,
|
||||
threadChannel,
|
||||
baseText: baseText ?? "",
|
||||
combinedBody,
|
||||
});
|
||||
const replyPlan = resolveDiscordReplyDeliveryPlan({
|
||||
replyTarget,
|
||||
replyToMode,
|
||||
messageId: message.id,
|
||||
threadChannel,
|
||||
createdThreadId,
|
||||
});
|
||||
const deliverTarget = replyPlan.deliverTarget;
|
||||
replyTarget = replyPlan.replyTarget;
|
||||
const replyReference = replyPlan.replyReference;
|
||||
|
||||
if (isDirectMessage) {
|
||||
const sessionCfg = cfg.session;
|
||||
const storePath = resolveStorePath(sessionCfg?.store, {
|
||||
agentId: route.agentId,
|
||||
});
|
||||
await updateLastRoute({
|
||||
storePath,
|
||||
sessionKey: route.mainSessionKey,
|
||||
channel: "discord",
|
||||
to: `user:${author.id}`,
|
||||
accountId: route.accountId,
|
||||
});
|
||||
}
|
||||
|
||||
if (shouldLogVerbose()) {
|
||||
const preview = truncateUtf16Safe(combinedBody, 200).replace(/\n/g, "\\n");
|
||||
logVerbose(
|
||||
`discord inbound: channel=${message.channelId} from=${ctxPayload.From} preview="${preview}"`,
|
||||
);
|
||||
}
|
||||
|
||||
let didSendReply = false;
|
||||
const { dispatcher, replyOptions, markDispatchIdle } =
|
||||
createReplyDispatcherWithTyping({
|
||||
responsePrefix: resolveEffectiveMessagesConfig(cfg, route.agentId)
|
||||
.responsePrefix,
|
||||
humanDelay: resolveHumanDelayConfig(cfg, route.agentId),
|
||||
deliver: async (payload: ReplyPayload) => {
|
||||
const replyToId = replyReference.use();
|
||||
await deliverDiscordReply({
|
||||
replies: [payload],
|
||||
target: deliverTarget,
|
||||
token,
|
||||
accountId,
|
||||
rest: client.rest,
|
||||
runtime,
|
||||
replyToId,
|
||||
textLimit,
|
||||
maxLinesPerMessage: discordConfig?.maxLinesPerMessage,
|
||||
});
|
||||
didSendReply = true;
|
||||
replyReference.markSent();
|
||||
},
|
||||
onError: (err, info) => {
|
||||
runtime.error?.(
|
||||
danger(`discord ${info.kind} reply failed: ${String(err)}`),
|
||||
);
|
||||
},
|
||||
onReplyStart: () => sendTyping({ client, channelId: message.channelId }),
|
||||
});
|
||||
|
||||
const { queuedFinal, counts } = await dispatchReplyFromConfig({
|
||||
ctx: ctxPayload,
|
||||
cfg,
|
||||
dispatcher,
|
||||
replyOptions: {
|
||||
...replyOptions,
|
||||
skillFilter: channelConfig?.skills,
|
||||
disableBlockStreaming:
|
||||
typeof discordConfig?.blockStreaming === "boolean"
|
||||
? !discordConfig.blockStreaming
|
||||
: undefined,
|
||||
},
|
||||
});
|
||||
markDispatchIdle();
|
||||
if (!queuedFinal) {
|
||||
if (
|
||||
isGuildMessage &&
|
||||
shouldClearHistory &&
|
||||
historyLimit > 0 &&
|
||||
didSendReply
|
||||
) {
|
||||
clearHistoryEntries({
|
||||
historyMap: guildHistories,
|
||||
historyKey: message.channelId,
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
didSendReply = true;
|
||||
if (shouldLogVerbose()) {
|
||||
const finalCount = counts.final;
|
||||
logVerbose(
|
||||
`discord: delivered ${finalCount} reply${finalCount === 1 ? "" : "ies"} to ${replyTarget}`,
|
||||
);
|
||||
}
|
||||
if (removeAckAfterReply && ackReactionPromise && ackReaction) {
|
||||
const ackReactionValue = ackReaction;
|
||||
void ackReactionPromise.then((didAck) => {
|
||||
if (!didAck) return;
|
||||
removeReactionDiscord(message.channelId, message.id, ackReactionValue, {
|
||||
rest: client.rest,
|
||||
}).catch((err) => {
|
||||
logVerbose(
|
||||
`discord: failed to remove ack reaction from ${message.channelId}/${message.id}: ${String(err)}`,
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
if (
|
||||
isGuildMessage &&
|
||||
shouldClearHistory &&
|
||||
historyLimit > 0 &&
|
||||
didSendReply
|
||||
) {
|
||||
clearHistoryEntries({
|
||||
historyMap: guildHistories,
|
||||
historyKey: message.channelId,
|
||||
});
|
||||
}
|
||||
}
|
||||
54
src/discord/monitor/message-handler.ts
Normal file
54
src/discord/monitor/message-handler.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
import type { HistoryEntry } from "../../auto-reply/reply/history.js";
|
||||
import type { ReplyToMode } from "../../config/config.js";
|
||||
import { danger } from "../../globals.js";
|
||||
import type { RuntimeEnv } from "../../runtime.js";
|
||||
import type { DiscordGuildEntryResolved } from "./allow-list.js";
|
||||
import type { DiscordMessageHandler } from "./listeners.js";
|
||||
import { preflightDiscordMessage } from "./message-handler.preflight.js";
|
||||
import { processDiscordMessage } from "./message-handler.process.js";
|
||||
|
||||
type LoadedConfig = ReturnType<
|
||||
typeof import("../../config/config.js").loadConfig
|
||||
>;
|
||||
type DiscordConfig = NonNullable<
|
||||
import("../../config/config.js").ClawdbotConfig["channels"]
|
||||
>["discord"];
|
||||
|
||||
export function createDiscordMessageHandler(params: {
|
||||
cfg: LoadedConfig;
|
||||
discordConfig: DiscordConfig;
|
||||
accountId: string;
|
||||
token: string;
|
||||
runtime: RuntimeEnv;
|
||||
botUserId?: string;
|
||||
guildHistories: Map<string, HistoryEntry[]>;
|
||||
historyLimit: number;
|
||||
mediaMaxBytes: number;
|
||||
textLimit: number;
|
||||
replyToMode: ReplyToMode;
|
||||
dmEnabled: boolean;
|
||||
groupDmEnabled: boolean;
|
||||
groupDmChannels?: Array<string | number>;
|
||||
allowFrom?: Array<string | number>;
|
||||
guildEntries?: Record<string, DiscordGuildEntryResolved>;
|
||||
}): DiscordMessageHandler {
|
||||
const groupPolicy = params.discordConfig?.groupPolicy ?? "open";
|
||||
const ackReactionScope =
|
||||
params.cfg.messages?.ackReactionScope ?? "group-mentions";
|
||||
|
||||
return async (data, client) => {
|
||||
try {
|
||||
const ctx = await preflightDiscordMessage({
|
||||
...params,
|
||||
ackReactionScope,
|
||||
groupPolicy,
|
||||
data,
|
||||
client,
|
||||
});
|
||||
if (!ctx) return;
|
||||
await processDiscordMessage(ctx);
|
||||
} catch (err) {
|
||||
params.runtime.error?.(danger(`handler failed: ${String(err)}`));
|
||||
}
|
||||
};
|
||||
}
|
||||
257
src/discord/monitor/message-utils.ts
Normal file
257
src/discord/monitor/message-utils.ts
Normal file
@@ -0,0 +1,257 @@
|
||||
import type { ChannelType, Client, Message } from "@buape/carbon";
|
||||
import type { APIAttachment } from "discord-api-types/v10";
|
||||
|
||||
import { logVerbose } from "../../globals.js";
|
||||
import { fetchRemoteMedia } from "../../media/fetch.js";
|
||||
import { saveMediaBuffer } from "../../media/store.js";
|
||||
|
||||
export type DiscordMediaInfo = {
|
||||
path: string;
|
||||
contentType?: string;
|
||||
placeholder: string;
|
||||
};
|
||||
|
||||
export type DiscordChannelInfo = {
|
||||
type: ChannelType;
|
||||
name?: string;
|
||||
topic?: string;
|
||||
parentId?: string;
|
||||
};
|
||||
|
||||
type DiscordSnapshotAuthor = {
|
||||
id?: string | null;
|
||||
username?: string | null;
|
||||
discriminator?: string | null;
|
||||
global_name?: string | null;
|
||||
name?: string | null;
|
||||
};
|
||||
|
||||
type DiscordSnapshotMessage = {
|
||||
content?: string | null;
|
||||
embeds?: Array<{ description?: string | null; title?: string | null }> | null;
|
||||
attachments?: APIAttachment[] | null;
|
||||
author?: DiscordSnapshotAuthor | null;
|
||||
};
|
||||
|
||||
type DiscordMessageSnapshot = {
|
||||
message?: DiscordSnapshotMessage | null;
|
||||
};
|
||||
|
||||
const DISCORD_CHANNEL_INFO_CACHE_TTL_MS = 5 * 60 * 1000;
|
||||
const DISCORD_CHANNEL_INFO_NEGATIVE_CACHE_TTL_MS = 30 * 1000;
|
||||
const DISCORD_CHANNEL_INFO_CACHE = new Map<
|
||||
string,
|
||||
{ value: DiscordChannelInfo | null; expiresAt: number }
|
||||
>();
|
||||
|
||||
export async function resolveDiscordChannelInfo(
|
||||
client: Client,
|
||||
channelId: string,
|
||||
): Promise<DiscordChannelInfo | null> {
|
||||
const cached = DISCORD_CHANNEL_INFO_CACHE.get(channelId);
|
||||
if (cached) {
|
||||
if (cached.expiresAt > Date.now()) return cached.value;
|
||||
DISCORD_CHANNEL_INFO_CACHE.delete(channelId);
|
||||
}
|
||||
try {
|
||||
const channel = await client.fetchChannel(channelId);
|
||||
if (!channel) {
|
||||
DISCORD_CHANNEL_INFO_CACHE.set(channelId, {
|
||||
value: null,
|
||||
expiresAt: Date.now() + DISCORD_CHANNEL_INFO_NEGATIVE_CACHE_TTL_MS,
|
||||
});
|
||||
return null;
|
||||
}
|
||||
const name = "name" in channel ? (channel.name ?? undefined) : undefined;
|
||||
const topic = "topic" in channel ? (channel.topic ?? undefined) : undefined;
|
||||
const parentId =
|
||||
"parentId" in channel ? (channel.parentId ?? undefined) : undefined;
|
||||
const payload: DiscordChannelInfo = {
|
||||
type: channel.type,
|
||||
name,
|
||||
topic,
|
||||
parentId,
|
||||
};
|
||||
DISCORD_CHANNEL_INFO_CACHE.set(channelId, {
|
||||
value: payload,
|
||||
expiresAt: Date.now() + DISCORD_CHANNEL_INFO_CACHE_TTL_MS,
|
||||
});
|
||||
return payload;
|
||||
} catch (err) {
|
||||
logVerbose(`discord: failed to fetch channel ${channelId}: ${String(err)}`);
|
||||
DISCORD_CHANNEL_INFO_CACHE.set(channelId, {
|
||||
value: null,
|
||||
expiresAt: Date.now() + DISCORD_CHANNEL_INFO_NEGATIVE_CACHE_TTL_MS,
|
||||
});
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function resolveMediaList(
|
||||
message: Message,
|
||||
maxBytes: number,
|
||||
): Promise<DiscordMediaInfo[]> {
|
||||
const attachments = message.attachments ?? [];
|
||||
if (attachments.length === 0) return [];
|
||||
const out: DiscordMediaInfo[] = [];
|
||||
for (const attachment of attachments) {
|
||||
try {
|
||||
const fetched = await fetchRemoteMedia({
|
||||
url: attachment.url,
|
||||
filePathHint: attachment.filename ?? attachment.url,
|
||||
});
|
||||
const saved = await saveMediaBuffer(
|
||||
fetched.buffer,
|
||||
fetched.contentType ?? attachment.content_type,
|
||||
"inbound",
|
||||
maxBytes,
|
||||
);
|
||||
out.push({
|
||||
path: saved.path,
|
||||
contentType: saved.contentType,
|
||||
placeholder: inferPlaceholder(attachment),
|
||||
});
|
||||
} catch (err) {
|
||||
const id = attachment.id ?? attachment.url;
|
||||
logVerbose(
|
||||
`discord: failed to download attachment ${id}: ${String(err)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function inferPlaceholder(attachment: APIAttachment): string {
|
||||
const mime = attachment.content_type ?? "";
|
||||
if (mime.startsWith("image/")) return "<media:image>";
|
||||
if (mime.startsWith("video/")) return "<media:video>";
|
||||
if (mime.startsWith("audio/")) return "<media:audio>";
|
||||
return "<media:document>";
|
||||
}
|
||||
|
||||
function isImageAttachment(attachment: APIAttachment): boolean {
|
||||
const mime = attachment.content_type ?? "";
|
||||
if (mime.startsWith("image/")) return true;
|
||||
const name = attachment.filename?.toLowerCase() ?? "";
|
||||
if (!name) return false;
|
||||
return /\.(avif|bmp|gif|heic|heif|jpe?g|png|tiff?|webp)$/.test(name);
|
||||
}
|
||||
|
||||
function buildDiscordAttachmentPlaceholder(
|
||||
attachments?: APIAttachment[],
|
||||
): string {
|
||||
if (!attachments || attachments.length === 0) return "";
|
||||
const count = attachments.length;
|
||||
const allImages = attachments.every(isImageAttachment);
|
||||
const label = allImages ? "image" : "file";
|
||||
const suffix = count === 1 ? label : `${label}s`;
|
||||
const tag = allImages ? "<media:image>" : "<media:document>";
|
||||
return `${tag} (${count} ${suffix})`;
|
||||
}
|
||||
|
||||
export function resolveDiscordMessageText(
|
||||
message: Message,
|
||||
options?: { fallbackText?: string; includeForwarded?: boolean },
|
||||
): string {
|
||||
const baseText =
|
||||
message.content?.trim() ||
|
||||
buildDiscordAttachmentPlaceholder(message.attachments) ||
|
||||
message.embeds?.[0]?.description ||
|
||||
options?.fallbackText?.trim() ||
|
||||
"";
|
||||
if (!options?.includeForwarded) return baseText;
|
||||
const forwardedText = resolveDiscordForwardedMessagesText(message);
|
||||
if (!forwardedText) return baseText;
|
||||
if (!baseText) return forwardedText;
|
||||
return `${baseText}\n${forwardedText}`;
|
||||
}
|
||||
|
||||
function resolveDiscordForwardedMessagesText(message: Message): string {
|
||||
const snapshots = resolveDiscordMessageSnapshots(message);
|
||||
if (snapshots.length === 0) return "";
|
||||
const forwardedBlocks = snapshots
|
||||
.map((snapshot) => {
|
||||
const snapshotMessage = snapshot.message;
|
||||
if (!snapshotMessage) return null;
|
||||
const text = resolveDiscordSnapshotMessageText(snapshotMessage);
|
||||
if (!text) return null;
|
||||
const authorLabel = formatDiscordSnapshotAuthor(snapshotMessage.author);
|
||||
const heading = authorLabel
|
||||
? `[Forwarded message from ${authorLabel}]`
|
||||
: "[Forwarded message]";
|
||||
return `${heading}\n${text}`;
|
||||
})
|
||||
.filter((entry): entry is string => Boolean(entry));
|
||||
if (forwardedBlocks.length === 0) return "";
|
||||
return forwardedBlocks.join("\n\n");
|
||||
}
|
||||
|
||||
function resolveDiscordMessageSnapshots(
|
||||
message: Message,
|
||||
): DiscordMessageSnapshot[] {
|
||||
const rawData = (message as { rawData?: { message_snapshots?: unknown } })
|
||||
.rawData;
|
||||
const snapshots =
|
||||
rawData?.message_snapshots ??
|
||||
(message as { message_snapshots?: unknown }).message_snapshots ??
|
||||
(message as { messageSnapshots?: unknown }).messageSnapshots;
|
||||
if (!Array.isArray(snapshots)) return [];
|
||||
return snapshots.filter(
|
||||
(entry): entry is DiscordMessageSnapshot =>
|
||||
Boolean(entry) && typeof entry === "object",
|
||||
);
|
||||
}
|
||||
|
||||
function resolveDiscordSnapshotMessageText(
|
||||
snapshot: DiscordSnapshotMessage,
|
||||
): string {
|
||||
const content = snapshot.content?.trim() ?? "";
|
||||
const attachmentText = buildDiscordAttachmentPlaceholder(
|
||||
snapshot.attachments ?? undefined,
|
||||
);
|
||||
const embed = snapshot.embeds?.[0];
|
||||
const embedText = embed?.description?.trim() || embed?.title?.trim() || "";
|
||||
return content || attachmentText || embedText || "";
|
||||
}
|
||||
|
||||
function formatDiscordSnapshotAuthor(
|
||||
author: DiscordSnapshotAuthor | null | undefined,
|
||||
): string | undefined {
|
||||
if (!author) return undefined;
|
||||
const globalName = author.global_name ?? undefined;
|
||||
const username = author.username ?? undefined;
|
||||
const name = author.name ?? undefined;
|
||||
const discriminator = author.discriminator ?? undefined;
|
||||
const base = globalName || username || name;
|
||||
if (username && discriminator && discriminator !== "0") {
|
||||
return `@${username}#${discriminator}`;
|
||||
}
|
||||
if (base) return `@${base}`;
|
||||
if (author.id) return `@${author.id}`;
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function buildDiscordMediaPayload(
|
||||
mediaList: Array<{ path: string; contentType?: string }>,
|
||||
): {
|
||||
MediaPath?: string;
|
||||
MediaType?: string;
|
||||
MediaUrl?: string;
|
||||
MediaPaths?: string[];
|
||||
MediaUrls?: string[];
|
||||
MediaTypes?: string[];
|
||||
} {
|
||||
const first = mediaList[0];
|
||||
const mediaPaths = mediaList.map((media) => media.path);
|
||||
const mediaTypes = mediaList
|
||||
.map((media) => media.contentType)
|
||||
.filter(Boolean) as string[];
|
||||
return {
|
||||
MediaPath: first?.path,
|
||||
MediaType: first?.contentType,
|
||||
MediaUrl: first?.path,
|
||||
MediaPaths: mediaPaths.length > 0 ? mediaPaths : undefined,
|
||||
MediaUrls: mediaPaths.length > 0 ? mediaPaths : undefined,
|
||||
MediaTypes: mediaTypes.length > 0 ? mediaTypes : undefined,
|
||||
};
|
||||
}
|
||||
381
src/discord/monitor/native-command.ts
Normal file
381
src/discord/monitor/native-command.ts
Normal file
@@ -0,0 +1,381 @@
|
||||
import {
|
||||
ChannelType,
|
||||
Command,
|
||||
type CommandInteraction,
|
||||
type CommandOptions,
|
||||
} from "@buape/carbon";
|
||||
import { ApplicationCommandOptionType } from "discord-api-types/v10";
|
||||
|
||||
import {
|
||||
resolveEffectiveMessagesConfig,
|
||||
resolveHumanDelayConfig,
|
||||
} from "../../agents/identity.js";
|
||||
import { resolveTextChunkLimit } from "../../auto-reply/chunk.js";
|
||||
import { buildCommandText } from "../../auto-reply/commands-registry.js";
|
||||
import { dispatchReplyWithDispatcher } from "../../auto-reply/reply/provider-dispatcher.js";
|
||||
import type { ReplyPayload } from "../../auto-reply/types.js";
|
||||
import type { ClawdbotConfig, loadConfig } from "../../config/config.js";
|
||||
import { buildPairingReply } from "../../pairing/pairing-messages.js";
|
||||
import {
|
||||
readChannelAllowFromStore,
|
||||
upsertChannelPairingRequest,
|
||||
} from "../../pairing/pairing-store.js";
|
||||
import { resolveAgentRoute } from "../../routing/resolve-route.js";
|
||||
import { loadWebMedia } from "../../web/media.js";
|
||||
import { chunkDiscordText } from "../chunk.js";
|
||||
import {
|
||||
allowListMatches,
|
||||
isDiscordGroupAllowedByPolicy,
|
||||
normalizeDiscordAllowList,
|
||||
normalizeDiscordSlug,
|
||||
resolveDiscordChannelConfig,
|
||||
resolveDiscordGuildEntry,
|
||||
resolveDiscordUserAllowed,
|
||||
} from "./allow-list.js";
|
||||
import { formatDiscordUserTag } from "./format.js";
|
||||
|
||||
type DiscordConfig = NonNullable<ClawdbotConfig["channels"]>["discord"];
|
||||
|
||||
export function createDiscordNativeCommand(params: {
|
||||
command: {
|
||||
name: string;
|
||||
description: string;
|
||||
acceptsArgs: boolean;
|
||||
};
|
||||
cfg: ReturnType<typeof loadConfig>;
|
||||
discordConfig: DiscordConfig;
|
||||
accountId: string;
|
||||
sessionPrefix: string;
|
||||
ephemeralDefault: boolean;
|
||||
}) {
|
||||
const {
|
||||
command,
|
||||
cfg,
|
||||
discordConfig,
|
||||
accountId,
|
||||
sessionPrefix,
|
||||
ephemeralDefault,
|
||||
} = params;
|
||||
return new (class extends Command {
|
||||
name = command.name;
|
||||
description = command.description;
|
||||
defer = true;
|
||||
ephemeral = ephemeralDefault;
|
||||
options = command.acceptsArgs
|
||||
? ([
|
||||
{
|
||||
name: "input",
|
||||
description: "Command input",
|
||||
type: ApplicationCommandOptionType.String,
|
||||
required: false,
|
||||
},
|
||||
] satisfies CommandOptions)
|
||||
: undefined;
|
||||
|
||||
async run(interaction: CommandInteraction) {
|
||||
const useAccessGroups = cfg.commands?.useAccessGroups !== false;
|
||||
const user = interaction.user;
|
||||
if (!user) return;
|
||||
const channel = interaction.channel;
|
||||
const channelType = channel?.type;
|
||||
const isDirectMessage = channelType === ChannelType.DM;
|
||||
const isGroupDm = channelType === ChannelType.GroupDM;
|
||||
const channelName =
|
||||
channel && "name" in channel ? (channel.name as string) : undefined;
|
||||
const channelSlug = channelName ? normalizeDiscordSlug(channelName) : "";
|
||||
const prompt = buildCommandText(
|
||||
this.name,
|
||||
command.acceptsArgs
|
||||
? interaction.options.getString("input")
|
||||
: undefined,
|
||||
);
|
||||
const guildInfo = resolveDiscordGuildEntry({
|
||||
guild: interaction.guild ?? undefined,
|
||||
guildEntries: discordConfig?.guilds,
|
||||
});
|
||||
const channelConfig = interaction.guild
|
||||
? resolveDiscordChannelConfig({
|
||||
guildInfo,
|
||||
channelId: channel?.id ?? "",
|
||||
channelName,
|
||||
channelSlug,
|
||||
})
|
||||
: null;
|
||||
if (channelConfig?.enabled === false) {
|
||||
await interaction.reply({
|
||||
content: "This channel is disabled.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (interaction.guild && channelConfig?.allowed === false) {
|
||||
await interaction.reply({
|
||||
content: "This channel is not allowed.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (useAccessGroups && interaction.guild) {
|
||||
const channelAllowlistConfigured =
|
||||
Boolean(guildInfo?.channels) &&
|
||||
Object.keys(guildInfo?.channels ?? {}).length > 0;
|
||||
const channelAllowed = channelConfig?.allowed !== false;
|
||||
const allowByPolicy = isDiscordGroupAllowedByPolicy({
|
||||
groupPolicy: discordConfig?.groupPolicy ?? "open",
|
||||
channelAllowlistConfigured,
|
||||
channelAllowed,
|
||||
});
|
||||
if (!allowByPolicy) {
|
||||
await interaction.reply({
|
||||
content: "This channel is not allowed.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
const dmEnabled = discordConfig?.dm?.enabled ?? true;
|
||||
const dmPolicy = discordConfig?.dm?.policy ?? "pairing";
|
||||
let commandAuthorized = true;
|
||||
if (isDirectMessage) {
|
||||
if (!dmEnabled || dmPolicy === "disabled") {
|
||||
await interaction.reply({ content: "Discord DMs are disabled." });
|
||||
return;
|
||||
}
|
||||
if (dmPolicy !== "open") {
|
||||
const storeAllowFrom = await readChannelAllowFromStore(
|
||||
"discord",
|
||||
).catch(() => []);
|
||||
const effectiveAllowFrom = [
|
||||
...(discordConfig?.dm?.allowFrom ?? []),
|
||||
...storeAllowFrom,
|
||||
];
|
||||
const allowList = normalizeDiscordAllowList(effectiveAllowFrom, [
|
||||
"discord:",
|
||||
"user:",
|
||||
]);
|
||||
const permitted = allowList
|
||||
? allowListMatches(allowList, {
|
||||
id: user.id,
|
||||
name: user.username,
|
||||
tag: formatDiscordUserTag(user),
|
||||
})
|
||||
: false;
|
||||
if (!permitted) {
|
||||
commandAuthorized = false;
|
||||
if (dmPolicy === "pairing") {
|
||||
const { code, created } = await upsertChannelPairingRequest({
|
||||
channel: "discord",
|
||||
id: user.id,
|
||||
meta: {
|
||||
tag: formatDiscordUserTag(user),
|
||||
name: user.username ?? undefined,
|
||||
},
|
||||
});
|
||||
if (created) {
|
||||
await interaction.reply({
|
||||
content: buildPairingReply({
|
||||
channel: "discord",
|
||||
idLine: `Your Discord user id: ${user.id}`,
|
||||
code,
|
||||
}),
|
||||
ephemeral: true,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
await interaction.reply({
|
||||
content: "You are not authorized to use this command.",
|
||||
ephemeral: true,
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
commandAuthorized = true;
|
||||
}
|
||||
}
|
||||
if (!isDirectMessage) {
|
||||
const channelUsers = channelConfig?.users ?? guildInfo?.users;
|
||||
if (Array.isArray(channelUsers) && channelUsers.length > 0) {
|
||||
const userOk = resolveDiscordUserAllowed({
|
||||
allowList: channelUsers,
|
||||
userId: user.id,
|
||||
userName: user.username,
|
||||
userTag: formatDiscordUserTag(user),
|
||||
});
|
||||
if (!userOk) {
|
||||
await interaction.reply({
|
||||
content: "You are not authorized to use this command.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (isGroupDm && discordConfig?.dm?.groupEnabled === false) {
|
||||
await interaction.reply({ content: "Discord group DMs are disabled." });
|
||||
return;
|
||||
}
|
||||
|
||||
const isGuild = Boolean(interaction.guild);
|
||||
const channelId = channel?.id ?? "unknown";
|
||||
const interactionId = interaction.rawData.id;
|
||||
const route = resolveAgentRoute({
|
||||
cfg,
|
||||
channel: "discord",
|
||||
accountId,
|
||||
guildId: interaction.guild?.id ?? undefined,
|
||||
peer: {
|
||||
kind: isDirectMessage ? "dm" : isGroupDm ? "group" : "channel",
|
||||
id: isDirectMessage ? user.id : channelId,
|
||||
},
|
||||
});
|
||||
const ctxPayload = {
|
||||
Body: prompt,
|
||||
CommandBody: prompt,
|
||||
From: isDirectMessage ? `discord:${user.id}` : `group:${channelId}`,
|
||||
To: `slash:${user.id}`,
|
||||
SessionKey: `agent:${route.agentId}:${sessionPrefix}:${user.id}`,
|
||||
CommandTargetSessionKey: route.sessionKey,
|
||||
AccountId: route.accountId,
|
||||
ChatType: isDirectMessage ? "direct" : "group",
|
||||
GroupSubject: isGuild ? interaction.guild?.name : undefined,
|
||||
GroupSystemPrompt: isGuild
|
||||
? (() => {
|
||||
const channelTopic =
|
||||
channel && "topic" in channel
|
||||
? (channel.topic ?? undefined)
|
||||
: undefined;
|
||||
const channelDescription = channelTopic?.trim();
|
||||
const systemPromptParts = [
|
||||
channelDescription
|
||||
? `Channel topic: ${channelDescription}`
|
||||
: null,
|
||||
channelConfig?.systemPrompt?.trim() || null,
|
||||
].filter((entry): entry is string => Boolean(entry));
|
||||
return systemPromptParts.length > 0
|
||||
? systemPromptParts.join("\n\n")
|
||||
: undefined;
|
||||
})()
|
||||
: undefined,
|
||||
SenderName: user.globalName ?? user.username,
|
||||
SenderId: user.id,
|
||||
SenderUsername: user.username,
|
||||
SenderTag: formatDiscordUserTag(user),
|
||||
Provider: "discord" as const,
|
||||
Surface: "discord" as const,
|
||||
WasMentioned: true,
|
||||
MessageSid: interactionId,
|
||||
Timestamp: Date.now(),
|
||||
CommandAuthorized: commandAuthorized,
|
||||
CommandSource: "native" as const,
|
||||
};
|
||||
|
||||
let didReply = false;
|
||||
await dispatchReplyWithDispatcher({
|
||||
ctx: ctxPayload,
|
||||
cfg,
|
||||
dispatcherOptions: {
|
||||
responsePrefix: resolveEffectiveMessagesConfig(cfg, route.agentId)
|
||||
.responsePrefix,
|
||||
humanDelay: resolveHumanDelayConfig(cfg, route.agentId),
|
||||
deliver: async (payload) => {
|
||||
await deliverDiscordInteractionReply({
|
||||
interaction,
|
||||
payload,
|
||||
textLimit: resolveTextChunkLimit(cfg, "discord", accountId, {
|
||||
fallbackLimit: 2000,
|
||||
}),
|
||||
maxLinesPerMessage: discordConfig?.maxLinesPerMessage,
|
||||
preferFollowUp: didReply,
|
||||
});
|
||||
didReply = true;
|
||||
},
|
||||
onError: (err, info) => {
|
||||
console.error(`discord slash ${info.kind} reply failed`, err);
|
||||
},
|
||||
},
|
||||
replyOptions: {
|
||||
skillFilter: channelConfig?.skills,
|
||||
disableBlockStreaming:
|
||||
typeof discordConfig?.blockStreaming === "boolean"
|
||||
? !discordConfig.blockStreaming
|
||||
: undefined,
|
||||
},
|
||||
});
|
||||
}
|
||||
})();
|
||||
}
|
||||
|
||||
async function deliverDiscordInteractionReply(params: {
|
||||
interaction: CommandInteraction;
|
||||
payload: ReplyPayload;
|
||||
textLimit: number;
|
||||
maxLinesPerMessage?: number;
|
||||
preferFollowUp: boolean;
|
||||
}) {
|
||||
const {
|
||||
interaction,
|
||||
payload,
|
||||
textLimit,
|
||||
maxLinesPerMessage,
|
||||
preferFollowUp,
|
||||
} = params;
|
||||
const mediaList =
|
||||
payload.mediaUrls ?? (payload.mediaUrl ? [payload.mediaUrl] : []);
|
||||
const text = payload.text ?? "";
|
||||
|
||||
let hasReplied = false;
|
||||
const sendMessage = async (
|
||||
content: string,
|
||||
files?: { name: string; data: Buffer }[],
|
||||
) => {
|
||||
const payload =
|
||||
files && files.length > 0
|
||||
? {
|
||||
content,
|
||||
files: files.map((file) => {
|
||||
if (file.data instanceof Blob) {
|
||||
return { name: file.name, data: file.data };
|
||||
}
|
||||
const arrayBuffer = Uint8Array.from(file.data).buffer;
|
||||
return { name: file.name, data: new Blob([arrayBuffer]) };
|
||||
}),
|
||||
}
|
||||
: { content };
|
||||
if (!preferFollowUp && !hasReplied) {
|
||||
await interaction.reply(payload);
|
||||
hasReplied = true;
|
||||
return;
|
||||
}
|
||||
await interaction.followUp(payload);
|
||||
hasReplied = true;
|
||||
};
|
||||
|
||||
if (mediaList.length > 0) {
|
||||
const media = await Promise.all(
|
||||
mediaList.map(async (url) => {
|
||||
const loaded = await loadWebMedia(url);
|
||||
return {
|
||||
name: loaded.fileName ?? "upload",
|
||||
data: loaded.buffer,
|
||||
};
|
||||
}),
|
||||
);
|
||||
const chunks = chunkDiscordText(text, {
|
||||
maxChars: textLimit,
|
||||
maxLines: maxLinesPerMessage,
|
||||
});
|
||||
const caption = chunks[0] ?? "";
|
||||
await sendMessage(caption, media);
|
||||
for (const chunk of chunks.slice(1)) {
|
||||
if (!chunk.trim()) continue;
|
||||
await interaction.followUp({ content: chunk });
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (!text.trim()) return;
|
||||
const chunks = chunkDiscordText(text, {
|
||||
maxChars: textLimit,
|
||||
maxLines: maxLinesPerMessage,
|
||||
});
|
||||
for (const chunk of chunks) {
|
||||
if (!chunk.trim()) continue;
|
||||
await sendMessage(chunk);
|
||||
}
|
||||
}
|
||||
318
src/discord/monitor/provider.ts
Normal file
318
src/discord/monitor/provider.ts
Normal file
@@ -0,0 +1,318 @@
|
||||
import { Client } from "@buape/carbon";
|
||||
import { GatewayIntents, GatewayPlugin } from "@buape/carbon/gateway";
|
||||
import { Routes } from "discord-api-types/v10";
|
||||
import { resolveTextChunkLimit } from "../../auto-reply/chunk.js";
|
||||
import { listNativeCommandSpecsForConfig } from "../../auto-reply/commands-registry.js";
|
||||
import type { HistoryEntry } from "../../auto-reply/reply/history.js";
|
||||
import {
|
||||
isNativeCommandsExplicitlyDisabled,
|
||||
resolveNativeCommandsEnabled,
|
||||
} from "../../config/commands.js";
|
||||
import type { ClawdbotConfig, ReplyToMode } from "../../config/config.js";
|
||||
import { loadConfig } from "../../config/config.js";
|
||||
import { danger, logVerbose, shouldLogVerbose } from "../../globals.js";
|
||||
import { getChildLogger } from "../../logging.js";
|
||||
import type { RuntimeEnv } from "../../runtime.js";
|
||||
import { resolveDiscordAccount } from "../accounts.js";
|
||||
import { attachDiscordGatewayLogging } from "../gateway-logging.js";
|
||||
import {
|
||||
getDiscordGatewayEmitter,
|
||||
waitForDiscordGatewayStop,
|
||||
} from "../monitor.gateway.js";
|
||||
import { fetchDiscordApplicationId } from "../probe.js";
|
||||
import { normalizeDiscordToken } from "../token.js";
|
||||
import {
|
||||
DiscordMessageListener,
|
||||
DiscordReactionListener,
|
||||
DiscordReactionRemoveListener,
|
||||
registerDiscordListener,
|
||||
} from "./listeners.js";
|
||||
import { createDiscordMessageHandler } from "./message-handler.js";
|
||||
import { createDiscordNativeCommand } from "./native-command.js";
|
||||
|
||||
export type MonitorDiscordOpts = {
|
||||
token?: string;
|
||||
accountId?: string;
|
||||
config?: ClawdbotConfig;
|
||||
runtime?: RuntimeEnv;
|
||||
abortSignal?: AbortSignal;
|
||||
mediaMaxMb?: number;
|
||||
historyLimit?: number;
|
||||
replyToMode?: ReplyToMode;
|
||||
};
|
||||
|
||||
function summarizeAllowList(list?: Array<string | number>) {
|
||||
if (!list || list.length === 0) return "any";
|
||||
const sample = list.slice(0, 4).map((entry) => String(entry));
|
||||
const suffix =
|
||||
list.length > sample.length ? ` (+${list.length - sample.length})` : "";
|
||||
return `${sample.join(", ")}${suffix}`;
|
||||
}
|
||||
|
||||
function summarizeGuilds(entries?: Record<string, unknown>) {
|
||||
if (!entries || Object.keys(entries).length === 0) return "any";
|
||||
const keys = Object.keys(entries);
|
||||
const sample = keys.slice(0, 4);
|
||||
const suffix =
|
||||
keys.length > sample.length ? ` (+${keys.length - sample.length})` : "";
|
||||
return `${sample.join(", ")}${suffix}`;
|
||||
}
|
||||
|
||||
export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) {
|
||||
const cfg = opts.config ?? loadConfig();
|
||||
const account = resolveDiscordAccount({
|
||||
cfg,
|
||||
accountId: opts.accountId,
|
||||
});
|
||||
const token = normalizeDiscordToken(opts.token ?? undefined) ?? account.token;
|
||||
if (!token) {
|
||||
throw new Error(
|
||||
`Discord bot token missing for account "${account.accountId}" (set discord.accounts.${account.accountId}.token or DISCORD_BOT_TOKEN for default).`,
|
||||
);
|
||||
}
|
||||
|
||||
const runtime: RuntimeEnv = opts.runtime ?? {
|
||||
log: console.log,
|
||||
error: console.error,
|
||||
exit: (code: number): never => {
|
||||
throw new Error(`exit ${code}`);
|
||||
},
|
||||
};
|
||||
|
||||
const discordCfg = account.config;
|
||||
const dmConfig = discordCfg.dm;
|
||||
const guildEntries = discordCfg.guilds;
|
||||
const groupPolicy = discordCfg.groupPolicy ?? "open";
|
||||
const allowFrom = dmConfig?.allowFrom;
|
||||
const mediaMaxBytes =
|
||||
(opts.mediaMaxMb ?? discordCfg.mediaMaxMb ?? 8) * 1024 * 1024;
|
||||
const textLimit = resolveTextChunkLimit(cfg, "discord", account.accountId, {
|
||||
fallbackLimit: 2000,
|
||||
});
|
||||
const historyLimit = Math.max(
|
||||
0,
|
||||
opts.historyLimit ??
|
||||
discordCfg.historyLimit ??
|
||||
cfg.messages?.groupChat?.historyLimit ??
|
||||
20,
|
||||
);
|
||||
const replyToMode = opts.replyToMode ?? discordCfg.replyToMode ?? "off";
|
||||
const dmEnabled = dmConfig?.enabled ?? true;
|
||||
const dmPolicy = dmConfig?.policy ?? "pairing";
|
||||
const groupDmEnabled = dmConfig?.groupEnabled ?? false;
|
||||
const groupDmChannels = dmConfig?.groupChannels;
|
||||
const nativeEnabled = resolveNativeCommandsEnabled({
|
||||
providerId: "discord",
|
||||
providerSetting: discordCfg.commands?.native,
|
||||
globalSetting: cfg.commands?.native,
|
||||
});
|
||||
const nativeDisabledExplicit = isNativeCommandsExplicitlyDisabled({
|
||||
providerSetting: discordCfg.commands?.native,
|
||||
globalSetting: cfg.commands?.native,
|
||||
});
|
||||
const useAccessGroups = cfg.commands?.useAccessGroups !== false;
|
||||
const sessionPrefix = "discord:slash";
|
||||
const ephemeralDefault = true;
|
||||
|
||||
if (shouldLogVerbose()) {
|
||||
logVerbose(
|
||||
`discord: config dm=${dmEnabled ? "on" : "off"} dmPolicy=${dmPolicy} allowFrom=${summarizeAllowList(allowFrom)} groupDm=${groupDmEnabled ? "on" : "off"} groupDmChannels=${summarizeAllowList(groupDmChannels)} groupPolicy=${groupPolicy} guilds=${summarizeGuilds(guildEntries)} historyLimit=${historyLimit} mediaMaxMb=${Math.round(mediaMaxBytes / (1024 * 1024))} native=${nativeEnabled ? "on" : "off"} accessGroups=${useAccessGroups ? "on" : "off"}`,
|
||||
);
|
||||
}
|
||||
|
||||
const applicationId = await fetchDiscordApplicationId(token, 4000);
|
||||
if (!applicationId) {
|
||||
throw new Error("Failed to resolve Discord application id");
|
||||
}
|
||||
|
||||
const commandSpecs = nativeEnabled
|
||||
? listNativeCommandSpecsForConfig(cfg)
|
||||
: [];
|
||||
const commands = commandSpecs.map((spec) =>
|
||||
createDiscordNativeCommand({
|
||||
command: spec,
|
||||
cfg,
|
||||
discordConfig: discordCfg,
|
||||
accountId: account.accountId,
|
||||
sessionPrefix,
|
||||
ephemeralDefault,
|
||||
}),
|
||||
);
|
||||
|
||||
const client = new Client(
|
||||
{
|
||||
baseUrl: "http://localhost",
|
||||
deploySecret: "a",
|
||||
clientId: applicationId,
|
||||
publicKey: "a",
|
||||
token,
|
||||
autoDeploy: nativeEnabled,
|
||||
eventQueue: {
|
||||
// Auto-threading (create thread + generate reply + post) can exceed the default
|
||||
// 30s listener timeout in some environments.
|
||||
listenerTimeout: 120_000,
|
||||
},
|
||||
},
|
||||
{
|
||||
commands,
|
||||
listeners: [],
|
||||
},
|
||||
[
|
||||
new GatewayPlugin({
|
||||
reconnect: {
|
||||
maxAttempts: Number.POSITIVE_INFINITY,
|
||||
},
|
||||
intents:
|
||||
GatewayIntents.Guilds |
|
||||
GatewayIntents.GuildMessages |
|
||||
GatewayIntents.MessageContent |
|
||||
GatewayIntents.DirectMessages |
|
||||
GatewayIntents.GuildMessageReactions |
|
||||
GatewayIntents.DirectMessageReactions,
|
||||
autoInteractions: true,
|
||||
}),
|
||||
],
|
||||
);
|
||||
|
||||
const logger = getChildLogger({ module: "discord-auto-reply" });
|
||||
const guildHistories = new Map<string, HistoryEntry[]>();
|
||||
let botUserId: string | undefined;
|
||||
|
||||
if (nativeDisabledExplicit) {
|
||||
await clearDiscordNativeCommands({
|
||||
client,
|
||||
applicationId,
|
||||
runtime,
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
const botUser = await client.fetchUser("@me");
|
||||
botUserId = botUser?.id;
|
||||
} catch (err) {
|
||||
runtime.error?.(
|
||||
danger(`discord: failed to fetch bot identity: ${String(err)}`),
|
||||
);
|
||||
}
|
||||
|
||||
const messageHandler = createDiscordMessageHandler({
|
||||
cfg,
|
||||
discordConfig: discordCfg,
|
||||
accountId: account.accountId,
|
||||
token,
|
||||
runtime,
|
||||
botUserId,
|
||||
guildHistories,
|
||||
historyLimit,
|
||||
mediaMaxBytes,
|
||||
textLimit,
|
||||
replyToMode,
|
||||
dmEnabled,
|
||||
groupDmEnabled,
|
||||
groupDmChannels,
|
||||
allowFrom,
|
||||
guildEntries,
|
||||
});
|
||||
|
||||
registerDiscordListener(
|
||||
client.listeners,
|
||||
new DiscordMessageListener(messageHandler, logger),
|
||||
);
|
||||
registerDiscordListener(
|
||||
client.listeners,
|
||||
new DiscordReactionListener({
|
||||
cfg,
|
||||
accountId: account.accountId,
|
||||
runtime,
|
||||
botUserId,
|
||||
guildEntries,
|
||||
logger,
|
||||
}),
|
||||
);
|
||||
registerDiscordListener(
|
||||
client.listeners,
|
||||
new DiscordReactionRemoveListener({
|
||||
cfg,
|
||||
accountId: account.accountId,
|
||||
runtime,
|
||||
botUserId,
|
||||
guildEntries,
|
||||
logger,
|
||||
}),
|
||||
);
|
||||
|
||||
runtime.log?.(`logged in to discord${botUserId ? ` as ${botUserId}` : ""}`);
|
||||
|
||||
const gateway = client.getPlugin<GatewayPlugin>("gateway");
|
||||
const gatewayEmitter = getDiscordGatewayEmitter(gateway);
|
||||
const stopGatewayLogging = attachDiscordGatewayLogging({
|
||||
emitter: gatewayEmitter,
|
||||
runtime,
|
||||
});
|
||||
// Timeout to detect zombie connections where HELLO is never received.
|
||||
const HELLO_TIMEOUT_MS = 30000;
|
||||
let helloTimeoutId: ReturnType<typeof setTimeout> | undefined;
|
||||
const onGatewayDebug = (msg: unknown) => {
|
||||
const message = String(msg);
|
||||
if (!message.includes("WebSocket connection opened")) return;
|
||||
if (helloTimeoutId) clearTimeout(helloTimeoutId);
|
||||
helloTimeoutId = setTimeout(() => {
|
||||
if (!gateway?.isConnected) {
|
||||
runtime.log?.(
|
||||
danger(
|
||||
`connection stalled: no HELLO received within ${HELLO_TIMEOUT_MS}ms, forcing reconnect`,
|
||||
),
|
||||
);
|
||||
gateway?.disconnect();
|
||||
gateway?.connect(false);
|
||||
}
|
||||
helloTimeoutId = undefined;
|
||||
}, HELLO_TIMEOUT_MS);
|
||||
};
|
||||
gatewayEmitter?.on("debug", onGatewayDebug);
|
||||
try {
|
||||
await waitForDiscordGatewayStop({
|
||||
gateway: gateway
|
||||
? {
|
||||
emitter: gatewayEmitter,
|
||||
disconnect: () => gateway.disconnect(),
|
||||
}
|
||||
: undefined,
|
||||
abortSignal: opts.abortSignal,
|
||||
onGatewayError: (err) => {
|
||||
runtime.error?.(danger(`discord gateway error: ${String(err)}`));
|
||||
},
|
||||
shouldStopOnError: (err) => {
|
||||
const message = String(err);
|
||||
return (
|
||||
message.includes("Max reconnect attempts") ||
|
||||
message.includes("Fatal Gateway error")
|
||||
);
|
||||
},
|
||||
});
|
||||
} finally {
|
||||
stopGatewayLogging();
|
||||
if (helloTimeoutId) clearTimeout(helloTimeoutId);
|
||||
gatewayEmitter?.removeListener("debug", onGatewayDebug);
|
||||
}
|
||||
}
|
||||
|
||||
async function clearDiscordNativeCommands(params: {
|
||||
client: Client;
|
||||
applicationId: string;
|
||||
runtime: RuntimeEnv;
|
||||
}) {
|
||||
try {
|
||||
await params.client.rest.put(
|
||||
Routes.applicationCommands(params.applicationId),
|
||||
{
|
||||
body: [],
|
||||
},
|
||||
);
|
||||
logVerbose("discord: cleared native commands (commands.native=false)");
|
||||
} catch (err) {
|
||||
params.runtime.error?.(
|
||||
danger(`discord: failed to clear native commands: ${String(err)}`),
|
||||
);
|
||||
}
|
||||
}
|
||||
43
src/discord/monitor/reply-context.ts
Normal file
43
src/discord/monitor/reply-context.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import type { Guild, Message, User } from "@buape/carbon";
|
||||
|
||||
import { formatAgentEnvelope } from "../../auto-reply/envelope.js";
|
||||
import { formatDiscordUserTag, resolveTimestampMs } from "./format.js";
|
||||
|
||||
export function resolveReplyContext(
|
||||
message: Message,
|
||||
resolveDiscordMessageText: (
|
||||
message: Message,
|
||||
options?: { includeForwarded?: boolean },
|
||||
) => string,
|
||||
): string | null {
|
||||
const referenced = message.referencedMessage;
|
||||
if (!referenced?.author) return null;
|
||||
const referencedText = resolveDiscordMessageText(referenced, {
|
||||
includeForwarded: true,
|
||||
});
|
||||
if (!referencedText) return null;
|
||||
const fromLabel = referenced.author
|
||||
? buildDirectLabel(referenced.author)
|
||||
: "Unknown";
|
||||
const body = `${referencedText}\n[discord message id: ${referenced.id} channel: ${referenced.channelId} from: ${formatDiscordUserTag(referenced.author)} user id:${referenced.author?.id ?? "unknown"}]`;
|
||||
return formatAgentEnvelope({
|
||||
channel: "Discord",
|
||||
from: fromLabel,
|
||||
timestamp: resolveTimestampMs(referenced.timestamp),
|
||||
body,
|
||||
});
|
||||
}
|
||||
|
||||
export function buildDirectLabel(author: User) {
|
||||
const username = formatDiscordUserTag(author);
|
||||
return `${username} user id:${author.id}`;
|
||||
}
|
||||
|
||||
export function buildGuildLabel(params: {
|
||||
guild?: Guild;
|
||||
channelName: string;
|
||||
channelId: string;
|
||||
}) {
|
||||
const { guild, channelName, channelId } = params;
|
||||
return `${guild?.name ?? "Guild"} #${channelName} channel id:${channelId}`;
|
||||
}
|
||||
64
src/discord/monitor/reply-delivery.ts
Normal file
64
src/discord/monitor/reply-delivery.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
import type { RequestClient } from "@buape/carbon";
|
||||
|
||||
import type { ReplyPayload } from "../../auto-reply/types.js";
|
||||
import type { RuntimeEnv } from "../../runtime.js";
|
||||
import { chunkDiscordText } from "../chunk.js";
|
||||
import { sendMessageDiscord } from "../send.js";
|
||||
|
||||
export async function deliverDiscordReply(params: {
|
||||
replies: ReplyPayload[];
|
||||
target: string;
|
||||
token: string;
|
||||
accountId?: string;
|
||||
rest?: RequestClient;
|
||||
runtime: RuntimeEnv;
|
||||
textLimit: number;
|
||||
maxLinesPerMessage?: number;
|
||||
replyToId?: string;
|
||||
}) {
|
||||
const chunkLimit = Math.min(params.textLimit, 2000);
|
||||
for (const payload of params.replies) {
|
||||
const mediaList =
|
||||
payload.mediaUrls ?? (payload.mediaUrl ? [payload.mediaUrl] : []);
|
||||
const text = payload.text ?? "";
|
||||
if (!text && mediaList.length === 0) continue;
|
||||
const replyTo = params.replyToId?.trim() || undefined;
|
||||
|
||||
if (mediaList.length === 0) {
|
||||
let isFirstChunk = true;
|
||||
for (const chunk of chunkDiscordText(text, {
|
||||
maxChars: chunkLimit,
|
||||
maxLines: params.maxLinesPerMessage,
|
||||
})) {
|
||||
const trimmed = chunk.trim();
|
||||
if (!trimmed) continue;
|
||||
await sendMessageDiscord(params.target, trimmed, {
|
||||
token: params.token,
|
||||
rest: params.rest,
|
||||
accountId: params.accountId,
|
||||
replyTo: isFirstChunk ? replyTo : undefined,
|
||||
});
|
||||
isFirstChunk = false;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
const firstMedia = mediaList[0];
|
||||
if (!firstMedia) continue;
|
||||
await sendMessageDiscord(params.target, text, {
|
||||
token: params.token,
|
||||
rest: params.rest,
|
||||
mediaUrl: firstMedia,
|
||||
accountId: params.accountId,
|
||||
replyTo,
|
||||
});
|
||||
for (const extra of mediaList.slice(1)) {
|
||||
await sendMessageDiscord(params.target, "", {
|
||||
token: params.token,
|
||||
rest: params.rest,
|
||||
mediaUrl: extra,
|
||||
accountId: params.accountId,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
101
src/discord/monitor/system-events.ts
Normal file
101
src/discord/monitor/system-events.ts
Normal file
@@ -0,0 +1,101 @@
|
||||
import { type Message, MessageType } from "@buape/carbon";
|
||||
|
||||
import { formatDiscordUserTag } from "./format.js";
|
||||
|
||||
export function resolveDiscordSystemEvent(
|
||||
message: Message,
|
||||
location: string,
|
||||
): string | null {
|
||||
switch (message.type) {
|
||||
case MessageType.ChannelPinnedMessage:
|
||||
return buildDiscordSystemEvent(message, location, "pinned a message");
|
||||
case MessageType.RecipientAdd:
|
||||
return buildDiscordSystemEvent(message, location, "added a recipient");
|
||||
case MessageType.RecipientRemove:
|
||||
return buildDiscordSystemEvent(message, location, "removed a recipient");
|
||||
case MessageType.UserJoin:
|
||||
return buildDiscordSystemEvent(message, location, "user joined");
|
||||
case MessageType.GuildBoost:
|
||||
return buildDiscordSystemEvent(message, location, "boosted the server");
|
||||
case MessageType.GuildBoostTier1:
|
||||
return buildDiscordSystemEvent(
|
||||
message,
|
||||
location,
|
||||
"boosted the server (Tier 1 reached)",
|
||||
);
|
||||
case MessageType.GuildBoostTier2:
|
||||
return buildDiscordSystemEvent(
|
||||
message,
|
||||
location,
|
||||
"boosted the server (Tier 2 reached)",
|
||||
);
|
||||
case MessageType.GuildBoostTier3:
|
||||
return buildDiscordSystemEvent(
|
||||
message,
|
||||
location,
|
||||
"boosted the server (Tier 3 reached)",
|
||||
);
|
||||
case MessageType.ThreadCreated:
|
||||
return buildDiscordSystemEvent(message, location, "created a thread");
|
||||
case MessageType.AutoModerationAction:
|
||||
return buildDiscordSystemEvent(
|
||||
message,
|
||||
location,
|
||||
"auto moderation action",
|
||||
);
|
||||
case MessageType.GuildIncidentAlertModeEnabled:
|
||||
return buildDiscordSystemEvent(
|
||||
message,
|
||||
location,
|
||||
"raid protection enabled",
|
||||
);
|
||||
case MessageType.GuildIncidentAlertModeDisabled:
|
||||
return buildDiscordSystemEvent(
|
||||
message,
|
||||
location,
|
||||
"raid protection disabled",
|
||||
);
|
||||
case MessageType.GuildIncidentReportRaid:
|
||||
return buildDiscordSystemEvent(message, location, "raid reported");
|
||||
case MessageType.GuildIncidentReportFalseAlarm:
|
||||
return buildDiscordSystemEvent(
|
||||
message,
|
||||
location,
|
||||
"raid report marked false alarm",
|
||||
);
|
||||
case MessageType.StageStart:
|
||||
return buildDiscordSystemEvent(message, location, "stage started");
|
||||
case MessageType.StageEnd:
|
||||
return buildDiscordSystemEvent(message, location, "stage ended");
|
||||
case MessageType.StageSpeaker:
|
||||
return buildDiscordSystemEvent(
|
||||
message,
|
||||
location,
|
||||
"stage speaker updated",
|
||||
);
|
||||
case MessageType.StageTopic:
|
||||
return buildDiscordSystemEvent(message, location, "stage topic updated");
|
||||
case MessageType.PollResult:
|
||||
return buildDiscordSystemEvent(message, location, "poll results posted");
|
||||
case MessageType.PurchaseNotification:
|
||||
return buildDiscordSystemEvent(
|
||||
message,
|
||||
location,
|
||||
"purchase notification",
|
||||
);
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function buildDiscordSystemEvent(
|
||||
message: Message,
|
||||
location: string,
|
||||
action: string,
|
||||
) {
|
||||
const authorLabel = message.author
|
||||
? formatDiscordUserTag(message.author)
|
||||
: "";
|
||||
const actor = authorLabel ? `${authorLabel} ` : "";
|
||||
return `Discord system: ${actor}${action} in ${location}`;
|
||||
}
|
||||
234
src/discord/monitor/threading.ts
Normal file
234
src/discord/monitor/threading.ts
Normal file
@@ -0,0 +1,234 @@
|
||||
import { ChannelType, type Client } from "@buape/carbon";
|
||||
import { Routes } from "discord-api-types/v10";
|
||||
import { createReplyReferencePlanner } from "../../auto-reply/reply/reply-reference.js";
|
||||
import type { ReplyToMode } from "../../config/config.js";
|
||||
import { logVerbose } from "../../globals.js";
|
||||
import { truncateUtf16Safe } from "../../utils.js";
|
||||
import type { DiscordChannelConfigResolved } from "./allow-list.js";
|
||||
import type { DiscordMessageEvent } from "./listeners.js";
|
||||
import { resolveDiscordChannelInfo } from "./message-utils.js";
|
||||
|
||||
export type DiscordThreadChannel = {
|
||||
id: string;
|
||||
name?: string | null;
|
||||
parentId?: string | null;
|
||||
parent?: { id?: string; name?: string };
|
||||
};
|
||||
|
||||
export type DiscordThreadStarter = {
|
||||
text: string;
|
||||
author: string;
|
||||
timestamp?: number;
|
||||
};
|
||||
|
||||
type DiscordThreadParentInfo = {
|
||||
id?: string;
|
||||
name?: string;
|
||||
type?: ChannelType;
|
||||
};
|
||||
|
||||
const DISCORD_THREAD_STARTER_CACHE = new Map<string, DiscordThreadStarter>();
|
||||
|
||||
function isDiscordThreadType(type: ChannelType | undefined): boolean {
|
||||
return (
|
||||
type === ChannelType.PublicThread ||
|
||||
type === ChannelType.PrivateThread ||
|
||||
type === ChannelType.AnnouncementThread
|
||||
);
|
||||
}
|
||||
|
||||
export function resolveDiscordThreadChannel(params: {
|
||||
isGuildMessage: boolean;
|
||||
message: DiscordMessageEvent["message"];
|
||||
channelInfo: import("./message-utils.js").DiscordChannelInfo | null;
|
||||
}): DiscordThreadChannel | null {
|
||||
if (!params.isGuildMessage) return null;
|
||||
const { message, channelInfo } = params;
|
||||
const channel =
|
||||
"channel" in message
|
||||
? (message as { channel?: unknown }).channel
|
||||
: undefined;
|
||||
const isThreadChannel =
|
||||
channel &&
|
||||
typeof channel === "object" &&
|
||||
"isThread" in channel &&
|
||||
typeof (channel as { isThread?: unknown }).isThread === "function" &&
|
||||
(channel as { isThread: () => boolean }).isThread();
|
||||
if (isThreadChannel) return channel as unknown as DiscordThreadChannel;
|
||||
if (!isDiscordThreadType(channelInfo?.type)) return null;
|
||||
return {
|
||||
id: message.channelId,
|
||||
name: channelInfo?.name ?? undefined,
|
||||
parentId: channelInfo?.parentId ?? undefined,
|
||||
parent: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
export async function resolveDiscordThreadParentInfo(params: {
|
||||
client: Client;
|
||||
threadChannel: DiscordThreadChannel;
|
||||
channelInfo: import("./message-utils.js").DiscordChannelInfo | null;
|
||||
}): Promise<DiscordThreadParentInfo> {
|
||||
const { threadChannel, channelInfo, client } = params;
|
||||
const parentId =
|
||||
threadChannel.parentId ??
|
||||
threadChannel.parent?.id ??
|
||||
channelInfo?.parentId ??
|
||||
undefined;
|
||||
if (!parentId) return {};
|
||||
let parentName = threadChannel.parent?.name;
|
||||
const parentInfo = await resolveDiscordChannelInfo(client, parentId);
|
||||
parentName = parentName ?? parentInfo?.name;
|
||||
const parentType = parentInfo?.type;
|
||||
return { id: parentId, name: parentName, type: parentType };
|
||||
}
|
||||
|
||||
export async function resolveDiscordThreadStarter(params: {
|
||||
channel: DiscordThreadChannel;
|
||||
client: Client;
|
||||
parentId?: string;
|
||||
parentType?: ChannelType;
|
||||
resolveTimestampMs: (value?: string | null) => number | undefined;
|
||||
}): Promise<DiscordThreadStarter | null> {
|
||||
const cacheKey = params.channel.id;
|
||||
const cached = DISCORD_THREAD_STARTER_CACHE.get(cacheKey);
|
||||
if (cached) return cached;
|
||||
try {
|
||||
const parentType = params.parentType;
|
||||
const isForumParent =
|
||||
parentType === ChannelType.GuildForum ||
|
||||
parentType === ChannelType.GuildMedia;
|
||||
const messageChannelId = isForumParent
|
||||
? params.channel.id
|
||||
: params.parentId;
|
||||
if (!messageChannelId) return null;
|
||||
const starter = (await params.client.rest.get(
|
||||
Routes.channelMessage(messageChannelId, params.channel.id),
|
||||
)) as {
|
||||
content?: string | null;
|
||||
embeds?: Array<{ description?: string | null }>;
|
||||
member?: { nick?: string | null; displayName?: string | null };
|
||||
author?: {
|
||||
id?: string | null;
|
||||
username?: string | null;
|
||||
discriminator?: string | null;
|
||||
};
|
||||
timestamp?: string | null;
|
||||
};
|
||||
if (!starter) return null;
|
||||
const text =
|
||||
starter.content?.trim() ?? starter.embeds?.[0]?.description?.trim() ?? "";
|
||||
if (!text) return null;
|
||||
const author =
|
||||
starter.member?.nick ??
|
||||
starter.member?.displayName ??
|
||||
(starter.author
|
||||
? starter.author.discriminator && starter.author.discriminator !== "0"
|
||||
? `${starter.author.username ?? "Unknown"}#${starter.author.discriminator}`
|
||||
: (starter.author.username ?? starter.author.id ?? "Unknown")
|
||||
: "Unknown");
|
||||
const timestamp = params.resolveTimestampMs(starter.timestamp);
|
||||
const payload: DiscordThreadStarter = {
|
||||
text,
|
||||
author,
|
||||
timestamp: timestamp ?? undefined,
|
||||
};
|
||||
DISCORD_THREAD_STARTER_CACHE.set(cacheKey, payload);
|
||||
return payload;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function resolveDiscordReplyTarget(opts: {
|
||||
replyToMode: ReplyToMode;
|
||||
replyToId?: string;
|
||||
hasReplied: boolean;
|
||||
}): string | undefined {
|
||||
if (opts.replyToMode === "off") return undefined;
|
||||
const replyToId = opts.replyToId?.trim();
|
||||
if (!replyToId) return undefined;
|
||||
if (opts.replyToMode === "all") return replyToId;
|
||||
return opts.hasReplied ? undefined : replyToId;
|
||||
}
|
||||
|
||||
export function sanitizeDiscordThreadName(
|
||||
rawName: string,
|
||||
fallbackId: string,
|
||||
): string {
|
||||
const cleanedName = rawName
|
||||
.replace(/<@!?\d+>/g, "") // user mentions
|
||||
.replace(/<@&\d+>/g, "") // role mentions
|
||||
.replace(/<#\d+>/g, "") // channel mentions
|
||||
.replace(/\s+/g, " ")
|
||||
.trim();
|
||||
const baseSource = cleanedName || `Thread ${fallbackId}`;
|
||||
const base = truncateUtf16Safe(baseSource, 80);
|
||||
return truncateUtf16Safe(base, 100) || `Thread ${fallbackId}`;
|
||||
}
|
||||
|
||||
type DiscordReplyDeliveryPlan = {
|
||||
deliverTarget: string;
|
||||
replyTarget: string;
|
||||
replyReference: ReturnType<typeof createReplyReferencePlanner>;
|
||||
};
|
||||
|
||||
export async function maybeCreateDiscordAutoThread(params: {
|
||||
client: Client;
|
||||
message: DiscordMessageEvent["message"];
|
||||
isGuildMessage: boolean;
|
||||
channelConfig?: DiscordChannelConfigResolved | null;
|
||||
threadChannel?: DiscordThreadChannel | null;
|
||||
baseText: string;
|
||||
combinedBody: string;
|
||||
}): Promise<string | undefined> {
|
||||
if (!params.isGuildMessage) return undefined;
|
||||
if (!params.channelConfig?.autoThread) return undefined;
|
||||
if (params.threadChannel) return undefined;
|
||||
try {
|
||||
const threadName = sanitizeDiscordThreadName(
|
||||
params.baseText || params.combinedBody || "Thread",
|
||||
params.message.id,
|
||||
);
|
||||
const created = (await params.client.rest.post(
|
||||
`${Routes.channelMessage(params.message.channelId, params.message.id)}/threads`,
|
||||
{
|
||||
body: {
|
||||
name: threadName,
|
||||
auto_archive_duration: 60,
|
||||
},
|
||||
},
|
||||
)) as { id?: string };
|
||||
const createdId = created?.id ? String(created.id) : "";
|
||||
return createdId || undefined;
|
||||
} catch (err) {
|
||||
logVerbose(
|
||||
`discord: autoThread failed for ${params.message.channelId}/${params.message.id}: ${String(err)}`,
|
||||
);
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
export function resolveDiscordReplyDeliveryPlan(params: {
|
||||
replyTarget: string;
|
||||
replyToMode: ReplyToMode;
|
||||
messageId: string;
|
||||
threadChannel?: DiscordThreadChannel | null;
|
||||
createdThreadId?: string | null;
|
||||
}): DiscordReplyDeliveryPlan {
|
||||
const originalReplyTarget = params.replyTarget;
|
||||
let deliverTarget = originalReplyTarget;
|
||||
let replyTarget = originalReplyTarget;
|
||||
if (params.createdThreadId) {
|
||||
deliverTarget = `channel:${params.createdThreadId}`;
|
||||
replyTarget = deliverTarget;
|
||||
}
|
||||
const allowReference = deliverTarget === originalReplyTarget;
|
||||
const replyReference = createReplyReferencePlanner({
|
||||
replyToMode: allowReference ? params.replyToMode : "off",
|
||||
existingId: params.threadChannel ? params.messageId : undefined,
|
||||
startId: params.messageId,
|
||||
allowReference,
|
||||
});
|
||||
return { deliverTarget, replyTarget, replyReference };
|
||||
}
|
||||
23
src/discord/monitor/typing.ts
Normal file
23
src/discord/monitor/typing.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import type { Client } from "@buape/carbon";
|
||||
|
||||
import { logVerbose } from "../../globals.js";
|
||||
|
||||
export async function sendTyping(params: {
|
||||
client: Client;
|
||||
channelId: string;
|
||||
}) {
|
||||
try {
|
||||
const channel = await params.client.fetchChannel(params.channelId);
|
||||
if (!channel) return;
|
||||
if (
|
||||
"triggerTyping" in channel &&
|
||||
typeof channel.triggerTyping === "function"
|
||||
) {
|
||||
await channel.triggerTyping();
|
||||
}
|
||||
} catch (err) {
|
||||
logVerbose(
|
||||
`discord typing cue failed for channel ${params.channelId}: ${String(err)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,31 +1,19 @@
|
||||
import { RateLimitError } from "@buape/carbon";
|
||||
import { PermissionFlagsBits, Routes } from "discord-api-types/v10";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import {
|
||||
addRoleDiscord,
|
||||
banMemberDiscord,
|
||||
createThreadDiscord,
|
||||
deleteMessageDiscord,
|
||||
editMessageDiscord,
|
||||
fetchChannelPermissionsDiscord,
|
||||
fetchReactionsDiscord,
|
||||
listGuildEmojisDiscord,
|
||||
listThreadsDiscord,
|
||||
pinMessageDiscord,
|
||||
reactMessageDiscord,
|
||||
readMessagesDiscord,
|
||||
removeOwnReactionsDiscord,
|
||||
removeReactionDiscord,
|
||||
removeRoleDiscord,
|
||||
searchMessagesDiscord,
|
||||
sendMessageDiscord,
|
||||
sendPollDiscord,
|
||||
sendStickerDiscord,
|
||||
timeoutMemberDiscord,
|
||||
unpinMessageDiscord,
|
||||
uploadEmojiDiscord,
|
||||
uploadStickerDiscord,
|
||||
} from "./send.js";
|
||||
|
||||
vi.mock("../web/media.js", () => ({
|
||||
@@ -453,356 +441,3 @@ describe("searchMessagesDiscord", () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("threads and moderation helpers", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("creates a thread", async () => {
|
||||
const { rest, postMock } = makeRest();
|
||||
postMock.mockResolvedValue({ id: "t1" });
|
||||
await createThreadDiscord(
|
||||
"chan1",
|
||||
{ name: "thread", messageId: "m1" },
|
||||
{ rest, token: "t" },
|
||||
);
|
||||
expect(postMock).toHaveBeenCalledWith(
|
||||
Routes.threads("chan1", "m1"),
|
||||
expect.objectContaining({ body: { name: "thread" } }),
|
||||
);
|
||||
});
|
||||
|
||||
it("lists active threads by guild", async () => {
|
||||
const { rest, getMock } = makeRest();
|
||||
getMock.mockResolvedValue({ threads: [] });
|
||||
await listThreadsDiscord({ guildId: "g1" }, { rest, token: "t" });
|
||||
expect(getMock).toHaveBeenCalledWith(Routes.guildActiveThreads("g1"));
|
||||
});
|
||||
|
||||
it("times out a member", async () => {
|
||||
const { rest, patchMock } = makeRest();
|
||||
patchMock.mockResolvedValue({ id: "m1" });
|
||||
await timeoutMemberDiscord(
|
||||
{ guildId: "g1", userId: "u1", durationMinutes: 10 },
|
||||
{ rest, token: "t" },
|
||||
);
|
||||
expect(patchMock).toHaveBeenCalledWith(
|
||||
Routes.guildMember("g1", "u1"),
|
||||
expect.objectContaining({
|
||||
body: expect.objectContaining({
|
||||
communication_disabled_until: expect.any(String),
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("adds and removes roles", async () => {
|
||||
const { rest, putMock, deleteMock } = makeRest();
|
||||
putMock.mockResolvedValue({});
|
||||
deleteMock.mockResolvedValue({});
|
||||
await addRoleDiscord(
|
||||
{ guildId: "g1", userId: "u1", roleId: "r1" },
|
||||
{ rest, token: "t" },
|
||||
);
|
||||
await removeRoleDiscord(
|
||||
{ guildId: "g1", userId: "u1", roleId: "r1" },
|
||||
{ rest, token: "t" },
|
||||
);
|
||||
expect(putMock).toHaveBeenCalledWith(
|
||||
Routes.guildMemberRole("g1", "u1", "r1"),
|
||||
);
|
||||
expect(deleteMock).toHaveBeenCalledWith(
|
||||
Routes.guildMemberRole("g1", "u1", "r1"),
|
||||
);
|
||||
});
|
||||
|
||||
it("bans a member", async () => {
|
||||
const { rest, putMock } = makeRest();
|
||||
putMock.mockResolvedValue({});
|
||||
await banMemberDiscord(
|
||||
{ guildId: "g1", userId: "u1", deleteMessageDays: 2 },
|
||||
{ rest, token: "t" },
|
||||
);
|
||||
expect(putMock).toHaveBeenCalledWith(
|
||||
Routes.guildBan("g1", "u1"),
|
||||
expect.objectContaining({ body: { delete_message_days: 2 } }),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("listGuildEmojisDiscord", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("lists emojis for a guild", async () => {
|
||||
const { rest, getMock } = makeRest();
|
||||
getMock.mockResolvedValue([{ id: "e1", name: "party" }]);
|
||||
await listGuildEmojisDiscord("g1", { rest, token: "t" });
|
||||
expect(getMock).toHaveBeenCalledWith(Routes.guildEmojis("g1"));
|
||||
});
|
||||
});
|
||||
|
||||
describe("uploadEmojiDiscord", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("uploads emoji assets", async () => {
|
||||
const { rest, postMock } = makeRest();
|
||||
postMock.mockResolvedValue({ id: "e1" });
|
||||
await uploadEmojiDiscord(
|
||||
{
|
||||
guildId: "g1",
|
||||
name: "party_blob",
|
||||
mediaUrl: "file:///tmp/party.png",
|
||||
roleIds: ["r1"],
|
||||
},
|
||||
{ rest, token: "t" },
|
||||
);
|
||||
expect(postMock).toHaveBeenCalledWith(
|
||||
Routes.guildEmojis("g1"),
|
||||
expect.objectContaining({
|
||||
body: {
|
||||
name: "party_blob",
|
||||
image: "data:image/png;base64,aW1n",
|
||||
roles: ["r1"],
|
||||
},
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("uploadStickerDiscord", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("uploads sticker assets", async () => {
|
||||
const { rest, postMock } = makeRest();
|
||||
postMock.mockResolvedValue({ id: "s1" });
|
||||
await uploadStickerDiscord(
|
||||
{
|
||||
guildId: "g1",
|
||||
name: "clawdbot_wave",
|
||||
description: "Clawdbot waving",
|
||||
tags: "👋",
|
||||
mediaUrl: "file:///tmp/wave.png",
|
||||
},
|
||||
{ rest, token: "t" },
|
||||
);
|
||||
expect(postMock).toHaveBeenCalledWith(
|
||||
Routes.guildStickers("g1"),
|
||||
expect.objectContaining({
|
||||
body: {
|
||||
name: "clawdbot_wave",
|
||||
description: "Clawdbot waving",
|
||||
tags: "👋",
|
||||
files: [
|
||||
expect.objectContaining({
|
||||
name: "asset.png",
|
||||
contentType: "image/png",
|
||||
}),
|
||||
],
|
||||
},
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("sendStickerDiscord", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("sends sticker payloads", async () => {
|
||||
const { rest, postMock } = makeRest();
|
||||
postMock.mockResolvedValue({ id: "msg1", channel_id: "789" });
|
||||
const res = await sendStickerDiscord("channel:789", ["123"], {
|
||||
rest,
|
||||
token: "t",
|
||||
content: "hiya",
|
||||
});
|
||||
expect(res).toEqual({ messageId: "msg1", channelId: "789" });
|
||||
expect(postMock).toHaveBeenCalledWith(
|
||||
Routes.channelMessages("789"),
|
||||
expect.objectContaining({
|
||||
body: {
|
||||
content: "hiya",
|
||||
sticker_ids: ["123"],
|
||||
},
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("sendPollDiscord", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("sends polls with answers", async () => {
|
||||
const { rest, postMock } = makeRest();
|
||||
postMock.mockResolvedValue({ id: "msg1", channel_id: "789" });
|
||||
const res = await sendPollDiscord(
|
||||
"channel:789",
|
||||
{
|
||||
question: "Lunch?",
|
||||
options: ["Pizza", "Sushi"],
|
||||
},
|
||||
{
|
||||
rest,
|
||||
token: "t",
|
||||
},
|
||||
);
|
||||
expect(res).toEqual({ messageId: "msg1", channelId: "789" });
|
||||
expect(postMock).toHaveBeenCalledWith(
|
||||
Routes.channelMessages("789"),
|
||||
expect.objectContaining({
|
||||
body: expect.objectContaining({
|
||||
poll: {
|
||||
question: { text: "Lunch?" },
|
||||
answers: [
|
||||
{ poll_media: { text: "Pizza" } },
|
||||
{ poll_media: { text: "Sushi" } },
|
||||
],
|
||||
duration: 24,
|
||||
allow_multiselect: false,
|
||||
layout_type: 1,
|
||||
},
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
function createMockRateLimitError(retryAfter = 0.001): RateLimitError {
|
||||
const response = new Response(null, {
|
||||
status: 429,
|
||||
headers: {
|
||||
"X-RateLimit-Scope": "user",
|
||||
"X-RateLimit-Bucket": "test-bucket",
|
||||
},
|
||||
});
|
||||
return new RateLimitError(response, {
|
||||
message: "You are being rate limited.",
|
||||
retry_after: retryAfter,
|
||||
global: false,
|
||||
});
|
||||
}
|
||||
|
||||
describe("retry rate limits", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("retries on Discord rate limits", async () => {
|
||||
const { rest, postMock } = makeRest();
|
||||
const rateLimitError = createMockRateLimitError(0);
|
||||
|
||||
postMock
|
||||
.mockRejectedValueOnce(rateLimitError)
|
||||
.mockResolvedValueOnce({ id: "msg1", channel_id: "789" });
|
||||
|
||||
const res = await sendMessageDiscord("channel:789", "hello", {
|
||||
rest,
|
||||
token: "t",
|
||||
retry: { attempts: 2, minDelayMs: 0, maxDelayMs: 0, jitter: 0 },
|
||||
});
|
||||
|
||||
expect(res.messageId).toBe("msg1");
|
||||
expect(postMock).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("uses retry_after delays when rate limited", async () => {
|
||||
vi.useFakeTimers();
|
||||
const setTimeoutSpy = vi.spyOn(global, "setTimeout");
|
||||
const { rest, postMock } = makeRest();
|
||||
const rateLimitError = createMockRateLimitError(0.5);
|
||||
|
||||
postMock
|
||||
.mockRejectedValueOnce(rateLimitError)
|
||||
.mockResolvedValueOnce({ id: "msg1", channel_id: "789" });
|
||||
|
||||
const promise = sendMessageDiscord("channel:789", "hello", {
|
||||
rest,
|
||||
token: "t",
|
||||
retry: { attempts: 2, minDelayMs: 0, maxDelayMs: 1000, jitter: 0 },
|
||||
});
|
||||
|
||||
await vi.runAllTimersAsync();
|
||||
await expect(promise).resolves.toEqual({
|
||||
messageId: "msg1",
|
||||
channelId: "789",
|
||||
});
|
||||
expect(setTimeoutSpy.mock.calls[0]?.[1]).toBe(500);
|
||||
setTimeoutSpy.mockRestore();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("stops after max retry attempts", async () => {
|
||||
const { rest, postMock } = makeRest();
|
||||
const rateLimitError = createMockRateLimitError(0);
|
||||
|
||||
postMock.mockRejectedValue(rateLimitError);
|
||||
|
||||
await expect(
|
||||
sendMessageDiscord("channel:789", "hello", {
|
||||
rest,
|
||||
token: "t",
|
||||
retry: { attempts: 2, minDelayMs: 0, maxDelayMs: 0, jitter: 0 },
|
||||
}),
|
||||
).rejects.toBeInstanceOf(RateLimitError);
|
||||
expect(postMock).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("does not retry non-rate-limit errors", async () => {
|
||||
const { rest, postMock } = makeRest();
|
||||
postMock.mockRejectedValueOnce(new Error("network error"));
|
||||
|
||||
await expect(
|
||||
sendMessageDiscord("channel:789", "hello", { rest, token: "t" }),
|
||||
).rejects.toThrow("network error");
|
||||
expect(postMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("retries reactions on rate limits", async () => {
|
||||
const { rest, putMock } = makeRest();
|
||||
const rateLimitError = createMockRateLimitError(0);
|
||||
|
||||
putMock
|
||||
.mockRejectedValueOnce(rateLimitError)
|
||||
.mockResolvedValueOnce(undefined);
|
||||
|
||||
const res = await reactMessageDiscord("chan1", "msg1", "ok", {
|
||||
rest,
|
||||
token: "t",
|
||||
retry: { attempts: 2, minDelayMs: 0, maxDelayMs: 0, jitter: 0 },
|
||||
});
|
||||
|
||||
expect(res.ok).toBe(true);
|
||||
expect(putMock).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("retries media upload without duplicating overflow text", async () => {
|
||||
const { rest, postMock } = makeRest();
|
||||
const rateLimitError = createMockRateLimitError(0);
|
||||
const text = "a".repeat(2005);
|
||||
|
||||
postMock
|
||||
.mockRejectedValueOnce(rateLimitError)
|
||||
.mockResolvedValueOnce({ id: "msg1", channel_id: "789" })
|
||||
.mockResolvedValueOnce({ id: "msg2", channel_id: "789" });
|
||||
|
||||
const res = await sendMessageDiscord("channel:789", text, {
|
||||
rest,
|
||||
token: "t",
|
||||
mediaUrl: "https://example.com/photo.jpg",
|
||||
retry: { attempts: 2, minDelayMs: 0, maxDelayMs: 0, jitter: 0 },
|
||||
});
|
||||
|
||||
expect(res.messageId).toBe("msg1");
|
||||
expect(postMock).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
});
|
||||
409
src/discord/send.part-2.test.ts
Normal file
409
src/discord/send.part-2.test.ts
Normal file
@@ -0,0 +1,409 @@
|
||||
import { RateLimitError } from "@buape/carbon";
|
||||
import { Routes } from "discord-api-types/v10";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import {
|
||||
addRoleDiscord,
|
||||
banMemberDiscord,
|
||||
createThreadDiscord,
|
||||
listGuildEmojisDiscord,
|
||||
listThreadsDiscord,
|
||||
reactMessageDiscord,
|
||||
removeRoleDiscord,
|
||||
sendMessageDiscord,
|
||||
sendPollDiscord,
|
||||
sendStickerDiscord,
|
||||
timeoutMemberDiscord,
|
||||
uploadEmojiDiscord,
|
||||
uploadStickerDiscord,
|
||||
} from "./send.js";
|
||||
|
||||
vi.mock("../web/media.js", () => ({
|
||||
loadWebMedia: vi.fn().mockResolvedValue({
|
||||
buffer: Buffer.from("img"),
|
||||
fileName: "photo.jpg",
|
||||
contentType: "image/jpeg",
|
||||
kind: "image",
|
||||
}),
|
||||
loadWebMediaRaw: vi.fn().mockResolvedValue({
|
||||
buffer: Buffer.from("img"),
|
||||
fileName: "asset.png",
|
||||
contentType: "image/png",
|
||||
kind: "image",
|
||||
}),
|
||||
}));
|
||||
|
||||
const makeRest = () => {
|
||||
const postMock = vi.fn();
|
||||
const putMock = vi.fn();
|
||||
const getMock = vi.fn();
|
||||
const patchMock = vi.fn();
|
||||
const deleteMock = vi.fn();
|
||||
return {
|
||||
rest: {
|
||||
post: postMock,
|
||||
put: putMock,
|
||||
get: getMock,
|
||||
patch: patchMock,
|
||||
delete: deleteMock,
|
||||
} as unknown as import("@buape/carbon").RequestClient,
|
||||
postMock,
|
||||
putMock,
|
||||
getMock,
|
||||
patchMock,
|
||||
deleteMock,
|
||||
};
|
||||
};
|
||||
|
||||
describe("sendMessageDiscord", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("creates a thread", async () => {
|
||||
const { rest, postMock } = makeRest();
|
||||
postMock.mockResolvedValue({ id: "t1" });
|
||||
await createThreadDiscord(
|
||||
"chan1",
|
||||
{ name: "thread", messageId: "m1" },
|
||||
{ rest, token: "t" },
|
||||
);
|
||||
expect(postMock).toHaveBeenCalledWith(
|
||||
Routes.threads("chan1", "m1"),
|
||||
expect.objectContaining({ body: { name: "thread" } }),
|
||||
);
|
||||
});
|
||||
|
||||
it("lists active threads by guild", async () => {
|
||||
const { rest, getMock } = makeRest();
|
||||
getMock.mockResolvedValue({ threads: [] });
|
||||
await listThreadsDiscord({ guildId: "g1" }, { rest, token: "t" });
|
||||
expect(getMock).toHaveBeenCalledWith(Routes.guildActiveThreads("g1"));
|
||||
});
|
||||
|
||||
it("times out a member", async () => {
|
||||
const { rest, patchMock } = makeRest();
|
||||
patchMock.mockResolvedValue({ id: "m1" });
|
||||
await timeoutMemberDiscord(
|
||||
{ guildId: "g1", userId: "u1", durationMinutes: 10 },
|
||||
{ rest, token: "t" },
|
||||
);
|
||||
expect(patchMock).toHaveBeenCalledWith(
|
||||
Routes.guildMember("g1", "u1"),
|
||||
expect.objectContaining({
|
||||
body: expect.objectContaining({
|
||||
communication_disabled_until: expect.any(String),
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("adds and removes roles", async () => {
|
||||
const { rest, putMock, deleteMock } = makeRest();
|
||||
putMock.mockResolvedValue({});
|
||||
deleteMock.mockResolvedValue({});
|
||||
await addRoleDiscord(
|
||||
{ guildId: "g1", userId: "u1", roleId: "r1" },
|
||||
{ rest, token: "t" },
|
||||
);
|
||||
await removeRoleDiscord(
|
||||
{ guildId: "g1", userId: "u1", roleId: "r1" },
|
||||
{ rest, token: "t" },
|
||||
);
|
||||
expect(putMock).toHaveBeenCalledWith(
|
||||
Routes.guildMemberRole("g1", "u1", "r1"),
|
||||
);
|
||||
expect(deleteMock).toHaveBeenCalledWith(
|
||||
Routes.guildMemberRole("g1", "u1", "r1"),
|
||||
);
|
||||
});
|
||||
|
||||
it("bans a member", async () => {
|
||||
const { rest, putMock } = makeRest();
|
||||
putMock.mockResolvedValue({});
|
||||
await banMemberDiscord(
|
||||
{ guildId: "g1", userId: "u1", deleteMessageDays: 2 },
|
||||
{ rest, token: "t" },
|
||||
);
|
||||
expect(putMock).toHaveBeenCalledWith(
|
||||
Routes.guildBan("g1", "u1"),
|
||||
expect.objectContaining({ body: { delete_message_days: 2 } }),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("listGuildEmojisDiscord", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("lists emojis for a guild", async () => {
|
||||
const { rest, getMock } = makeRest();
|
||||
getMock.mockResolvedValue([{ id: "e1", name: "party" }]);
|
||||
await listGuildEmojisDiscord("g1", { rest, token: "t" });
|
||||
expect(getMock).toHaveBeenCalledWith(Routes.guildEmojis("g1"));
|
||||
});
|
||||
});
|
||||
|
||||
describe("uploadEmojiDiscord", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("uploads emoji assets", async () => {
|
||||
const { rest, postMock } = makeRest();
|
||||
postMock.mockResolvedValue({ id: "e1" });
|
||||
await uploadEmojiDiscord(
|
||||
{
|
||||
guildId: "g1",
|
||||
name: "party_blob",
|
||||
mediaUrl: "file:///tmp/party.png",
|
||||
roleIds: ["r1"],
|
||||
},
|
||||
{ rest, token: "t" },
|
||||
);
|
||||
expect(postMock).toHaveBeenCalledWith(
|
||||
Routes.guildEmojis("g1"),
|
||||
expect.objectContaining({
|
||||
body: {
|
||||
name: "party_blob",
|
||||
image: "data:image/png;base64,aW1n",
|
||||
roles: ["r1"],
|
||||
},
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("uploadStickerDiscord", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("uploads sticker assets", async () => {
|
||||
const { rest, postMock } = makeRest();
|
||||
postMock.mockResolvedValue({ id: "s1" });
|
||||
await uploadStickerDiscord(
|
||||
{
|
||||
guildId: "g1",
|
||||
name: "clawdbot_wave",
|
||||
description: "Clawdbot waving",
|
||||
tags: "👋",
|
||||
mediaUrl: "file:///tmp/wave.png",
|
||||
},
|
||||
{ rest, token: "t" },
|
||||
);
|
||||
expect(postMock).toHaveBeenCalledWith(
|
||||
Routes.guildStickers("g1"),
|
||||
expect.objectContaining({
|
||||
body: {
|
||||
name: "clawdbot_wave",
|
||||
description: "Clawdbot waving",
|
||||
tags: "👋",
|
||||
files: [
|
||||
expect.objectContaining({
|
||||
name: "asset.png",
|
||||
contentType: "image/png",
|
||||
}),
|
||||
],
|
||||
},
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("sendStickerDiscord", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("sends sticker payloads", async () => {
|
||||
const { rest, postMock } = makeRest();
|
||||
postMock.mockResolvedValue({ id: "msg1", channel_id: "789" });
|
||||
const res = await sendStickerDiscord("channel:789", ["123"], {
|
||||
rest,
|
||||
token: "t",
|
||||
content: "hiya",
|
||||
});
|
||||
expect(res).toEqual({ messageId: "msg1", channelId: "789" });
|
||||
expect(postMock).toHaveBeenCalledWith(
|
||||
Routes.channelMessages("789"),
|
||||
expect.objectContaining({
|
||||
body: {
|
||||
content: "hiya",
|
||||
sticker_ids: ["123"],
|
||||
},
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("sendPollDiscord", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("sends polls with answers", async () => {
|
||||
const { rest, postMock } = makeRest();
|
||||
postMock.mockResolvedValue({ id: "msg1", channel_id: "789" });
|
||||
const res = await sendPollDiscord(
|
||||
"channel:789",
|
||||
{
|
||||
question: "Lunch?",
|
||||
options: ["Pizza", "Sushi"],
|
||||
},
|
||||
{
|
||||
rest,
|
||||
token: "t",
|
||||
},
|
||||
);
|
||||
expect(res).toEqual({ messageId: "msg1", channelId: "789" });
|
||||
expect(postMock).toHaveBeenCalledWith(
|
||||
Routes.channelMessages("789"),
|
||||
expect.objectContaining({
|
||||
body: expect.objectContaining({
|
||||
poll: {
|
||||
question: { text: "Lunch?" },
|
||||
answers: [
|
||||
{ poll_media: { text: "Pizza" } },
|
||||
{ poll_media: { text: "Sushi" } },
|
||||
],
|
||||
duration: 24,
|
||||
allow_multiselect: false,
|
||||
layout_type: 1,
|
||||
},
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
function createMockRateLimitError(retryAfter = 0.001): RateLimitError {
|
||||
const response = new Response(null, {
|
||||
status: 429,
|
||||
headers: {
|
||||
"X-RateLimit-Scope": "user",
|
||||
"X-RateLimit-Bucket": "test-bucket",
|
||||
},
|
||||
});
|
||||
return new RateLimitError(response, {
|
||||
message: "You are being rate limited.",
|
||||
retry_after: retryAfter,
|
||||
global: false,
|
||||
});
|
||||
}
|
||||
|
||||
describe("retry rate limits", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("retries on Discord rate limits", async () => {
|
||||
const { rest, postMock } = makeRest();
|
||||
const rateLimitError = createMockRateLimitError(0);
|
||||
|
||||
postMock
|
||||
.mockRejectedValueOnce(rateLimitError)
|
||||
.mockResolvedValueOnce({ id: "msg1", channel_id: "789" });
|
||||
|
||||
const res = await sendMessageDiscord("channel:789", "hello", {
|
||||
rest,
|
||||
token: "t",
|
||||
retry: { attempts: 2, minDelayMs: 0, maxDelayMs: 0, jitter: 0 },
|
||||
});
|
||||
|
||||
expect(res.messageId).toBe("msg1");
|
||||
expect(postMock).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("uses retry_after delays when rate limited", async () => {
|
||||
vi.useFakeTimers();
|
||||
const setTimeoutSpy = vi.spyOn(global, "setTimeout");
|
||||
const { rest, postMock } = makeRest();
|
||||
const rateLimitError = createMockRateLimitError(0.5);
|
||||
|
||||
postMock
|
||||
.mockRejectedValueOnce(rateLimitError)
|
||||
.mockResolvedValueOnce({ id: "msg1", channel_id: "789" });
|
||||
|
||||
const promise = sendMessageDiscord("channel:789", "hello", {
|
||||
rest,
|
||||
token: "t",
|
||||
retry: { attempts: 2, minDelayMs: 0, maxDelayMs: 1000, jitter: 0 },
|
||||
});
|
||||
|
||||
await vi.runAllTimersAsync();
|
||||
await expect(promise).resolves.toEqual({
|
||||
messageId: "msg1",
|
||||
channelId: "789",
|
||||
});
|
||||
expect(setTimeoutSpy.mock.calls[0]?.[1]).toBe(500);
|
||||
setTimeoutSpy.mockRestore();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("stops after max retry attempts", async () => {
|
||||
const { rest, postMock } = makeRest();
|
||||
const rateLimitError = createMockRateLimitError(0);
|
||||
|
||||
postMock.mockRejectedValue(rateLimitError);
|
||||
|
||||
await expect(
|
||||
sendMessageDiscord("channel:789", "hello", {
|
||||
rest,
|
||||
token: "t",
|
||||
retry: { attempts: 2, minDelayMs: 0, maxDelayMs: 0, jitter: 0 },
|
||||
}),
|
||||
).rejects.toBeInstanceOf(RateLimitError);
|
||||
expect(postMock).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("does not retry non-rate-limit errors", async () => {
|
||||
const { rest, postMock } = makeRest();
|
||||
postMock.mockRejectedValueOnce(new Error("network error"));
|
||||
|
||||
await expect(
|
||||
sendMessageDiscord("channel:789", "hello", { rest, token: "t" }),
|
||||
).rejects.toThrow("network error");
|
||||
expect(postMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("retries reactions on rate limits", async () => {
|
||||
const { rest, putMock } = makeRest();
|
||||
const rateLimitError = createMockRateLimitError(0);
|
||||
|
||||
putMock
|
||||
.mockRejectedValueOnce(rateLimitError)
|
||||
.mockResolvedValueOnce(undefined);
|
||||
|
||||
const res = await reactMessageDiscord("chan1", "msg1", "ok", {
|
||||
rest,
|
||||
token: "t",
|
||||
retry: { attempts: 2, minDelayMs: 0, maxDelayMs: 0, jitter: 0 },
|
||||
});
|
||||
|
||||
expect(res.ok).toBe(true);
|
||||
expect(putMock).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("retries media upload without duplicating overflow text", async () => {
|
||||
const { rest, postMock } = makeRest();
|
||||
const rateLimitError = createMockRateLimitError(0);
|
||||
const text = "a".repeat(2005);
|
||||
|
||||
postMock
|
||||
.mockRejectedValueOnce(rateLimitError)
|
||||
.mockResolvedValueOnce({ id: "msg1", channel_id: "789" })
|
||||
.mockResolvedValueOnce({ id: "msg2", channel_id: "789" });
|
||||
|
||||
const res = await sendMessageDiscord("channel:789", text, {
|
||||
rest,
|
||||
token: "t",
|
||||
mediaUrl: "https://example.com/photo.jpg",
|
||||
retry: { attempts: 2, minDelayMs: 0, maxDelayMs: 0, jitter: 0 },
|
||||
});
|
||||
|
||||
expect(res.messageId).toBe("msg1");
|
||||
expect(postMock).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user