953 lines
31 KiB
TypeScript
953 lines
31 KiB
TypeScript
import { resolve } from "node:path";
|
|
import { readFile, writeFile } from "node:fs/promises";
|
|
|
|
import {
|
|
Client,
|
|
Events,
|
|
GatewayIntentBits,
|
|
type Message,
|
|
} from "discord.js";
|
|
|
|
import { loadConfig } from "@voidbot/config";
|
|
import {
|
|
buildVoidMcpServerConfig,
|
|
ContextBuilder,
|
|
OllamaSituationalSocialReadInferer,
|
|
PermissionEngine,
|
|
createStateStorage,
|
|
ensureRepoFaceInitialized,
|
|
faceRegistryAsRepoDiscordRegistry,
|
|
findRepoDiscordIdentityByPersonaName,
|
|
findRepoDiscordIdentityByRoleIds,
|
|
findRepoDiscordIdentitiesByTextMentions,
|
|
findRepoDiscordIdentityByTextAddress,
|
|
isRepoDiscordIdentityAllowedInChannel,
|
|
loadFaceIdentityRegistry,
|
|
queueAgentHeartbeatMention,
|
|
queueRepoFaceMention,
|
|
type RepoDiscordIdentity,
|
|
stripRepoIdentityTextAddress,
|
|
loadStylePack,
|
|
loadSystemMessageCatalog,
|
|
VoidUsageRateLimiter,
|
|
} from "@voidbot/core";
|
|
import {
|
|
LocalLlmProvider,
|
|
OpenAiApiProvider,
|
|
OwnerCodexProvider,
|
|
ProviderRegistry,
|
|
} from "@voidbot/providers";
|
|
import {
|
|
createTextEmbedder,
|
|
createVectorStores,
|
|
FileMessageArchiveRepository,
|
|
FileSourceDocumentArchiveRepository,
|
|
HistoryIngester,
|
|
RagPipeline,
|
|
RetrievalService,
|
|
SourceDocumentIngester,
|
|
} from "@voidbot/rag";
|
|
import { shouldIndexChannel } from "@voidbot/shared";
|
|
|
|
import {
|
|
handleApproveJob,
|
|
handleProfile,
|
|
handlePrompt,
|
|
handleRejectJob,
|
|
handleReindexChannel,
|
|
handleSearchHistory,
|
|
handleSummarizeChannel,
|
|
maybeRegisterCommands,
|
|
parseProviderOverride,
|
|
replyEphemeral,
|
|
} from "./discord-bot-handlers";
|
|
import {
|
|
buildActorFromInteraction,
|
|
buildChannelIndexingTarget,
|
|
buildGuildContextFromInteraction,
|
|
filterPromptEchoHistoryResults,
|
|
formatArchivedMessageContext,
|
|
formatHistoryResults,
|
|
formatProviderStatuses,
|
|
formatSourceResults,
|
|
getRecentMessages,
|
|
getRoleIdsFromInteraction,
|
|
ingestIfIndexed,
|
|
materializeMessage,
|
|
notifyOwnerOfBotIssue,
|
|
rememberAmbientVoidReference,
|
|
renderSystemMessage,
|
|
searchHistoryWithArchiveFallback,
|
|
stripBotMention,
|
|
} from "./discord-bot-support";
|
|
|
|
export async function startBot(): Promise<void> {
|
|
const config = loadConfig();
|
|
|
|
if (!config.botToken) {
|
|
throw new Error("DISCORD_BOT_TOKEN is required to start the bot.");
|
|
}
|
|
|
|
const client = new Client({
|
|
intents: [
|
|
GatewayIntentBits.Guilds,
|
|
GatewayIntentBits.GuildMessages,
|
|
GatewayIntentBits.MessageContent,
|
|
],
|
|
});
|
|
let repoDiscordIdentities = faceRegistryAsRepoDiscordRegistry(
|
|
await loadFaceIdentityRegistry(config.repoDiscordIdentitiesPath),
|
|
);
|
|
repoDiscordIdentities = await ensureRepoIdentityRoles({
|
|
botToken: config.botToken,
|
|
guildId: config.developmentGuildId,
|
|
registryPath: config.repoDiscordIdentitiesPath,
|
|
identities: repoDiscordIdentities.identities,
|
|
});
|
|
const botDirectedRoleIds = [
|
|
...new Set([
|
|
...config.botTriggerRoleIds,
|
|
...repoDiscordIdentities.identities
|
|
.map((identity) => identity.roleId)
|
|
.filter((roleId): roleId is string => typeof roleId === "string" && roleId.length > 0),
|
|
]),
|
|
];
|
|
|
|
const permissionEngine = new PermissionEngine(config.ownerDiscordId, {
|
|
localLlmAllowPublic: config.localLlm.allowPublic,
|
|
});
|
|
const {
|
|
jobQueue,
|
|
auditLog,
|
|
interactionMemory,
|
|
voidUsageRateLimits,
|
|
} = await createStateStorage({
|
|
backend: config.stateStorageBackend,
|
|
databaseDsn: config.databaseDsn,
|
|
jobsFile: config.jobsFile,
|
|
auditLogFile: config.auditLogFile,
|
|
interactionMemoryFile: config.interactionMemoryFile,
|
|
rateLimitStateFile: config.rateLimitStateFile,
|
|
});
|
|
const voidUsageRateLimiter = new VoidUsageRateLimiter(
|
|
voidUsageRateLimits,
|
|
config.rateLimits,
|
|
);
|
|
const contextBuilder = new ContextBuilder();
|
|
const situationalSocialReadInferer = new OllamaSituationalSocialReadInferer({
|
|
ollamaBaseUrl: config.localLlm.ollamaBaseUrl,
|
|
ollamaModel: config.localLlm.socialReadOllamaModel,
|
|
ollamaTimeoutMs: config.localLlm.ollamaTimeoutMs,
|
|
ollamaKeepAlive: config.localLlm.ollamaKeepAlive,
|
|
ollamaNumCtx: config.localLlm.ollamaNumCtx,
|
|
});
|
|
const baseSystemMessagesPath = resolve("config/system-messages.json");
|
|
let activeStylePack = await loadStylePack(config.stylePackPath);
|
|
let activeSystemMessages = await loadSystemMessageCatalog(
|
|
config.systemMessagesPath,
|
|
baseSystemMessagesPath,
|
|
);
|
|
const archiveRepository = new FileMessageArchiveRepository(config.ragArchivePath);
|
|
const sourceArchiveRepository = new FileSourceDocumentArchiveRepository(
|
|
config.ragSourceArchivePath,
|
|
);
|
|
const embedder = createTextEmbedder({
|
|
backend: config.ragEmbeddingBackend,
|
|
hashDimensions: config.ragEmbeddingDimensions,
|
|
ollamaBaseUrl: config.ragOllamaBaseUrl,
|
|
ollamaModel: config.ragOllamaModel,
|
|
ollamaTimeoutMs: config.ragOllamaTimeoutMs,
|
|
queryInstruction: config.ragQueryInstruction,
|
|
});
|
|
const sourceQueryEmbedder = createTextEmbedder({
|
|
backend: config.ragEmbeddingBackend,
|
|
hashDimensions: config.ragEmbeddingDimensions,
|
|
ollamaBaseUrl: config.ragOllamaBaseUrl,
|
|
ollamaModel: config.ragOllamaModel,
|
|
ollamaTimeoutMs: config.ragOllamaTimeoutMs,
|
|
queryInstruction: config.ragSourceQueryInstruction,
|
|
});
|
|
const vectorStores = createVectorStores({
|
|
kind: config.vectorStore.kind,
|
|
historyPath: config.vectorStore.path,
|
|
sourceRoot: config.sourceVectorStoreRoot,
|
|
qdrant: config.qdrant,
|
|
historyEmbedder: embedder,
|
|
sourceEmbedder: sourceQueryEmbedder,
|
|
});
|
|
const vectorStore = vectorStores.history;
|
|
const sourceRetrievalStore = vectorStores.source;
|
|
const retrievalService = new RetrievalService(vectorStore, sourceRetrievalStore);
|
|
const historyIngester = new HistoryIngester();
|
|
const sourceDocumentIngester = new SourceDocumentIngester();
|
|
const ragPipeline = new RagPipeline(archiveRepository, historyIngester, vectorStore);
|
|
const providerRegistry = new ProviderRegistry([
|
|
new OwnerCodexProvider({
|
|
ownerDiscordId: config.ownerDiscordId,
|
|
enabled: config.enabledProviders.includes("owner_codex"),
|
|
mode: config.ownerCodexMode,
|
|
executable: config.codexExecutable,
|
|
executableArgs: config.codexExecArgs,
|
|
model: config.codexModel,
|
|
reasoningEffort: config.codexModelReasoningEffort,
|
|
timeoutMs: config.codexExecTimeoutMs,
|
|
workingDirectory: process.cwd(),
|
|
historyLookup: retrievalService,
|
|
handoffNoticeBuilder: (jobId) =>
|
|
renderSystemMessage(activeSystemMessages, "owner_codex.handoff_notice", {
|
|
handoffPath: `.voidbot/artifacts/${jobId}/handoff.md`,
|
|
debugPath: `.voidbot/artifacts/${jobId}/debug-trace.md`,
|
|
}),
|
|
mcpServers: [buildVoidMcpServerConfig(process.cwd())],
|
|
}),
|
|
new OpenAiApiProvider(config.enabledProviders.includes("openai_api")),
|
|
new LocalLlmProvider({
|
|
enabled: config.enabledProviders.includes("local_llm"),
|
|
ownerDiscordId: config.ownerDiscordId,
|
|
ollamaBaseUrl: config.localLlm.ollamaBaseUrl,
|
|
ollamaModel: config.localLlm.ollamaModel,
|
|
ollamaTimeoutMs: config.localLlm.ollamaTimeoutMs,
|
|
ollamaKeepAlive: config.localLlm.ollamaKeepAlive,
|
|
ollamaThink: config.localLlm.ollamaThink,
|
|
ollamaNumCtx: config.localLlm.ollamaNumCtx,
|
|
allowPublicResponses: config.localLlm.allowPublic,
|
|
toolbox: {
|
|
listIndexedRepos: async () => {
|
|
const repos = await sourceArchiveRepository.listRepoSummaries();
|
|
|
|
return {
|
|
repoCount: repos.length,
|
|
repos,
|
|
};
|
|
},
|
|
searchHistory: async (input) => {
|
|
const rawResults = await searchHistoryWithArchiveFallback({
|
|
retrievalService,
|
|
archiveRepository,
|
|
query: input.query,
|
|
limit: input.limit,
|
|
guildId: input.guildId,
|
|
channelId: input.channelId,
|
|
authorId: input.authorId,
|
|
preserveOverfetch: true,
|
|
});
|
|
const results = filterPromptEchoHistoryResults(rawResults, input.query).slice(0, input.limit);
|
|
|
|
return {
|
|
query: input.query,
|
|
resultCount: results.length,
|
|
results: formatHistoryResults(results),
|
|
};
|
|
},
|
|
getMessageContext: async (input) => {
|
|
const messages = await archiveRepository.listContextWindow(
|
|
input.messageId,
|
|
input.before,
|
|
input.after,
|
|
);
|
|
|
|
return {
|
|
found: messages.length > 0,
|
|
messageId: input.messageId,
|
|
count: messages.length,
|
|
messages: messages.map((message) =>
|
|
formatArchivedMessageContext(message, input.messageId),
|
|
),
|
|
};
|
|
},
|
|
searchSources: async (input) => {
|
|
const results = await retrievalService.searchRepositorySources(input.query, input.limit, {
|
|
repoName: input.repoName,
|
|
pathPrefix: input.pathPrefix,
|
|
language: input.language,
|
|
});
|
|
|
|
return {
|
|
query: input.query,
|
|
resultCount: results.length,
|
|
results: formatSourceResults(results),
|
|
};
|
|
},
|
|
getSourceContext: async (input) => {
|
|
const document = await sourceArchiveRepository.get(input.sourceId);
|
|
|
|
if (!document) {
|
|
return {
|
|
found: false,
|
|
sourceId: input.sourceId,
|
|
count: 0,
|
|
chunks: [],
|
|
};
|
|
}
|
|
|
|
const chunks = sourceDocumentIngester.buildContextWindow(
|
|
document,
|
|
input.chunkIndex,
|
|
input.before,
|
|
input.after,
|
|
);
|
|
|
|
return {
|
|
found: true,
|
|
sourceId: input.sourceId,
|
|
repoName: document.repoName,
|
|
path: document.path,
|
|
language: document.language,
|
|
count: chunks.length,
|
|
chunks: chunks.map((chunk) => ({
|
|
chunkId: chunk.chunkId,
|
|
chunkIndex: chunk.chunkIndex,
|
|
lineStart: chunk.lineStart,
|
|
lineEnd: chunk.lineEnd,
|
|
isAnchor: chunk.chunkIndex === input.chunkIndex,
|
|
text: chunk.text,
|
|
})),
|
|
};
|
|
},
|
|
},
|
|
}),
|
|
]);
|
|
client.once(Events.ClientReady, async (readyClient) => {
|
|
console.log(`VoidBot connected as ${readyClient.user.tag}.`);
|
|
await maybeRegisterCommands(config.botToken!, config.applicationId, config.developmentGuildId);
|
|
});
|
|
|
|
client.on(Events.MessageCreate, async (message) => {
|
|
if (message.author.bot) {
|
|
return;
|
|
}
|
|
|
|
const isDirectMessage = !message.inGuild();
|
|
|
|
await ingestIfIndexed(
|
|
message,
|
|
config.channelIndexing,
|
|
ragPipeline,
|
|
client.user?.id,
|
|
botDirectedRoleIds,
|
|
);
|
|
await rememberAmbientVoidReference(message, client.user?.id, interactionMemory);
|
|
|
|
const roleAddressedRepoIdentity = isDirectMessage
|
|
? undefined
|
|
: findRepoDiscordIdentityByRoleIds(
|
|
repoDiscordIdentities,
|
|
message.mentions.roles.keys(),
|
|
message.channelId,
|
|
);
|
|
const roleAddressedRepoIdentities = isDirectMessage
|
|
? []
|
|
: repoDiscordIdentities.identities.filter((identity) =>
|
|
identity.roleId &&
|
|
message.mentions.roles.has(identity.roleId),
|
|
);
|
|
const textAddressedRepoIdentity = isDirectMessage
|
|
? undefined
|
|
: findRepoDiscordIdentityByTextAddress(
|
|
repoDiscordIdentities,
|
|
stripBotMention(message.content),
|
|
message.channelId,
|
|
);
|
|
const textMentionedRepoIdentities = isDirectMessage
|
|
? []
|
|
: findRepoDiscordIdentitiesByTextMentions(
|
|
repoDiscordIdentities,
|
|
stripBotMention(message.content),
|
|
message.channelId,
|
|
);
|
|
const repliedRepoIdentity = isDirectMessage ||
|
|
roleAddressedRepoIdentity ||
|
|
textAddressedRepoIdentity
|
|
? undefined
|
|
: await resolveRepliedRepoIdentity(message, repoDiscordIdentities);
|
|
const addressedRepoIdentities = uniqueRepoIdentities([
|
|
...roleAddressedRepoIdentities,
|
|
...textMentionedRepoIdentities,
|
|
...(repliedRepoIdentity ? [repliedRepoIdentity] : []),
|
|
]);
|
|
const broadcastAddressedRepoIdentities = !isDirectMessage && addressedRepoIdentities.length === 0 &&
|
|
isRepoFaceBroadcastInvitation(stripBotMention(message.content))
|
|
? repoDiscordIdentities.identities.filter((identity) =>
|
|
isRepoDiscordIdentityAllowedInChannel(identity, message.channelId),
|
|
)
|
|
: [];
|
|
const pendingRepoIdentities = addressedRepoIdentities.length > 0
|
|
? addressedRepoIdentities
|
|
: broadcastAddressedRepoIdentities;
|
|
const addressedRepoIdentity = roleAddressedRepoIdentity ?? textAddressedRepoIdentity ?? repliedRepoIdentity;
|
|
const isBotMentioned = Boolean(client.user && message.mentions.has(client.user));
|
|
|
|
if (!client.user || (!isDirectMessage && !isBotMentioned && pendingRepoIdentities.length === 0)) {
|
|
return;
|
|
}
|
|
|
|
const visiblePrompt = isDirectMessage
|
|
? message.content.trim()
|
|
: addressedRepoIdentity
|
|
? stripRepoIdentityTextAddress(
|
|
stripAddressingMentions(
|
|
stripBotMention(message.content),
|
|
roleAddressedRepoIdentity?.roleId,
|
|
),
|
|
addressedRepoIdentity,
|
|
).trim() ||
|
|
renderRepliedRepoIdentityPrompt(message, addressedRepoIdentity, repliedRepoIdentity)
|
|
: stripBotMention(message.content).trim();
|
|
|
|
try {
|
|
if (pendingRepoIdentities.length > 0) {
|
|
let queuedCount = 0;
|
|
for (const identity of pendingRepoIdentities) {
|
|
const identityVisiblePrompt = renderRepoIdentityVisiblePrompt({
|
|
message,
|
|
identity,
|
|
roleAddressed: roleAddressedRepoIdentities.some((entry) => entry.id === identity.id),
|
|
textAddressed: textAddressedRepoIdentity?.id === identity.id,
|
|
replied: repliedRepoIdentity?.id === identity.id,
|
|
broadcastAddressed: broadcastAddressedRepoIdentities.some((entry) => entry.id === identity.id),
|
|
});
|
|
if (!identityVisiblePrompt) {
|
|
console.log(`Ignored empty repo Face mention ${message.id} for ${identity.id}.`);
|
|
continue;
|
|
}
|
|
const faceInitialization = await ensureRepoFaceInitialized({
|
|
identity,
|
|
storageRoot: config.storageRoot,
|
|
sourceRepoRoot: config.sourceRepoRoot,
|
|
epiphanyAgentRoot: config.epiphanyAgentRoot,
|
|
workspaceRoot: process.cwd(),
|
|
birthMode: config.repoFaceBirthMode,
|
|
birthExecutor: config.repoFaceBirthExecutor,
|
|
});
|
|
const queuedMention = await queueRepoFaceMention({
|
|
statePath: config.repoFaceHeartbeats.statePath,
|
|
identity,
|
|
channelId: message.channelId,
|
|
messageId: message.id,
|
|
authorId: message.author.id,
|
|
authorName: message.author.username,
|
|
content: message.content,
|
|
visiblePrompt: identityVisiblePrompt,
|
|
});
|
|
if (queuedMention.queued) {
|
|
queuedCount += 1;
|
|
}
|
|
console.log(
|
|
`Queued repo Face mention ${message.id} for ${identity.id} via CTB turn queue (${queuedMention.pendingCount} pending). Birth status: ${
|
|
faceInitialization.birthStatusPath ?? faceInitialization.skippedReason ?? "unknown"
|
|
}`,
|
|
);
|
|
}
|
|
if (queuedCount === 0) {
|
|
console.log(`Repo Face mention ${message.id} matched existing pending obligations only.`);
|
|
}
|
|
return;
|
|
}
|
|
|
|
const queuedMention = await queueAgentHeartbeatMention({
|
|
statePath: config.repoFaceHeartbeats.statePath,
|
|
identityId: "void",
|
|
channelId: message.channelId,
|
|
messageId: message.id,
|
|
authorId: message.author.id,
|
|
authorName: message.author.username,
|
|
content: message.content,
|
|
visiblePrompt: visiblePrompt || "Void was mentioned without a visible prompt; inspect recent room context and decide whether a response is warranted.",
|
|
});
|
|
console.log(
|
|
`Queued Void mention ${message.id} via CTB turn queue (${queuedMention.pendingCount} pending).`,
|
|
);
|
|
} catch (error) {
|
|
console.error(error);
|
|
await notifyOwnerOfBotIssue(
|
|
client,
|
|
config.ownerDiscordId,
|
|
renderSystemMessage(activeSystemMessages, "job.owner.queue_failed_dm", {
|
|
channelName:
|
|
"name" in message.channel && message.channel.name
|
|
? message.channel.name
|
|
: message.channelId,
|
|
errorMessage:
|
|
error instanceof Error ? error.message : "Unexpected bot-side failure.",
|
|
}),
|
|
);
|
|
}
|
|
});
|
|
|
|
client.on(Events.MessageUpdate, async (_oldMessage, newMessage) => {
|
|
const materializedMessage = await materializeMessage(newMessage);
|
|
|
|
if (!materializedMessage || materializedMessage.author.bot) {
|
|
return;
|
|
}
|
|
|
|
await ingestIfIndexed(
|
|
materializedMessage,
|
|
config.channelIndexing,
|
|
ragPipeline,
|
|
client.user?.id,
|
|
botDirectedRoleIds,
|
|
);
|
|
await rememberAmbientVoidReference(
|
|
materializedMessage,
|
|
client.user?.id,
|
|
interactionMemory,
|
|
);
|
|
});
|
|
|
|
client.on(Events.MessageDelete, async (message) => {
|
|
if (!shouldIndexChannel(config.channelIndexing, buildChannelIndexingTarget(message.channel))) {
|
|
return;
|
|
}
|
|
|
|
await ragPipeline.markDeleted(message.id);
|
|
});
|
|
|
|
client.on(Events.InteractionCreate, async (interaction) => {
|
|
if (!interaction.isChatInputCommand()) {
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const actor = buildActorFromInteraction(interaction);
|
|
const roleIds = getRoleIdsFromInteraction(interaction);
|
|
const guildContext = buildGuildContextFromInteraction(interaction);
|
|
|
|
switch (interaction.commandName) {
|
|
case "ask":
|
|
const requestedProvider = parseProviderOverride(
|
|
interaction.options.getString("provider", false),
|
|
);
|
|
await interaction.deferReply({ ephemeral: true });
|
|
await handlePrompt({
|
|
prompt: interaction.options.getString("question", true),
|
|
command: "ask",
|
|
actor,
|
|
roleIds,
|
|
guildContext,
|
|
outputChannelId: interaction.channelId,
|
|
requestMessageId: interaction.id,
|
|
channel: interaction.channel?.isTextBased() ? interaction.channel : null,
|
|
respond: async (content) => {
|
|
await replyEphemeral(interaction, content);
|
|
},
|
|
config,
|
|
permissionEngine,
|
|
contextBuilder,
|
|
retrievalService,
|
|
archiveRepository,
|
|
sourceArchiveRepository,
|
|
jobQueue,
|
|
auditLog,
|
|
interactionMemory,
|
|
voidUsageRateLimiter,
|
|
providerRegistry,
|
|
situationalSocialReadInferer,
|
|
stylePack: activeStylePack,
|
|
systemMessages: activeSystemMessages,
|
|
forceProvider: requestedProvider,
|
|
});
|
|
break;
|
|
case "queue-codex":
|
|
await interaction.deferReply({ ephemeral: true });
|
|
await handlePrompt({
|
|
prompt: interaction.options.getString("prompt", true),
|
|
command: "queue-codex",
|
|
actor,
|
|
roleIds,
|
|
guildContext,
|
|
outputChannelId: interaction.channelId,
|
|
requestMessageId: interaction.id,
|
|
channel: interaction.channel?.isTextBased() ? interaction.channel : null,
|
|
respond: async (content) => {
|
|
await replyEphemeral(interaction, content);
|
|
},
|
|
config,
|
|
permissionEngine,
|
|
contextBuilder,
|
|
retrievalService,
|
|
archiveRepository,
|
|
sourceArchiveRepository,
|
|
jobQueue,
|
|
auditLog,
|
|
interactionMemory,
|
|
voidUsageRateLimiter,
|
|
providerRegistry,
|
|
situationalSocialReadInferer,
|
|
stylePack: activeStylePack,
|
|
systemMessages: activeSystemMessages,
|
|
forceProvider: "owner_codex",
|
|
});
|
|
break;
|
|
case "approve-job":
|
|
await handleApproveJob({
|
|
interaction,
|
|
client,
|
|
actor,
|
|
jobQueue,
|
|
auditLog,
|
|
permissionEngine,
|
|
systemMessages: activeSystemMessages,
|
|
});
|
|
break;
|
|
case "reject-job":
|
|
await handleRejectJob({
|
|
interaction,
|
|
actor,
|
|
jobQueue,
|
|
auditLog,
|
|
permissionEngine,
|
|
systemMessages: activeSystemMessages,
|
|
});
|
|
break;
|
|
case "provider-status":
|
|
await replyEphemeral(
|
|
interaction,
|
|
formatProviderStatuses(
|
|
providerRegistry.listStatuses(actor, guildContext),
|
|
activeSystemMessages,
|
|
),
|
|
);
|
|
break;
|
|
case "profile":
|
|
await handleProfile({
|
|
interaction,
|
|
actor,
|
|
interactionMemory,
|
|
auditLog,
|
|
systemMessages: activeSystemMessages,
|
|
});
|
|
break;
|
|
case "search-history":
|
|
await handleSearchHistory(
|
|
interaction,
|
|
retrievalService,
|
|
archiveRepository,
|
|
activeSystemMessages,
|
|
);
|
|
break;
|
|
case "summarize-channel":
|
|
await handleSummarizeChannel(interaction, activeSystemMessages);
|
|
break;
|
|
case "reindex-channel":
|
|
await handleReindexChannel(
|
|
interaction,
|
|
actor,
|
|
config.channelIndexing,
|
|
permissionEngine,
|
|
ragPipeline,
|
|
client.user?.id,
|
|
botDirectedRoleIds,
|
|
activeSystemMessages,
|
|
);
|
|
break;
|
|
case "set-style":
|
|
if (!permissionEngine.canManageConfiguration(actor)) {
|
|
await replyEphemeral(
|
|
interaction,
|
|
renderSystemMessage(activeSystemMessages, "config.set_style.forbidden"),
|
|
);
|
|
break;
|
|
}
|
|
|
|
activeStylePack = await loadStylePack(config.stylePackPath);
|
|
activeSystemMessages = await loadSystemMessageCatalog(
|
|
config.systemMessagesPath,
|
|
baseSystemMessagesPath,
|
|
);
|
|
await replyEphemeral(
|
|
interaction,
|
|
activeStylePack
|
|
? renderSystemMessage(activeSystemMessages, "config.set_style.reloaded", {
|
|
stylePackName: activeStylePack.name,
|
|
stylePackPath: config.stylePackPath,
|
|
systemMessagesPath: config.systemMessagesPath,
|
|
})
|
|
: renderSystemMessage(activeSystemMessages, "config.set_style.style_missing", {
|
|
stylePackPath: config.stylePackPath,
|
|
systemMessagesPath: config.systemMessagesPath,
|
|
}),
|
|
);
|
|
break;
|
|
default:
|
|
await replyEphemeral(
|
|
interaction,
|
|
renderSystemMessage(activeSystemMessages, "command.unimplemented"),
|
|
);
|
|
}
|
|
} catch (error) {
|
|
console.error(error);
|
|
|
|
const message =
|
|
error instanceof Error
|
|
? renderSystemMessage(activeSystemMessages, "error.with_message", {
|
|
errorMessage: error.message,
|
|
})
|
|
: renderSystemMessage(activeSystemMessages, "error.generic");
|
|
|
|
await replyEphemeral(interaction, message);
|
|
}
|
|
});
|
|
|
|
await client.login(config.botToken);
|
|
}
|
|
|
|
async function resolveRepliedRepoIdentity(
|
|
message: Message,
|
|
registry: { identities: RepoDiscordIdentity[] },
|
|
): Promise<RepoDiscordIdentity | undefined> {
|
|
if (!message.reference?.messageId) {
|
|
return undefined;
|
|
}
|
|
|
|
try {
|
|
const referenced = await message.fetchReference();
|
|
const personaName = referenced.webhookId
|
|
? referenced.author.username
|
|
: referenced.member?.displayName ?? referenced.author.globalName ?? referenced.author.username;
|
|
|
|
return findRepoDiscordIdentityByPersonaName(registry, personaName, message.channelId);
|
|
} catch (error) {
|
|
console.warn(
|
|
`Could not resolve referenced message ${message.reference.messageId} for repo Face reply routing: ${
|
|
error instanceof Error ? error.message : String(error)
|
|
}`,
|
|
);
|
|
return undefined;
|
|
}
|
|
}
|
|
|
|
function renderRepliedRepoIdentityPrompt(
|
|
message: Message,
|
|
addressedRepoIdentity: RepoDiscordIdentity,
|
|
repliedRepoIdentity: RepoDiscordIdentity | undefined,
|
|
): string {
|
|
const content = message.content.trim();
|
|
|
|
if (content.length > 0) {
|
|
return content;
|
|
}
|
|
|
|
if (repliedRepoIdentity?.id === addressedRepoIdentity.id) {
|
|
return `A human replied directly to ${addressedRepoIdentity.displayName}'s previous message without visible text. Inspect the replied-to exchange and decide whether a response is warranted.`;
|
|
}
|
|
|
|
return "";
|
|
}
|
|
|
|
function renderRepoIdentityVisiblePrompt(input: {
|
|
message: Message;
|
|
identity: RepoDiscordIdentity;
|
|
roleAddressed: boolean;
|
|
textAddressed: boolean;
|
|
replied: boolean;
|
|
broadcastAddressed: boolean;
|
|
}): string {
|
|
const strippedBotMention = stripBotMention(input.message.content);
|
|
if (input.roleAddressed || input.textAddressed) {
|
|
const strippedAddress = stripRepoIdentityTextAddress(
|
|
stripAddressingMentions(strippedBotMention, input.roleAddressed ? input.identity.roleId : undefined),
|
|
input.identity,
|
|
).trim();
|
|
if (strippedAddress.length > 0) {
|
|
return strippedAddress;
|
|
}
|
|
}
|
|
|
|
if (input.broadcastAddressed && strippedBotMention.trim().length > 0) {
|
|
return `The room was explicitly invited to answer. For ${input.identity.displayName}, this invitation is live: ${strippedBotMention.trim()}`;
|
|
}
|
|
|
|
if (input.message.content.trim().length > 0) {
|
|
return strippedBotMention.trim();
|
|
}
|
|
|
|
return renderRepliedRepoIdentityPrompt(
|
|
input.message,
|
|
input.identity,
|
|
input.replied ? input.identity : undefined,
|
|
);
|
|
}
|
|
|
|
function isRepoFaceBroadcastInvitation(content: string): boolean {
|
|
const normalized = collapseWhitespace(content).toLowerCase();
|
|
if (normalized.length === 0) {
|
|
return false;
|
|
}
|
|
|
|
return [
|
|
/\b(everyone|everybody|all of you|anyone|anybody|the swarm|you all|y'all)\b/,
|
|
/\bhow (are|is) (everyone|everybody|you all|y'all)\b/,
|
|
/\bwhat about (your|everyone's|everybody's) (minds?|bodies?|feelings?)\b/,
|
|
/\banything i can do to help\b/,
|
|
].some((pattern) => pattern.test(normalized));
|
|
}
|
|
|
|
function collapseWhitespace(value: string): string {
|
|
return value.replace(/\s+/g, " ").trim();
|
|
}
|
|
|
|
function uniqueRepoIdentities(identities: RepoDiscordIdentity[]): RepoDiscordIdentity[] {
|
|
const seen = new Set<string>();
|
|
const result: RepoDiscordIdentity[] = [];
|
|
for (const identity of identities) {
|
|
const key = identity.id.trim().toLowerCase();
|
|
if (seen.has(key)) {
|
|
continue;
|
|
}
|
|
seen.add(key);
|
|
result.push(identity);
|
|
}
|
|
return result;
|
|
}
|
|
|
|
async function ensureRepoIdentityRoles(options: {
|
|
botToken: string;
|
|
guildId?: string;
|
|
registryPath: string;
|
|
identities: RepoDiscordIdentity[];
|
|
}): Promise<{ identities: RepoDiscordIdentity[] }> {
|
|
const identitiesMissingRoles = options.identities.filter((identity) => !identity.roleId);
|
|
|
|
if (identitiesMissingRoles.length === 0) {
|
|
return { identities: options.identities };
|
|
}
|
|
|
|
if (!options.guildId) {
|
|
console.warn(
|
|
`Repo Discord identities missing roles, but DISCORD_GUILD_ID is not configured: ${
|
|
identitiesMissingRoles.map((identity) => identity.id).join(", ")
|
|
}`,
|
|
);
|
|
return { identities: options.identities };
|
|
}
|
|
|
|
try {
|
|
const existingRoles = await fetchGuildRoles(options.botToken, options.guildId);
|
|
const nextIdentities = [...options.identities];
|
|
let changed = false;
|
|
|
|
for (const identity of identitiesMissingRoles) {
|
|
const existingRole = existingRoles.find((role) => role.name === identity.displayName);
|
|
const roleId = existingRole?.id ?? await createRepoIdentityRole(
|
|
options.botToken,
|
|
options.guildId,
|
|
identity.displayName,
|
|
);
|
|
const index = nextIdentities.findIndex((entry) => entry.id === identity.id);
|
|
|
|
if (index !== -1) {
|
|
nextIdentities[index] = {
|
|
...nextIdentities[index],
|
|
roleId,
|
|
};
|
|
changed = true;
|
|
}
|
|
}
|
|
|
|
if (changed) {
|
|
await writeRepoIdentityRegistry(options.registryPath, nextIdentities);
|
|
}
|
|
|
|
return { identities: nextIdentities };
|
|
} catch (error) {
|
|
console.warn(
|
|
`Failed to ensure repo identity Discord roles: ${
|
|
error instanceof Error ? error.message : String(error)
|
|
}`,
|
|
);
|
|
return { identities: options.identities };
|
|
}
|
|
}
|
|
|
|
async function fetchGuildRoles(
|
|
botToken: string,
|
|
guildId: string,
|
|
): Promise<Array<{ id: string; name: string }>> {
|
|
const response = await fetch(`https://discord.com/api/v10/guilds/${guildId}/roles`, {
|
|
method: "GET",
|
|
headers: {
|
|
Authorization: `Bot ${botToken}`,
|
|
"Content-Type": "application/json",
|
|
},
|
|
});
|
|
|
|
if (!response.ok) {
|
|
throw new Error(`Discord role lookup failed: ${response.status} ${await response.text()}`);
|
|
}
|
|
|
|
const payload = await response.json() as Array<{ id?: string; name?: string }>;
|
|
return payload
|
|
.filter((role): role is { id: string; name: string } => Boolean(role.id && role.name));
|
|
}
|
|
|
|
async function createRepoIdentityRole(
|
|
botToken: string,
|
|
guildId: string,
|
|
name: string,
|
|
): Promise<string> {
|
|
const response = await fetch(`https://discord.com/api/v10/guilds/${guildId}/roles`, {
|
|
method: "POST",
|
|
headers: {
|
|
Authorization: `Bot ${botToken}`,
|
|
"Content-Type": "application/json",
|
|
},
|
|
body: JSON.stringify({
|
|
name,
|
|
mentionable: true,
|
|
hoist: false,
|
|
}),
|
|
});
|
|
|
|
if (!response.ok) {
|
|
throw new Error(`Discord role creation failed for ${name}: ${response.status} ${await response.text()}`);
|
|
}
|
|
|
|
const payload = await response.json() as { id?: string };
|
|
if (!payload.id) {
|
|
throw new Error(`Discord role creation for ${name} returned no role id.`);
|
|
}
|
|
|
|
return payload.id;
|
|
}
|
|
|
|
async function writeRepoIdentityRegistry(
|
|
registryPath: string,
|
|
identities: RepoDiscordIdentity[],
|
|
): Promise<void> {
|
|
let existing: unknown;
|
|
|
|
try {
|
|
existing = JSON.parse(stripLeadingBom(await readFile(registryPath, "utf8"))) as unknown;
|
|
} catch {
|
|
existing = {};
|
|
}
|
|
|
|
const payload = Array.isArray(existing)
|
|
? identities
|
|
: {
|
|
...(isRecord(existing) ? existing : {}),
|
|
identities,
|
|
};
|
|
await writeFile(registryPath, `${JSON.stringify(payload, null, 2)}\n`, "utf8");
|
|
}
|
|
|
|
function stripAddressingMentions(content: string, roleId?: string): string {
|
|
if (!roleId) {
|
|
return content;
|
|
}
|
|
|
|
return content.replace(new RegExp(`<@&${escapeRegExp(roleId)}>`, "g"), "").trim();
|
|
}
|
|
|
|
function escapeRegExp(value: string): string {
|
|
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
}
|
|
|
|
function stripLeadingBom(input: string): string {
|
|
return input.charCodeAt(0) === 0xfeff ? input.slice(1) : input;
|
|
}
|
|
|
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
}
|