import type { Database } from 'better-sqlite3' export interface MentionTarget { handle: string recipient: string type: 'user' | 'agent' display: string role?: string } export interface MentionResolution { tokens: string[] unresolved: string[] recipients: string[] resolved: MentionTarget[] } const MENTION_PATTERN = /(^|[^A-Za-z0-9._-])@([A-Za-z0-9][A-Za-z0-9._-]{0,63})/g function normalizeAgentHandle(name: string): string { return name.trim().toLowerCase().replace(/\s+/g, '-') } export function parseMentions(text: string): string[] { if (!text || typeof text !== 'string') return [] const found: string[] = [] const seen = new Set() let match: RegExpExecArray | null while ((match = MENTION_PATTERN.exec(text)) !== null) { const token = String(match[2] || '').trim() if (!token) continue const key = token.toLowerCase() if (seen.has(key)) continue seen.add(key) found.push(token) } return found } export function getMentionTargets(db: Database, workspaceId: number): MentionTarget[] { const targets: MentionTarget[] = [] const seenHandles = new Set() const users = db.prepare(` SELECT username, display_name FROM users WHERE workspace_id = ? ORDER BY username ASC `).all(workspaceId) as Array<{ username: string; display_name?: string | null }> for (const user of users) { const username = String(user.username || '').trim() if (!username) continue const handle = username.toLowerCase() if (seenHandles.has(handle)) continue seenHandles.add(handle) targets.push({ handle, recipient: username, type: 'user', display: user.display_name?.trim() || username, }) } const agents = db.prepare(` SELECT name, role, config FROM agents WHERE workspace_id = ? ORDER BY name ASC `).all(workspaceId) as Array<{ name: string; role?: string | null; config?: string | null }> for (const agent of agents) { const recipient = String(agent.name || '').trim() if (!recipient) continue let openclawId: string | null = null try { const parsed = agent.config ? JSON.parse(agent.config) : null if (parsed && typeof parsed.openclawId === 'string' && parsed.openclawId.trim()) { openclawId = parsed.openclawId.trim() } } catch { // ignore invalid config JSON for mention indexing } const candidateHandles = [openclawId, normalizeAgentHandle(recipient), recipient.toLowerCase()] .filter((value): value is string => Boolean(value)) for (const rawHandle of candidateHandles) { const handle = rawHandle.toLowerCase() if (!handle || seenHandles.has(handle)) continue seenHandles.add(handle) targets.push({ handle, recipient, type: 'agent', display: recipient, role: agent.role || undefined, }) } } return targets } export function resolveMentionRecipients(text: string, db: Database, workspaceId: number): MentionResolution { const tokens = parseMentions(text) if (tokens.length === 0) { return { tokens: [], unresolved: [], recipients: [], resolved: [] } } const targets = getMentionTargets(db, workspaceId) const byHandle = new Map() for (const target of targets) { byHandle.set(target.handle.toLowerCase(), target) } const resolved: MentionTarget[] = [] const unresolved: string[] = [] const recipientSeen = new Set() for (const token of tokens) { const key = token.toLowerCase() const target = byHandle.get(key) if (!target) { unresolved.push(token) continue } if (!recipientSeen.has(target.recipient)) { recipientSeen.add(target.recipient) resolved.push(target) } } return { tokens, unresolved, recipients: resolved.map((item) => item.recipient), resolved, } }