@friendlyrobot/discord-pi-agent 0.21.0 → 0.21.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,22 @@
1
+ /**
2
+ * Print a text body to stdout with ASCII fences.
3
+ * Useful for ad-hoc debug output that stands out in logs.
4
+ *
5
+ * @param body The text content to print.
6
+ * @param title Optional label for the opening fence (defaults to "DEBUG").
7
+ */
8
+ export function debugPrint(body, title) {
9
+ const WIDTH = 80;
10
+ const label = title ?? "DEBUG";
11
+ function centeredFence(text) {
12
+ const inner = ` ${text} `;
13
+ const padLen = Math.floor((WIDTH - inner.length) / 2);
14
+ const pad = "=".repeat(padLen);
15
+ const result = pad + inner + pad;
16
+ // tack on one extra "=" if the width is odd
17
+ return result.length < WIDTH ? result + "=" : result;
18
+ }
19
+ console.info(centeredFence(label));
20
+ console.info(body);
21
+ console.info(centeredFence(`${label} END`));
22
+ }
@@ -0,0 +1,148 @@
1
+ import { createModuleLogger } from "./logger";
2
+ const logger = createModuleLogger("discord-attachments");
3
+ const TEXT_ATTACHMENT_EXTENSIONS = [
4
+ ".txt",
5
+ ".md",
6
+ ".json",
7
+ ".csv",
8
+ ".log",
9
+ ".yml",
10
+ ".yaml",
11
+ ".xml",
12
+ ".toml",
13
+ ".ini",
14
+ ".cfg",
15
+ ];
16
+ const MAX_TEXT_ATTACHMENT_SIZE_BYTES = 25 * 1024 * 1024;
17
+ const MEDIA_ATTACHMENT_EXTENSIONS = [
18
+ ".png",
19
+ ".jpg",
20
+ ".jpeg",
21
+ ".gif",
22
+ ".webp",
23
+ ".pdf",
24
+ ".docx",
25
+ ".doc",
26
+ ".pptx",
27
+ ".ppt",
28
+ ".xlsx",
29
+ ".xls",
30
+ ];
31
+ const MAX_MEDIA_ATTACHMENT_SIZE_BYTES = 25 * 1024 * 1024;
32
+ const OFFICE_MIME_TYPES = new Set([
33
+ "application/pdf",
34
+ "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
35
+ "application/msword",
36
+ "application/vnd.openxmlformats-officedocument.presentationml.presentation",
37
+ "application/vnd.ms-powerpoint",
38
+ "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
39
+ "application/vnd.ms-excel",
40
+ ]);
41
+ export function isSupportedTextAttachment(attachment) {
42
+ const ext = attachment.name
43
+ ?.slice(attachment.name.lastIndexOf("."))
44
+ .toLowerCase();
45
+ return Boolean(ext && TEXT_ATTACHMENT_EXTENSIONS.includes(ext));
46
+ }
47
+ export function isSupportedMediaAttachment(attachment) {
48
+ const ext = attachment.name
49
+ ?.slice(attachment.name.lastIndexOf("."))
50
+ .toLowerCase();
51
+ if (!ext || !MEDIA_ATTACHMENT_EXTENSIONS.includes(ext)) {
52
+ return false;
53
+ }
54
+ const contentType = attachment.contentType;
55
+ if (!contentType) {
56
+ return false;
57
+ }
58
+ return contentType.startsWith("image/") || OFFICE_MIME_TYPES.has(contentType);
59
+ }
60
+ export async function readTextAttachments(message) {
61
+ const attachments = message.attachments;
62
+ if (attachments.size === 0) {
63
+ return [];
64
+ }
65
+ const results = [];
66
+ for (const [, attachment] of attachments) {
67
+ if (!isSupportedTextAttachment(attachment)) {
68
+ logger.debug({ messageId: message.id, filename: attachment.name }, "skipping non-text attachment");
69
+ continue;
70
+ }
71
+ if (attachment.size > MAX_TEXT_ATTACHMENT_SIZE_BYTES) {
72
+ logger.warn({
73
+ messageId: message.id,
74
+ filename: attachment.name,
75
+ size: attachment.size,
76
+ }, "attachment too large, skipping");
77
+ continue;
78
+ }
79
+ try {
80
+ logger.info({
81
+ messageId: message.id,
82
+ filename: attachment.name,
83
+ size: attachment.size,
84
+ }, "fetching attachment");
85
+ const response = await fetch(attachment.url);
86
+ if (!response.ok) {
87
+ logger.warn({
88
+ messageId: message.id,
89
+ filename: attachment.name,
90
+ status: response.status,
91
+ }, "failed to fetch attachment");
92
+ continue;
93
+ }
94
+ const content = await response.text();
95
+ results.push({ filename: attachment.name, content });
96
+ }
97
+ catch (error) {
98
+ logger.error({ messageId: message.id, filename: attachment.name, error }, "error fetching attachment");
99
+ }
100
+ }
101
+ return results;
102
+ }
103
+ export async function readMediaAttachments(message) {
104
+ const attachments = message.attachments;
105
+ if (attachments.size === 0) {
106
+ return [];
107
+ }
108
+ const results = [];
109
+ for (const [, attachment] of attachments) {
110
+ if (!isSupportedMediaAttachment(attachment)) {
111
+ continue;
112
+ }
113
+ if (attachment.size > MAX_MEDIA_ATTACHMENT_SIZE_BYTES) {
114
+ logger.warn({
115
+ messageId: message.id,
116
+ filename: attachment.name,
117
+ size: attachment.size,
118
+ }, "media attachment too large, skipping");
119
+ continue;
120
+ }
121
+ try {
122
+ logger.info({
123
+ messageId: message.id,
124
+ filename: attachment.name,
125
+ size: attachment.size,
126
+ }, "fetching media attachment");
127
+ const response = await fetch(attachment.url);
128
+ if (!response.ok) {
129
+ logger.warn({
130
+ messageId: message.id,
131
+ filename: attachment.name,
132
+ status: response.status,
133
+ }, "failed to fetch media attachment");
134
+ continue;
135
+ }
136
+ const buffer = await response.arrayBuffer();
137
+ results.push({
138
+ filename: attachment.name,
139
+ data: Buffer.from(buffer).toString("base64"),
140
+ mimeType: attachment.contentType ?? "application/octet-stream",
141
+ });
142
+ }
143
+ catch (error) {
144
+ logger.error({ messageId: message.id, filename: attachment.name, error }, "error fetching media attachment");
145
+ }
146
+ }
147
+ return results;
148
+ }
@@ -0,0 +1,37 @@
1
+ import { ChannelType } from "discord.js";
2
+ export function getAuthorDisplayName(message) {
3
+ return (message.member?.displayName ||
4
+ message.author.globalName ||
5
+ message.author.username);
6
+ }
7
+ /**
8
+ * Determine the session scope from an incoming message.
9
+ * Returns null for unsupported channel types (silently ignored).
10
+ */
11
+ export function resolveMessageScope(message) {
12
+ if (message.channel.type === ChannelType.DM) {
13
+ return "dm";
14
+ }
15
+ if (message.channel.isThread()) {
16
+ return `thread:${message.channel.id}`;
17
+ }
18
+ return null;
19
+ }
20
+ export function isAuthorizedMessage(message, scope, accessConfig) {
21
+ if (scope === "dm") {
22
+ return message.author.id === accessConfig.discordAllowedUserId;
23
+ }
24
+ if (scope.startsWith("thread:")) {
25
+ const channel = message.channel;
26
+ if (!channel.isThread()) {
27
+ return false;
28
+ }
29
+ const parentId = channel.parentId;
30
+ if (!parentId ||
31
+ !accessConfig.discordAllowedForumChannelIds.includes(parentId)) {
32
+ return false;
33
+ }
34
+ return accessConfig.discordAllowedUserIds.includes(message.author.id);
35
+ }
36
+ return false;
37
+ }
@@ -0,0 +1,49 @@
1
+ import { Client, Events, GatewayIntentBits, Partials } from "discord.js";
2
+ import { handleDiscordMessage } from "./discord-message-handler";
3
+ import { sendReply } from "./discord-replies";
4
+ import { createModuleLogger } from "./logger";
5
+ const logger = createModuleLogger("discord-gateway");
6
+ export async function startGatewayClient(config, agentService, sessionRegistry, accessConfig) {
7
+ const client = new Client({
8
+ intents: [
9
+ GatewayIntentBits.DirectMessages,
10
+ GatewayIntentBits.Guilds,
11
+ GatewayIntentBits.GuildMessages,
12
+ GatewayIntentBits.MessageContent,
13
+ ],
14
+ partials: [Partials.Channel],
15
+ });
16
+ client.once(Events.ClientReady, async (readyClient) => {
17
+ logger.info({ userTag: readyClient.user.tag }, "logged in");
18
+ if (!accessConfig.startupMessage) {
19
+ return;
20
+ }
21
+ try {
22
+ const user = await readyClient.users.fetch(accessConfig.discordAllowedUserId);
23
+ const dmChannel = await user.createDM();
24
+ await dmChannel.send(accessConfig.startupMessage);
25
+ logger.info({
26
+ userId: accessConfig.discordAllowedUserId,
27
+ }, "sent startup dm");
28
+ }
29
+ catch (error) {
30
+ logger.error({ error }, "failed to send startup dm");
31
+ }
32
+ });
33
+ client.on(Events.MessageCreate, async (message) => {
34
+ try {
35
+ await handleDiscordMessage(message, config, agentService, sessionRegistry, accessConfig);
36
+ }
37
+ catch (error) {
38
+ logger.error({ error, direction: "IN" }, "message handling failed");
39
+ await sendReply(message, "The bot hit an error while handling that message.");
40
+ }
41
+ });
42
+ client.on(Events.ThreadDelete, async (thread) => {
43
+ const scope = `thread:${thread.id}`;
44
+ logger.info({ threadId: thread.id, scope }, "thread deleted");
45
+ await sessionRegistry.remove(scope);
46
+ });
47
+ await client.login(config.discordBotToken);
48
+ return client;
49
+ }
@@ -0,0 +1,107 @@
1
+ import { describeMediaAttachment } from "./media-description";
2
+ import { createModuleLogger } from "./logger";
3
+ const logger = createModuleLogger("discord-media-resolution");
4
+ /**
5
+ * Parse a "provider/modelId" string, handling model IDs that contain slashes.
6
+ */
7
+ export function parseProviderModelId(value) {
8
+ const trimmed = value.trim();
9
+ if (!trimmed) {
10
+ return null;
11
+ }
12
+ const slashIndex = trimmed.indexOf("/");
13
+ if (slashIndex === -1) {
14
+ return null;
15
+ }
16
+ return {
17
+ provider: trimmed.substring(0, slashIndex),
18
+ modelId: trimmed.substring(slashIndex + 1),
19
+ };
20
+ }
21
+ export function getMediaAttachmentLabel(filename, mimeType) {
22
+ if (mimeType === "application/pdf") {
23
+ return `[PDF: ${filename}]`;
24
+ }
25
+ if (mimeType ===
26
+ "application/vnd.openxmlformats-officedocument.wordprocessingml.document" ||
27
+ mimeType === "application/msword") {
28
+ return `[Word: ${filename}]`;
29
+ }
30
+ if (mimeType ===
31
+ "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" ||
32
+ mimeType === "application/vnd.ms-excel") {
33
+ return `[Excel: ${filename}]`;
34
+ }
35
+ if (mimeType ===
36
+ "application/vnd.openxmlformats-officedocument.presentationml.presentation" ||
37
+ mimeType === "application/vnd.ms-powerpoint") {
38
+ return `[PowerPoint: ${filename}]`;
39
+ }
40
+ return `[Image: ${filename}]`;
41
+ }
42
+ export async function resolveMediaAttachmentsForPrompt(mediaAttachments, content, currentModel, config, agentService) {
43
+ const modelSupportsVision = currentModel?.input.includes("image") ?? false;
44
+ if (modelSupportsVision) {
45
+ const names = mediaAttachments.map((media) => media.filename).join(", ");
46
+ logger.info({
47
+ count: mediaAttachments.length,
48
+ filenames: names,
49
+ model: currentModel
50
+ ? `${currentModel.provider}/${currentModel.id}`
51
+ : "none",
52
+ }, "passing media natively to vision-capable model");
53
+ return {
54
+ content,
55
+ images: mediaAttachments.map((media) => ({
56
+ type: "image",
57
+ data: media.data,
58
+ mimeType: media.mimeType,
59
+ })),
60
+ };
61
+ }
62
+ if (!config.visionModelId) {
63
+ const names = mediaAttachments.map((media) => media.filename).join(", ");
64
+ logger.info({ filenames: names }, "media attachments received but vision model not configured");
65
+ const note = `\n\n[User sent media attachment(s): ${names}]\n` +
66
+ "(Media vision not configured. Set visionModelId to enable image/PDF/document understanding.)";
67
+ return {
68
+ content: content ? content + note : note,
69
+ images: [],
70
+ };
71
+ }
72
+ const parsedVisionModelId = parseProviderModelId(config.visionModelId);
73
+ if (!parsedVisionModelId) {
74
+ return { content, images: [] };
75
+ }
76
+ const visionModel = agentService.models.findModel(parsedVisionModelId.provider, parsedVisionModelId.modelId);
77
+ if (!visionModel) {
78
+ logger.warn({ visionModelId: config.visionModelId }, "vision model not found in registry");
79
+ const names = mediaAttachments.map((media) => media.filename).join(", ");
80
+ const note = `\n\n[User sent media attachment(s): ${names}]\n` +
81
+ `(Vision model not found: ${config.visionModelId})`;
82
+ return {
83
+ content: content ? content + note : note,
84
+ images: [],
85
+ };
86
+ }
87
+ logger.info({
88
+ count: mediaAttachments.length,
89
+ visionModel: `${visionModel.provider}/${visionModel.id}`,
90
+ }, "describing media with vision model");
91
+ const descriptions = [];
92
+ for (const media of mediaAttachments) {
93
+ const description = await describeMediaAttachment(agentService, media.data, media.mimeType, content, visionModel);
94
+ const label = getMediaAttachmentLabel(media.filename, media.mimeType);
95
+ descriptions.push(`${label}\n${description}`);
96
+ }
97
+ if (descriptions.length === 0) {
98
+ return { content, images: [] };
99
+ }
100
+ const descriptionPrefix = descriptions.join("\n\n");
101
+ return {
102
+ content: content
103
+ ? `${descriptionPrefix}\n\n---\n${content}`
104
+ : descriptionPrefix,
105
+ images: [],
106
+ };
107
+ }
@@ -0,0 +1,189 @@
1
+ import { executeSessionCommand } from "./session-commands";
2
+ import { readMediaAttachments, readTextAttachments, } from "./discord-attachments";
3
+ import { getAuthorDisplayName, isAuthorizedMessage, resolveMessageScope, } from "./discord-auth";
4
+ import { addWorkingReaction, removeWorkingReaction, sendCommandReply, sendReply, } from "./discord-replies";
5
+ import { resolveMediaAttachmentsForPrompt } from "./discord-media-resolution";
6
+ import { startTypingForChannel, stopTypingForChannel } from "./discord-typing";
7
+ import { createModuleLogger } from "./logger";
8
+ import { formatDiscordPromptTime, wrapXmlTag } from "./prompt-context";
9
+ import { runAgentTurn } from "./agent-turn-runner";
10
+ const logger = createModuleLogger("discord-message-handler");
11
+ /** Build a Discord metadata XML string for a message. */
12
+ function formatDiscordMessageMetadata(message, scope) {
13
+ const isThread = scope.startsWith("thread:") && message.channel.isThread();
14
+ const contextEntries = [
15
+ ["scope", scope === "dm" ? "dm" : "thread"],
16
+ ["sent_at", message.createdAt.toISOString()],
17
+ ["sent_at_local", formatDiscordPromptTime(message.createdAt)],
18
+ ["message_id", message.id],
19
+ [
20
+ "author_name",
21
+ getAuthorDisplayName(message).replace(/\s+/g, " ").trim() || undefined,
22
+ ],
23
+ ["author_id", message.author.id],
24
+ [
25
+ "thread_title",
26
+ isThread
27
+ ? (message.channel.name ?? "").replace(/\s+/g, " ").trim()
28
+ : undefined,
29
+ ],
30
+ ["thread_id", isThread ? message.channel.id : undefined],
31
+ [
32
+ "forum_channel_id",
33
+ isThread ? (message.channel.parentId ?? undefined) : undefined,
34
+ ],
35
+ ].filter((entry) => {
36
+ return typeof entry[1] === "string" && entry[1].length > 0;
37
+ });
38
+ const contextJson = JSON.stringify(Object.fromEntries(contextEntries), null, 2);
39
+ return `<discord_message_context>${contextJson}</discord_message_context>`;
40
+ }
41
+ export async function handleDiscordMessage(message, config, agentService, sessionRegistry, accessConfig) {
42
+ if (message.author.bot) {
43
+ // logger.debug("ignored bot message");
44
+ return;
45
+ }
46
+ if (message.system) {
47
+ // logger.debug({ messageId: message.id }, "ignored system message");
48
+ return;
49
+ }
50
+ const scope = resolveMessageScope(message);
51
+ if (scope === null) {
52
+ logger.debug({
53
+ messageId: message.id,
54
+ channelType: message.channel.type,
55
+ }, "unsupported channel type, ignoring");
56
+ return;
57
+ }
58
+ if (!isAuthorizedMessage(message, scope, accessConfig)) {
59
+ logger.debug({
60
+ messageId: message.id,
61
+ authorId: message.author.id,
62
+ scope,
63
+ }, "unauthorized");
64
+ return;
65
+ }
66
+ let content = message.content.trim();
67
+ const textAttachments = await readTextAttachments(message);
68
+ if (textAttachments.length > 0) {
69
+ const attachmentSuffix = textAttachments
70
+ .map((attachment) => {
71
+ return `\n\n--- Attachment: ${attachment.filename} ---\n${attachment.content}`;
72
+ })
73
+ .join("");
74
+ content = content
75
+ ? content + attachmentSuffix
76
+ : textAttachments[0].content;
77
+ }
78
+ const mediaAttachments = await readMediaAttachments(message);
79
+ if (!content && mediaAttachments.length === 0) {
80
+ logger.debug({ messageId: message.id }, "ignored empty message (no text or images)");
81
+ return;
82
+ }
83
+ logger.info({
84
+ // direction: "IN",
85
+ scope,
86
+ // messageId: message.id,
87
+ // authorId: message.author.id,
88
+ // channelType: message.channel.type,
89
+ content,
90
+ }, "message received");
91
+ const channelKey = message.channel.id;
92
+ if (message.channel.isSendable()) {
93
+ startTypingForChannel(message.channel, channelKey);
94
+ }
95
+ const { entry, created } = await sessionRegistry.getOrCreate(scope);
96
+ const { session, promptQueue } = entry;
97
+ if (created && scope.startsWith("thread:") && message.channel.isThread()) {
98
+ logger.info({
99
+ scope,
100
+ threadName: message.channel.name,
101
+ }, "new thread session");
102
+ }
103
+ const commandResult = await executeSessionCommand(content, {
104
+ agentService,
105
+ promptQueue,
106
+ session,
107
+ scope,
108
+ workingEmoji: entry.workingEmoji,
109
+ });
110
+ if (commandResult.handled) {
111
+ stopTypingForChannel(channelKey);
112
+ if (commandResult.workingEmoji) {
113
+ entry.workingEmoji = commandResult.workingEmoji;
114
+ logger.info({ scope, emoji: commandResult.workingEmoji }, "working emoji updated");
115
+ }
116
+ if (commandResult.newSession) {
117
+ entry.session = commandResult.newSession;
118
+ logger.info({ scope, sessionId: commandResult.newSession.sessionId }, "session replaced");
119
+ }
120
+ if (commandResult.archive && scope.startsWith("thread:")) {
121
+ logger.info({ scope }, "archiving thread");
122
+ const archiveChannel = message.channel;
123
+ if (archiveChannel.isSendable()) {
124
+ await archiveChannel.send(`\`\`\`\n${commandResult.response ?? "Archiving..."}\n\`\`\``);
125
+ }
126
+ try {
127
+ if (archiveChannel.isThread()) {
128
+ await archiveChannel.setArchived(true);
129
+ }
130
+ }
131
+ catch (error) {
132
+ logger.error({ error }, "failed to archive thread");
133
+ }
134
+ await sessionRegistry.remove(scope);
135
+ return;
136
+ }
137
+ logger.info({
138
+ messageId: message.id,
139
+ command: content,
140
+ hasResponse: Boolean(commandResult.response),
141
+ }, `command handled: ${content}`);
142
+ if (commandResult.response) {
143
+ await sendCommandReply(message, commandResult.response);
144
+ }
145
+ return;
146
+ }
147
+ if (!message.channel.isSendable()) {
148
+ stopTypingForChannel(channelKey);
149
+ logger.debug({ messageId: message.id }, "channel not sendable");
150
+ return;
151
+ }
152
+ await addWorkingReaction(message, entry.workingEmoji);
153
+ const queuePosition = promptQueue.getSnapshot().pending;
154
+ if (queuePosition > 0) {
155
+ await sendReply(message, `Queued. ${queuePosition} request(s) ahead of this one.`);
156
+ }
157
+ let response;
158
+ try {
159
+ response = await promptQueue.enqueue(async () => {
160
+ let promptContent = content;
161
+ let promptImages;
162
+ if (mediaAttachments.length > 0) {
163
+ const resolvedPromptMedia = await resolveMediaAttachmentsForPrompt(mediaAttachments, promptContent, session.model, config, agentService);
164
+ promptContent = resolvedPromptMedia.content;
165
+ if (resolvedPromptMedia.images.length > 0) {
166
+ promptImages = resolvedPromptMedia.images;
167
+ }
168
+ }
169
+ const discordMetadata = formatDiscordMessageMetadata(message, scope);
170
+ const transformedPrompt = await config.promptTransform({
171
+ rawContent: promptContent,
172
+ discordMetadata,
173
+ now: () => wrapXmlTag("datetime", formatDiscordPromptTime(new Date(), {
174
+ timeZone: config.promptTimeZone,
175
+ locale: config.promptLocale,
176
+ })),
177
+ userMessage: () => wrapXmlTag("user_message", promptContent),
178
+ });
179
+ return runAgentTurn(session, transformedPrompt, {
180
+ images: promptImages,
181
+ });
182
+ });
183
+ }
184
+ finally {
185
+ stopTypingForChannel(channelKey);
186
+ await removeWorkingReaction(message, entry.workingEmoji);
187
+ }
188
+ await sendReply(message, response);
189
+ }
@@ -0,0 +1,112 @@
1
+ import { createModuleLogger } from "./logger";
2
+ import { chunkMessage } from "./message-chunker";
3
+ const logger = createModuleLogger("discord-replies");
4
+ const DISCORD_MESSAGE_LIMIT = 2000;
5
+ const FENCE_OVERHEAD = 8; // \`\`\`\n + \n\`\`\`
6
+ const MAX_CODE_FENCE_CONTENT = DISCORD_MESSAGE_LIMIT - FENCE_OVERHEAD;
7
+ /**
8
+ * Splits text by newlines into chunks that fit within maxSize.
9
+ * Keeps lines intact unless a single line exceeds maxSize.
10
+ */
11
+ function chunkByLines(text, maxSize) {
12
+ const lines = text.split("\n");
13
+ const chunks = [];
14
+ let current = "";
15
+ for (const line of lines) {
16
+ const candidate = current ? current + "\n" + line : line;
17
+ if (candidate.length > maxSize) {
18
+ if (current) {
19
+ chunks.push(current);
20
+ current = line;
21
+ }
22
+ else {
23
+ // A single line exceeds maxSize; split it mid-line.
24
+ chunks.push(line.slice(0, maxSize));
25
+ current = line.slice(maxSize);
26
+ }
27
+ }
28
+ else {
29
+ current = candidate;
30
+ }
31
+ }
32
+ if (current) {
33
+ chunks.push(current);
34
+ }
35
+ return chunks;
36
+ }
37
+ export const DEFAULT_WORKING_EMOJI = "⚙️";
38
+ export async function addWorkingReaction(message, emoji = DEFAULT_WORKING_EMOJI) {
39
+ try {
40
+ await message.react(emoji);
41
+ }
42
+ catch (error) {
43
+ logger.debug({ messageId: message.id, error }, "failed to add working reaction");
44
+ }
45
+ }
46
+ export async function removeWorkingReaction(message, emoji = DEFAULT_WORKING_EMOJI) {
47
+ try {
48
+ const reaction = message.reactions.cache.get(emoji);
49
+ if (reaction) {
50
+ await reaction.users.remove(message.client.user);
51
+ }
52
+ }
53
+ catch (error) {
54
+ logger.debug({ messageId: message.id, error }, "failed to remove working reaction");
55
+ }
56
+ }
57
+ export async function sendReply(message, text) {
58
+ const channel = message.channel;
59
+ if (!channel.isSendable()) {
60
+ logger.debug({
61
+ messageId: message.id,
62
+ }, "reply skipped, channel not sendable");
63
+ return;
64
+ }
65
+ const chunks = chunkMessage(text);
66
+ const [firstChunk, ...remainingChunks] = chunks;
67
+ if (!firstChunk) {
68
+ return;
69
+ }
70
+ try {
71
+ await message.reply(firstChunk);
72
+ for (const chunk of remainingChunks) {
73
+ await channel.send(chunk);
74
+ }
75
+ }
76
+ catch (error) {
77
+ logger.error({
78
+ messageId: message.id,
79
+ error,
80
+ }, "send reply failed");
81
+ }
82
+ }
83
+ /**
84
+ * Sends a command response wrapped in triple backticks.
85
+ * Splits by newlines so each chunk stays within Discord's 2000-char limit.
86
+ */
87
+ export async function sendCommandReply(message, text) {
88
+ const channel = message.channel;
89
+ if (!channel.isSendable()) {
90
+ logger.debug({
91
+ messageId: message.id,
92
+ }, "command reply skipped, channel not sendable");
93
+ return;
94
+ }
95
+ const chunks = chunkByLines(text, MAX_CODE_FENCE_CONTENT).map((c) => `\`\`\`\n${c}\n\`\`\``);
96
+ const [firstChunk, ...remainingChunks] = chunks;
97
+ if (!firstChunk) {
98
+ return;
99
+ }
100
+ try {
101
+ await message.reply(firstChunk);
102
+ for (const chunk of remainingChunks) {
103
+ await channel.send(chunk);
104
+ }
105
+ }
106
+ catch (error) {
107
+ logger.error({
108
+ messageId: message.id,
109
+ error,
110
+ }, "send command reply failed");
111
+ }
112
+ }