@friendlyrobot/discord-pi-agent 0.21.3 → 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.
@@ -138,10 +138,10 @@ function getLatestAssistantText(messages) {
138
138
  }
139
139
  return latestAssistantMessage.content
140
140
  .filter((item) => {
141
- return item.type === "text";
141
+ return item.type === "text" && typeof item.text === "string";
142
142
  })
143
143
  .map((item) => {
144
- return item.text ?? "";
144
+ return item.text;
145
145
  })
146
146
  .join("\n")
147
147
  .trim();
@@ -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;
@@ -1,59 +1,50 @@
1
- import { executeSessionCommand } from "./session-commands";
2
1
  import { readMediaAttachments, readTextAttachments, } from "./discord-attachments";
3
- import { getAuthorDisplayName, isAuthorizedMessage, resolveMessageScope, } from "./discord-auth";
4
- import { addWorkingReaction, removeWorkingReaction, sendCommandReply, sendReply, } from "./discord-replies";
2
+ import { isAuthorizedMessage, resolveMessageScope } from "./discord-auth";
5
3
  import { resolveMediaAttachmentsForPrompt } from "./discord-media-resolution";
4
+ import { addWorkingReaction, removeWorkingReaction, sendCommandReply, sendReply, } from "./discord-replies";
5
+ import { executeSessionCommand } from "./session-commands";
6
6
  import { startTypingForChannel, stopTypingForChannel } from "./discord-typing";
7
7
  import { createModuleLogger } from "./logger";
8
- import { formatDiscordPromptTime, wrapXmlTag } from "./prompt-context";
8
+ import { buildDiscordMessageMetadata, formatDiscordPromptTime, wrapXmlTag, } from "./prompt-context";
9
9
  import { runAgentTurn } from "./agent-turn-runner";
10
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
11
  export async function handleDiscordMessage(message, config, agentService, sessionRegistry, accessConfig) {
42
- if (message.author.bot) {
43
- // logger.debug("ignored bot message");
12
+ const preparedMessage = await prepareDiscordMessage(message, accessConfig);
13
+ if (!preparedMessage) {
44
14
  return;
45
15
  }
46
- if (message.system) {
47
- // logger.debug({ messageId: message.id }, "ignored system message");
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);
48
33
  return;
49
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
+ }
50
41
  const scope = resolveMessageScope(message);
51
42
  if (scope === null) {
52
43
  logger.debug({
53
44
  messageId: message.id,
54
45
  channelType: message.channel.type,
55
46
  }, "unsupported channel type, ignoring");
56
- return;
47
+ return null;
57
48
  }
58
49
  if (!isAuthorizedMessage(message, scope, accessConfig)) {
59
50
  logger.debug({
@@ -61,129 +52,144 @@ export async function handleDiscordMessage(message, config, agentService, sessio
61
52
  authorId: message.author.id,
62
53
  scope,
63
54
  }, "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;
55
+ return null;
77
56
  }
57
+ const content = await buildMessageContent(message);
78
58
  const mediaAttachments = await readMediaAttachments(message);
79
59
  if (!content && mediaAttachments.length === 0) {
80
60
  logger.debug({ messageId: message.id }, "ignored empty message (no text or images)");
81
- return;
61
+ return null;
82
62
  }
83
- logger.info({
84
- // direction: "IN",
63
+ return {
85
64
  scope,
86
- // messageId: message.id,
87
- // authorId: message.author.id,
88
- // channelType: message.channel.type,
89
65
  content,
90
- }, "message received");
91
- const channelKey = message.channel.id;
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) {
92
86
  if (message.channel.isSendable()) {
93
87
  startTypingForChannel(message.channel, channelKey);
94
88
  }
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");
89
+ }
90
+ function logNewThreadSession(message, scope, created) {
91
+ if (!created || !scope.startsWith("thread:") || !message.channel.isThread()) {
92
+ return;
102
93
  }
103
- const commandResult = await executeSessionCommand(content, {
104
- agentService,
105
- promptQueue,
106
- session,
94
+ logger.info({
107
95
  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
- }
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);
145
111
  return;
146
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) {
147
138
  if (!message.channel.isSendable()) {
148
139
  stopTypingForChannel(channelKey);
149
140
  logger.debug({ messageId: message.id }, "channel not sendable");
150
141
  return;
151
142
  }
152
143
  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;
144
+ await notifyIfPromptQueued(message, entry.promptQueue.getSnapshot().pending);
158
145
  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,
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,
181
150
  });
182
151
  });
152
+ await sendReply(message, response);
183
153
  }
184
154
  finally {
185
155
  stopTypingForChannel(channelKey);
186
156
  await removeWorkingReaction(message, entry.workingEmoji);
187
157
  }
188
- await sendReply(message, response);
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);
189
195
  }
