import { getChannelPlugin } from "../../channels/plugins/index.js"; import type { ChannelDirectoryEntry, ChannelDirectoryEntryKind, ChannelId, } from "../../channels/plugins/types.js"; import type { MoltbotConfig } from "../../config/config.js"; import { defaultRuntime, type RuntimeEnv } from "../../runtime.js"; import { buildDirectoryCacheKey, DirectoryCache } from "./directory-cache.js"; import { buildTargetResolverSignature, normalizeChannelTargetInput, normalizeTargetForProvider, } from "./target-normalization.js"; import { ambiguousTargetError, unknownTargetError } from "./target-errors.js"; export type TargetResolveKind = ChannelDirectoryEntryKind | "channel"; export type ResolveAmbiguousMode = "error" | "best" | "first"; export type ResolvedMessagingTarget = { to: string; kind: TargetResolveKind; display?: string; source: "normalized" | "directory"; }; export type ResolveMessagingTargetResult = | { ok: true; target: ResolvedMessagingTarget } | { ok: false; error: Error; candidates?: ChannelDirectoryEntry[] }; export async function resolveChannelTarget(params: { cfg: MoltbotConfig; channel: ChannelId; input: string; accountId?: string | null; preferredKind?: TargetResolveKind; runtime?: RuntimeEnv; }): Promise { return resolveMessagingTarget(params); } const CACHE_TTL_MS = 30 * 60 * 1000; const directoryCache = new DirectoryCache(CACHE_TTL_MS); export function resetDirectoryCache(params?: { channel?: ChannelId; accountId?: string | null }) { if (!params?.channel) { directoryCache.clear(); return; } const channelKey = params.channel; const accountKey = params.accountId ?? "default"; directoryCache.clearMatching((key) => { if (!key.startsWith(`${channelKey}:`)) return false; if (!params.accountId) return true; return key.startsWith(`${channelKey}:${accountKey}:`); }); } function normalizeQuery(value: string): string { return value.trim().toLowerCase(); } function stripTargetPrefixes(value: string): string { return value .replace(/^(channel|user):/i, "") .replace(/^[@#]/, "") .trim(); } export function formatTargetDisplay(params: { channel: ChannelId; target: string; display?: string; kind?: ChannelDirectoryEntryKind; }): string { const plugin = getChannelPlugin(params.channel); if (plugin?.messaging?.formatTargetDisplay) { return plugin.messaging.formatTargetDisplay({ target: params.target, display: params.display, kind: params.kind, }); } const trimmedTarget = params.target.trim(); const lowered = trimmedTarget.toLowerCase(); const display = params.display?.trim(); const kind = params.kind ?? (lowered.startsWith("user:") ? "user" : lowered.startsWith("channel:") ? "group" : undefined); if (display) { if (display.startsWith("#") || display.startsWith("@")) return display; if (kind === "user") return `@${display}`; if (kind === "group" || kind === "channel") return `#${display}`; return display; } if (!trimmedTarget) return trimmedTarget; if (trimmedTarget.startsWith("#") || trimmedTarget.startsWith("@")) return trimmedTarget; const channelPrefix = `${params.channel}:`; const withoutProvider = trimmedTarget.toLowerCase().startsWith(channelPrefix) ? trimmedTarget.slice(channelPrefix.length) : trimmedTarget; const withoutPrefix = withoutProvider.replace(/^telegram:/i, ""); if (/^channel:/i.test(withoutPrefix)) { return `#${withoutPrefix.replace(/^channel:/i, "")}`; } if (/^user:/i.test(withoutPrefix)) { return `@${withoutPrefix.replace(/^user:/i, "")}`; } return withoutPrefix; } function preserveTargetCase(channel: ChannelId, raw: string, normalized: string): string { if (channel !== "slack") return normalized; const trimmed = raw.trim(); if (/^channel:/i.test(trimmed) || /^user:/i.test(trimmed)) return trimmed; if (trimmed.startsWith("#")) return `channel:${trimmed.slice(1).trim()}`; if (trimmed.startsWith("@")) return `user:${trimmed.slice(1).trim()}`; return trimmed; } function detectTargetKind( channel: ChannelId, raw: string, preferred?: TargetResolveKind, ): TargetResolveKind { if (preferred) return preferred; const trimmed = raw.trim(); if (!trimmed) return "group"; if (trimmed.startsWith("@") || /^<@!?/.test(trimmed) || /^user:/i.test(trimmed)) return "user"; if (trimmed.startsWith("#") || /^channel:/i.test(trimmed)) return "group"; // For some channels (e.g., BlueBubbles/iMessage), bare phone numbers are almost always DM targets. if ((channel === "bluebubbles" || channel === "imessage") && /^\+?\d{6,}$/.test(trimmed)) { return "user"; } return "group"; } function normalizeDirectoryEntryId(channel: ChannelId, entry: ChannelDirectoryEntry): string { const normalized = normalizeTargetForProvider(channel, entry.id); return normalized ?? entry.id.trim(); } function matchesDirectoryEntry(params: { channel: ChannelId; entry: ChannelDirectoryEntry; query: string; }): boolean { const query = normalizeQuery(params.query); if (!query) return false; const id = stripTargetPrefixes(normalizeDirectoryEntryId(params.channel, params.entry)); const name = params.entry.name ? stripTargetPrefixes(params.entry.name) : ""; const handle = params.entry.handle ? stripTargetPrefixes(params.entry.handle) : ""; const candidates = [id, name, handle].map((value) => normalizeQuery(value)).filter(Boolean); return candidates.some((value) => value === query || value.includes(query)); } function resolveMatch(params: { channel: ChannelId; entries: ChannelDirectoryEntry[]; query: string; }) { const matches = params.entries.filter((entry) => matchesDirectoryEntry({ channel: params.channel, entry, query: params.query }), ); if (matches.length === 0) return { kind: "none" as const }; if (matches.length === 1) return { kind: "single" as const, entry: matches[0] }; return { kind: "ambiguous" as const, entries: matches }; } async function listDirectoryEntries(params: { cfg: MoltbotConfig; channel: ChannelId; accountId?: string | null; kind: ChannelDirectoryEntryKind; runtime?: RuntimeEnv; query?: string; source: "cache" | "live"; }): Promise { const plugin = getChannelPlugin(params.channel); const directory = plugin?.directory; if (!directory) return []; const runtime = params.runtime ?? defaultRuntime; const useLive = params.source === "live"; if (params.kind === "user") { const fn = useLive ? (directory.listPeersLive ?? directory.listPeers) : directory.listPeers; if (!fn) return []; return await fn({ cfg: params.cfg, accountId: params.accountId ?? undefined, query: params.query ?? undefined, limit: undefined, runtime, }); } const fn = useLive ? (directory.listGroupsLive ?? directory.listGroups) : directory.listGroups; if (!fn) return []; return await fn({ cfg: params.cfg, accountId: params.accountId ?? undefined, query: params.query ?? undefined, limit: undefined, runtime, }); } async function getDirectoryEntries(params: { cfg: MoltbotConfig; channel: ChannelId; accountId?: string | null; kind: ChannelDirectoryEntryKind; query?: string; runtime?: RuntimeEnv; preferLiveOnMiss?: boolean; }): Promise { const signature = buildTargetResolverSignature(params.channel); const cacheKey = buildDirectoryCacheKey({ channel: params.channel, accountId: params.accountId, kind: params.kind, source: "cache", signature, }); const cached = directoryCache.get(cacheKey, params.cfg); if (cached) return cached; const entries = await listDirectoryEntries({ cfg: params.cfg, channel: params.channel, accountId: params.accountId, kind: params.kind, query: params.query, runtime: params.runtime, source: "cache", }); if (entries.length > 0 || !params.preferLiveOnMiss) { directoryCache.set(cacheKey, entries, params.cfg); return entries; } const liveKey = buildDirectoryCacheKey({ channel: params.channel, accountId: params.accountId, kind: params.kind, source: "live", signature, }); const liveEntries = await listDirectoryEntries({ cfg: params.cfg, channel: params.channel, accountId: params.accountId, kind: params.kind, query: params.query, runtime: params.runtime, source: "live", }); directoryCache.set(liveKey, liveEntries, params.cfg); directoryCache.set(cacheKey, liveEntries, params.cfg); return liveEntries; } function pickAmbiguousMatch( entries: ChannelDirectoryEntry[], mode: ResolveAmbiguousMode, ): ChannelDirectoryEntry | null { if (entries.length === 0) return null; if (mode === "first") return entries[0] ?? null; const ranked = entries.map((entry) => ({ entry, rank: typeof entry.rank === "number" ? entry.rank : 0, })); const bestRank = Math.max(...ranked.map((item) => item.rank)); const best = ranked.find((item) => item.rank === bestRank)?.entry; return best ?? entries[0] ?? null; } export async function resolveMessagingTarget(params: { cfg: MoltbotConfig; channel: ChannelId; input: string; accountId?: string | null; preferredKind?: TargetResolveKind; runtime?: RuntimeEnv; resolveAmbiguous?: ResolveAmbiguousMode; }): Promise { const raw = normalizeChannelTargetInput(params.input); if (!raw) { return { ok: false, error: new Error("Target is required") }; } const plugin = getChannelPlugin(params.channel); const providerLabel = plugin?.meta?.label ?? params.channel; const hint = plugin?.messaging?.targetResolver?.hint; const kind = detectTargetKind(params.channel, raw, params.preferredKind); const normalized = normalizeTargetForProvider(params.channel, raw) ?? raw; const looksLikeTargetId = (): boolean => { const trimmed = raw.trim(); if (!trimmed) return false; const lookup = plugin?.messaging?.targetResolver?.looksLikeId; if (lookup) return lookup(trimmed, normalized); if (/^(channel|group|user):/i.test(trimmed)) return true; if (/^[@#]/.test(trimmed)) return true; if (/^\+?\d{6,}$/.test(trimmed)) { // BlueBubbles/iMessage phone numbers should usually resolve via the directory to a DM chat, // otherwise the provider may pick an existing group containing that handle. if (params.channel === "bluebubbles" || params.channel === "imessage") return false; return true; } if (trimmed.includes("@thread")) return true; if (/^(conversation|user):/i.test(trimmed)) return true; return false; }; if (looksLikeTargetId()) { const directTarget = preserveTargetCase(params.channel, raw, normalized); return { ok: true, target: { to: directTarget, kind, display: stripTargetPrefixes(raw), source: "normalized", }, }; } const query = stripTargetPrefixes(raw); const entries = await getDirectoryEntries({ cfg: params.cfg, channel: params.channel, accountId: params.accountId, kind: kind === "user" ? "user" : "group", query, runtime: params.runtime, preferLiveOnMiss: true, }); const match = resolveMatch({ channel: params.channel, entries, query }); if (match.kind === "single") { const entry = match.entry; return { ok: true, target: { to: normalizeDirectoryEntryId(params.channel, entry), kind, display: entry.name ?? entry.handle ?? stripTargetPrefixes(entry.id), source: "directory", }, }; } if (match.kind === "ambiguous") { const mode = params.resolveAmbiguous ?? "error"; if (mode !== "error") { const best = pickAmbiguousMatch(match.entries, mode); if (best) { return { ok: true, target: { to: normalizeDirectoryEntryId(params.channel, best), kind, display: best.name ?? best.handle ?? stripTargetPrefixes(best.id), source: "directory", }, }; } } return { ok: false, error: ambiguousTargetError(providerLabel, raw, hint), candidates: match.entries, }; } // For iMessage-style channels, allow sending directly to the normalized handle // even if the directory doesn't contain an entry yet. if ( (params.channel === "bluebubbles" || params.channel === "imessage") && /^\+?\d{6,}$/.test(query) ) { const directTarget = preserveTargetCase(params.channel, raw, normalized); return { ok: true, target: { to: directTarget, kind, display: stripTargetPrefixes(raw), source: "normalized", }, }; } return { ok: false, error: unknownTargetError(providerLabel, raw, hint), }; } export async function lookupDirectoryDisplay(params: { cfg: MoltbotConfig; channel: ChannelId; targetId: string; accountId?: string | null; runtime?: RuntimeEnv; }): Promise { const normalized = normalizeTargetForProvider(params.channel, params.targetId) ?? params.targetId; // Targets can resolve to either peers (DMs) or groups. Try both. const [groups, users] = await Promise.all([ getDirectoryEntries({ cfg: params.cfg, channel: params.channel, accountId: params.accountId, kind: "group", runtime: params.runtime, preferLiveOnMiss: false, }), getDirectoryEntries({ cfg: params.cfg, channel: params.channel, accountId: params.accountId, kind: "user", runtime: params.runtime, preferLiveOnMiss: false, }), ]); const findMatch = (candidates: ChannelDirectoryEntry[]) => candidates.find( (candidate) => normalizeDirectoryEntryId(params.channel, candidate) === normalized, ); const entry = findMatch(groups) ?? findMatch(users); return entry?.name ?? entry?.handle ?? undefined; }