import type { DirectoryConfigParams } from "openclaw/plugin-sdk/channel-runtime"; import { buildMessagingTarget, parseMentionPrefixOrAtUserTarget, requireTargetKind, type MessagingTarget, type MessagingTargetKind, type MessagingTargetParseOptions, } from "openclaw/plugin-sdk/channel-runtime"; import { rememberDiscordDirectoryUser } from "./directory-cache.js"; import { listDiscordDirectoryPeersLive } from "./directory-live.js"; export type DiscordTargetKind = MessagingTargetKind; export type DiscordTarget = MessagingTarget; type DiscordTargetParseOptions = MessagingTargetParseOptions; export function parseDiscordTarget( raw: string, options: DiscordTargetParseOptions = {}, ): DiscordTarget | undefined { const trimmed = raw.trim(); if (!trimmed) { return undefined; } const userTarget = parseMentionPrefixOrAtUserTarget({ raw: trimmed, mentionPattern: /^<@!?(\d+)>$/, prefixes: [ { prefix: "user:", kind: "user" }, { prefix: "channel:", kind: "channel" }, { prefix: "discord:", kind: "user" }, ], atUserPattern: /^\d+$/, atUserErrorMessage: "Discord DMs require a user id (use user: or a <@id> mention)", }); if (userTarget) { return userTarget; } if (/^\d+$/.test(trimmed)) { if (options.defaultKind) { return buildMessagingTarget(options.defaultKind, trimmed, trimmed); } throw new Error( options.ambiguousMessage ?? `Ambiguous Discord recipient "${trimmed}". Use "user:${trimmed}" for DMs or "channel:${trimmed}" for channel messages.`, ); } return buildMessagingTarget("channel", trimmed, trimmed); } export function resolveDiscordChannelId(raw: string): string { const target = parseDiscordTarget(raw, { defaultKind: "channel" }); return requireTargetKind({ platform: "Discord", target, kind: "channel" }); } /** * Resolve a Discord username to user ID using the directory lookup. * This enables sending DMs by username instead of requiring explicit user IDs. * * @param raw - The username or raw target string (e.g., "john.doe") * @param options - Directory configuration params (cfg, accountId, limit) * @param parseOptions - Messaging target parsing options (defaults, ambiguity message) * @returns Parsed MessagingTarget with user ID, or undefined if not found */ export async function resolveDiscordTarget( raw: string, options: DirectoryConfigParams, parseOptions: DiscordTargetParseOptions = {}, ): Promise { const trimmed = raw.trim(); if (!trimmed) { return undefined; } const likelyUsername = isLikelyUsername(trimmed); const shouldLookup = isExplicitUserLookup(trimmed, parseOptions) || likelyUsername; // Parse directly if it's already a known format. Use a safe parse so ambiguous // numeric targets don't throw when we still want to attempt username lookup. const directParse = safeParseDiscordTarget(trimmed, parseOptions); if (directParse && directParse.kind !== "channel" && !likelyUsername) { return directParse; } if (!shouldLookup) { return directParse ?? parseDiscordTarget(trimmed, parseOptions); } // Try to resolve as a username via directory lookup try { const directoryEntries = await listDiscordDirectoryPeersLive({ ...options, query: trimmed, limit: 1, }); const match = directoryEntries[0]; if (match && match.kind === "user") { // Extract user ID from the directory entry (format: "user:") const userId = match.id.replace(/^user:/, ""); rememberDiscordDirectoryUser({ accountId: options.accountId, userId, handles: [trimmed, match.name, match.handle], }); return buildMessagingTarget("user", userId, trimmed); } } catch { // Directory lookup failed - fall through to parse as-is // This preserves existing behavior for channel names } // Fallback to original parsing (for channels, etc.) return parseDiscordTarget(trimmed, parseOptions); } function safeParseDiscordTarget( input: string, options: DiscordTargetParseOptions, ): MessagingTarget | undefined { try { return parseDiscordTarget(input, options); } catch { return undefined; } } function isExplicitUserLookup(input: string, options: DiscordTargetParseOptions): boolean { if (/^<@!?(\d+)>$/.test(input)) { return true; } if (/^(user:|discord:)/.test(input)) { return true; } if (input.startsWith("@")) { return true; } if (/^\d+$/.test(input)) { return options.defaultKind === "user"; } return false; } /** * Check if a string looks like a Discord username (not a mention, prefix, or ID). * Usernames typically don't start with special characters except underscore. */ function isLikelyUsername(input: string): boolean { // Skip if it's already a known format if (/^(user:|channel:|discord:|@|<@!?)|[\d]+$/.test(input)) { return false; } // Likely a username if it doesn't match known patterns return true; }