@@ -1,7 +1,10 @@
1
+ import type { Message } from "discord.js";
2
+ import type { SessionScope } from "./session-registry";
1
3
  export type DiscordPromptTimeFormatOptions = {
2
4
  timeZone?: string;
3
5
  locale?: string;
4
6
  };
5
7
  export declare function formatDiscordPromptTime(date: Date, options?: DiscordPromptTimeFormatOptions): string;
8
+ export declare function buildDiscordMessageMetadata(message: Message, scope: SessionScope): string;
6
9
  /** Wrap content in an XML-style tag: `<tag>content</tag>`. */
7
10
  export declare function wrapXmlTag(tag: string, content: string): string;
@@ -1,3 +1,4 @@
1
+ import { getAuthorDisplayName } from "./discord-auth";
1
2
  export function formatDiscordPromptTime(date, options = {}) {
2
3
  const timeZone = options.timeZone || "UTC";
3
4
  const locale = options.locale || "en-AU";
@@ -13,6 +14,34 @@ export function formatDiscordPromptTime(date, options = {}) {
13
14
  timeZoneName: "short",
14
15
  }).format(date);
15
16
  }
17
+ export function buildDiscordMessageMetadata(message, scope) {
18
+ const isThread = scope.startsWith("thread:") && message.channel.isThread();
19
+ const contextEntries = [
20
+ ["scope", scope === "dm" ? "dm" : "thread"],
21
+ ["sent_at", message.createdAt.toISOString()],
22
+ ["sent_at_local", formatDiscordPromptTime(message.createdAt)],
23
+ ["message_id", message.id],
24
+ [
25
+ "author_name",
26
+ getAuthorDisplayName(message).replace(/\s+/g, " ").trim() || undefined,
27
+ ],
28
+ ["author_id", message.author.id],
29
+ [
30
+ "thread_title",
31
+ isThread
32
+ ? (message.channel.name ?? "").replace(/\s+/g, " ").trim()
33
+ : undefined,
34
+ ],
35
+ ["thread_id", isThread ? message.channel.id : undefined],
36
+ [
37
+ "forum_channel_id",
38
+ isThread ? (message.channel.parentId ?? undefined) : undefined,
39
+ ],
40
+ ].filter((entry) => {
41
+ return typeof entry[1] === "string" && entry[1].length > 0;
42
+ });
43
+ return wrapXmlTag("discord_message_context", JSON.stringify(Object.fromEntries(contextEntries), null, 2));
44
+ }
16
45
  /** Wrap content in an XML-style tag: `<tag>content</tag>`. */
17
46
  export function wrapXmlTag(tag, content) {
18
47
  return `<${tag}>${content}</${tag}>`;
@@ -1,7 +1,7 @@
1
1
  import type { AgentSession } from "@earendil-works/pi-coding-agent";
2
2
  import type { AgentService } from "./agent-service";
3
3
  import { PromptQueue } from "./prompt-queue";
4
- export type SessionScope = string;
4
+ export type SessionScope = "dm" | `thread:${string}`;
5
5
  export type ScopedSessionEntry = {
6
6
  session: AgentSession;
7
7
  promptQueue: PromptQueue;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@friendlyrobot/discord-pi-agent",
3
- "version": "0.21.3",
3
+ "version": "0.21.4",
4
4
  "description": "Reusable Discord gateway for persistent pi agent sessions",
5
5
  "license": "MIT",
6
6
  "type": "module",