Add role-backed repo Discord identities
This commit is contained in:
parent
e8acb67141
commit
ed36561c8d
15 changed files with 427 additions and 9 deletions
|
|
@ -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, "\\$&");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 ?? []);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
{
|
||||
|
|
|
|||
Loading…
Reference in a new issue