diff --git a/src/channels/mentions.ts b/src/channels/mentions.ts index ad4193c..f96be2e 100644 --- a/src/channels/mentions.ts +++ b/src/channels/mentions.ts @@ -1,3 +1,58 @@ +// Split content into parts, tagging which are inside backtick spans so we +// only touch mentions in non-code regions. Splitting keeps backtick runs. +function codeAwareParts(content: string): { text: string; code: boolean }[] { + const raw = content.split(/(`+)/); + const parts: { text: string; code: boolean }[] = []; + let inCode = false; + for (const seg of raw) { + if (/^`+$/.test(seg)) { + parts.push({ text: seg, code: true }); + inCode = !inCode; + } else { + parts.push({ text: seg, code: inCode }); + } + } + return parts; +} + +const NAME_MENTION_RE = /<@user\.name:([^>]+)>/g; + +// Names referenced via <@user.name:NAME> outside backtick spans. +export function extractNameMentions(content: string): string[] { + if (typeof content !== 'string' || !content) return []; + const out: string[] = []; + const seen = new Set(); + for (const p of codeAwareParts(content)) { + if (p.code) continue; + let m: RegExpExecArray | null; + NAME_MENTION_RE.lastIndex = 0; + while ((m = NAME_MENTION_RE.exec(p.text)) !== null) { + const name = m[1].trim(); + if (name && !seen.has(name)) { + seen.add(name); + out.push(name); + } + } + } + return out; +} + +// Replace <@user.name:NAME> with <@userId> for resolved names (outside +// backticks only); unresolved tokens are left untouched. +export function replaceNameMentions(content: string, resolved: Record): string { + if (typeof content !== 'string' || !content) return content; + return codeAwareParts(content) + .map((p) => + p.code + ? p.text + : p.text.replace(NAME_MENTION_RE, (full, name: string) => { + const id = resolved[String(name).trim()]; + return id ? `<@${id}>` : full; + }), + ) + .join(''); +} + // Parse <@user-id> mentions from message content. A mention does NOT count // when it sits inside a backtick span (single ` or triple ``` — any backtick // run toggles a code region). Returns unique ids in first-seen order. diff --git a/src/common/center-auth.ts b/src/common/center-auth.ts index c1b7a1f..295d7d8 100644 --- a/src/common/center-auth.ts +++ b/src/common/center-auth.ts @@ -25,3 +25,25 @@ export async function introspectGuildToken(token: string): Promise<{ active: boo user: data.user, }; } + +// Resolve <@user.name:NAME> names to userIds within this guild node via +// Center. Unresolved names are simply absent from the returned map. +export async function resolveUserNames(names: string[]): Promise> { + const centerBaseUrl = process.env.FABRIC_BACKEND_GUILD_CENTER_BASE_URL; + const guildNodeId = process.env.FABRIC_BACKEND_GUILD_NODE_ID; + const centerApiKey = process.env.FABRIC_BACKEND_GUILD_CENTER_API_KEY; + if (!centerBaseUrl || !guildNodeId || !centerApiKey || !names.length) return {}; + + try { + const res = await fetch(`${centerBaseUrl}/api/auth/resolve-names`, { + method: 'POST', + headers: { 'content-type': 'application/json', 'x-api-key': centerApiKey }, + body: JSON.stringify({ guildNodeId, names }), + }); + if (!res.ok) return {}; + const data = (await res.json()) as { resolved?: Record }; + return data.resolved ?? {}; + } catch { + return {}; + } +} diff --git a/src/messaging/messaging.controller.ts b/src/messaging/messaging.controller.ts index 5980b2a..72a97f3 100644 --- a/src/messaging/messaging.controller.ts +++ b/src/messaging/messaging.controller.ts @@ -18,7 +18,8 @@ import { Message } from '../entities/message.entity'; import { IdempotencyRecord } from '../entities/idempotency-record.entity'; import { WakeMapping } from '../entities/wake-mapping.entity'; import { parseSlashCommand } from '../channels/slash-commands'; -import { parseMentions } from '../channels/mentions'; +import { parseMentions, extractNameMentions, replaceNameMentions } from '../channels/mentions'; +import { resolveUserNames } from '../common/center-auth'; import { TurnService } from '../channels/turn.service'; import { EventsService } from '../events/events.service'; import { clampLimit, computeNextExpectedSeq } from './pagination.util'; @@ -146,8 +147,17 @@ export class MessagingController { const isRotating = xType === 'discuss' || xType === 'work'; const authorUserId = String(body.authorUserId ?? 'anonymous'); + // ---- translate <@user.name:NAME> -> <@userId> (outside backticks) via + // Center before anything else persists/parses the content + let content = body.content ?? ''; + const names = extractNameMentions(content); + if (names.length) { + const map = await resolveUserNames(names); + content = replaceNameMentions(content, map); + } + // ---- command interception: registered slash commands are never delivered - const cmd = parseSlashCommand(body.content ?? ''); + const cmd = parseSlashCommand(content); if (cmd) { if (isRotating && cmd.name === 'no-reply') { const { ack } = await this.turn.onNoReply(channelId, authorUserId); @@ -163,7 +173,7 @@ export class MessagingController { // ---- normal message const message = await this.persistMessage(channelId, { authorUserId, - content: body.content, + content, clientMessageId: body.clientMessageId, replyToMessageId: body.replyToMessageId, mentions: body.mentions, @@ -180,8 +190,8 @@ export class MessagingController { data: responseBody, }); - // mentions: <@id> outside backtick spans - const mentionIds = parseMentions(body.content ?? ''); + // mentions: <@id> outside backtick spans (post name-translation) + const mentionIds = parseMentions(content); if (isRotating) { // discuss/work: rotation (incl. mention sub-frames) picks the target