feat(guild): translate <@user.name:NAME> -> <@userId>
Before persist/parse, resolve <@user.name:NAME> (outside backticks) via Center and rewrite to <@userId>; unresolved tokens left as-is. Translated ids then flow into the existing mention/wakeup/sub-frame logic. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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<string>();
|
||||||
|
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, string>): 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
|
// Parse <@user-id> mentions from message content. A mention does NOT count
|
||||||
// when it sits inside a backtick span (single ` or triple ``` — any backtick
|
// when it sits inside a backtick span (single ` or triple ``` — any backtick
|
||||||
// run toggles a code region). Returns unique ids in first-seen order.
|
// run toggles a code region). Returns unique ids in first-seen order.
|
||||||
|
|||||||
@@ -25,3 +25,25 @@ export async function introspectGuildToken(token: string): Promise<{ active: boo
|
|||||||
user: data.user,
|
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<Record<string, string>> {
|
||||||
|
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<string, string> };
|
||||||
|
return data.resolved ?? {};
|
||||||
|
} catch {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -18,7 +18,8 @@ import { Message } from '../entities/message.entity';
|
|||||||
import { IdempotencyRecord } from '../entities/idempotency-record.entity';
|
import { IdempotencyRecord } from '../entities/idempotency-record.entity';
|
||||||
import { WakeMapping } from '../entities/wake-mapping.entity';
|
import { WakeMapping } from '../entities/wake-mapping.entity';
|
||||||
import { parseSlashCommand } from '../channels/slash-commands';
|
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 { TurnService } from '../channels/turn.service';
|
||||||
import { EventsService } from '../events/events.service';
|
import { EventsService } from '../events/events.service';
|
||||||
import { clampLimit, computeNextExpectedSeq } from './pagination.util';
|
import { clampLimit, computeNextExpectedSeq } from './pagination.util';
|
||||||
@@ -146,8 +147,17 @@ export class MessagingController {
|
|||||||
const isRotating = xType === 'discuss' || xType === 'work';
|
const isRotating = xType === 'discuss' || xType === 'work';
|
||||||
const authorUserId = String(body.authorUserId ?? 'anonymous');
|
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
|
// ---- command interception: registered slash commands are never delivered
|
||||||
const cmd = parseSlashCommand(body.content ?? '');
|
const cmd = parseSlashCommand(content);
|
||||||
if (cmd) {
|
if (cmd) {
|
||||||
if (isRotating && cmd.name === 'no-reply') {
|
if (isRotating && cmd.name === 'no-reply') {
|
||||||
const { ack } = await this.turn.onNoReply(channelId, authorUserId);
|
const { ack } = await this.turn.onNoReply(channelId, authorUserId);
|
||||||
@@ -163,7 +173,7 @@ export class MessagingController {
|
|||||||
// ---- normal message
|
// ---- normal message
|
||||||
const message = await this.persistMessage(channelId, {
|
const message = await this.persistMessage(channelId, {
|
||||||
authorUserId,
|
authorUserId,
|
||||||
content: body.content,
|
content,
|
||||||
clientMessageId: body.clientMessageId,
|
clientMessageId: body.clientMessageId,
|
||||||
replyToMessageId: body.replyToMessageId,
|
replyToMessageId: body.replyToMessageId,
|
||||||
mentions: body.mentions,
|
mentions: body.mentions,
|
||||||
@@ -180,8 +190,8 @@ export class MessagingController {
|
|||||||
data: responseBody,
|
data: responseBody,
|
||||||
});
|
});
|
||||||
|
|
||||||
// mentions: <@id> outside backtick spans
|
// mentions: <@id> outside backtick spans (post name-translation)
|
||||||
const mentionIds = parseMentions(body.content ?? '');
|
const mentionIds = parseMentions(content);
|
||||||
|
|
||||||
if (isRotating) {
|
if (isRotating) {
|
||||||
// discuss/work: rotation (incl. mention sub-frames) picks the target
|
// discuss/work: rotation (incl. mention sub-frames) picks the target
|
||||||
|
|||||||
Reference in New Issue
Block a user