@friendlyrobot/discord-pi-agent 0.14.0 → 0.15.0
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.
- package/README.md +1 -1
- package/dist/agent-service.d.ts +3 -2
- package/dist/commands.d.ts +1 -1
- package/dist/commands.test.d.ts +1 -0
- package/dist/config.d.ts +2 -9
- package/dist/discord-attachments.d.ts +19 -0
- package/dist/discord-attachments.test.d.ts +1 -0
- package/dist/discord-auth.d.ts +9 -0
- package/dist/discord-auth.test.d.ts +1 -0
- package/dist/discord-gateway-client.d.ts +2 -2
- package/dist/discord-media-resolution.d.ts +17 -0
- package/dist/discord-media-resolution.test.d.ts +1 -0
- package/dist/discord-message-handler.d.ts +6 -0
- package/dist/discord-replies.d.ts +4 -0
- package/dist/discord-typing.d.ts +4 -0
- package/dist/index.d.ts +3 -7
- package/dist/index.js +698 -633
- package/dist/types.d.ts +11 -20
- package/package.json +2 -2
package/dist/index.js
CHANGED
|
@@ -282,13 +282,25 @@ class AgentService {
|
|
|
282
282
|
logPrefix: `[agent:${session.sessionId}]`
|
|
283
283
|
});
|
|
284
284
|
}
|
|
285
|
+
getSkillsSummary() {
|
|
286
|
+
const result = this.resourceLoader.getSkills();
|
|
287
|
+
const { skills } = result;
|
|
288
|
+
if (skills.length === 0) {
|
|
289
|
+
return "Skills: (none loaded)";
|
|
290
|
+
}
|
|
291
|
+
const names = skills.map((s) => s.name);
|
|
292
|
+
return `Skills (${skills.length}): ${names.join(", ") || "(none)"}`;
|
|
293
|
+
}
|
|
285
294
|
async reloadResources() {
|
|
286
295
|
await this.resourceLoader.reload();
|
|
287
296
|
const extensions = this.resourceLoader.getExtensions().extensions.map((ext) => ext.path);
|
|
297
|
+
const skills = this.resourceLoader.getSkills();
|
|
298
|
+
const skillNames = skills.skills.map((s) => s.name);
|
|
288
299
|
const agentsFiles = this.resourceLoader.getAgentsFiles().agentsFiles.map((f) => f.path);
|
|
289
300
|
return [
|
|
290
301
|
"Resources reloaded.",
|
|
291
302
|
`Extensions (${extensions.length}): ${extensions.join(", ") || "(none)"}`,
|
|
303
|
+
`Skills (${skills.skills.length}): ${skillNames.join(", ") || "(none)"}`,
|
|
292
304
|
`AGENTS.md files (${agentsFiles.length}): ${agentsFiles.join(", ") || "(none)"}`
|
|
293
305
|
].join(`
|
|
294
306
|
`);
|
|
@@ -519,9 +531,10 @@ function isSameModel(currentModel, desiredModel) {
|
|
|
519
531
|
import path2 from "node:path";
|
|
520
532
|
import dotenv from "dotenv";
|
|
521
533
|
function resolveConfig(config) {
|
|
534
|
+
const discordAllowedUserId = readRequiredValue("discordAllowedUserId", config.discordAllowedUserId);
|
|
522
535
|
return {
|
|
523
536
|
discordBotToken: readRequiredValue("discordBotToken", config.discordBotToken),
|
|
524
|
-
discordAllowedUserId
|
|
537
|
+
discordAllowedUserId,
|
|
525
538
|
cwd: readRequiredValue("cwd", config.cwd),
|
|
526
539
|
agentDir: config.agentDir?.trim() || path2.join(config.cwd, ".pi-agent"),
|
|
527
540
|
modelProvider: config.modelProvider?.trim() || "openrouter",
|
|
@@ -532,10 +545,13 @@ function resolveConfig(config) {
|
|
|
532
545
|
promptTransform: config.promptTransform || identityPromptTransform,
|
|
533
546
|
startupMessage: config.startupMessage === undefined ? "Bot is online and ready." : config.startupMessage,
|
|
534
547
|
shutdownOnSignals: config.shutdownOnSignals ?? true,
|
|
535
|
-
visionModelId: config.visionModelId?.trim() || null
|
|
548
|
+
visionModelId: config.visionModelId?.trim() || null,
|
|
549
|
+
discordAllowedForumChannelIds: config.discordAllowedForumChannelIds ?? [],
|
|
550
|
+
discordAllowedUserIds: config.discordAllowedUserIds ?? [discordAllowedUserId],
|
|
551
|
+
sessionIdleTimeoutMs: config.sessionIdleTimeoutMs ?? null
|
|
536
552
|
};
|
|
537
553
|
}
|
|
538
|
-
function
|
|
554
|
+
function loadDiscordGatewayConfigFromEnv(overrides = {}) {
|
|
539
555
|
dotenv.config();
|
|
540
556
|
return resolveConfig({
|
|
541
557
|
discordBotToken: overrides.discordBotToken || process.env.DISCORD_BOT_TOKEN || "",
|
|
@@ -550,19 +566,11 @@ function loadDiscordPiBridgeConfigFromEnv(overrides = {}) {
|
|
|
550
566
|
promptTransform: overrides.promptTransform,
|
|
551
567
|
startupMessage: overrides.startupMessage ?? readStartupMessageFromEnv(),
|
|
552
568
|
shutdownOnSignals: overrides.shutdownOnSignals,
|
|
553
|
-
visionModelId: overrides.visionModelId ?? process.env.PI_VISION_MODEL_ID
|
|
554
|
-
});
|
|
555
|
-
}
|
|
556
|
-
function loadDiscordGatewayConfigFromEnv(overrides = {}) {
|
|
557
|
-
const base = loadDiscordPiBridgeConfigFromEnv(overrides);
|
|
558
|
-
return {
|
|
559
|
-
...base,
|
|
569
|
+
visionModelId: overrides.visionModelId ?? process.env.PI_VISION_MODEL_ID,
|
|
560
570
|
discordAllowedForumChannelIds: overrides.discordAllowedForumChannelIds ?? parseStringArrayFromEnv("DISCORD_FORUM_CHANNEL_IDS") ?? [],
|
|
561
|
-
discordAllowedUserIds: overrides.discordAllowedUserIds ?? parseStringArrayFromEnv("DISCORD_ALLOWED_USER_IDS")
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
sessionIdleTimeoutMs: overrides.sessionIdleTimeoutMs ?? parseOptionalIntFromEnv("DISCORD_SESSION_IDLE_TIMEOUT_MS") ?? null
|
|
565
|
-
};
|
|
571
|
+
discordAllowedUserIds: overrides.discordAllowedUserIds ?? parseStringArrayFromEnv("DISCORD_ALLOWED_USER_IDS"),
|
|
572
|
+
sessionIdleTimeoutMs: overrides.sessionIdleTimeoutMs ?? parseOptionalIntFromEnv("DISCORD_SESSION_IDLE_TIMEOUT_MS") ?? undefined
|
|
573
|
+
});
|
|
566
574
|
}
|
|
567
575
|
function readRequiredValue(name, value) {
|
|
568
576
|
const trimmedValue = value.trim();
|
|
@@ -582,17 +590,6 @@ function readStartupMessageFromEnv() {
|
|
|
582
590
|
}
|
|
583
591
|
return trimmedValue;
|
|
584
592
|
}
|
|
585
|
-
function resolveGatewayConfig(config) {
|
|
586
|
-
const base = resolveConfig(config);
|
|
587
|
-
return {
|
|
588
|
-
...base,
|
|
589
|
-
discordAllowedForumChannelIds: config.discordAllowedForumChannelIds ?? [],
|
|
590
|
-
discordAllowedUserIds: config.discordAllowedUserIds ?? [
|
|
591
|
-
base.discordAllowedUserId
|
|
592
|
-
],
|
|
593
|
-
sessionIdleTimeoutMs: config.sessionIdleTimeoutMs ?? null
|
|
594
|
-
};
|
|
595
|
-
}
|
|
596
593
|
function parseThinkingLevel(value) {
|
|
597
594
|
if (!value) {
|
|
598
595
|
return;
|
|
@@ -632,7 +629,6 @@ function parseOptionalIntFromEnv(key) {
|
|
|
632
629
|
|
|
633
630
|
// src/discord-gateway-client.ts
|
|
634
631
|
import {
|
|
635
|
-
ChannelType,
|
|
636
632
|
Client,
|
|
637
633
|
Events,
|
|
638
634
|
GatewayIntentBits,
|
|
@@ -657,8 +653,11 @@ function getSessionStatusText(session, promptQueue, extras) {
|
|
|
657
653
|
`queue-busy: ${queueStatus.busy}`
|
|
658
654
|
];
|
|
659
655
|
if (extras?.tools && extras.tools.length > 0) {
|
|
660
|
-
const
|
|
661
|
-
lines.push("", `Tools (${extras.tools.length})
|
|
656
|
+
const toolNames = extras.tools.map((tool) => tool.name);
|
|
657
|
+
lines.push("", `Tools (${extras.tools.length}): ${toolNames.join(", ")}`);
|
|
658
|
+
}
|
|
659
|
+
if (extras?.skillsSummary) {
|
|
660
|
+
lines.push("", extras.skillsSummary);
|
|
662
661
|
}
|
|
663
662
|
if (extras?.extensionsSummary) {
|
|
664
663
|
lines.push("", extras.extensionsSummary);
|
|
@@ -676,362 +675,386 @@ function getThinkingInfo(session) {
|
|
|
676
675
|
supported: true
|
|
677
676
|
};
|
|
678
677
|
}
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
if (trimmed === "!help") {
|
|
686
|
-
const extraCommands = session ? `
|
|
687
|
-
!archive - archive this thread and end the session` : "";
|
|
678
|
+
function getEffectiveSession(context) {
|
|
679
|
+
return context.session ?? context.agentService.getSession();
|
|
680
|
+
}
|
|
681
|
+
function requireEffectiveSession(context) {
|
|
682
|
+
const session = getEffectiveSession(context);
|
|
683
|
+
if (!session) {
|
|
688
684
|
return {
|
|
689
685
|
handled: true,
|
|
690
|
-
response:
|
|
691
|
-
"Commands:",
|
|
692
|
-
"!help - show this message",
|
|
693
|
-
"!status - show current session status",
|
|
694
|
-
"!thinking - show or set thinking/reasoning level",
|
|
695
|
-
"!model - list available models or switch to one",
|
|
696
|
-
"!compact - compact the persistent session",
|
|
697
|
-
"!reset-session - start a fresh persistent session",
|
|
698
|
-
"!reload - reload resources (AGENTS.md, extensions, skills, etc.)",
|
|
699
|
-
extraCommands,
|
|
700
|
-
"Any other text goes to the agent session."
|
|
701
|
-
].filter(Boolean).join(`
|
|
702
|
-
`)
|
|
686
|
+
response: "No active session."
|
|
703
687
|
};
|
|
704
688
|
}
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
689
|
+
return { session };
|
|
690
|
+
}
|
|
691
|
+
async function handleHelpCommand(trimmedInput, context) {
|
|
692
|
+
if (trimmedInput !== "!help") {
|
|
693
|
+
return null;
|
|
694
|
+
}
|
|
695
|
+
const extraCommands = context.session ? `
|
|
696
|
+
!archive - archive this thread and end the session` : "";
|
|
697
|
+
return {
|
|
698
|
+
handled: true,
|
|
699
|
+
response: [
|
|
700
|
+
"Commands:",
|
|
701
|
+
"!help - show this message",
|
|
702
|
+
"!status - show current session status",
|
|
703
|
+
"!thinking - show or set thinking/reasoning level",
|
|
704
|
+
"!model - list available models or switch to one",
|
|
705
|
+
"!compact - compact the persistent session",
|
|
706
|
+
"!reset-session - start a fresh persistent session",
|
|
707
|
+
"!reload - reload resources (AGENTS.md, extensions, skills, etc.)",
|
|
708
|
+
extraCommands,
|
|
709
|
+
"Any other text goes to the agent session."
|
|
710
|
+
].filter(Boolean).join(`
|
|
711
|
+
`)
|
|
712
|
+
};
|
|
713
|
+
}
|
|
714
|
+
async function handleArchiveCommand(trimmedInput, context) {
|
|
715
|
+
if (trimmedInput !== "!archive") {
|
|
716
|
+
return null;
|
|
717
|
+
}
|
|
718
|
+
if (!context.session) {
|
|
712
719
|
return {
|
|
713
720
|
handled: true,
|
|
714
|
-
|
|
715
|
-
response: "Archiving thread and shutting down session."
|
|
721
|
+
response: "!archive is only available in forum threads."
|
|
716
722
|
};
|
|
717
723
|
}
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
724
|
+
return {
|
|
725
|
+
handled: true,
|
|
726
|
+
archive: true,
|
|
727
|
+
response: "Archiving thread and shutting down session."
|
|
728
|
+
};
|
|
729
|
+
}
|
|
730
|
+
async function handleStatusCommand(trimmedInput, context) {
|
|
731
|
+
if (trimmedInput !== "!status") {
|
|
732
|
+
return null;
|
|
733
|
+
}
|
|
734
|
+
const effectiveSession = requireEffectiveSession(context);
|
|
735
|
+
if ("handled" in effectiveSession) {
|
|
736
|
+
return effectiveSession;
|
|
737
|
+
}
|
|
738
|
+
const tools = effectiveSession.session.getAllTools();
|
|
739
|
+
const extensionsSummary = context.agentService.getExtensionsSummary();
|
|
740
|
+
const skillsSummary = context.agentService.getSkillsSummary();
|
|
741
|
+
return {
|
|
742
|
+
handled: true,
|
|
743
|
+
response: getSessionStatusText(effectiveSession.session, context.promptQueue, {
|
|
744
|
+
tools,
|
|
745
|
+
extensionsSummary,
|
|
746
|
+
skillsSummary
|
|
747
|
+
})
|
|
748
|
+
};
|
|
749
|
+
}
|
|
750
|
+
async function handleThinkingCommand(trimmedInput, context) {
|
|
751
|
+
if (trimmedInput !== "!thinking" && !trimmedInput.startsWith("!thinking ")) {
|
|
752
|
+
return null;
|
|
753
|
+
}
|
|
754
|
+
const effectiveSession = requireEffectiveSession(context);
|
|
755
|
+
if ("handled" in effectiveSession) {
|
|
756
|
+
return effectiveSession;
|
|
757
|
+
}
|
|
758
|
+
const parts = trimmedInput.split(" ");
|
|
759
|
+
if (parts.length === 1) {
|
|
760
|
+
const info = getThinkingInfo(effectiveSession.session);
|
|
761
|
+
if (!info.supported) {
|
|
721
762
|
return {
|
|
722
763
|
handled: true,
|
|
723
|
-
response: "
|
|
764
|
+
response: "Current model does not support reasoning/thinking."
|
|
724
765
|
};
|
|
725
766
|
}
|
|
726
|
-
const tools = effectiveSession.getAllTools();
|
|
727
|
-
const extensionsSummary = agentService.getExtensionsSummary();
|
|
728
767
|
return {
|
|
729
768
|
handled: true,
|
|
730
|
-
response:
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
769
|
+
response: [
|
|
770
|
+
`Current: ${info.current}`,
|
|
771
|
+
`Available: ${info.available.join(", ")}`,
|
|
772
|
+
`Usage: !thinking <level>`
|
|
773
|
+
].join(`
|
|
774
|
+
`)
|
|
734
775
|
};
|
|
735
776
|
}
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
if (!effectiveSession) {
|
|
739
|
-
return {
|
|
740
|
-
handled: true,
|
|
741
|
-
response: "No active session."
|
|
742
|
-
};
|
|
743
|
-
}
|
|
744
|
-
const parts = trimmed.split(" ");
|
|
745
|
-
if (parts.length === 1) {
|
|
746
|
-
const info = getThinkingInfo(effectiveSession);
|
|
747
|
-
if (!info.supported) {
|
|
748
|
-
return {
|
|
749
|
-
handled: true,
|
|
750
|
-
response: "Current model does not support reasoning/thinking."
|
|
751
|
-
};
|
|
752
|
-
}
|
|
753
|
-
return {
|
|
754
|
-
handled: true,
|
|
755
|
-
response: [
|
|
756
|
-
`Current: ${info.current}`,
|
|
757
|
-
`Available: ${info.available.join(", ")}`,
|
|
758
|
-
`Usage: !thinking <level>`
|
|
759
|
-
].join(`
|
|
760
|
-
`)
|
|
761
|
-
};
|
|
762
|
-
}
|
|
763
|
-
const requestedLevel = parts[1];
|
|
764
|
-
if (!effectiveSession.supportsThinking()) {
|
|
765
|
-
return {
|
|
766
|
-
handled: true,
|
|
767
|
-
response: "Current model does not support reasoning/thinking."
|
|
768
|
-
};
|
|
769
|
-
}
|
|
770
|
-
const available = effectiveSession.getAvailableThinkingLevels();
|
|
771
|
-
if (!available.includes(requestedLevel)) {
|
|
772
|
-
return {
|
|
773
|
-
handled: true,
|
|
774
|
-
response: `Invalid thinking level "${requestedLevel}" for current model. Available: ${available.join(", ")}`
|
|
775
|
-
};
|
|
776
|
-
}
|
|
777
|
-
effectiveSession.setThinkingLevel(requestedLevel);
|
|
777
|
+
const requestedLevel = parts[1];
|
|
778
|
+
if (!effectiveSession.session.supportsThinking()) {
|
|
778
779
|
return {
|
|
779
780
|
handled: true,
|
|
780
|
-
response:
|
|
781
|
+
response: "Current model does not support reasoning/thinking."
|
|
781
782
|
};
|
|
782
783
|
}
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
if (!effectiveSession) {
|
|
786
|
-
return {
|
|
787
|
-
handled: true,
|
|
788
|
-
response: "No active session."
|
|
789
|
-
};
|
|
790
|
-
}
|
|
791
|
-
const parts = trimmed.split(" ");
|
|
792
|
-
if (parts.length === 1) {
|
|
793
|
-
const current = agentService.getCurrentModelDisplay(effectiveSession);
|
|
794
|
-
const modelList = await agentService.listModels(effectiveSession);
|
|
795
|
-
return {
|
|
796
|
-
handled: true,
|
|
797
|
-
response: `Current model: ${current}
|
|
798
|
-
|
|
799
|
-
${modelList}`
|
|
800
|
-
};
|
|
801
|
-
}
|
|
802
|
-
const arg = parts.slice(1).join(" ");
|
|
803
|
-
const slashIndex = arg.indexOf("/");
|
|
804
|
-
if (slashIndex === -1) {
|
|
805
|
-
return {
|
|
806
|
-
handled: true,
|
|
807
|
-
response: `Usage: !model <provider/modelId>
|
|
808
|
-
Example: !model openrouter/anthropic/claude-sonnet-4
|
|
809
|
-
Use !model without args to see available models.`
|
|
810
|
-
};
|
|
811
|
-
}
|
|
812
|
-
const provider = arg.substring(0, slashIndex).trim();
|
|
813
|
-
const modelId = arg.substring(slashIndex + 1).trim();
|
|
784
|
+
const available = effectiveSession.session.getAvailableThinkingLevels();
|
|
785
|
+
if (!available.includes(requestedLevel)) {
|
|
814
786
|
return {
|
|
815
787
|
handled: true,
|
|
816
|
-
response:
|
|
788
|
+
response: `Invalid thinking level "${requestedLevel}" for current model. Available: ${available.join(", ")}`
|
|
817
789
|
};
|
|
818
790
|
}
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
791
|
+
effectiveSession.session.setThinkingLevel(requestedLevel);
|
|
792
|
+
return {
|
|
793
|
+
handled: true,
|
|
794
|
+
response: `Thinking level set to "${requestedLevel}".`
|
|
795
|
+
};
|
|
796
|
+
}
|
|
797
|
+
async function handleModelCommand(trimmedInput, context) {
|
|
798
|
+
if (trimmedInput !== "!model" && !trimmedInput.startsWith("!model ")) {
|
|
799
|
+
return null;
|
|
800
|
+
}
|
|
801
|
+
const effectiveSession = requireEffectiveSession(context);
|
|
802
|
+
if ("handled" in effectiveSession) {
|
|
803
|
+
return effectiveSession;
|
|
804
|
+
}
|
|
805
|
+
const parts = trimmedInput.split(" ");
|
|
806
|
+
if (parts.length === 1) {
|
|
807
|
+
const current = context.agentService.getCurrentModelDisplay(effectiveSession.session);
|
|
808
|
+
const modelList = await context.agentService.listModels(effectiveSession.session);
|
|
827
809
|
return {
|
|
828
810
|
handled: true,
|
|
829
|
-
response:
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
})
|
|
811
|
+
response: `Current model: ${current}
|
|
812
|
+
|
|
813
|
+
${modelList}`
|
|
833
814
|
};
|
|
834
815
|
}
|
|
835
|
-
|
|
816
|
+
const argument = parts.slice(1).join(" ");
|
|
817
|
+
const slashIndex = argument.indexOf("/");
|
|
818
|
+
if (slashIndex === -1) {
|
|
836
819
|
return {
|
|
837
820
|
handled: true,
|
|
838
|
-
response:
|
|
839
|
-
|
|
840
|
-
|
|
821
|
+
response: `Usage: !model <provider/modelId>
|
|
822
|
+
` + `Example: !model openrouter/anthropic/claude-sonnet-4
|
|
823
|
+
` + "Use !model without args to see available models."
|
|
841
824
|
};
|
|
842
825
|
}
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
826
|
+
const provider = argument.substring(0, slashIndex).trim();
|
|
827
|
+
const modelId = argument.substring(slashIndex + 1).trim();
|
|
828
|
+
return {
|
|
829
|
+
handled: true,
|
|
830
|
+
response: await context.agentService.switchModel(provider, modelId, effectiveSession.session)
|
|
831
|
+
};
|
|
832
|
+
}
|
|
833
|
+
async function handleCompactCommand(trimmedInput, context) {
|
|
834
|
+
if (trimmedInput !== "!compact") {
|
|
835
|
+
return null;
|
|
836
|
+
}
|
|
837
|
+
const effectiveSession = requireEffectiveSession(context);
|
|
838
|
+
if ("handled" in effectiveSession) {
|
|
839
|
+
return effectiveSession;
|
|
840
|
+
}
|
|
841
|
+
return {
|
|
842
|
+
handled: true,
|
|
843
|
+
response: await context.promptQueue.enqueue(async () => {
|
|
844
|
+
await effectiveSession.session.compact();
|
|
845
|
+
return `Compaction finished for session ${effectiveSession.session.sessionId}.`;
|
|
846
|
+
})
|
|
847
|
+
};
|
|
848
|
+
}
|
|
849
|
+
async function handleReloadCommand(trimmedInput, context) {
|
|
850
|
+
if (trimmedInput !== "!reload") {
|
|
851
|
+
return null;
|
|
852
|
+
}
|
|
853
|
+
return {
|
|
854
|
+
handled: true,
|
|
855
|
+
response: await context.promptQueue.enqueue(async () => {
|
|
856
|
+
return context.agentService.reloadResources();
|
|
857
|
+
})
|
|
858
|
+
};
|
|
859
|
+
}
|
|
860
|
+
async function handleResetSessionCommand(trimmedInput, context) {
|
|
861
|
+
if (trimmedInput !== "!reset-session") {
|
|
862
|
+
return null;
|
|
863
|
+
}
|
|
864
|
+
if (context.session) {
|
|
855
865
|
return {
|
|
856
866
|
handled: true,
|
|
857
|
-
response: await promptQueue.enqueue(async () => {
|
|
858
|
-
|
|
867
|
+
response: await context.promptQueue.enqueue(async () => {
|
|
868
|
+
const previousSession = context.session;
|
|
869
|
+
await previousSession.abort();
|
|
870
|
+
previousSession.dispose();
|
|
871
|
+
return `Session reset. Old session kept at ${previousSession.sessionFile ?? "(unknown path)"}. Use !archive to archive the thread and start fresh.`;
|
|
859
872
|
})
|
|
860
873
|
};
|
|
861
874
|
}
|
|
862
875
|
return {
|
|
863
876
|
handled: true,
|
|
864
|
-
response:
|
|
877
|
+
response: await context.promptQueue.enqueue(async () => {
|
|
878
|
+
return context.agentService.resetSession();
|
|
879
|
+
})
|
|
865
880
|
};
|
|
866
881
|
}
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
promptText = userText.trim().length > 0 ? `The user sent a document with the following message: "${userText}". Please extract and summarize the text content of this document. Be thorough — include all important details, sections, and data from the document.` : "Please extract and summarize the text content of this document. Be thorough — include all important details, sections, data, and key points.";
|
|
882
|
-
} else {
|
|
883
|
-
promptText = userText.trim().length > 0 ? `The user sent this image with the following message: "${userText}". Please describe the image in detail and address any questions from the user's message.` : "Please describe this image in detail. What do you see?";
|
|
882
|
+
var commandHandlers = [
|
|
883
|
+
handleHelpCommand,
|
|
884
|
+
handleArchiveCommand,
|
|
885
|
+
handleStatusCommand,
|
|
886
|
+
handleThinkingCommand,
|
|
887
|
+
handleModelCommand,
|
|
888
|
+
handleCompactCommand,
|
|
889
|
+
handleReloadCommand,
|
|
890
|
+
handleResetSessionCommand
|
|
891
|
+
];
|
|
892
|
+
async function handleCommand(input, context) {
|
|
893
|
+
const trimmedInput = input.trim();
|
|
894
|
+
if (!trimmedInput.startsWith("!")) {
|
|
895
|
+
return { handled: false };
|
|
884
896
|
}
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
logger5.error({ error, mimeType }, "vision model prompt failed");
|
|
891
|
-
text = "(Vision model failed to process the file.)";
|
|
892
|
-
} finally {
|
|
893
|
-
session.dispose();
|
|
897
|
+
for (const handler of commandHandlers) {
|
|
898
|
+
const result = await handler(trimmedInput, context);
|
|
899
|
+
if (result) {
|
|
900
|
+
return result;
|
|
901
|
+
}
|
|
894
902
|
}
|
|
895
|
-
|
|
896
|
-
|
|
903
|
+
return {
|
|
904
|
+
handled: true,
|
|
905
|
+
response: `Unknown command: ${trimmedInput}. Try !help.`
|
|
906
|
+
};
|
|
907
|
+
}
|
|
908
|
+
|
|
909
|
+
// src/discord-attachments.ts
|
|
910
|
+
var logger5 = createModuleLogger("discord-attachments");
|
|
911
|
+
var TEXT_ATTACHMENT_EXTENSIONS = [
|
|
912
|
+
".txt",
|
|
913
|
+
".md",
|
|
914
|
+
".json",
|
|
915
|
+
".csv",
|
|
916
|
+
".log",
|
|
917
|
+
".yml",
|
|
918
|
+
".yaml",
|
|
919
|
+
".xml",
|
|
920
|
+
".toml",
|
|
921
|
+
".ini",
|
|
922
|
+
".cfg"
|
|
923
|
+
];
|
|
924
|
+
var MAX_TEXT_ATTACHMENT_SIZE_BYTES = 25 * 1024 * 1024;
|
|
925
|
+
var MEDIA_ATTACHMENT_EXTENSIONS = [
|
|
926
|
+
".png",
|
|
927
|
+
".jpg",
|
|
928
|
+
".jpeg",
|
|
929
|
+
".gif",
|
|
930
|
+
".webp",
|
|
931
|
+
".pdf",
|
|
932
|
+
".docx",
|
|
933
|
+
".doc",
|
|
934
|
+
".pptx",
|
|
935
|
+
".ppt",
|
|
936
|
+
".xlsx",
|
|
937
|
+
".xls"
|
|
938
|
+
];
|
|
939
|
+
var MAX_MEDIA_ATTACHMENT_SIZE_BYTES = 25 * 1024 * 1024;
|
|
940
|
+
var OFFICE_MIME_TYPES = new Set([
|
|
941
|
+
"application/pdf",
|
|
942
|
+
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
|
943
|
+
"application/msword",
|
|
944
|
+
"application/vnd.openxmlformats-officedocument.presentationml.presentation",
|
|
945
|
+
"application/vnd.ms-powerpoint",
|
|
946
|
+
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
|
947
|
+
"application/vnd.ms-excel"
|
|
948
|
+
]);
|
|
949
|
+
function isSupportedTextAttachment(attachment) {
|
|
950
|
+
const ext = attachment.name?.slice(attachment.name.lastIndexOf(".")).toLowerCase();
|
|
951
|
+
return Boolean(ext && TEXT_ATTACHMENT_EXTENSIONS.includes(ext));
|
|
952
|
+
}
|
|
953
|
+
function isSupportedMediaAttachment(attachment) {
|
|
954
|
+
const ext = attachment.name?.slice(attachment.name.lastIndexOf(".")).toLowerCase();
|
|
955
|
+
if (!ext || !MEDIA_ATTACHMENT_EXTENSIONS.includes(ext)) {
|
|
956
|
+
return false;
|
|
897
957
|
}
|
|
898
|
-
|
|
899
|
-
|
|
958
|
+
const contentType = attachment.contentType;
|
|
959
|
+
if (!contentType) {
|
|
960
|
+
return false;
|
|
961
|
+
}
|
|
962
|
+
return contentType.startsWith("image/") || OFFICE_MIME_TYPES.has(contentType);
|
|
900
963
|
}
|
|
901
|
-
function
|
|
902
|
-
const
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
964
|
+
async function readTextAttachments(message) {
|
|
965
|
+
const attachments = message.attachments;
|
|
966
|
+
if (attachments.size === 0) {
|
|
967
|
+
return [];
|
|
968
|
+
}
|
|
969
|
+
const results = [];
|
|
970
|
+
for (const [, attachment] of attachments) {
|
|
971
|
+
if (!isSupportedTextAttachment(attachment)) {
|
|
972
|
+
logger5.debug({ messageId: message.id, filename: attachment.name }, "skipping non-text attachment");
|
|
906
973
|
continue;
|
|
907
974
|
}
|
|
908
|
-
|
|
909
|
-
|
|
975
|
+
if (attachment.size > MAX_TEXT_ATTACHMENT_SIZE_BYTES) {
|
|
976
|
+
logger5.warn({
|
|
977
|
+
messageId: message.id,
|
|
978
|
+
filename: attachment.name,
|
|
979
|
+
size: attachment.size
|
|
980
|
+
}, "attachment too large, skipping");
|
|
910
981
|
continue;
|
|
911
982
|
}
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
983
|
+
try {
|
|
984
|
+
logger5.info({
|
|
985
|
+
messageId: message.id,
|
|
986
|
+
filename: attachment.name,
|
|
987
|
+
size: attachment.size
|
|
988
|
+
}, "fetching attachment");
|
|
989
|
+
const response = await fetch(attachment.url);
|
|
990
|
+
if (!response.ok) {
|
|
991
|
+
logger5.warn({
|
|
992
|
+
messageId: message.id,
|
|
993
|
+
filename: attachment.name,
|
|
994
|
+
status: response.status
|
|
995
|
+
}, "failed to fetch attachment");
|
|
996
|
+
continue;
|
|
916
997
|
}
|
|
998
|
+
const content = await response.text();
|
|
999
|
+
results.push({ filename: attachment.name, content });
|
|
1000
|
+
} catch (error) {
|
|
1001
|
+
logger5.error({ messageId: message.id, filename: attachment.name, error }, "error fetching attachment");
|
|
917
1002
|
}
|
|
918
|
-
return textBlocks.join(`
|
|
919
|
-
`).trim();
|
|
920
1003
|
}
|
|
921
|
-
return
|
|
922
|
-
}
|
|
923
|
-
function getMediaType(mimeType) {
|
|
924
|
-
if (mimeType.startsWith("image/")) {
|
|
925
|
-
return "image";
|
|
926
|
-
}
|
|
927
|
-
return "document";
|
|
1004
|
+
return results;
|
|
928
1005
|
}
|
|
929
|
-
function
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
// src/message-chunker.ts
|
|
934
|
-
import { marked } from "marked";
|
|
935
|
-
var DISCORD_MESSAGE_LIMIT = 2000;
|
|
936
|
-
var SAFE_MESSAGE_LIMIT = 1900;
|
|
937
|
-
function chunkMessage(text, maxChunkSize = SAFE_MESSAGE_LIMIT) {
|
|
938
|
-
if (text.length <= maxChunkSize) {
|
|
939
|
-
return [text];
|
|
1006
|
+
async function readMediaAttachments(message) {
|
|
1007
|
+
const attachments = message.attachments;
|
|
1008
|
+
if (attachments.size === 0) {
|
|
1009
|
+
return [];
|
|
940
1010
|
}
|
|
941
|
-
const
|
|
942
|
-
const
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
const flushChunk = () => {
|
|
946
|
-
if (currentTokens.length > 0) {
|
|
947
|
-
chunks.push(currentTokens.map((t) => t.raw).join("").trim());
|
|
948
|
-
currentTokens = [];
|
|
949
|
-
currentSize = 0;
|
|
1011
|
+
const results = [];
|
|
1012
|
+
for (const [, attachment] of attachments) {
|
|
1013
|
+
if (!isSupportedMediaAttachment(attachment)) {
|
|
1014
|
+
continue;
|
|
950
1015
|
}
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
1016
|
+
if (attachment.size > MAX_MEDIA_ATTACHMENT_SIZE_BYTES) {
|
|
1017
|
+
logger5.warn({
|
|
1018
|
+
messageId: message.id,
|
|
1019
|
+
filename: attachment.name,
|
|
1020
|
+
size: attachment.size
|
|
1021
|
+
}, "media attachment too large, skipping");
|
|
1022
|
+
continue;
|
|
1023
|
+
}
|
|
1024
|
+
try {
|
|
1025
|
+
logger5.info({
|
|
1026
|
+
messageId: message.id,
|
|
1027
|
+
filename: attachment.name,
|
|
1028
|
+
size: attachment.size
|
|
1029
|
+
}, "fetching media attachment");
|
|
1030
|
+
const response = await fetch(attachment.url);
|
|
1031
|
+
if (!response.ok) {
|
|
1032
|
+
logger5.warn({
|
|
1033
|
+
messageId: message.id,
|
|
1034
|
+
filename: attachment.name,
|
|
1035
|
+
status: response.status
|
|
1036
|
+
}, "failed to fetch media attachment");
|
|
1037
|
+
continue;
|
|
1038
|
+
}
|
|
1039
|
+
const buffer = await response.arrayBuffer();
|
|
1040
|
+
results.push({
|
|
1041
|
+
filename: attachment.name,
|
|
1042
|
+
data: Buffer.from(buffer).toString("base64"),
|
|
1043
|
+
mimeType: attachment.contentType ?? "application/octet-stream"
|
|
1044
|
+
});
|
|
1045
|
+
} catch (error) {
|
|
1046
|
+
logger5.error({ messageId: message.id, filename: attachment.name, error }, "error fetching media attachment");
|
|
956
1047
|
}
|
|
957
|
-
currentTokens.push(token);
|
|
958
|
-
currentSize += size;
|
|
959
|
-
}
|
|
960
|
-
flushChunk();
|
|
961
|
-
return chunks.map((chunk) => chunk.slice(0, DISCORD_MESSAGE_LIMIT));
|
|
962
|
-
}
|
|
963
|
-
|
|
964
|
-
// src/prompt-context.ts
|
|
965
|
-
function buildDiscordMessageContextPrompt(userMessage, options) {
|
|
966
|
-
const contextEntries = [
|
|
967
|
-
["scope", options.scope],
|
|
968
|
-
["sent_at", options.sentAt],
|
|
969
|
-
["sent_at_local", options.sentAtLocal],
|
|
970
|
-
["message_id", options.messageId],
|
|
971
|
-
["author_name", normalizeContextValue(options.authorName)],
|
|
972
|
-
["author_id", options.authorId],
|
|
973
|
-
["thread_title", normalizeContextValue(options.threadTitle)],
|
|
974
|
-
["thread_id", options.threadId],
|
|
975
|
-
["forum_channel_id", options.forumChannelId ?? undefined]
|
|
976
|
-
].filter((entry) => {
|
|
977
|
-
return typeof entry[1] === "string" && entry[1].trim().length > 0;
|
|
978
|
-
});
|
|
979
|
-
const contextJson = JSON.stringify(Object.fromEntries(contextEntries), null, 2);
|
|
980
|
-
return [
|
|
981
|
-
"<discord_message_context>",
|
|
982
|
-
contextJson,
|
|
983
|
-
"</discord_message_context>",
|
|
984
|
-
"",
|
|
985
|
-
"User message:",
|
|
986
|
-
userMessage.trim()
|
|
987
|
-
].join(`
|
|
988
|
-
`);
|
|
989
|
-
}
|
|
990
|
-
function formatDiscordPromptTime(date, options = {}) {
|
|
991
|
-
const timeZone = options.timeZone || "UTC";
|
|
992
|
-
const locale = options.locale || "en-AU";
|
|
993
|
-
return new Intl.DateTimeFormat(locale, {
|
|
994
|
-
timeZone,
|
|
995
|
-
weekday: "short",
|
|
996
|
-
day: "numeric",
|
|
997
|
-
month: "short",
|
|
998
|
-
year: "2-digit",
|
|
999
|
-
hour: "2-digit",
|
|
1000
|
-
minute: "2-digit",
|
|
1001
|
-
hour12: false,
|
|
1002
|
-
timeZoneName: "short"
|
|
1003
|
-
}).format(date);
|
|
1004
|
-
}
|
|
1005
|
-
function normalizeContextValue(value) {
|
|
1006
|
-
if (value === undefined) {
|
|
1007
|
-
return;
|
|
1008
1048
|
}
|
|
1009
|
-
return
|
|
1049
|
+
return results;
|
|
1010
1050
|
}
|
|
1011
1051
|
|
|
1012
|
-
// src/discord-
|
|
1013
|
-
|
|
1052
|
+
// src/discord-auth.ts
|
|
1053
|
+
import { ChannelType } from "discord.js";
|
|
1014
1054
|
function getAuthorDisplayName(message) {
|
|
1015
1055
|
return message.member?.displayName || message.author.globalName || message.author.username;
|
|
1016
1056
|
}
|
|
1017
|
-
function
|
|
1018
|
-
const isThread = scope.startsWith("thread:") && message.channel.isThread();
|
|
1019
|
-
return buildDiscordMessageContextPrompt(content, {
|
|
1020
|
-
scope: scope === "dm" ? "dm" : "thread",
|
|
1021
|
-
sentAt: message.createdAt.toISOString(),
|
|
1022
|
-
sentAtLocal: formatDiscordPromptTime(message.createdAt, {
|
|
1023
|
-
timeZone: config.promptTimeZone,
|
|
1024
|
-
locale: config.promptLocale
|
|
1025
|
-
}),
|
|
1026
|
-
messageId: message.id,
|
|
1027
|
-
authorId: message.author.id,
|
|
1028
|
-
authorName: getAuthorDisplayName(message),
|
|
1029
|
-
threadId: isThread ? message.channel.id : undefined,
|
|
1030
|
-
threadTitle: isThread ? message.channel.name : undefined,
|
|
1031
|
-
forumChannelId: isThread ? message.channel.parentId : undefined
|
|
1032
|
-
});
|
|
1033
|
-
}
|
|
1034
|
-
function resolveScope(message) {
|
|
1057
|
+
function resolveMessageScope(message) {
|
|
1035
1058
|
if (message.channel.type === ChannelType.DM) {
|
|
1036
1059
|
return "dm";
|
|
1037
1060
|
}
|
|
@@ -1040,7 +1063,7 @@ function resolveScope(message) {
|
|
|
1040
1063
|
}
|
|
1041
1064
|
return null;
|
|
1042
1065
|
}
|
|
1043
|
-
function
|
|
1066
|
+
function isAuthorizedMessage(message, scope, authConfig) {
|
|
1044
1067
|
if (scope === "dm") {
|
|
1045
1068
|
return message.author.id === authConfig.discordAllowedUserId;
|
|
1046
1069
|
}
|
|
@@ -1057,6 +1080,40 @@ function isAuthorized(message, scope, authConfig) {
|
|
|
1057
1080
|
}
|
|
1058
1081
|
return false;
|
|
1059
1082
|
}
|
|
1083
|
+
|
|
1084
|
+
// src/message-chunker.ts
|
|
1085
|
+
import { marked } from "marked";
|
|
1086
|
+
var DISCORD_MESSAGE_LIMIT = 2000;
|
|
1087
|
+
var SAFE_MESSAGE_LIMIT = 1900;
|
|
1088
|
+
function chunkMessage(text, maxChunkSize = SAFE_MESSAGE_LIMIT) {
|
|
1089
|
+
if (text.length <= maxChunkSize) {
|
|
1090
|
+
return [text];
|
|
1091
|
+
}
|
|
1092
|
+
const tokens = marked.lexer(text);
|
|
1093
|
+
const chunks = [];
|
|
1094
|
+
let currentTokens = [];
|
|
1095
|
+
let currentSize = 0;
|
|
1096
|
+
const flushChunk = () => {
|
|
1097
|
+
if (currentTokens.length > 0) {
|
|
1098
|
+
chunks.push(currentTokens.map((t) => t.raw).join("").trim());
|
|
1099
|
+
currentTokens = [];
|
|
1100
|
+
currentSize = 0;
|
|
1101
|
+
}
|
|
1102
|
+
};
|
|
1103
|
+
for (const token of tokens) {
|
|
1104
|
+
const size = token.raw.length;
|
|
1105
|
+
if (currentSize + size > maxChunkSize && currentTokens.length > 0) {
|
|
1106
|
+
flushChunk();
|
|
1107
|
+
}
|
|
1108
|
+
currentTokens.push(token);
|
|
1109
|
+
currentSize += size;
|
|
1110
|
+
}
|
|
1111
|
+
flushChunk();
|
|
1112
|
+
return chunks.map((chunk) => chunk.slice(0, DISCORD_MESSAGE_LIMIT));
|
|
1113
|
+
}
|
|
1114
|
+
|
|
1115
|
+
// src/discord-replies.ts
|
|
1116
|
+
var logger6 = createModuleLogger("discord-replies");
|
|
1060
1117
|
var WORKING_EMOJI = "⚙️";
|
|
1061
1118
|
async function addWorkingReaction(message) {
|
|
1062
1119
|
try {
|
|
@@ -1075,72 +1132,6 @@ async function removeWorkingReaction(message) {
|
|
|
1075
1132
|
logger6.debug({ messageId: message.id, error }, "failed to remove working reaction");
|
|
1076
1133
|
}
|
|
1077
1134
|
}
|
|
1078
|
-
var TYPING_INTERVAL_MS = 9000;
|
|
1079
|
-
var typingIntervals = new Map;
|
|
1080
|
-
async function sendTypingSafe(channel, channelKey) {
|
|
1081
|
-
try {
|
|
1082
|
-
const token = channel.client.token;
|
|
1083
|
-
const url = `https://discord.com/api/v10/channels/${channel.id}/typing`;
|
|
1084
|
-
const res = await fetch(url, {
|
|
1085
|
-
method: "POST",
|
|
1086
|
-
headers: { Authorization: `Bot ${token}` }
|
|
1087
|
-
});
|
|
1088
|
-
if (res.ok) {
|
|
1089
|
-
logger6.debug(`[TYPING] STATUS UPDATED OK`);
|
|
1090
|
-
return;
|
|
1091
|
-
}
|
|
1092
|
-
if (res.status === 429) {
|
|
1093
|
-
const body = await res.text();
|
|
1094
|
-
let retryMs = 3000;
|
|
1095
|
-
try {
|
|
1096
|
-
const parsed = JSON.parse(body);
|
|
1097
|
-
if (typeof parsed.retry_after === "number") {
|
|
1098
|
-
retryMs = parsed.retry_after * 1000 + 500;
|
|
1099
|
-
}
|
|
1100
|
-
} catch {}
|
|
1101
|
-
logger6.warn({ channelKey, retryMs, response: body }, `[TYPING] 429, retrying after ${retryMs}ms delay`);
|
|
1102
|
-
await new Promise((resolve) => setTimeout(resolve, retryMs));
|
|
1103
|
-
await fetch(url, {
|
|
1104
|
-
method: "POST",
|
|
1105
|
-
headers: { Authorization: `Bot ${token}` }
|
|
1106
|
-
});
|
|
1107
|
-
logger6.info({ channelKey }, "[TYPING] retry done");
|
|
1108
|
-
return;
|
|
1109
|
-
}
|
|
1110
|
-
logger6.warn({ channelKey, status: res.status }, "[TYPING] unexpected status");
|
|
1111
|
-
} catch (error) {
|
|
1112
|
-
logger6.warn({ channelKey, error }, "[TYPING] FAILED");
|
|
1113
|
-
}
|
|
1114
|
-
}
|
|
1115
|
-
function startTypingForChannel(channel, channelKey) {
|
|
1116
|
-
const existing = typingIntervals.get(channelKey);
|
|
1117
|
-
if (existing) {
|
|
1118
|
-
existing.refs += 1;
|
|
1119
|
-
logger6.debug({ channelKey, refs: existing.refs }, "[TYPING] ref++ (reusing existing interval)");
|
|
1120
|
-
return;
|
|
1121
|
-
}
|
|
1122
|
-
logger6.debug("[TYPING] started new interval");
|
|
1123
|
-
sendTypingSafe(channel, channelKey);
|
|
1124
|
-
const interval = setInterval(() => {
|
|
1125
|
-
sendTypingSafe(channel, channelKey);
|
|
1126
|
-
}, TYPING_INTERVAL_MS);
|
|
1127
|
-
typingIntervals.set(channelKey, { interval, refs: 1 });
|
|
1128
|
-
}
|
|
1129
|
-
function stopTypingForChannel(channelKey) {
|
|
1130
|
-
const entry = typingIntervals.get(channelKey);
|
|
1131
|
-
if (!entry) {
|
|
1132
|
-
logger6.debug({ channelKey }, "[TYPING] stop called but no entry found");
|
|
1133
|
-
return;
|
|
1134
|
-
}
|
|
1135
|
-
entry.refs -= 1;
|
|
1136
|
-
if (entry.refs <= 0) {
|
|
1137
|
-
clearInterval(entry.interval);
|
|
1138
|
-
typingIntervals.delete(channelKey);
|
|
1139
|
-
logger6.debug("[TYPING] interval cleared (refs hit 0)");
|
|
1140
|
-
} else {
|
|
1141
|
-
logger6.debug("[TYPING] ref-- (interval still active)");
|
|
1142
|
-
}
|
|
1143
|
-
}
|
|
1144
1135
|
async function sendReply(message, text) {
|
|
1145
1136
|
const channel = message.channel;
|
|
1146
1137
|
if (!channel.isSendable()) {
|
|
@@ -1166,146 +1157,76 @@ async function sendReply(message, text) {
|
|
|
1166
1157
|
}, "send reply failed");
|
|
1167
1158
|
}
|
|
1168
1159
|
}
|
|
1169
|
-
|
|
1170
|
-
|
|
1171
|
-
|
|
1172
|
-
|
|
1173
|
-
|
|
1174
|
-
|
|
1175
|
-
|
|
1176
|
-
|
|
1177
|
-
|
|
1178
|
-
|
|
1179
|
-
|
|
1180
|
-
|
|
1181
|
-
|
|
1182
|
-
|
|
1183
|
-
|
|
1184
|
-
|
|
1185
|
-
|
|
1186
|
-
return [];
|
|
1187
|
-
}
|
|
1188
|
-
const results = [];
|
|
1189
|
-
for (const [, attachment] of attachments) {
|
|
1190
|
-
const ext = attachment.name?.slice(attachment.name.lastIndexOf(".")).toLowerCase();
|
|
1191
|
-
if (!ext || !TEXT_ATTACHMENT_EXTENSIONS.includes(ext)) {
|
|
1192
|
-
logger6.debug({ messageId: message.id, filename: attachment.name, ext }, "skipping non-text attachment");
|
|
1193
|
-
continue;
|
|
1194
|
-
}
|
|
1195
|
-
if (attachment.size > MAX_ATTACHMENT_SIZE_BYTES) {
|
|
1196
|
-
logger6.warn({
|
|
1197
|
-
messageId: message.id,
|
|
1198
|
-
filename: attachment.name,
|
|
1199
|
-
size: attachment.size
|
|
1200
|
-
}, "attachment too large, skipping");
|
|
1201
|
-
continue;
|
|
1202
|
-
}
|
|
1203
|
-
try {
|
|
1204
|
-
logger6.info({
|
|
1205
|
-
messageId: message.id,
|
|
1206
|
-
filename: attachment.name,
|
|
1207
|
-
size: attachment.size
|
|
1208
|
-
}, "fetching attachment");
|
|
1209
|
-
const response = await fetch(attachment.url);
|
|
1210
|
-
if (!response.ok) {
|
|
1211
|
-
logger6.warn({
|
|
1212
|
-
messageId: message.id,
|
|
1213
|
-
filename: attachment.name,
|
|
1214
|
-
status: response.status
|
|
1215
|
-
}, "failed to fetch attachment");
|
|
1216
|
-
continue;
|
|
1217
|
-
}
|
|
1218
|
-
const content = await response.text();
|
|
1219
|
-
results.push({ filename: attachment.name, content });
|
|
1220
|
-
} catch (error) {
|
|
1221
|
-
logger6.error({ messageId: message.id, filename: attachment.name, error }, "error fetching attachment");
|
|
1222
|
-
}
|
|
1223
|
-
}
|
|
1224
|
-
return results;
|
|
1225
|
-
}
|
|
1226
|
-
var MEDIA_ATTACHMENT_EXTENSIONS = [
|
|
1227
|
-
".png",
|
|
1228
|
-
".jpg",
|
|
1229
|
-
".jpeg",
|
|
1230
|
-
".gif",
|
|
1231
|
-
".webp",
|
|
1232
|
-
".pdf",
|
|
1233
|
-
".docx",
|
|
1234
|
-
".doc",
|
|
1235
|
-
".pptx",
|
|
1236
|
-
".ppt",
|
|
1237
|
-
".xlsx",
|
|
1238
|
-
".xls"
|
|
1239
|
-
];
|
|
1240
|
-
var MAX_MEDIA_ATTACHMENT_SIZE = 25 * 1024 * 1024;
|
|
1241
|
-
var OFFICE_MIME_TYPES = new Set([
|
|
1242
|
-
"application/pdf",
|
|
1243
|
-
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
|
1244
|
-
"application/msword",
|
|
1245
|
-
"application/vnd.openxmlformats-officedocument.presentationml.presentation",
|
|
1246
|
-
"application/vnd.ms-powerpoint",
|
|
1247
|
-
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
|
1248
|
-
"application/vnd.ms-excel"
|
|
1249
|
-
]);
|
|
1250
|
-
function isMediaAttachment(attachment) {
|
|
1251
|
-
const ext = attachment.name?.slice(attachment.name.lastIndexOf(".")).toLowerCase();
|
|
1252
|
-
if (!ext || !MEDIA_ATTACHMENT_EXTENSIONS.includes(ext)) {
|
|
1253
|
-
return false;
|
|
1254
|
-
}
|
|
1255
|
-
const ct = attachment.contentType;
|
|
1256
|
-
if (!ct) {
|
|
1257
|
-
return false;
|
|
1160
|
+
|
|
1161
|
+
// src/image-description.ts
|
|
1162
|
+
var logger7 = createModuleLogger("image-description");
|
|
1163
|
+
async function describeImage(agentService, imageData, mimeType, userText, visionModel) {
|
|
1164
|
+
const session = await agentService.createTemporarySession();
|
|
1165
|
+
await session.setModel(visionModel);
|
|
1166
|
+
const mediaType = getMediaType(mimeType);
|
|
1167
|
+
const imageContent = {
|
|
1168
|
+
type: "image",
|
|
1169
|
+
data: imageData,
|
|
1170
|
+
mimeType
|
|
1171
|
+
};
|
|
1172
|
+
let promptText;
|
|
1173
|
+
if (mediaType === "document") {
|
|
1174
|
+
promptText = userText.trim().length > 0 ? `The user sent a document with the following message: "${userText}". Please extract and summarize the text content of this document. Be thorough — include all important details, sections, and data from the document.` : "Please extract and summarize the text content of this document. Be thorough — include all important details, sections, data, and key points.";
|
|
1175
|
+
} else {
|
|
1176
|
+
promptText = userText.trim().length > 0 ? `The user sent this image with the following message: "${userText}". Please describe the image in detail and address any questions from the user's message.` : "Please describe this image in detail. What do you see?";
|
|
1258
1177
|
}
|
|
1259
|
-
|
|
1260
|
-
|
|
1261
|
-
|
|
1262
|
-
|
|
1263
|
-
|
|
1264
|
-
|
|
1178
|
+
let text = "";
|
|
1179
|
+
try {
|
|
1180
|
+
await session.prompt(promptText, { images: [imageContent] });
|
|
1181
|
+
text = extractLastAssistantText(session);
|
|
1182
|
+
} catch (error) {
|
|
1183
|
+
logger7.error({ error, mimeType }, "vision model prompt failed");
|
|
1184
|
+
text = "(Vision model failed to process the file.)";
|
|
1185
|
+
} finally {
|
|
1186
|
+
session.dispose();
|
|
1265
1187
|
}
|
|
1266
|
-
|
|
1267
|
-
|
|
1268
|
-
|
|
1188
|
+
if (!text) {
|
|
1189
|
+
return "(Vision model returned no description.)";
|
|
1190
|
+
}
|
|
1191
|
+
logger7.debug({ textLength: text.length, mimeType }, "media described");
|
|
1192
|
+
return text;
|
|
1193
|
+
}
|
|
1194
|
+
function extractLastAssistantText(session) {
|
|
1195
|
+
const messages = session.messages;
|
|
1196
|
+
for (let i = messages.length - 1;i >= 0; i--) {
|
|
1197
|
+
const msg = messages[i];
|
|
1198
|
+
if (!msg || !isAssistantMessage(msg)) {
|
|
1269
1199
|
continue;
|
|
1270
1200
|
}
|
|
1271
|
-
|
|
1272
|
-
|
|
1273
|
-
messageId: message.id,
|
|
1274
|
-
filename: attachment.name,
|
|
1275
|
-
size: attachment.size
|
|
1276
|
-
}, "media attachment too large, skipping");
|
|
1201
|
+
const content = msg.content;
|
|
1202
|
+
if (!Array.isArray(content)) {
|
|
1277
1203
|
continue;
|
|
1278
1204
|
}
|
|
1279
|
-
|
|
1280
|
-
|
|
1281
|
-
|
|
1282
|
-
|
|
1283
|
-
size: attachment.size
|
|
1284
|
-
}, "fetching media attachment");
|
|
1285
|
-
const response = await fetch(attachment.url);
|
|
1286
|
-
if (!response.ok) {
|
|
1287
|
-
logger6.warn({
|
|
1288
|
-
messageId: message.id,
|
|
1289
|
-
filename: attachment.name,
|
|
1290
|
-
status: response.status
|
|
1291
|
-
}, "failed to fetch media attachment");
|
|
1292
|
-
continue;
|
|
1205
|
+
const textBlocks = [];
|
|
1206
|
+
for (const item of content) {
|
|
1207
|
+
if (typeof item === "object" && item !== null && "type" in item && item.type === "text") {
|
|
1208
|
+
textBlocks.push(item.text);
|
|
1293
1209
|
}
|
|
1294
|
-
const buffer = await response.arrayBuffer();
|
|
1295
|
-
const base64 = Buffer.from(buffer).toString("base64");
|
|
1296
|
-
results.push({
|
|
1297
|
-
filename: attachment.name,
|
|
1298
|
-
data: base64,
|
|
1299
|
-
mimeType: attachment.contentType ?? "application/octet-stream"
|
|
1300
|
-
});
|
|
1301
|
-
} catch (error) {
|
|
1302
|
-
logger6.error({ messageId: message.id, filename: attachment.name, error }, "error fetching media attachment");
|
|
1303
1210
|
}
|
|
1211
|
+
return textBlocks.join(`
|
|
1212
|
+
`).trim();
|
|
1304
1213
|
}
|
|
1305
|
-
return
|
|
1214
|
+
return "";
|
|
1215
|
+
}
|
|
1216
|
+
function getMediaType(mimeType) {
|
|
1217
|
+
if (mimeType.startsWith("image/")) {
|
|
1218
|
+
return "image";
|
|
1219
|
+
}
|
|
1220
|
+
return "document";
|
|
1221
|
+
}
|
|
1222
|
+
function isAssistantMessage(msg) {
|
|
1223
|
+
return typeof msg === "object" && msg !== null && "role" in msg && msg.role === "assistant";
|
|
1306
1224
|
}
|
|
1307
|
-
|
|
1308
|
-
|
|
1225
|
+
|
|
1226
|
+
// src/discord-media-resolution.ts
|
|
1227
|
+
var logger8 = createModuleLogger("discord-media-resolution");
|
|
1228
|
+
function parseProviderModelId(value) {
|
|
1229
|
+
const trimmed = value.trim();
|
|
1309
1230
|
if (!trimmed) {
|
|
1310
1231
|
return null;
|
|
1311
1232
|
}
|
|
@@ -1318,7 +1239,7 @@ function parseVisionModelId(visionModelId) {
|
|
|
1318
1239
|
modelId: trimmed.substring(slashIndex + 1)
|
|
1319
1240
|
};
|
|
1320
1241
|
}
|
|
1321
|
-
function
|
|
1242
|
+
function getMediaAttachmentLabel(filename, mimeType) {
|
|
1322
1243
|
if (mimeType === "application/pdf") {
|
|
1323
1244
|
return `[PDF: ${filename}]`;
|
|
1324
1245
|
}
|
|
@@ -1333,131 +1254,234 @@ function getMediaLabel(filename, mimeType) {
|
|
|
1333
1254
|
}
|
|
1334
1255
|
return `[Image: ${filename}]`;
|
|
1335
1256
|
}
|
|
1336
|
-
async function
|
|
1257
|
+
async function resolveMediaAttachmentsForPrompt(mediaAttachments, content, currentModel, config, agentService) {
|
|
1337
1258
|
const modelSupportsVision = currentModel?.input.includes("image") ?? false;
|
|
1338
1259
|
if (modelSupportsVision) {
|
|
1339
|
-
const names =
|
|
1340
|
-
|
|
1341
|
-
count:
|
|
1260
|
+
const names = mediaAttachments.map((media) => media.filename).join(", ");
|
|
1261
|
+
logger8.info({
|
|
1262
|
+
count: mediaAttachments.length,
|
|
1342
1263
|
filenames: names,
|
|
1343
1264
|
model: currentModel ? `${currentModel.provider}/${currentModel.id}` : "none"
|
|
1344
1265
|
}, "passing media natively to vision-capable model");
|
|
1345
|
-
|
|
1346
|
-
|
|
1347
|
-
|
|
1348
|
-
|
|
1349
|
-
|
|
1350
|
-
|
|
1266
|
+
return {
|
|
1267
|
+
content,
|
|
1268
|
+
images: mediaAttachments.map((media) => ({
|
|
1269
|
+
type: "image",
|
|
1270
|
+
data: media.data,
|
|
1271
|
+
mimeType: media.mimeType
|
|
1272
|
+
}))
|
|
1273
|
+
};
|
|
1351
1274
|
}
|
|
1352
1275
|
if (!config.visionModelId) {
|
|
1353
|
-
const names =
|
|
1354
|
-
|
|
1276
|
+
const names = mediaAttachments.map((media) => media.filename).join(", ");
|
|
1277
|
+
logger8.info({ filenames: names }, "media attachments received but vision model not configured");
|
|
1355
1278
|
const note = `
|
|
1356
1279
|
|
|
1357
1280
|
[User sent media attachment(s): ${names}]
|
|
1358
1281
|
` + "(Media vision not configured. Set visionModelId to enable image/PDF/document understanding.)";
|
|
1359
|
-
return {
|
|
1282
|
+
return {
|
|
1283
|
+
content: content ? content + note : note,
|
|
1284
|
+
images: []
|
|
1285
|
+
};
|
|
1360
1286
|
}
|
|
1361
|
-
const
|
|
1362
|
-
if (!
|
|
1287
|
+
const parsedVisionModelId = parseProviderModelId(config.visionModelId);
|
|
1288
|
+
if (!parsedVisionModelId) {
|
|
1363
1289
|
return { content, images: [] };
|
|
1364
1290
|
}
|
|
1365
|
-
const visionModel = agentService.findModel(
|
|
1291
|
+
const visionModel = agentService.findModel(parsedVisionModelId.provider, parsedVisionModelId.modelId);
|
|
1366
1292
|
if (!visionModel) {
|
|
1367
|
-
|
|
1368
|
-
const names =
|
|
1293
|
+
logger8.warn({ visionModelId: config.visionModelId }, "vision model not found in registry");
|
|
1294
|
+
const names = mediaAttachments.map((media) => media.filename).join(", ");
|
|
1369
1295
|
const note = `
|
|
1370
1296
|
|
|
1371
1297
|
[User sent media attachment(s): ${names}]
|
|
1372
|
-
(Vision model not found: ${config.visionModelId})`;
|
|
1373
|
-
return {
|
|
1298
|
+
` + `(Vision model not found: ${config.visionModelId})`;
|
|
1299
|
+
return {
|
|
1300
|
+
content: content ? content + note : note,
|
|
1301
|
+
images: []
|
|
1302
|
+
};
|
|
1374
1303
|
}
|
|
1375
|
-
|
|
1376
|
-
count:
|
|
1304
|
+
logger8.info({
|
|
1305
|
+
count: mediaAttachments.length,
|
|
1377
1306
|
visionModel: `${visionModel.provider}/${visionModel.id}`
|
|
1378
1307
|
}, "describing media with vision model");
|
|
1379
1308
|
const descriptions = [];
|
|
1380
|
-
for (const
|
|
1381
|
-
const description = await describeImage(agentService,
|
|
1382
|
-
const label =
|
|
1309
|
+
for (const media of mediaAttachments) {
|
|
1310
|
+
const description = await describeImage(agentService, media.data, media.mimeType, content, visionModel);
|
|
1311
|
+
const label = getMediaAttachmentLabel(media.filename, media.mimeType);
|
|
1383
1312
|
descriptions.push(`${label}
|
|
1384
1313
|
${description}`);
|
|
1385
1314
|
}
|
|
1386
|
-
if (descriptions.length
|
|
1387
|
-
|
|
1315
|
+
if (descriptions.length === 0) {
|
|
1316
|
+
return { content, images: [] };
|
|
1317
|
+
}
|
|
1318
|
+
const descriptionPrefix = descriptions.join(`
|
|
1388
1319
|
|
|
1389
1320
|
`);
|
|
1390
|
-
|
|
1391
|
-
|
|
1321
|
+
return {
|
|
1322
|
+
content: content ? `${descriptionPrefix}
|
|
1392
1323
|
|
|
1393
1324
|
---
|
|
1394
|
-
${content}` :
|
|
1395
|
-
|
|
1396
|
-
|
|
1397
|
-
}
|
|
1398
|
-
return { content, images: [] };
|
|
1325
|
+
${content}` : descriptionPrefix,
|
|
1326
|
+
images: []
|
|
1327
|
+
};
|
|
1399
1328
|
}
|
|
1400
|
-
|
|
1401
|
-
|
|
1402
|
-
|
|
1403
|
-
|
|
1404
|
-
|
|
1405
|
-
|
|
1406
|
-
|
|
1407
|
-
|
|
1408
|
-
|
|
1409
|
-
|
|
1410
|
-
|
|
1411
|
-
|
|
1412
|
-
|
|
1329
|
+
|
|
1330
|
+
// src/discord-typing.ts
|
|
1331
|
+
var logger9 = createModuleLogger("discord-typing");
|
|
1332
|
+
var TYPING_INTERVAL_MS = 9000;
|
|
1333
|
+
var typingIntervals = new Map;
|
|
1334
|
+
async function sendTypingSafe(channel, channelKey) {
|
|
1335
|
+
try {
|
|
1336
|
+
const token = channel.client.token;
|
|
1337
|
+
const url = `https://discord.com/api/v10/channels/${channel.id}/typing`;
|
|
1338
|
+
const response = await fetch(url, {
|
|
1339
|
+
method: "POST",
|
|
1340
|
+
headers: { Authorization: `Bot ${token}` }
|
|
1341
|
+
});
|
|
1342
|
+
if (response.ok) {
|
|
1343
|
+
logger9.debug("[TYPING] STATUS UPDATED OK");
|
|
1413
1344
|
return;
|
|
1414
1345
|
}
|
|
1415
|
-
|
|
1416
|
-
const
|
|
1417
|
-
|
|
1418
|
-
|
|
1419
|
-
|
|
1420
|
-
|
|
1421
|
-
|
|
1422
|
-
|
|
1423
|
-
|
|
1424
|
-
|
|
1425
|
-
|
|
1426
|
-
|
|
1427
|
-
|
|
1428
|
-
|
|
1429
|
-
|
|
1430
|
-
|
|
1431
|
-
|
|
1346
|
+
if (response.status === 429) {
|
|
1347
|
+
const body = await response.text();
|
|
1348
|
+
let retryMs = 3000;
|
|
1349
|
+
try {
|
|
1350
|
+
const parsed = JSON.parse(body);
|
|
1351
|
+
if (typeof parsed.retry_after === "number") {
|
|
1352
|
+
retryMs = parsed.retry_after * 1000 + 500;
|
|
1353
|
+
}
|
|
1354
|
+
} catch {}
|
|
1355
|
+
logger9.warn({ channelKey, retryMs, response: body }, `[TYPING] 429, retrying after ${retryMs}ms delay`);
|
|
1356
|
+
await new Promise((resolve) => setTimeout(resolve, retryMs));
|
|
1357
|
+
await fetch(url, {
|
|
1358
|
+
method: "POST",
|
|
1359
|
+
headers: { Authorization: `Bot ${token}` }
|
|
1360
|
+
});
|
|
1361
|
+
logger9.info({ channelKey }, "[TYPING] retry done");
|
|
1362
|
+
return;
|
|
1432
1363
|
}
|
|
1364
|
+
logger9.warn({ channelKey, status: response.status }, "[TYPING] unexpected status");
|
|
1365
|
+
} catch (error) {
|
|
1366
|
+
logger9.warn({ channelKey, error }, "[TYPING] FAILED");
|
|
1367
|
+
}
|
|
1368
|
+
}
|
|
1369
|
+
function startTypingForChannel(channel, channelKey) {
|
|
1370
|
+
const existing = typingIntervals.get(channelKey);
|
|
1371
|
+
if (existing) {
|
|
1372
|
+
existing.refs += 1;
|
|
1373
|
+
logger9.debug({ channelKey, refs: existing.refs }, "[TYPING] ref++ (reusing existing interval)");
|
|
1374
|
+
return;
|
|
1375
|
+
}
|
|
1376
|
+
logger9.debug("[TYPING] started new interval");
|
|
1377
|
+
sendTypingSafe(channel, channelKey);
|
|
1378
|
+
const interval = setInterval(() => {
|
|
1379
|
+
sendTypingSafe(channel, channelKey);
|
|
1380
|
+
}, TYPING_INTERVAL_MS);
|
|
1381
|
+
typingIntervals.set(channelKey, { interval, refs: 1 });
|
|
1382
|
+
}
|
|
1383
|
+
function stopTypingForChannel(channelKey) {
|
|
1384
|
+
const entry = typingIntervals.get(channelKey);
|
|
1385
|
+
if (!entry) {
|
|
1386
|
+
logger9.debug({ channelKey }, "[TYPING] stop called but no entry found");
|
|
1387
|
+
return;
|
|
1388
|
+
}
|
|
1389
|
+
entry.refs -= 1;
|
|
1390
|
+
if (entry.refs <= 0) {
|
|
1391
|
+
clearInterval(entry.interval);
|
|
1392
|
+
typingIntervals.delete(channelKey);
|
|
1393
|
+
logger9.debug("[TYPING] interval cleared (refs hit 0)");
|
|
1394
|
+
return;
|
|
1395
|
+
}
|
|
1396
|
+
logger9.debug("[TYPING] ref-- (interval still active)");
|
|
1397
|
+
}
|
|
1398
|
+
|
|
1399
|
+
// src/prompt-context.ts
|
|
1400
|
+
function buildDiscordMessageContextPrompt(userMessage, options) {
|
|
1401
|
+
const contextEntries = [
|
|
1402
|
+
["scope", options.scope],
|
|
1403
|
+
["sent_at", options.sentAt],
|
|
1404
|
+
["sent_at_local", options.sentAtLocal],
|
|
1405
|
+
["message_id", options.messageId],
|
|
1406
|
+
["author_name", normalizeContextValue(options.authorName)],
|
|
1407
|
+
["author_id", options.authorId],
|
|
1408
|
+
["thread_title", normalizeContextValue(options.threadTitle)],
|
|
1409
|
+
["thread_id", options.threadId],
|
|
1410
|
+
["forum_channel_id", options.forumChannelId ?? undefined]
|
|
1411
|
+
].filter((entry) => {
|
|
1412
|
+
return typeof entry[1] === "string" && entry[1].trim().length > 0;
|
|
1433
1413
|
});
|
|
1434
|
-
|
|
1435
|
-
|
|
1436
|
-
|
|
1437
|
-
|
|
1414
|
+
const contextJson = JSON.stringify(Object.fromEntries(contextEntries), null, 2);
|
|
1415
|
+
return [
|
|
1416
|
+
"<discord_message_context>",
|
|
1417
|
+
contextJson,
|
|
1418
|
+
"</discord_message_context>",
|
|
1419
|
+
"",
|
|
1420
|
+
"User message:",
|
|
1421
|
+
userMessage.trim()
|
|
1422
|
+
].join(`
|
|
1423
|
+
`);
|
|
1424
|
+
}
|
|
1425
|
+
function formatDiscordPromptTime(date, options = {}) {
|
|
1426
|
+
const timeZone = options.timeZone || "UTC";
|
|
1427
|
+
const locale = options.locale || "en-AU";
|
|
1428
|
+
return new Intl.DateTimeFormat(locale, {
|
|
1429
|
+
timeZone,
|
|
1430
|
+
weekday: "short",
|
|
1431
|
+
day: "numeric",
|
|
1432
|
+
month: "short",
|
|
1433
|
+
year: "2-digit",
|
|
1434
|
+
hour: "2-digit",
|
|
1435
|
+
minute: "2-digit",
|
|
1436
|
+
hour12: false,
|
|
1437
|
+
timeZoneName: "short"
|
|
1438
|
+
}).format(date);
|
|
1439
|
+
}
|
|
1440
|
+
function normalizeContextValue(value) {
|
|
1441
|
+
if (value === undefined) {
|
|
1442
|
+
return;
|
|
1443
|
+
}
|
|
1444
|
+
return value.replace(/\s+/g, " ").trim();
|
|
1445
|
+
}
|
|
1446
|
+
|
|
1447
|
+
// src/discord-message-handler.ts
|
|
1448
|
+
var logger10 = createModuleLogger("discord-message-handler");
|
|
1449
|
+
function buildDiscordPromptContent(message, scope, content, config) {
|
|
1450
|
+
const isThread = scope.startsWith("thread:") && message.channel.isThread();
|
|
1451
|
+
return buildDiscordMessageContextPrompt(content, {
|
|
1452
|
+
scope: scope === "dm" ? "dm" : "thread",
|
|
1453
|
+
sentAt: message.createdAt.toISOString(),
|
|
1454
|
+
sentAtLocal: formatDiscordPromptTime(message.createdAt, {
|
|
1455
|
+
timeZone: config.promptTimeZone,
|
|
1456
|
+
locale: config.promptLocale
|
|
1457
|
+
}),
|
|
1458
|
+
messageId: message.id,
|
|
1459
|
+
authorId: message.author.id,
|
|
1460
|
+
authorName: getAuthorDisplayName(message),
|
|
1461
|
+
threadId: isThread ? message.channel.id : undefined,
|
|
1462
|
+
threadTitle: isThread ? message.channel.name : undefined,
|
|
1463
|
+
forumChannelId: isThread ? message.channel.parentId : undefined
|
|
1438
1464
|
});
|
|
1439
|
-
await client.login(config.discordBotToken);
|
|
1440
|
-
return client;
|
|
1441
1465
|
}
|
|
1442
|
-
async function
|
|
1466
|
+
async function handleDiscordMessage(message, config, agentService, sessionRegistry, authConfig) {
|
|
1443
1467
|
if (message.author.bot) {
|
|
1444
|
-
|
|
1468
|
+
logger10.debug("ignored bot message");
|
|
1445
1469
|
return;
|
|
1446
1470
|
}
|
|
1447
1471
|
if (message.system) {
|
|
1448
|
-
|
|
1472
|
+
logger10.debug({ messageId: message.id }, "ignored system message");
|
|
1449
1473
|
return;
|
|
1450
1474
|
}
|
|
1451
|
-
const scope =
|
|
1475
|
+
const scope = resolveMessageScope(message);
|
|
1452
1476
|
if (scope === null) {
|
|
1453
|
-
|
|
1477
|
+
logger10.debug({
|
|
1454
1478
|
messageId: message.id,
|
|
1455
1479
|
channelType: message.channel.type
|
|
1456
1480
|
}, "unsupported channel type, ignoring");
|
|
1457
1481
|
return;
|
|
1458
1482
|
}
|
|
1459
|
-
if (!
|
|
1460
|
-
|
|
1483
|
+
if (!isAuthorizedMessage(message, scope, authConfig)) {
|
|
1484
|
+
logger10.debug({
|
|
1461
1485
|
messageId: message.id,
|
|
1462
1486
|
authorId: message.author.id,
|
|
1463
1487
|
scope
|
|
@@ -1465,20 +1489,22 @@ async function onMessage(message, config, agentService, sessionRegistry, authCon
|
|
|
1465
1489
|
return;
|
|
1466
1490
|
}
|
|
1467
1491
|
let content = message.content.trim();
|
|
1468
|
-
const
|
|
1469
|
-
if (
|
|
1470
|
-
const
|
|
1492
|
+
const textAttachments = await readTextAttachments(message);
|
|
1493
|
+
if (textAttachments.length > 0) {
|
|
1494
|
+
const attachmentSuffix = textAttachments.map((attachment) => {
|
|
1495
|
+
return `
|
|
1471
1496
|
|
|
1472
|
-
--- Attachment: ${
|
|
1473
|
-
${
|
|
1474
|
-
|
|
1497
|
+
--- Attachment: ${attachment.filename} ---
|
|
1498
|
+
${attachment.content}`;
|
|
1499
|
+
}).join("");
|
|
1500
|
+
content = content ? content + attachmentSuffix : textAttachments[0].content;
|
|
1475
1501
|
}
|
|
1476
1502
|
const mediaAttachments = await readMediaAttachments(message);
|
|
1477
1503
|
if (!content && mediaAttachments.length === 0) {
|
|
1478
|
-
|
|
1504
|
+
logger10.debug({ messageId: message.id }, "ignored empty message (no text or images)");
|
|
1479
1505
|
return;
|
|
1480
1506
|
}
|
|
1481
|
-
|
|
1507
|
+
logger10.info({
|
|
1482
1508
|
direction: "IN",
|
|
1483
1509
|
scope,
|
|
1484
1510
|
messageId: message.id,
|
|
@@ -1487,14 +1513,13 @@ ${a.content}`).join("");
|
|
|
1487
1513
|
content
|
|
1488
1514
|
}, "message received");
|
|
1489
1515
|
const channelKey = message.channel.id;
|
|
1490
|
-
|
|
1491
|
-
if (canSend) {
|
|
1516
|
+
if (message.channel.isSendable()) {
|
|
1492
1517
|
startTypingForChannel(message.channel, channelKey);
|
|
1493
1518
|
}
|
|
1494
1519
|
const { entry, created } = await sessionRegistry.getOrCreate(scope);
|
|
1495
1520
|
const { session, promptQueue } = entry;
|
|
1496
1521
|
if (created && scope.startsWith("thread:") && message.channel.isThread()) {
|
|
1497
|
-
|
|
1522
|
+
logger10.info({
|
|
1498
1523
|
scope,
|
|
1499
1524
|
threadName: message.channel.name
|
|
1500
1525
|
}, "new thread session");
|
|
@@ -1507,7 +1532,7 @@ ${a.content}`).join("");
|
|
|
1507
1532
|
if (commandResult.handled) {
|
|
1508
1533
|
stopTypingForChannel(channelKey);
|
|
1509
1534
|
if (commandResult.archive && scope.startsWith("thread:")) {
|
|
1510
|
-
|
|
1535
|
+
logger10.info({ scope }, "archiving thread");
|
|
1511
1536
|
const archiveChannel = message.channel;
|
|
1512
1537
|
if (archiveChannel.isSendable()) {
|
|
1513
1538
|
await archiveChannel.send(commandResult.response ?? "Archiving...");
|
|
@@ -1517,12 +1542,12 @@ ${a.content}`).join("");
|
|
|
1517
1542
|
await archiveChannel.setArchived(true);
|
|
1518
1543
|
}
|
|
1519
1544
|
} catch (error) {
|
|
1520
|
-
|
|
1545
|
+
logger10.error({ error }, "failed to archive thread");
|
|
1521
1546
|
}
|
|
1522
1547
|
await sessionRegistry.remove(scope);
|
|
1523
1548
|
return;
|
|
1524
1549
|
}
|
|
1525
|
-
|
|
1550
|
+
logger10.info({
|
|
1526
1551
|
messageId: message.id,
|
|
1527
1552
|
command: content,
|
|
1528
1553
|
hasResponse: Boolean(commandResult.response)
|
|
@@ -1534,7 +1559,7 @@ ${a.content}`).join("");
|
|
|
1534
1559
|
}
|
|
1535
1560
|
if (!message.channel.isSendable()) {
|
|
1536
1561
|
stopTypingForChannel(channelKey);
|
|
1537
|
-
|
|
1562
|
+
logger10.debug({ messageId: message.id }, "channel not sendable");
|
|
1538
1563
|
return;
|
|
1539
1564
|
}
|
|
1540
1565
|
await addWorkingReaction(message);
|
|
@@ -1548,10 +1573,10 @@ ${a.content}`).join("");
|
|
|
1548
1573
|
let promptContent = content;
|
|
1549
1574
|
let promptImages;
|
|
1550
1575
|
if (mediaAttachments.length > 0) {
|
|
1551
|
-
const
|
|
1552
|
-
promptContent =
|
|
1553
|
-
if (
|
|
1554
|
-
promptImages =
|
|
1576
|
+
const resolvedPromptMedia = await resolveMediaAttachmentsForPrompt(mediaAttachments, promptContent, session.model, config, agentService);
|
|
1577
|
+
promptContent = resolvedPromptMedia.content;
|
|
1578
|
+
if (resolvedPromptMedia.images.length > 0) {
|
|
1579
|
+
promptImages = resolvedPromptMedia.images;
|
|
1555
1580
|
}
|
|
1556
1581
|
}
|
|
1557
1582
|
const wrappedContent = buildDiscordPromptContent(message, scope, promptContent, config);
|
|
@@ -1568,6 +1593,51 @@ ${a.content}`).join("");
|
|
|
1568
1593
|
await sendReply(message, response);
|
|
1569
1594
|
}
|
|
1570
1595
|
|
|
1596
|
+
// src/discord-gateway-client.ts
|
|
1597
|
+
var logger11 = createModuleLogger("discord-gateway");
|
|
1598
|
+
async function startGatewayClient(config, agentService, sessionRegistry, authConfig) {
|
|
1599
|
+
const client = new Client({
|
|
1600
|
+
intents: [
|
|
1601
|
+
GatewayIntentBits.DirectMessages,
|
|
1602
|
+
GatewayIntentBits.Guilds,
|
|
1603
|
+
GatewayIntentBits.GuildMessages,
|
|
1604
|
+
GatewayIntentBits.MessageContent
|
|
1605
|
+
],
|
|
1606
|
+
partials: [Partials.Channel]
|
|
1607
|
+
});
|
|
1608
|
+
client.once(Events.ClientReady, async (readyClient) => {
|
|
1609
|
+
logger11.info({ userTag: readyClient.user.tag }, "logged in");
|
|
1610
|
+
if (!authConfig.startupMessage) {
|
|
1611
|
+
return;
|
|
1612
|
+
}
|
|
1613
|
+
try {
|
|
1614
|
+
const user = await readyClient.users.fetch(authConfig.discordAllowedUserId);
|
|
1615
|
+
const dmChannel = await user.createDM();
|
|
1616
|
+
await dmChannel.send(authConfig.startupMessage);
|
|
1617
|
+
logger11.info({
|
|
1618
|
+
userId: authConfig.discordAllowedUserId
|
|
1619
|
+
}, "sent startup dm");
|
|
1620
|
+
} catch (error) {
|
|
1621
|
+
logger11.error({ error }, "failed to send startup dm");
|
|
1622
|
+
}
|
|
1623
|
+
});
|
|
1624
|
+
client.on(Events.MessageCreate, async (message) => {
|
|
1625
|
+
try {
|
|
1626
|
+
await handleDiscordMessage(message, config, agentService, sessionRegistry, authConfig);
|
|
1627
|
+
} catch (error) {
|
|
1628
|
+
logger11.error({ error, direction: "IN" }, "message handling failed");
|
|
1629
|
+
await sendReply(message, "The bot hit an error while handling that message.");
|
|
1630
|
+
}
|
|
1631
|
+
});
|
|
1632
|
+
client.on(Events.ThreadDelete, async (thread) => {
|
|
1633
|
+
const scope = `thread:${thread.id}`;
|
|
1634
|
+
logger11.info({ threadId: thread.id, scope }, "thread deleted");
|
|
1635
|
+
await sessionRegistry.remove(scope);
|
|
1636
|
+
});
|
|
1637
|
+
await client.login(config.discordBotToken);
|
|
1638
|
+
return client;
|
|
1639
|
+
}
|
|
1640
|
+
|
|
1571
1641
|
// src/session-registry.ts
|
|
1572
1642
|
import path3 from "node:path";
|
|
1573
1643
|
|
|
@@ -1620,7 +1690,7 @@ function sessionDirForScope(agentDir, scope) {
|
|
|
1620
1690
|
}
|
|
1621
1691
|
throw new Error(`Unknown session scope: ${scope}`);
|
|
1622
1692
|
}
|
|
1623
|
-
var
|
|
1693
|
+
var logger12 = createModuleLogger("session-registry");
|
|
1624
1694
|
|
|
1625
1695
|
class SessionRegistry {
|
|
1626
1696
|
scopes = new Map;
|
|
@@ -1642,7 +1712,7 @@ class SessionRegistry {
|
|
|
1642
1712
|
createdAt: new Date
|
|
1643
1713
|
};
|
|
1644
1714
|
this.scopes.set(scope, entry);
|
|
1645
|
-
|
|
1715
|
+
logger12.debug({
|
|
1646
1716
|
scope,
|
|
1647
1717
|
sessionDir,
|
|
1648
1718
|
sessionId: session.sessionId
|
|
@@ -1654,7 +1724,7 @@ class SessionRegistry {
|
|
|
1654
1724
|
if (!entry) {
|
|
1655
1725
|
return;
|
|
1656
1726
|
}
|
|
1657
|
-
|
|
1727
|
+
logger12.debug({ scope }, "removing scope");
|
|
1658
1728
|
await entry.session.abort();
|
|
1659
1729
|
entry.session.dispose();
|
|
1660
1730
|
this.scopes.delete(scope);
|
|
@@ -1666,7 +1736,7 @@ class SessionRegistry {
|
|
|
1666
1736
|
return Array.from(this.scopes.keys());
|
|
1667
1737
|
}
|
|
1668
1738
|
async shutdownAll() {
|
|
1669
|
-
|
|
1739
|
+
logger12.info({ count: this.scopes.size }, "shutting down all scopes");
|
|
1670
1740
|
const scopes = Array.from(this.scopes.keys());
|
|
1671
1741
|
for (const scope of scopes) {
|
|
1672
1742
|
await this.remove(scope);
|
|
@@ -1675,13 +1745,13 @@ class SessionRegistry {
|
|
|
1675
1745
|
}
|
|
1676
1746
|
|
|
1677
1747
|
// src/index.ts
|
|
1678
|
-
var
|
|
1748
|
+
var logger13 = createModuleLogger("index");
|
|
1679
1749
|
async function startDiscordGateway(config) {
|
|
1680
|
-
const resolvedConfig =
|
|
1750
|
+
const resolvedConfig = resolveConfig(config);
|
|
1681
1751
|
const agentService = new AgentService(resolvedConfig);
|
|
1682
|
-
|
|
1752
|
+
logger13.info("initializing agent service");
|
|
1683
1753
|
await agentService.initialize();
|
|
1684
|
-
|
|
1754
|
+
logger13.info(agentService.getStatus(), "agent ready");
|
|
1685
1755
|
const authConfig = {
|
|
1686
1756
|
discordAllowedUserId: resolvedConfig.discordAllowedUserId,
|
|
1687
1757
|
discordAllowedForumChannelIds: resolvedConfig.discordAllowedForumChannelIds,
|
|
@@ -1702,9 +1772,6 @@ async function startDiscordGateway(config) {
|
|
|
1702
1772
|
}
|
|
1703
1773
|
};
|
|
1704
1774
|
}
|
|
1705
|
-
async function startDiscordPiBridge(config) {
|
|
1706
|
-
return startDiscordGateway(config);
|
|
1707
|
-
}
|
|
1708
1775
|
function createGatewayStopHandler(client, agentService, sessionRegistry, config) {
|
|
1709
1776
|
let stopped = false;
|
|
1710
1777
|
return async () => {
|
|
@@ -1712,7 +1779,7 @@ function createGatewayStopHandler(client, agentService, sessionRegistry, config)
|
|
|
1712
1779
|
return;
|
|
1713
1780
|
}
|
|
1714
1781
|
stopped = true;
|
|
1715
|
-
|
|
1782
|
+
logger13.info({
|
|
1716
1783
|
cwd: config.cwd,
|
|
1717
1784
|
agentDir: config.agentDir
|
|
1718
1785
|
}, "stopping discord gateway");
|
|
@@ -1723,9 +1790,9 @@ function createGatewayStopHandler(client, agentService, sessionRegistry, config)
|
|
|
1723
1790
|
}
|
|
1724
1791
|
function registerSignalHandlers(stop) {
|
|
1725
1792
|
const handleSignal = (signal) => {
|
|
1726
|
-
|
|
1793
|
+
logger13.info({ signal }, "received signal");
|
|
1727
1794
|
stop().finally(() => {
|
|
1728
|
-
|
|
1795
|
+
logger13.info("done");
|
|
1729
1796
|
process.exit(0);
|
|
1730
1797
|
});
|
|
1731
1798
|
};
|
|
@@ -1737,10 +1804,8 @@ function registerSignalHandlers(stop) {
|
|
|
1737
1804
|
});
|
|
1738
1805
|
}
|
|
1739
1806
|
export {
|
|
1740
|
-
startDiscordPiBridge,
|
|
1741
1807
|
startDiscordGateway,
|
|
1742
1808
|
resolveConfig,
|
|
1743
|
-
loadDiscordPiBridgeConfigFromEnv,
|
|
1744
1809
|
loadDiscordGatewayConfigFromEnv,
|
|
1745
1810
|
formatDiscordPromptTime,
|
|
1746
1811
|
buildDiscordMessageContextPrompt
|