Add role-backed repo Discord identities

This commit is contained in:
Meta Vens 2026-05-18 10:39:02 +01:00
commit ed36561c8d
15 changed files with 427 additions and 9 deletions

View file

@ -13,6 +13,8 @@ import {
OllamaSituationalSocialReadInferer,
PermissionEngine,
createStateStorage,
findRepoDiscordIdentityByRoleIds,
loadRepoDiscordIdentityRegistry,
loadStylePack,
loadSystemMessageCatalog,
VoidUsageRateLimiter,
@ -84,6 +86,17 @@ export async function startBot(): Promise<void> {
GatewayIntentBits.MessageContent,
],
});
const repoDiscordIdentities = await loadRepoDiscordIdentityRegistry(
config.repoDiscordIdentitiesPath,
);
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,
@ -296,17 +309,34 @@ export async function startBot(): Promise<void> {
config.channelIndexing,
ragPipeline,
client.user?.id,
config.botTriggerRoleIds,
botDirectedRoleIds,
);
await rememberAmbientVoidReference(message, client.user?.id, interactionMemory);
if (!client.user || (!isDirectMessage && !message.mentions.has(client.user))) {
const addressedRepoIdentity = isDirectMessage
? undefined
: findRepoDiscordIdentityByRoleIds(
repoDiscordIdentities,
message.mentions.roles.keys(),
message.channelId,
);
const isBotMentioned = Boolean(client.user && message.mentions.has(client.user));
if (!client.user || (!isDirectMessage && !isBotMentioned && !addressedRepoIdentity)) {
return;
}
const prompt = isDirectMessage ? message.content.trim() : stripBotMention(message.content).trim();
const visiblePrompt = isDirectMessage
? message.content.trim()
: stripAddressingMentions(
stripBotMention(message.content),
addressedRepoIdentity?.roleId,
).trim();
const prompt = addressedRepoIdentity
? buildRepoIdentityPrompt(visiblePrompt, addressedRepoIdentity)
: visiblePrompt;
if (!prompt) {
if (!visiblePrompt) {
await replyToMessage(
message,
renderSystemMessage(activeSystemMessages, "mention.missing_prompt"),
@ -317,7 +347,7 @@ export async function startBot(): Promise<void> {
try {
await handlePrompt({
prompt,
command: "ask",
command: addressedRepoIdentity ? "repo-identity-mention" : "ask",
actor: buildActorFromMessage(message),
roleIds: getRoleIdsFromMessage(message),
guildContext: buildGuildContextFromMessage(message),
@ -342,6 +372,7 @@ export async function startBot(): Promise<void> {
stylePack: activeStylePack,
systemMessages: activeSystemMessages,
silentOwnerQueueAck: !isDirectMessage,
forceProvider: addressedRepoIdentity ? "owner_codex" : undefined,
});
} catch (error) {
console.error(error);
@ -372,7 +403,7 @@ export async function startBot(): Promise<void> {
config.channelIndexing,
ragPipeline,
client.user?.id,
config.botTriggerRoleIds,
botDirectedRoleIds,
);
await rememberAmbientVoidReference(
materializedMessage,
@ -523,7 +554,7 @@ export async function startBot(): Promise<void> {
permissionEngine,
ragPipeline,
client.user?.id,
config.botTriggerRoleIds,
botDirectedRoleIds,
activeSystemMessages,
);
break;
@ -577,3 +608,36 @@ export async function startBot(): Promise<void> {
await client.login(config.botToken);
}
function stripAddressingMentions(content: string, roleId?: string): string {
if (!roleId) {
return content;
}
return content.replace(new RegExp(`<@&${escapeRegExp(roleId)}>`, "g"), "").trim();
}
function buildRepoIdentityPrompt(
visiblePrompt: string,
identity: {
id: string;
repoName: string;
displayName: string;
roleId?: string;
},
): string {
const mention = identity.roleId ? `<@&${identity.roleId}>` : "unregistered-role";
return [
`You were addressed on Discord as repo identity ${identity.displayName} (${identity.id}) for repo ${identity.repoName}.`,
`The Discord role mention ${mention} is the addressable identity; the voice must speak through the VoidBot MCP tool post_repo_identity_message with identity "${identity.id}" if an in-channel answer is warranted.`,
"Do not answer as base VoidBot. If you post with the tool, keep your final provider response to a private delivery summary; the worker will not auto-post this command's final text.",
"",
"User prompt:",
visiblePrompt,
].join("\n");
}
function escapeRegExp(value: string): string {
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}

View file

@ -322,6 +322,24 @@ async function processJob(job: JobRecord): Promise<void> {
return;
}
if (job.command === "repo-identity-mention") {
await jobQueue.completeJobDirect(job.id, finalResponse);
await deliverOwnerNotifications(job, response.notifications ?? []);
await auditLog.record({
type: "provider.completed",
actorId: job.requester.id,
jobId: job.id,
provider: job.provider,
details: {
summary: response.summary,
autoPosted: false,
reason: "repo_identity_must_post_through_mcp",
},
});
console.log(`Completed repo identity mention job ${job.id}.`);
return;
}
await postJobResponse(job, finalResponse);
await jobQueue.completeJobDirect(job.id, finalResponse);
await deliverOwnerNotifications(job, response.notifications ?? []);

View file

@ -32,6 +32,13 @@ export const postDiscordMessageInputSchema = {
personaAvatarUrl: z.string().url().max(512).optional(),
};
export const postRepoIdentityMessageInputSchema = {
identity: z.string().min(1),
channelId: z.string().min(1),
content: z.string().min(1).max(1800),
replyToMessageId: z.string().min(1).optional(),
};
export const searchSourcesInputSchema = {
query: z.string().min(1).max(240),
limit: z.number().int().min(1).max(MAX_RETRIEVAL_RESULT_LIMIT).optional(),
@ -73,6 +80,13 @@ export interface PostDiscordMessageArgs {
personaAvatarUrl?: string;
}
export interface PostRepoIdentityMessageArgs {
identity: string;
channelId: string;
content: string;
replyToMessageId?: string;
}
export interface ListIndexedReposArgs {
[key: string]: never;
}

View file

@ -3,6 +3,11 @@ import { type CallToolResult } from "@modelcontextprotocol/sdk/types.js";
import { DEFAULT_RETRIEVAL_RESULT_LIMIT } from "@voidbot/shared";
import { searchHistoryWithArchiveFallback } from "@voidbot/rag";
import {
findRepoDiscordIdentity,
isRepoDiscordIdentityAllowedInChannel,
loadRepoDiscordIdentityRegistry,
} from "@voidbot/core";
import { type VoidbotMcpContext } from "./mcp-server-context";
import { openOwnerDmChannel, postDiscordMessage } from "./mcp-server-discord";
@ -11,6 +16,7 @@ import {
type MessageContextArgs,
type NotifyOwnerArgs,
type PostDiscordMessageArgs,
type PostRepoIdentityMessageArgs,
type SearchHistoryArgs,
type SearchSourcesArgs,
type SourceContextArgs,
@ -20,6 +26,7 @@ import {
messageContextInputSchema,
notifyOwnerInputSchema,
postDiscordMessageInputSchema,
postRepoIdentityMessageInputSchema,
renderJsonBlock,
searchHistoryInputSchema,
searchSourcesInputSchema,
@ -364,6 +371,162 @@ export function registerVoidbotTools(
},
);
server.registerTool(
"list_repo_discord_identities",
{
title: "List Repo Discord Identities",
description:
"List registered repo identities that can be addressed by Discord roles and speak through the shared webhook persona pipe.",
inputSchema: {},
annotations: READ_ONLY_ANNOTATIONS,
},
async (): Promise<CallToolResult> => {
const registry = await loadRepoDiscordIdentityRegistry(
context.config.repoDiscordIdentitiesPath,
);
const identities = registry.identities.map((identity) => ({
id: identity.id,
repoName: identity.repoName,
displayName: identity.displayName,
roleId: identity.roleId ?? null,
mention: identity.roleId ? `<@&${identity.roleId}>` : null,
allowedChannelIds: identity.allowedChannelIds,
hasAvatarUrl: Boolean(identity.avatarUrl),
description: identity.description ?? null,
}));
return {
content: [
{
type: "text",
text:
identities.length > 0
? renderJsonBlock({
identityCount: identities.length,
identities,
})
: "No repo Discord identities are registered.",
},
],
structuredContent: {
identityCount: identities.length,
identities,
},
};
},
);
server.registerTool(
"post_repo_identity_message",
{
title: "Post Repo Identity Message",
description:
"Post in Discord as a registered repo identity. The identity's Discord role is the mention target; this tool speaks through the shared webhook persona pipe using the registered display name and avatar.",
inputSchema: postRepoIdentityMessageInputSchema,
annotations: {
readOnlyHint: false,
destructiveHint: false,
idempotentHint: false,
openWorldHint: true,
},
},
async (input: PostRepoIdentityMessageArgs): Promise<CallToolResult> => {
const { identity: identitySelector, channelId, content, replyToMessageId } = input;
const registry = await loadRepoDiscordIdentityRegistry(
context.config.repoDiscordIdentitiesPath,
);
const identity = findRepoDiscordIdentity(registry, identitySelector);
if (!identity) {
return {
content: [
{
type: "text",
text: `No registered repo Discord identity matched "${identitySelector}".`,
},
],
structuredContent: {
sent: false,
reason: "unknown_identity",
identity: identitySelector,
},
isError: true,
};
}
if (!isRepoDiscordIdentityAllowedInChannel(identity, channelId)) {
return {
content: [
{
type: "text",
text: `Repo identity ${identity.id} is not registered for Discord channel ${channelId}.`,
},
],
structuredContent: {
sent: false,
reason: "channel_not_allowed",
identity: identity.id,
channelId,
allowedChannelIds: identity.allowedChannelIds,
},
isError: true,
};
}
if (!context.config.botToken) {
return {
content: [
{
type: "text",
text: "DISCORD_BOT_TOKEN is not configured, so Discord posting is unavailable.",
},
],
structuredContent: {
sent: false,
reason: "missing_bot_token",
channelId,
identity: identity.id,
},
isError: true,
};
}
const posted = await postDiscordMessage(
context.config.botToken,
channelId,
content,
replyToMessageId,
{
personaName: identity.displayName,
personaAvatarUrl: identity.avatarUrl,
},
);
return {
content: [
{
type: "text",
text: replyToMessageId
? `Posted a reply in Discord channel ${channelId} as ${identity.displayName} (${identity.id}) message ${posted.id} via ${posted.transport}.`
: `Posted a message in Discord channel ${channelId} as ${identity.displayName} (${identity.id}) message ${posted.id} via ${posted.transport}.`,
},
],
structuredContent: {
sent: true,
channelId,
messageId: posted.id,
transport: posted.transport,
identity: identity.id,
repoName: identity.repoName,
personaName: identity.displayName,
roleId: identity.roleId ?? null,
mention: identity.roleId ? `<@&${identity.roleId}>` : null,
replyToMessageId: replyToMessageId ?? null,
},
};
},
);
server.registerTool(
"notify_owner",
{