Nyk
feat: add validated @mentions for tasks and comments
200c64b
Raw
History Blame Contribute Delete
3.92 kB
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<string>()
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<string>()
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<string, MentionTarget>()
for (const target of targets) {
byHandle.set(target.handle.toLowerCase(), target)
}
const resolved: MentionTarget[] = []
const unresolved: string[] = []
const recipientSeen = new Set<string>()
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,
}
}