@friendlyrobot/discord-pi-agent 0.21.1 → 0.21.4

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
+ }
@@ -1,9 +1,10 @@
1
1
  import { type Message } from "discord.js";
2
+ import type { SessionScope } from "./session-registry";
2
3
  import type { GatewayAccessConfig } from "./types";
3
4
  export declare function getAuthorDisplayName(message: Message): string;
4
5
  /**
5
6
  * Determine the session scope from an incoming message.
6
7
  * Returns null for unsupported channel types (silently ignored).
7
8
  */
8
- export declare function resolveMessageScope(message: Message): string | null;
9
- export declare function isAuthorizedMessage(message: Message, scope: string, accessConfig: GatewayAccessConfig): boolean;
9
+ export declare function resolveMessageScope(message: Message): SessionScope | null;
10
+ export declare function isAuthorizedMessage(message: Message, scope: SessionScope, accessConfig: GatewayAccessConfig): boolean;
@@ -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,195 @@
1
+ import { readMediaAttachments, readTextAttachments, } from "./discord-attachments";
2
+ import { isAuthorizedMessage, resolveMessageScope } from "./discord-auth";
3
+ import { resolveMediaAttachmentsForPrompt } from "./discord-media-resolution";
4
+ import { addWorkingReaction, removeWorkingReaction, sendCommandReply, sendReply, } from "./discord-replies";
5
+ import { executeSessionCommand } from "./session-commands";
6
+ import { startTypingForChannel, stopTypingForChannel } from "./discord-typing";
7
+ import { createModuleLogger } from "./logger";
8
+ import { buildDiscordMessageMetadata, formatDiscordPromptTime, wrapXmlTag, } from "./prompt-context";
9
+ import { runAgentTurn } from "./agent-turn-runner";
10
+ const logger = createModuleLogger("discord-message-handler");
11
+ export async function handleDiscordMessage(message, config, agentService, sessionRegistry, accessConfig) {
12
+ const preparedMessage = await prepareDiscordMessage(message, accessConfig);
13
+ if (!preparedMessage) {
14
+ return;
15
+ }
16
+ logger.info({
17
+ scope: preparedMessage.scope,
18
+ content: preparedMessage.content,
19
+ }, "message received");
20
+ const channelKey = message.channel.id;
21
+ startTypingIfPossible(message, channelKey);
22
+ const { entry, created } = await sessionRegistry.getOrCreate(preparedMessage.scope);
23
+ logNewThreadSession(message, preparedMessage.scope, created);
24
+ const commandResult = await executeSessionCommand(preparedMessage.content, {
25
+ agentService,
26
+ promptQueue: entry.promptQueue,
27
+ session: entry.session,
28
+ scope: preparedMessage.scope,
29
+ workingEmoji: entry.workingEmoji,
30
+ });
31
+ if (commandResult.handled) {
32
+ await handleCommandResult(message, sessionRegistry, preparedMessage.scope, entry, commandResult, preparedMessage.content, channelKey);
33
+ return;
34
+ }
35
+ await processAgentPrompt(message, config, agentService, entry, preparedMessage, channelKey);
36
+ }
37
+ async function prepareDiscordMessage(message, accessConfig) {
38
+ if (message.author.bot || message.system) {
39
+ return null;
40
+ }
41
+ const scope = resolveMessageScope(message);
42
+ if (scope === null) {
43
+ logger.debug({
44
+ messageId: message.id,
45
+ channelType: message.channel.type,
46
+ }, "unsupported channel type, ignoring");
47
+ return null;
48
+ }
49
+ if (!isAuthorizedMessage(message, scope, accessConfig)) {
50
+ logger.debug({
51
+ messageId: message.id,
52
+ authorId: message.author.id,
53
+ scope,
54
+ }, "unauthorized");
55
+ return null;
56
+ }
57
+ const content = await buildMessageContent(message);
58
+ const mediaAttachments = await readMediaAttachments(message);
59
+ if (!content && mediaAttachments.length === 0) {
60
+ logger.debug({ messageId: message.id }, "ignored empty message (no text or images)");
61
+ return null;
62
+ }
63
+ return {
64
+ scope,
65
+ content,
66
+ mediaAttachments,
67
+ };
68
+ }
69
+ async function buildMessageContent(message) {
70
+ const baseContent = message.content.trim();
71
+ const textAttachments = await readTextAttachments(message);
72
+ if (textAttachments.length === 0) {
73
+ return baseContent;
74
+ }
75
+ const attachmentText = textAttachments
76
+ .map((attachment) => {
77
+ return `\n\n--- Attachment: ${attachment.filename} ---\n${attachment.content}`;
78
+ })
79
+ .join("");
80
+ if (!baseContent) {
81
+ return textAttachments[0]?.content ?? "";
82
+ }
83
+ return baseContent + attachmentText;
84
+ }
85
+ function startTypingIfPossible(message, channelKey) {
86
+ if (message.channel.isSendable()) {
87
+ startTypingForChannel(message.channel, channelKey);
88
+ }
89
+ }
90
+ function logNewThreadSession(message, scope, created) {
91
+ if (!created || !scope.startsWith("thread:") || !message.channel.isThread()) {
92
+ return;
93
+ }
94
+ logger.info({
95
+ scope,
96
+ threadName: message.channel.name,
97
+ }, "new thread session");
98
+ }
99
+ async function handleCommandResult(message, sessionRegistry, scope, entry, commandResult, content, channelKey) {
100
+ stopTypingForChannel(channelKey);
101
+ if (commandResult.workingEmoji) {
102
+ entry.workingEmoji = commandResult.workingEmoji;
103
+ logger.info({ scope, emoji: commandResult.workingEmoji }, "working emoji updated");
104
+ }
105
+ if (commandResult.newSession) {
106
+ entry.session = commandResult.newSession;
107
+ logger.info({ scope, sessionId: commandResult.newSession.sessionId }, "session replaced");
108
+ }
109
+ if (commandResult.archive && scope.startsWith("thread:")) {
110
+ await archiveThreadSession(message, sessionRegistry, scope, commandResult);
111
+ return;
112
+ }
113
+ logger.info({
114
+ messageId: message.id,
115
+ command: content,
116
+ hasResponse: Boolean(commandResult.response),
117
+ }, `command handled: ${content}`);
118
+ if (commandResult.response) {
119
+ await sendCommandReply(message, commandResult.response);
120
+ }
121
+ }
122
+ async function archiveThreadSession(message, sessionRegistry, scope, commandResult) {
123
+ logger.info({ scope }, "archiving thread");
124
+ if (message.channel.isSendable()) {
125
+ await message.channel.send(`\`\`\`\n${commandResult.response ?? "Archiving..."}\n\`\`\``);
126
+ }
127
+ try {
128
+ if (message.channel.isThread()) {
129
+ await message.channel.setArchived(true);
130
+ }
131
+ }
132
+ catch (error) {
133
+ logger.error({ error }, "failed to archive thread");
134
+ }
135
+ await sessionRegistry.remove(scope);
136
+ }
137
+ async function processAgentPrompt(message, config, agentService, entry, preparedMessage, channelKey) {
138
+ if (!message.channel.isSendable()) {
139
+ stopTypingForChannel(channelKey);
140
+ logger.debug({ messageId: message.id }, "channel not sendable");
141
+ return;
142
+ }
143
+ await addWorkingReaction(message, entry.workingEmoji);
144
+ await notifyIfPromptQueued(message, entry.promptQueue.getSnapshot().pending);
145
+ try {
146
+ const response = await entry.promptQueue.enqueue(async () => {
147
+ const promptInput = await buildPromptInput(message, config, agentService, entry, preparedMessage);
148
+ return runAgentTurn(entry.session, promptInput.prompt, {
149
+ images: promptInput.images,
150
+ });
151
+ });
152
+ await sendReply(message, response);
153
+ }
154
+ finally {
155
+ stopTypingForChannel(channelKey);
156
+ await removeWorkingReaction(message, entry.workingEmoji);
157
+ }
158
+ }
159
+ async function notifyIfPromptQueued(message, pendingCount) {
160
+ if (pendingCount > 0) {
161
+ await sendReply(message, `Queued. ${pendingCount} request(s) ahead of this one.`);
162
+ }
163
+ }
164
+ async function buildPromptInput(message, config, agentService, entry, preparedMessage) {
165
+ const resolvedPromptMedia = await resolvePromptMedia(preparedMessage, entry, config, agentService);
166
+ const discordMetadata = buildDiscordMessageMetadata(message, preparedMessage.scope);
167
+ const prompt = await config.promptTransform({
168
+ rawContent: resolvedPromptMedia.content,
169
+ discordMetadata,
170
+ now: () => {
171
+ return wrapXmlTag("datetime", formatDiscordPromptTime(new Date(), {
172
+ timeZone: config.promptTimeZone,
173
+ locale: config.promptLocale,
174
+ }));
175
+ },
176
+ userMessage: () => {
177
+ return wrapXmlTag("user_message", resolvedPromptMedia.content);
178
+ },
179
+ });
180
+ return {
181
+ prompt,
182
+ images: resolvedPromptMedia.images.length > 0
183
+ ? resolvedPromptMedia.images
184
+ : undefined,
185
+ };
186
+ }
187
+ async function resolvePromptMedia(preparedMessage, entry, config, agentService) {
188
+ if (preparedMessage.mediaAttachments.length === 0) {
189
+ return {
190
+ content: preparedMessage.content,
191
+ images: [],
192
+ };
193
+ }
194
+ return resolveMediaAttachmentsForPrompt(preparedMessage.mediaAttachments, preparedMessage.content, entry.session.model, config, agentService);
195
+ }
@@ -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
+ }