fix: resolve Discord usernames to user IDs for outbound messages
When sending Discord messages via cron jobs or the message tool, usernames like "john.doe" were incorrectly treated as channel names, causing silent delivery failures. This fix adds a resolveDiscordTarget() function that: - Queries Discord directory to resolve usernames to user IDs - Falls back to standard parsing for known formats - Enables sending DMs by username without requiring explicit user:ID format Changes: - Added resolveDiscordTarget() in targets.ts with directory lookup - Added parseAndResolveRecipient() in send.shared.ts - Updated all outbound send functions to use username resolution Fixes #2627
This commit is contained in:
parent
57d9c09f6e
commit
7958ead91a
@ -13,7 +13,7 @@ import {
|
||||
createDiscordClient,
|
||||
normalizeDiscordPollInput,
|
||||
normalizeStickerIds,
|
||||
parseRecipient,
|
||||
parseAndResolveRecipient,
|
||||
resolveChannelId,
|
||||
sendDiscordMedia,
|
||||
sendDiscordText,
|
||||
@ -49,7 +49,7 @@ export async function sendMessageDiscord(
|
||||
const chunkMode = resolveChunkMode(cfg, "discord", accountInfo.accountId);
|
||||
const textWithTables = convertMarkdownTables(text ?? "", tableMode);
|
||||
const { token, rest, request } = createDiscordClient(opts, cfg);
|
||||
const recipient = parseRecipient(to);
|
||||
const recipient = await parseAndResolveRecipient(to, opts.accountId);
|
||||
const { channelId } = await resolveChannelId(rest, recipient, request);
|
||||
let result: { id: string; channel_id: string } | { id: string | null; channel_id: string };
|
||||
try {
|
||||
@ -104,7 +104,7 @@ export async function sendStickerDiscord(
|
||||
): Promise<DiscordSendResult> {
|
||||
const cfg = loadConfig();
|
||||
const { rest, request } = createDiscordClient(opts, cfg);
|
||||
const recipient = parseRecipient(to);
|
||||
const recipient = await parseAndResolveRecipient(to, opts.accountId);
|
||||
const { channelId } = await resolveChannelId(rest, recipient, request);
|
||||
const content = opts.content?.trim();
|
||||
const stickers = normalizeStickerIds(stickerIds);
|
||||
@ -131,7 +131,7 @@ export async function sendPollDiscord(
|
||||
): Promise<DiscordSendResult> {
|
||||
const cfg = loadConfig();
|
||||
const { rest, request } = createDiscordClient(opts, cfg);
|
||||
const recipient = parseRecipient(to);
|
||||
const recipient = await parseAndResolveRecipient(to, opts.accountId);
|
||||
const { channelId } = await resolveChannelId(rest, recipient, request);
|
||||
const content = opts.content?.trim();
|
||||
const payload = normalizeDiscordPollInput(poll);
|
||||
|
||||
@ -13,7 +13,7 @@ import type { ChunkMode } from "../auto-reply/chunk.js";
|
||||
import { chunkDiscordTextWithMode } from "./chunk.js";
|
||||
import { fetchChannelPermissionsDiscord, isThreadChannelType } from "./send.permissions.js";
|
||||
import { DiscordSendError } from "./send.types.js";
|
||||
import { parseDiscordTarget } from "./targets.js";
|
||||
import { parseDiscordTarget, resolveDiscordTarget } from "./targets.js";
|
||||
import { normalizeDiscordToken } from "./token.js";
|
||||
|
||||
const DISCORD_TEXT_LIMIT = 2000;
|
||||
@ -101,6 +101,44 @@ function parseRecipient(raw: string): DiscordRecipient {
|
||||
return { kind: target.kind, id: target.id };
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse and resolve Discord recipient, including username lookup.
|
||||
* This enables sending DMs by username (e.g., "john.doe") by querying
|
||||
* the Discord directory to resolve usernames to user IDs.
|
||||
*
|
||||
* @param raw - The recipient string (username, ID, or known format)
|
||||
* @param accountId - Discord account ID to use for directory lookup
|
||||
* @returns Parsed DiscordRecipient with resolved user ID if applicable
|
||||
*/
|
||||
export async function parseAndResolveRecipient(
|
||||
raw: string,
|
||||
accountId?: string,
|
||||
): Promise<DiscordRecipient> {
|
||||
const cfg = loadConfig();
|
||||
const accountInfo = resolveDiscordAccount({ cfg, accountId });
|
||||
|
||||
// First try to resolve using directory lookup (handles usernames)
|
||||
const resolved = await resolveDiscordTarget(raw, {
|
||||
cfg,
|
||||
accountId: accountInfo.accountId,
|
||||
});
|
||||
|
||||
if (resolved) {
|
||||
return { kind: resolved.kind, id: resolved.id };
|
||||
}
|
||||
|
||||
// Fallback to standard parsing (for channels, etc.)
|
||||
const parsed = parseDiscordTarget(raw, {
|
||||
ambiguousMessage: `Ambiguous Discord recipient "${raw.trim()}". Use "user:${raw.trim()}" for DMs or "channel:${raw.trim()}" for channel messages.`,
|
||||
});
|
||||
|
||||
if (!parsed) {
|
||||
throw new Error("Recipient is required for Discord sends");
|
||||
}
|
||||
|
||||
return { kind: parsed.kind, id: parsed.id };
|
||||
}
|
||||
|
||||
function normalizeStickerIds(raw: string[]) {
|
||||
const ids = raw.map((entry) => entry.trim()).filter(Boolean);
|
||||
if (ids.length === 0) {
|
||||
|
||||
@ -5,8 +5,13 @@ import {
|
||||
type MessagingTarget,
|
||||
type MessagingTargetKind,
|
||||
type MessagingTargetParseOptions,
|
||||
type DirectoryConfigParams,
|
||||
type ChannelDirectoryEntry,
|
||||
} from "../channels/targets.js";
|
||||
|
||||
import { listDiscordDirectoryPeersLive } from "./directory-live.js";
|
||||
import { resolveDiscordAccount } from "./accounts.js";
|
||||
|
||||
export type DiscordTargetKind = MessagingTargetKind;
|
||||
|
||||
export type DiscordTarget = MessagingTarget;
|
||||
@ -60,3 +65,60 @@ 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)
|
||||
* @returns Parsed MessagingTarget with user ID, or undefined if not found
|
||||
*/
|
||||
export async function resolveDiscordTarget(
|
||||
raw: string,
|
||||
options: DirectoryConfigParams,
|
||||
): Promise<MessagingTarget | undefined> {
|
||||
const trimmed = raw.trim();
|
||||
if (!trimmed) return undefined;
|
||||
|
||||
// If already a known format, parse directly
|
||||
const directParse = parseDiscordTarget(trimmed, options);
|
||||
if (directParse && directParse.kind !== "channel" && !isLikelyUsername(trimmed)) {
|
||||
return directParse;
|
||||
}
|
||||
|
||||
// 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:<id>")
|
||||
const userId = match.id.replace(/^user:/, "");
|
||||
return buildMessagingTarget("user", userId, trimmed);
|
||||
}
|
||||
} catch (error) {
|
||||
// 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, options);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user