This commit is contained in:
Nattapong Tapachoom 2026-01-28 10:16:43 +07:00
commit e4875d5044
9 changed files with 171 additions and 19 deletions

View File

@ -80,6 +80,8 @@ Status: beta.
- Agents: guard channel tool listActions to avoid plugin crashes. (#2859) Thanks @mbelinky. - Agents: guard channel tool listActions to avoid plugin crashes. (#2859) Thanks @mbelinky.
- Providers: update MiniMax API endpoint and compatibility mode. (#3064) Thanks @hlbbbbbbb. - Providers: update MiniMax API endpoint and compatibility mode. (#3064) Thanks @hlbbbbbbb.
- Telegram: treat more network errors as recoverable in polling. (#3013) Thanks @ryancontent. - 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. - 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. - 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. - Security: pin npm overrides to keep tar@7.5.4 for install toolchains.

View File

@ -130,9 +130,10 @@ Moonshot uses OpenAI-compatible endpoints, so configure it as a custom provider:
- Provider: `moonshot` - Provider: `moonshot`
- Auth: `MOONSHOT_API_KEY` - Auth: `MOONSHOT_API_KEY`
- Example model: `moonshot/kimi-k2-0905-preview` - Example model: `moonshot/kimi-k2.5`
- Kimi K2 model IDs: - Kimi K2 model IDs:
{/* moonshot-kimi-k2-model-refs:start */} {/* moonshot-kimi-k2-model-refs:start */}
- `moonshot/kimi-k2.5`
- `moonshot/kimi-k2-0905-preview` - `moonshot/kimi-k2-0905-preview`
- `moonshot/kimi-k2-turbo-preview` - `moonshot/kimi-k2-turbo-preview`
- `moonshot/kimi-k2-thinking` - `moonshot/kimi-k2-thinking`
@ -141,7 +142,7 @@ Moonshot uses OpenAI-compatible endpoints, so configure it as a custom provider:
```json5 ```json5
{ {
agents: { agents: {
defaults: { model: { primary: "moonshot/kimi-k2-0905-preview" } } defaults: { model: { primary: "moonshot/kimi-k2.5" } }
}, },
models: { models: {
mode: "merge", mode: "merge",
@ -150,7 +151,7 @@ Moonshot uses OpenAI-compatible endpoints, so configure it as a custom provider:
baseUrl: "https://api.moonshot.ai/v1", baseUrl: "https://api.moonshot.ai/v1",
apiKey: "${MOONSHOT_API_KEY}", apiKey: "${MOONSHOT_API_KEY}",
api: "openai-completions", api: "openai-completions",
models: [{ id: "kimi-k2-0905-preview", name: "Kimi K2 0905 Preview" }] models: [{ id: "kimi-k2.5", name: "Kimi K2.5" }]
} }
} }
} }

View File

@ -2396,8 +2396,8 @@ Use Moonshot's OpenAI-compatible endpoint:
env: { MOONSHOT_API_KEY: "sk-..." }, env: { MOONSHOT_API_KEY: "sk-..." },
agents: { agents: {
defaults: { defaults: {
model: { primary: "moonshot/kimi-k2-0905-preview" }, model: { primary: "moonshot/kimi-k2.5" },
models: { "moonshot/kimi-k2-0905-preview": { alias: "Kimi K2" } } models: { "moonshot/kimi-k2.5": { alias: "Kimi K2.5" } }
} }
}, },
models: { models: {
@ -2409,8 +2409,8 @@ Use Moonshot's OpenAI-compatible endpoint:
api: "openai-completions", api: "openai-completions",
models: [ models: [
{ {
id: "kimi-k2-0905-preview", id: "kimi-k2.5",
name: "Kimi K2 0905 Preview", name: "Kimi K2.5",
reasoning: false, reasoning: false,
input: ["text"], input: ["text"],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
@ -2426,7 +2426,7 @@ Use Moonshot's OpenAI-compatible endpoint:
Notes: Notes:
- Set `MOONSHOT_API_KEY` in the environment or use `moltbot onboard --auth-choice moonshot-api-key`. - 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. - Use `https://api.moonshot.cn/v1` if you need the China endpoint.
### Kimi Code ### Kimi Code

View File

@ -9,11 +9,12 @@ read_when:
# Moonshot AI (Kimi) # Moonshot AI (Kimi)
Moonshot provides the Kimi API with OpenAI-compatible endpoints. Configure the 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`. Kimi Code with `kimi-code/kimi-for-coding`.
Current Kimi K2 model IDs: Current Kimi K2 model IDs:
{/* moonshot-kimi-k2-ids:start */} {/* moonshot-kimi-k2-ids:start */}
- `kimi-k2.5`
- `kimi-k2-0905-preview` - `kimi-k2-0905-preview`
- `kimi-k2-turbo-preview` - `kimi-k2-turbo-preview`
- `kimi-k2-thinking` - `kimi-k2-thinking`
@ -39,9 +40,10 @@ Note: Moonshot and Kimi Code are separate providers. Keys are not interchangeabl
env: { MOONSHOT_API_KEY: "sk-..." }, env: { MOONSHOT_API_KEY: "sk-..." },
agents: { agents: {
defaults: { defaults: {
model: { primary: "moonshot/kimi-k2-0905-preview" }, model: { primary: "moonshot/kimi-k2.5" },
models: { models: {
// moonshot-kimi-k2-aliases:start // moonshot-kimi-k2-aliases:start
"moonshot/kimi-k2.5": { alias: "Kimi K2.5" },
"moonshot/kimi-k2-0905-preview": { alias: "Kimi K2" }, "moonshot/kimi-k2-0905-preview": { alias: "Kimi K2" },
"moonshot/kimi-k2-turbo-preview": { alias: "Kimi K2 Turbo" }, "moonshot/kimi-k2-turbo-preview": { alias: "Kimi K2 Turbo" },
"moonshot/kimi-k2-thinking": { alias: "Kimi K2 Thinking" }, "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", api: "openai-completions",
models: [ models: [
// moonshot-kimi-k2-models:start // 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", id: "kimi-k2-0905-preview",
name: "Kimi K2 0905 Preview", name: "Kimi K2 0905 Preview",

View File

@ -31,7 +31,7 @@ const MINIMAX_API_COST = {
}; };
const MOONSHOT_BASE_URL = "https://api.moonshot.ai/v1"; 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_CONTEXT_WINDOW = 256000;
const MOONSHOT_DEFAULT_MAX_TOKENS = 8192; const MOONSHOT_DEFAULT_MAX_TOKENS = 8192;
const MOONSHOT_DEFAULT_COST = { const MOONSHOT_DEFAULT_COST = {
@ -275,7 +275,7 @@ function buildMoonshotProvider(): ProviderConfig {
models: [ models: [
{ {
id: MOONSHOT_DEFAULT_MODEL_ID, id: MOONSHOT_DEFAULT_MODEL_ID,
name: "Kimi K2 0905 Preview", name: "Kimi K2.5",
reasoning: false, reasoning: false,
input: ["text"], input: ["text"],
cost: MOONSHOT_DEFAULT_COST, cost: MOONSHOT_DEFAULT_COST,

View File

@ -13,7 +13,7 @@ import {
createDiscordClient, createDiscordClient,
normalizeDiscordPollInput, normalizeDiscordPollInput,
normalizeStickerIds, normalizeStickerIds,
parseRecipient, parseAndResolveRecipient,
resolveChannelId, resolveChannelId,
sendDiscordMedia, sendDiscordMedia,
sendDiscordText, sendDiscordText,
@ -49,7 +49,7 @@ export async function sendMessageDiscord(
const chunkMode = resolveChunkMode(cfg, "discord", accountInfo.accountId); const chunkMode = resolveChunkMode(cfg, "discord", accountInfo.accountId);
const textWithTables = convertMarkdownTables(text ?? "", tableMode); const textWithTables = convertMarkdownTables(text ?? "", tableMode);
const { token, rest, request } = createDiscordClient(opts, cfg); 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); const { channelId } = await resolveChannelId(rest, recipient, request);
let result: { id: string; channel_id: string } | { id: string | null; channel_id: string }; let result: { id: string; channel_id: string } | { id: string | null; channel_id: string };
try { try {
@ -104,7 +104,7 @@ export async function sendStickerDiscord(
): Promise<DiscordSendResult> { ): Promise<DiscordSendResult> {
const cfg = loadConfig(); const cfg = loadConfig();
const { rest, request } = createDiscordClient(opts, cfg); 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 { channelId } = await resolveChannelId(rest, recipient, request);
const content = opts.content?.trim(); const content = opts.content?.trim();
const stickers = normalizeStickerIds(stickerIds); const stickers = normalizeStickerIds(stickerIds);
@ -131,7 +131,7 @@ export async function sendPollDiscord(
): Promise<DiscordSendResult> { ): Promise<DiscordSendResult> {
const cfg = loadConfig(); const cfg = loadConfig();
const { rest, request } = createDiscordClient(opts, cfg); 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 { channelId } = await resolveChannelId(rest, recipient, request);
const content = opts.content?.trim(); const content = opts.content?.trim();
const payload = normalizeDiscordPollInput(poll); const payload = normalizeDiscordPollInput(poll);

View File

@ -13,7 +13,7 @@ import type { ChunkMode } from "../auto-reply/chunk.js";
import { chunkDiscordTextWithMode } from "./chunk.js"; import { chunkDiscordTextWithMode } from "./chunk.js";
import { fetchChannelPermissionsDiscord, isThreadChannelType } from "./send.permissions.js"; import { fetchChannelPermissionsDiscord, isThreadChannelType } from "./send.permissions.js";
import { DiscordSendError } from "./send.types.js"; import { DiscordSendError } from "./send.types.js";
import { parseDiscordTarget } from "./targets.js"; import { parseDiscordTarget, resolveDiscordTarget } from "./targets.js";
import { normalizeDiscordToken } from "./token.js"; import { normalizeDiscordToken } from "./token.js";
const DISCORD_TEXT_LIMIT = 2000; const DISCORD_TEXT_LIMIT = 2000;
@ -101,6 +101,44 @@ function parseRecipient(raw: string): DiscordRecipient {
return { kind: target.kind, id: target.id }; 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[]) { function normalizeStickerIds(raw: string[]) {
const ids = raw.map((entry) => entry.trim()).filter(Boolean); const ids = raw.map((entry) => entry.trim()).filter(Boolean);
if (ids.length === 0) { if (ids.length === 0) {

View File

@ -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 { 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", () => { describe("parseDiscordTarget", () => {
it("parses user mention and prefixes", () => { 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", () => { describe("normalizeDiscordMessagingTarget", () => {
it("defaults raw numeric ids to channels", () => { it("defaults raw numeric ids to channels", () => {
expect(normalizeDiscordMessagingTarget("123")).toBe("channel:123"); expect(normalizeDiscordMessagingTarget("123")).toBe("channel:123");

View File

@ -5,8 +5,13 @@ import {
type MessagingTarget, type MessagingTarget,
type MessagingTargetKind, type MessagingTargetKind,
type MessagingTargetParseOptions, type MessagingTargetParseOptions,
type DirectoryConfigParams,
type ChannelDirectoryEntry,
} from "../channels/targets.js"; } from "../channels/targets.js";
import { listDiscordDirectoryPeersLive } from "./directory-live.js";
import { resolveDiscordAccount } from "./accounts.js";
export type DiscordTargetKind = MessagingTargetKind; export type DiscordTargetKind = MessagingTargetKind;
export type DiscordTarget = MessagingTarget; export type DiscordTarget = MessagingTarget;
@ -60,3 +65,60 @@ export function resolveDiscordChannelId(raw: string): string {
const target = parseDiscordTarget(raw, { defaultKind: "channel" }); const target = parseDiscordTarget(raw, { defaultKind: "channel" });
return requireTargetKind({ platform: "Discord", target, kind: "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;
}