From 7958ead91a724c18fda084776173504492ad813b Mon Sep 17 00:00:00 2001 From: "nonggia.liang" Date: Tue, 27 Jan 2026 15:55:53 +0800 Subject: [PATCH 1/6] 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 --- src/discord/send.outbound.ts | 8 ++--- src/discord/send.shared.ts | 40 ++++++++++++++++++++++- src/discord/targets.ts | 62 ++++++++++++++++++++++++++++++++++++ 3 files changed, 105 insertions(+), 5 deletions(-) diff --git a/src/discord/send.outbound.ts b/src/discord/send.outbound.ts index a47d0f4f1..22b402ae3 100644 --- a/src/discord/send.outbound.ts +++ b/src/discord/send.outbound.ts @@ -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 { 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 { 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); diff --git a/src/discord/send.shared.ts b/src/discord/send.shared.ts index 4919be29d..1cf2a93a9 100644 --- a/src/discord/send.shared.ts +++ b/src/discord/send.shared.ts @@ -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 { + 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) { diff --git a/src/discord/targets.ts b/src/discord/targets.ts index 3a3c93ec8..311955182 100644 --- a/src/discord/targets.ts +++ b/src/discord/targets.ts @@ -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 { + 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:") + 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; +} From cf827f03e8999189d34101f03a87414e4902da70 Mon Sep 17 00:00:00 2001 From: Shadow Date: Tue, 27 Jan 2026 20:31:51 -0600 Subject: [PATCH 2/6] tests: cover Discord username resolution --- src/discord/targets.test.ts | 42 +++++++++++++++++++++++++++++++++++-- 1 file changed, 40 insertions(+), 2 deletions(-) diff --git a/src/discord/targets.test.ts b/src/discord/targets.test.ts index 3eee1eb1e..7ac39450b 100644 --- a/src/discord/targets.test.ts +++ b/src/discord/targets.test.ts @@ -1,7 +1,13 @@ -import { describe, expect, it } from "vitest"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { ClawdbotConfig } from "../config/config.js"; import { normalizeDiscordMessagingTarget } from "../channels/plugins/normalize/discord.js"; -import { parseDiscordTarget, resolveDiscordChannelId } from "./targets.js"; +import { listDiscordDirectoryPeersLive } from "./directory-live.js"; +import { parseDiscordTarget, resolveDiscordChannelId, resolveDiscordTarget } from "./targets.js"; + +vi.mock("./directory-live.js", () => ({ + listDiscordDirectoryPeersLive: vi.fn(), +})); describe("parseDiscordTarget", () => { it("parses user mention and prefixes", () => { @@ -68,6 +74,38 @@ describe("resolveDiscordChannelId", () => { }); }); +describe("resolveDiscordTarget", () => { + const cfg = { channels: { discord: {} } } as ClawdbotConfig; + const listPeers = vi.mocked(listDiscordDirectoryPeersLive); + + beforeEach(() => { + listPeers.mockReset(); + }); + + it("returns a resolved user for usernames", async () => { + listPeers.mockResolvedValueOnce([{ kind: "user", id: "user:999", name: "Jane" } as const]); + + await expect( + resolveDiscordTarget("jane", { cfg, accountId: "default" }), + ).resolves.toMatchObject({ kind: "user", id: "999", normalized: "user:999" }); + }); + + it("falls back to parsing when lookup misses", async () => { + listPeers.mockResolvedValueOnce([]); + await expect( + resolveDiscordTarget("general", { cfg, accountId: "default" }), + ).resolves.toMatchObject({ kind: "channel", id: "general" }); + }); + + it("does not call directory lookup for explicit user ids", async () => { + listPeers.mockResolvedValueOnce([]); + await expect( + resolveDiscordTarget("user:123", { cfg, accountId: "default" }), + ).resolves.toMatchObject({ kind: "user", id: "123" }); + expect(listPeers).not.toHaveBeenCalled(); + }); +}); + describe("normalizeDiscordMessagingTarget", () => { it("defaults raw numeric ids to channels", () => { expect(normalizeDiscordMessagingTarget("123")).toBe("channel:123"); From 7bfe6ab2d64e5fe635a775d8805847843e28b487 Mon Sep 17 00:00:00 2001 From: Shadow Date: Tue, 27 Jan 2026 21:04:38 -0600 Subject: [PATCH 3/6] fix: resolve Discord usernames for outbound sends (#2649) (thanks @nonggialiang) --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9e503882d..79bca9908 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -80,6 +80,7 @@ Status: beta. - Agents: guard channel tool listActions to avoid plugin crashes. (#2859) Thanks @mbelinky. - Providers: update MiniMax API endpoint and compatibility mode. (#3064) Thanks @hlbbbbbbb. - Telegram: treat more network errors as recoverable in polling. (#3013) Thanks @ryancontent. +- Discord: resolve usernames to user IDs for outbound messages. (#2649) Thanks @nonggialiang. - Gateway: suppress AbortError and transient network errors in unhandled rejections. (#2451) Thanks @Glucksberg. - TTS: keep /tts status replies on text-only commands and avoid duplicate block-stream audio. (#2451) Thanks @Glucksberg. - Security: pin npm overrides to keep tar@7.5.4 for install toolchains. From 394308076aefadefb3a718a4cddce46004d8dffe Mon Sep 17 00:00:00 2001 From: Boran Cui Date: Tue, 27 Jan 2026 21:10:09 +0800 Subject: [PATCH 4/6] Update Moonshot Kimi model references from kimi-k2-0905-preview to the latest kimi-k2.5 --- docs/concepts/model-providers.md | 7 ++++--- docs/gateway/configuration.md | 10 +++++----- docs/providers/moonshot.md | 15 +++++++++++++-- 3 files changed, 22 insertions(+), 10 deletions(-) diff --git a/docs/concepts/model-providers.md b/docs/concepts/model-providers.md index ef27fc9e3..9dbb984fc 100644 --- a/docs/concepts/model-providers.md +++ b/docs/concepts/model-providers.md @@ -130,9 +130,10 @@ Moonshot uses OpenAI-compatible endpoints, so configure it as a custom provider: - Provider: `moonshot` - Auth: `MOONSHOT_API_KEY` -- Example model: `moonshot/kimi-k2-0905-preview` +- Example model: `moonshot/kimi-k2.5` - Kimi K2 model IDs: {/* moonshot-kimi-k2-model-refs:start */} + - `moonshot/kimi-k2.5` - `moonshot/kimi-k2-0905-preview` - `moonshot/kimi-k2-turbo-preview` - `moonshot/kimi-k2-thinking` @@ -141,7 +142,7 @@ Moonshot uses OpenAI-compatible endpoints, so configure it as a custom provider: ```json5 { agents: { - defaults: { model: { primary: "moonshot/kimi-k2-0905-preview" } } + defaults: { model: { primary: "moonshot/kimi-k2.5" } } }, models: { mode: "merge", @@ -150,7 +151,7 @@ Moonshot uses OpenAI-compatible endpoints, so configure it as a custom provider: baseUrl: "https://api.moonshot.ai/v1", apiKey: "${MOONSHOT_API_KEY}", api: "openai-completions", - models: [{ id: "kimi-k2-0905-preview", name: "Kimi K2 0905 Preview" }] + models: [{ id: "kimi-k2.5", name: "Kimi K2.5" }] } } } diff --git a/docs/gateway/configuration.md b/docs/gateway/configuration.md index f5438fb46..15261c809 100644 --- a/docs/gateway/configuration.md +++ b/docs/gateway/configuration.md @@ -2396,8 +2396,8 @@ Use Moonshot's OpenAI-compatible endpoint: env: { MOONSHOT_API_KEY: "sk-..." }, agents: { defaults: { - model: { primary: "moonshot/kimi-k2-0905-preview" }, - models: { "moonshot/kimi-k2-0905-preview": { alias: "Kimi K2" } } + model: { primary: "moonshot/kimi-k2.5" }, + models: { "moonshot/kimi-k2.5": { alias: "Kimi K2.5" } } } }, models: { @@ -2409,8 +2409,8 @@ Use Moonshot's OpenAI-compatible endpoint: api: "openai-completions", models: [ { - id: "kimi-k2-0905-preview", - name: "Kimi K2 0905 Preview", + id: "kimi-k2.5", + name: "Kimi K2.5", reasoning: false, input: ["text"], cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, @@ -2426,7 +2426,7 @@ Use Moonshot's OpenAI-compatible endpoint: Notes: - Set `MOONSHOT_API_KEY` in the environment or use `moltbot onboard --auth-choice moonshot-api-key`. -- Model ref: `moonshot/kimi-k2-0905-preview`. +- Model ref: `moonshot/kimi-k2.5`. - Use `https://api.moonshot.cn/v1` if you need the China endpoint. ### Kimi Code diff --git a/docs/providers/moonshot.md b/docs/providers/moonshot.md index 7e0723f7e..a1f2d18ad 100644 --- a/docs/providers/moonshot.md +++ b/docs/providers/moonshot.md @@ -9,11 +9,12 @@ read_when: # Moonshot AI (Kimi) Moonshot provides the Kimi API with OpenAI-compatible endpoints. Configure the -provider and set the default model to `moonshot/kimi-k2-0905-preview`, or use +provider and set the default model to `moonshot/kimi-k2.5`, or use Kimi Code with `kimi-code/kimi-for-coding`. Current Kimi K2 model IDs: {/* moonshot-kimi-k2-ids:start */} +- `kimi-k2.5` - `kimi-k2-0905-preview` - `kimi-k2-turbo-preview` - `kimi-k2-thinking` @@ -39,9 +40,10 @@ Note: Moonshot and Kimi Code are separate providers. Keys are not interchangeabl env: { MOONSHOT_API_KEY: "sk-..." }, agents: { defaults: { - model: { primary: "moonshot/kimi-k2-0905-preview" }, + model: { primary: "moonshot/kimi-k2.5" }, models: { // moonshot-kimi-k2-aliases:start + "moonshot/kimi-k2.5": { alias: "Kimi K2.5" }, "moonshot/kimi-k2-0905-preview": { alias: "Kimi K2" }, "moonshot/kimi-k2-turbo-preview": { alias: "Kimi K2 Turbo" }, "moonshot/kimi-k2-thinking": { alias: "Kimi K2 Thinking" }, @@ -59,6 +61,15 @@ Note: Moonshot and Kimi Code are separate providers. Keys are not interchangeabl api: "openai-completions", models: [ // moonshot-kimi-k2-models:start + { + id: "kimi-k2.5", + name: "Kimi K2.5", + reasoning: false, + input: ["text"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 256000, + maxTokens: 8192 + }, { id: "kimi-k2-0905-preview", name: "Kimi K2 0905 Preview", From b8aa041dcc469d77a5016b28f4e6c8a485a1bc41 Mon Sep 17 00:00:00 2001 From: Boran Cui Date: Tue, 27 Jan 2026 21:15:57 +0800 Subject: [PATCH 5/6] Update Moonshot Kimi model references to kimi-k2.5 --- src/agents/models-config.providers.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/agents/models-config.providers.ts b/src/agents/models-config.providers.ts index cb556aced..a176dac8a 100644 --- a/src/agents/models-config.providers.ts +++ b/src/agents/models-config.providers.ts @@ -31,7 +31,7 @@ const MINIMAX_API_COST = { }; const MOONSHOT_BASE_URL = "https://api.moonshot.ai/v1"; -const MOONSHOT_DEFAULT_MODEL_ID = "kimi-k2-0905-preview"; +const MOONSHOT_DEFAULT_MODEL_ID = "kimi-k2.5"; const MOONSHOT_DEFAULT_CONTEXT_WINDOW = 256000; const MOONSHOT_DEFAULT_MAX_TOKENS = 8192; const MOONSHOT_DEFAULT_COST = { @@ -275,7 +275,7 @@ function buildMoonshotProvider(): ProviderConfig { models: [ { id: MOONSHOT_DEFAULT_MODEL_ID, - name: "Kimi K2 0905 Preview", + name: "Kimi K2.5", reasoning: false, input: ["text"], cost: MOONSHOT_DEFAULT_COST, From d0ef4d3b85bb2df98925169d50e38042fe28f161 Mon Sep 17 00:00:00 2001 From: Shadow Date: Tue, 27 Jan 2026 21:09:48 -0600 Subject: [PATCH 6/6] fix: update Moonshot Kimi model references (#2762) (thanks @MarvinCui) --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 79bca9908..6a68c3769 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -81,6 +81,7 @@ Status: beta. - Providers: update MiniMax API endpoint and compatibility mode. (#3064) Thanks @hlbbbbbbb. - Telegram: treat more network errors as recoverable in polling. (#3013) Thanks @ryancontent. - Discord: resolve usernames to user IDs for outbound messages. (#2649) Thanks @nonggialiang. +- Providers: update Moonshot Kimi model references to kimi-k2.5. (#2762) Thanks @MarvinCui. - Gateway: suppress AbortError and transient network errors in unhandled rejections. (#2451) Thanks @Glucksberg. - TTS: keep /tts status replies on text-only commands and avoid duplicate block-stream audio. (#2451) Thanks @Glucksberg. - Security: pin npm overrides to keep tar@7.5.4 for install toolchains.