Compare commits

...
Author SHA1 Message Date
Ayaan Zaidi a81c1daca7 fix: restore discord username lookup and align minimax test (#3131) (thanks @bonald) 2026-01-28 11:01:54 +05:30
Jarvis a3ff7daf70 test: update MiniMax API URL expectation to match #3064
The MiniMax provider config was updated to use api.minimax.chat
instead of api.minimax.io in PR #3064, but the test expectation
was not updated.

🤖 Generated with Claude Code
2026-01-28 11:01:54 +05:30
Jarvis e59e6aa757 fix(discord): add missing type exports and fix unused imports
- Re-export DirectoryConfigParams and ChannelDirectoryEntry from channels/targets
- Remove unused ChannelDirectoryEntry and resolveDiscordAccount imports
- Fix parseDiscordTarget calls to not pass incompatible options type
- Fix unused catch parameter

Fixes CI build failures on main.

🤖 Generated with Claude Code
2026-01-28 11:01:54 +05:30
Shadow 6fc3ca4996 CI: add auto-response labels 2026-01-27 23:17:22 -06:00
Shadow 61ab348dd3 Discord: fix target type imports 2026-01-27 22:56:12 -06:00
Shadow b01612c262 Discord: gate username lookups 2026-01-27 22:48:18 -06:00
Ayaan Zaidi 14e4b88bf0 fix: keep telegram dm thread sessions (#2731) (thanks @dylanneve1) 2026-01-28 09:32:20 +05:30
Dylan Neve 915497114e fix(telegram): ignore message_thread_id for non-forum group sessions
Regular Telegram groups (without Topics/Forums enabled) can send
message_thread_id when users reply to messages. This was incorrectly
being used to create separate session keys like '-123:topic:42',
causing each reply chain to get its own conversation context.

Now resolveTelegramForumThreadId only returns a thread ID when the
chat is actually a forum (is_forum=true). For regular groups, the
thread ID is ignored, ensuring all messages share the same session.

DMs continue to use messageThreadId for thread sessions as before.
2026-01-28 09:32:20 +05:30
Gustavo Madeira Santana 8f452dbc08 Update wizard header with new ASCII art 2026-01-27 22:30:38 -05:00
Gustavo Madeira Santana c5effb78f3 Modify CLI banner ASCII art
Updated the ASCII art for the CLI banner.
2026-01-27 22:29:09 -05:00
Shadow d0ef4d3b85 fix: update Moonshot Kimi model references (#2762) (thanks @MarvinCui) 2026-01-27 21:10:59 -06:00
Boran Cui b8aa041dcc Update Moonshot Kimi model references to kimi-k2.5 2026-01-27 21:10:59 -06:00
Boran Cui 394308076a Update Moonshot Kimi model references from kimi-k2-0905-preview to the latest kimi-k2.5 2026-01-27 21:10:59 -06:00
Shadow 7bfe6ab2d6 fix: resolve Discord usernames for outbound sends (#2649) (thanks @nonggialiang) 2026-01-27 21:05:37 -06:00
Shadow cf827f03e8 tests: cover Discord username resolution 2026-01-27 21:05:37 -06:00
nonggia.liang 7958ead91a 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
2026-01-27 21:05:37 -06:00
Shadow 57d9c09f6e fix: expand Telegram polling network recovery (#3013) (thanks @ryancontent) 2026-01-27 19:56:24 -06:00
ryan 558b64f5fa fix: handle Telegram network errors gracefully to prevent gateway crashes
- Expand recoverable error codes (ECONNABORTED, ERR_NETWORK)
- Add message patterns for 'typeerror: fetch failed' and 'undici' errors
- Add isNetworkRelatedError() helper for broad network failure detection
- Retry on all network-related errors instead of crashing gateway
- Remove unnecessary 'void' from fire-and-forget patterns
- Add tests for new error patterns

Fixes #3005
2026-01-27 19:56:24 -06:00
Shadow eb50314d7d fix: update MiniMax provider config (#3064) (thanks @hlbbbbbbb) 2026-01-27 19:48:38 -06:00
hlbbbbbbb 2496056886 fix(minimax): use correct API endpoint and format
MiniMax has updated their API. The previous configuration used an
incorrect endpoint (api.minimax.io/anthropic) with anthropic-messages
format, which no longer works.

Changes:
- Update MINIMAX_API_BASE_URL to https://api.minimax.chat/v1
- Change API format from anthropic-messages to openai-completions
- Remove minimax from isAnthropicApi check in transcript-policy

This fixes the issue where MiniMax API calls return no results.
2026-01-27 19:48:38 -06:00
27 changed files with 455 additions and 59 deletions

No files matched your search

+14 -1
View File
@@ -24,13 +24,26 @@ jobs:
with:
github-token: ${{ steps.app-token.outputs.token }}
script: |
// Labels prefixed with "r:" are auto-response triggers.
const rules = [
{
label: "skill-clawdhub",
label: "r: skill",
close: true,
message:
"Thanks for the contribution! New skills should be published to Clawdhub for everyone to use. We’re keeping the core lean on skills, so I’m closing this out.",
},
{
label: "r: support",
close: true,
message:
"Please use our support server https://molt.bot/discord and ask in #help or #users-helping-users to resolve this, or follow the stuck FAQ at https://docs.molt.bot/help/faq#im-stuck-whats-the-fastest-way-to-get-unstuck.",
},
{
label: "r: third-party-extension",
close: true,
message:
"This would be better made as a third-party extension with our SDK that you maintain yourself. Docs: https://docs.molt.bot/plugin.",
},
];
const labelName = context.payload.label?.name;
+9
View File
@@ -71,6 +71,8 @@ Status: beta.
- **BREAKING:** Gateway auth mode "none" is removed; gateway now requires token/password (Tailscale Serve identity still allowed).
### Fixes
- Discord: restore username directory lookup in target resolution. (#3131) Thanks @bonald.
- Agents: align MiniMax base URL test expectation with default provider config. (#3131) Thanks @bonald.
- Agents: prevent retries on oversized image errors and surface size limits. (#2871) Thanks @Suksham-sharma.
- Agents: inherit provider baseUrl/api for inline models. (#2740) Thanks @lploc94.
- Memory Search: keep auto provider model defaults and only include remote when configured. (#2576) Thanks @papago2355.
@@ -78,6 +80,12 @@ Status: beta.
- Web UI: auto-expand the chat compose textarea while typing (with sensible max height). (#2950) Thanks @shivamraut101.
- Gateway: prevent crashes on transient network errors (fetch failures, timeouts, DNS). Added fatal error detection to only exit on truly critical errors. Fixes #2895, #2879, #2873. (#2980) Thanks @elliotsecops.
- Agents: guard channel tool listActions to avoid plugin crashes. (#2859) Thanks @mbelinky.
- Discord: avoid resolving bare channel names to user DMs when a username matches. Thanks @thewilloftheshadow.
- Discord: fix directory config type import for target resolution. Thanks @thewilloftheshadow.
- 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.
@@ -91,6 +99,7 @@ Status: beta.
- Agents: release session locks on process termination and cover more signals. (#2483) Thanks @janeexai.
- Agents: skip cooldowned providers during model failover. (#2143) Thanks @YiWang24.
- Telegram: harden polling + retry behavior for transient network errors and Node 22 transport issues. (#2420) Thanks @techboss.
- Telegram: ignore non-forum group message_thread_id while preserving DM thread sessions. (#2731) Thanks @dylanneve1.
- Telegram: wrap reasoning italics per line to avoid raw underscores. (#2181) Thanks @YuriNachos.
- Telegram: centralize API error logging for delivery and bot calls. (#2492) Thanks @altryne.
- Voice Call: enforce Twilio webhook signature verification for ngrok URLs; disable ngrok free tier bypass by default.
+4 -3
View File
@@ -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" }]
}
}
}
+5 -5
View File
@@ -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
+13 -2
View File
@@ -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",
+4 -4
View File
@@ -17,7 +17,7 @@ import { discoverVeniceModels, VENICE_BASE_URL } from "./venice-models.js";
type ModelsConfig = NonNullable<MoltbotConfig["models"]>;
export type ProviderConfig = NonNullable<ModelsConfig["providers"]>[string];
const MINIMAX_API_BASE_URL = "https://api.minimax.io/anthropic";
const MINIMAX_API_BASE_URL = "https://api.minimax.chat/v1";
const MINIMAX_DEFAULT_MODEL_ID = "MiniMax-M2.1";
const MINIMAX_DEFAULT_VISION_MODEL_ID = "MiniMax-VL-01";
const MINIMAX_DEFAULT_CONTEXT_WINDOW = 200000;
@@ -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 = {
@@ -244,7 +244,7 @@ export function normalizeProviders(params: {
function buildMinimaxProvider(): ProviderConfig {
return {
baseUrl: MINIMAX_API_BASE_URL,
api: "anthropic-messages",
api: "openai-completions",
models: [
{
id: MINIMAX_DEFAULT_MODEL_ID,
@@ -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,
@@ -136,7 +136,7 @@ describe("models-config", () => {
}
>;
};
expect(parsed.providers.minimax?.baseUrl).toBe("https://api.minimax.io/anthropic");
expect(parsed.providers.minimax?.baseUrl).toBe("https://api.minimax.chat/v1");
expect(parsed.providers.minimax?.apiKey).toBe("MINIMAX_API_KEY");
const ids = parsed.providers.minimax?.models?.map((model) => model.id);
expect(ids).toContain("MiniMax-M2.1");
+1 -1
View File
@@ -275,7 +275,7 @@ describe("image tool MiniMax VLM routing", () => {
expect(fetch).toHaveBeenCalledTimes(1);
const [url, init] = fetch.mock.calls[0];
expect(String(url)).toBe("https://api.minimax.io/v1/coding_plan/vlm");
expect(String(url)).toBe("https://api.minimax.chat/v1/coding_plan/vlm");
expect(init?.method).toBe("POST");
expect(String((init?.headers as Record<string, string>)?.Authorization)).toBe(
"Bearer minimax-test",
+2 -1
View File
@@ -51,7 +51,8 @@ function isOpenAiProvider(provider?: string | null): boolean {
function isAnthropicApi(modelApi?: string | null, provider?: string | null): boolean {
if (modelApi === "anthropic-messages") return true;
const normalized = normalizeProviderId(provider ?? "");
return normalized === "anthropic" || normalized === "minimax";
// MiniMax now uses openai-completions API, not anthropic-messages
return normalized === "anthropic";
}
function isMistralModel(params: { provider?: string | null; modelId?: string | null }): boolean {
+3
View File
@@ -1,3 +1,6 @@
export type { DirectoryConfigParams } from "./plugins/directory-config.js";
export type { ChannelDirectoryEntry } from "./plugins/types.js";
export type MessagingTargetKind = "user" | "channel";
export type MessagingTarget = {
+6 -6
View File
@@ -65,12 +65,12 @@ export function formatCliBannerLine(version: string, options: BannerOptions = {}
}
const LOBSTER_ASCII = [
"░████░█░░░░░█████░█░░░█░███░░████░░████░░▀█▀",
"█░░░░░█░░░░░█░░░█░█░█░█░█░░█░█░░░█░█░░░█░░█░",
"█░░░░░█░░░░░█████░█░█░█░█░░█░████░░█░░░█░░█░",
"█░░░░░█░░░░░█░░░█░█░█░█░█░░█░█░░█░░█░░░█░░█░",
"░████░█████░█░░░█░░█░█░░███░░████░░░███░░░█░",
" 🦞 FRESH DAILY 🦞",
"▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄",
"██░▄▀▄░██░▄▄▄░██░████▄▄░▄▄██░▄▄▀██░▄▄▄░█▄▄░▄▄██",
"██░█░█░██░███░██░██████░████░▄▄▀██░███░███░████",
"██░███░██░▀▀▀░██░▀▀░███░████░▀▀░██░▀▀▀░███░████",
"▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀",
" 🦞 FRESH DAILY 🦞 ",
];
export function formatCliBannerArt(options: BannerOptions = {}): string {
+6 -6
View File
@@ -64,12 +64,12 @@ export function randomToken(): string {
export function printWizardHeader(runtime: RuntimeEnv) {
const header = [
"░████░█░░░░░█████░█░░░█░███░░████░░████░░▀█▀",
"█░░░░░█░░░░░█░░░█░█░█░█░█░░█░█░░░█░█░░░█░░█░",
"█░░░░░█░░░░░█████░█░█░█░█░░█░████░░█░░░█░░█░",
"█░░░░░█░░░░░█░░░█░█░█░█░█░░█░█░░█░░█░░░█░░█░",
"░████░█████░█░░░█░░█░█░░███░░████░░░███░░░█░",
" 🦞 FRESH DAILY 🦞",
"▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄",
"██░▄▀▄░██░▄▄▄░██░████▄▄░▄▄██░▄▄▀██░▄▄▄░█▄▄░▄▄██",
"██░█░█░██░███░██░██████░████░▄▄▀██░███░███░████",
"██░███░██░▀▀▀░██░▀▀░███░████░▀▀░██░▀▀▀░███░████",
"▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀",
" 🦞 FRESH DAILY 🦞 ",
].join("\n");
runtime.log(header);
}
+4 -4
View File
@@ -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);
+39 -1
View File
@@ -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) {
+40 -2
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 { 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");
+93
View File
@@ -7,6 +7,10 @@ import {
type MessagingTargetParseOptions,
} from "../channels/targets.js";
import type { DirectoryConfigParams } from "../channels/plugins/directory-config.js";
import { listDiscordDirectoryPeersLive } from "./directory-live.js";
export type DiscordTargetKind = MessagingTargetKind;
export type DiscordTarget = MessagingTarget;
@@ -60,3 +64,92 @@ 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;
const parseOptions: DiscordTargetParseOptions = {};
const likelyUsername = isLikelyUsername(trimmed);
const shouldLookup = isExplicitUserLookup(trimmed, parseOptions) || likelyUsername;
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:<id>")
const userId = match.id.replace(/^user:/, "");
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;
}
@@ -70,3 +70,102 @@ describe("buildTelegramMessageContext dm thread sessions", () => {
expect(ctx?.ctxPayload?.SessionKey).toBe("agent:main:main");
});
});
describe("buildTelegramMessageContext group sessions without forum", () => {
const baseConfig = {
agents: { defaults: { model: "anthropic/claude-opus-4-5", workspace: "/tmp/clawd" } },
channels: { telegram: {} },
messages: { groupChat: { mentionPatterns: [] } },
} as never;
const buildContext = async (message: Record<string, unknown>) =>
await buildTelegramMessageContext({
primaryCtx: {
message,
me: { id: 7, username: "bot" },
} as never,
allMedia: [],
storeAllowFrom: [],
options: { forceWasMentioned: true },
bot: {
api: {
sendChatAction: vi.fn(),
setMessageReaction: vi.fn(),
},
} as never,
cfg: baseConfig,
account: { accountId: "default" } as never,
historyLimit: 0,
groupHistories: new Map(),
dmPolicy: "open",
allowFrom: [],
groupAllowFrom: [],
ackReactionScope: "off",
logger: { info: vi.fn() },
resolveGroupActivation: () => true,
resolveGroupRequireMention: () => false,
resolveTelegramGroupConfig: () => ({
groupConfig: { requireMention: false },
topicConfig: undefined,
}),
});
it("ignores message_thread_id for regular groups (not forums)", async () => {
// When someone replies to a message in a non-forum group, Telegram sends
// message_thread_id but this should NOT create a separate session
const ctx = await buildContext({
message_id: 1,
chat: { id: -1001234567890, type: "supergroup", title: "Test Group" },
date: 1700000000,
text: "@bot hello",
message_thread_id: 42, // This is a reply thread, NOT a forum topic
from: { id: 42, first_name: "Alice" },
});
expect(ctx).not.toBeNull();
// Session key should NOT include :topic:42
expect(ctx?.ctxPayload?.SessionKey).toBe("agent:main:telegram:group:-1001234567890");
// MessageThreadId should be undefined (not a forum)
expect(ctx?.ctxPayload?.MessageThreadId).toBeUndefined();
});
it("keeps same session for regular group with and without message_thread_id", async () => {
const ctxWithThread = await buildContext({
message_id: 1,
chat: { id: -1001234567890, type: "supergroup", title: "Test Group" },
date: 1700000000,
text: "@bot hello",
message_thread_id: 42,
from: { id: 42, first_name: "Alice" },
});
const ctxWithoutThread = await buildContext({
message_id: 2,
chat: { id: -1001234567890, type: "supergroup", title: "Test Group" },
date: 1700000001,
text: "@bot world",
from: { id: 42, first_name: "Alice" },
});
expect(ctxWithThread).not.toBeNull();
expect(ctxWithoutThread).not.toBeNull();
// Both messages should use the same session key
expect(ctxWithThread?.ctxPayload?.SessionKey).toBe(ctxWithoutThread?.ctxPayload?.SessionKey);
});
it("uses topic session for forum groups with message_thread_id", async () => {
const ctx = await buildContext({
message_id: 1,
chat: { id: -1001234567890, type: "supergroup", title: "Test Forum", is_forum: true },
date: 1700000000,
text: "@bot hello",
message_thread_id: 99,
from: { id: 42, first_name: "Alice" },
});
expect(ctx).not.toBeNull();
// Session key SHOULD include :topic:99 for forums
expect(ctx?.ctxPayload?.SessionKey).toBe("agent:main:telegram:group:-1001234567890:topic:99");
expect(ctx?.ctxPayload?.MessageThreadId).toBe(99);
});
});
+4 -2
View File
@@ -173,7 +173,8 @@ export const buildTelegramMessageContext = async ({
},
});
const baseSessionKey = route.sessionKey;
const dmThreadId = !isGroup ? resolvedThreadId : undefined;
// DMs: use raw messageThreadId for thread sessions (not resolvedThreadId which is for forums)
const dmThreadId = !isGroup ? messageThreadId : undefined;
const threadKeys =
dmThreadId != null
? resolveThreadSessionKeys({ baseSessionKey, threadId: String(dmThreadId) })
@@ -601,7 +602,8 @@ export const buildTelegramMessageContext = async ({
Sticker: allMedia[0]?.stickerMetadata,
...(locationData ? toLocationContext(locationData) : undefined),
CommandAuthorized: commandAuthorized,
MessageThreadId: resolvedThreadId,
// For groups: use resolvedThreadId (forum topics only); for DMs: use raw messageThreadId
MessageThreadId: isGroup ? resolvedThreadId : messageThreadId,
IsForum: isForum,
// Originating channel for reply routing.
OriginatingChannel: "telegram" as const,
+13 -8
View File
@@ -322,7 +322,7 @@ export const registerTelegramNativeCommands = ({
];
if (allCommands.length > 0) {
void withTelegramApiErrorLogging({
withTelegramApiErrorLogging({
operation: "setMyCommands",
runtime,
fn: () => bot.api.setMyCommands(allCommands),
@@ -360,6 +360,8 @@ export const registerTelegramNativeCommands = ({
topicConfig,
commandAuthorized,
} = auth;
const messageThreadId = (msg as { message_thread_id?: number }).message_thread_id;
const threadIdForSend = isGroup ? resolvedThreadId : messageThreadId;
const commandDefinition = findCommandByNativeName(command.name, "telegram");
const rawText = ctx.match?.trim() ?? "";
@@ -406,7 +408,7 @@ export const registerTelegramNativeCommands = ({
fn: () =>
bot.api.sendMessage(chatId, title, {
...(replyMarkup ? { reply_markup: replyMarkup } : {}),
...(resolvedThreadId != null ? { message_thread_id: resolvedThreadId } : {}),
...(threadIdForSend != null ? { message_thread_id: threadIdForSend } : {}),
}),
});
return;
@@ -421,7 +423,8 @@ export const registerTelegramNativeCommands = ({
},
});
const baseSessionKey = route.sessionKey;
const dmThreadId = !isGroup ? resolvedThreadId : undefined;
// DMs: use raw messageThreadId for thread sessions (not resolvedThreadId which is for forums)
const dmThreadId = !isGroup ? messageThreadId : undefined;
const threadKeys =
dmThreadId != null
? resolveThreadSessionKeys({ baseSessionKey, threadId: String(dmThreadId) })
@@ -466,7 +469,7 @@ export const registerTelegramNativeCommands = ({
CommandSource: "native" as const,
SessionKey: `telegram:slash:${senderId || chatId}`,
CommandTargetSessionKey: sessionKey,
MessageThreadId: resolvedThreadId,
MessageThreadId: threadIdForSend,
IsForum: isForum,
// Originating context for sub-agent announce routing
OriginatingChannel: "telegram" as const,
@@ -493,7 +496,7 @@ export const registerTelegramNativeCommands = ({
bot,
replyToMode,
textLimit,
messageThreadId: resolvedThreadId,
messageThreadId: threadIdForSend,
tableMode,
chunkMode,
linkPreview: telegramCfg.linkPreview,
@@ -541,7 +544,9 @@ export const registerTelegramNativeCommands = ({
requireAuth: match.command.requireAuth !== false,
});
if (!auth) return;
const { resolvedThreadId, senderId, commandAuthorized } = auth;
const { resolvedThreadId, senderId, commandAuthorized, isGroup } = auth;
const messageThreadId = (msg as { message_thread_id?: number }).message_thread_id;
const threadIdForSend = isGroup ? resolvedThreadId : messageThreadId;
const result = await executePluginCommand({
command: match.command,
@@ -567,7 +572,7 @@ export const registerTelegramNativeCommands = ({
bot,
replyToMode,
textLimit,
messageThreadId: resolvedThreadId,
messageThreadId: threadIdForSend,
tableMode,
chunkMode,
linkPreview: telegramCfg.linkPreview,
@@ -576,7 +581,7 @@ export const registerTelegramNativeCommands = ({
}
}
} else if (nativeDisabledExplicit) {
void withTelegramApiErrorLogging({
withTelegramApiErrorLogging({
operation: "setMyCommands",
runtime,
fn: () => bot.api.setMyCommands([]),
@@ -238,12 +238,17 @@ describe("createTelegramBot", () => {
expect(getTelegramSequentialKey({ message: { chat: { id: 123 } } })).toBe("telegram:123");
expect(
getTelegramSequentialKey({
message: { chat: { id: 123 }, message_thread_id: 9 },
message: { chat: { id: 123, type: "private" }, message_thread_id: 9 },
}),
).toBe("telegram:123:topic:9");
expect(
getTelegramSequentialKey({
message: { chat: { id: 123, is_forum: true } },
message: { chat: { id: 123, type: "supergroup" }, message_thread_id: 9 },
}),
).toBe("telegram:123");
expect(
getTelegramSequentialKey({
message: { chat: { id: 123, type: "supergroup", is_forum: true } },
}),
).toBe("telegram:123:topic:1");
expect(
+7 -2
View File
@@ -340,12 +340,17 @@ describe("createTelegramBot", () => {
expect(getTelegramSequentialKey({ message: { chat: { id: 123 } } })).toBe("telegram:123");
expect(
getTelegramSequentialKey({
message: { chat: { id: 123 }, message_thread_id: 9 },
message: { chat: { id: 123, type: "private" }, message_thread_id: 9 },
}),
).toBe("telegram:123:topic:9");
expect(
getTelegramSequentialKey({
message: { chat: { id: 123, is_forum: true } },
message: { chat: { id: 123, type: "supergroup" }, message_thread_id: 9 },
}),
).toBe("telegram:123");
expect(
getTelegramSequentialKey({
message: { chat: { id: 123, type: "supergroup", is_forum: true } },
}),
).toBe("telegram:123:topic:1");
expect(
+7 -5
View File
@@ -94,11 +94,12 @@ export function getTelegramSequentialKey(ctx: {
if (typeof chatId === "number") return `telegram:${chatId}:control`;
return "telegram:control";
}
const isGroup = msg?.chat?.type === "group" || msg?.chat?.type === "supergroup";
const messageThreadId = msg?.message_thread_id;
const isForum = (msg?.chat as { is_forum?: boolean } | undefined)?.is_forum;
const threadId = resolveTelegramForumThreadId({
isForum,
messageThreadId: msg?.message_thread_id,
});
const threadId = isGroup
? resolveTelegramForumThreadId({ isForum, messageThreadId })
: messageThreadId;
if (typeof chatId === "number") {
return threadId != null ? `telegram:${chatId}:topic:${threadId}` : `telegram:${chatId}`;
}
@@ -427,7 +428,8 @@ export function createTelegramBot(opts: TelegramBotOptions) {
peer: { kind: isGroup ? "group" : "dm", id: peerId },
});
const baseSessionKey = route.sessionKey;
const dmThreadId = !isGroup ? resolvedThreadId : undefined;
// DMs: use raw messageThreadId for thread sessions (not resolvedThreadId which is for forums)
const dmThreadId = !isGroup ? messageThreadId : undefined;
const threadKeys =
dmThreadId != null
? resolveThreadSessionKeys({ baseSessionKey, threadId: String(dmThreadId) })
+26
View File
@@ -3,8 +3,34 @@ import {
buildTelegramThreadParams,
buildTypingThreadParams,
normalizeForwardedContext,
resolveTelegramForumThreadId,
} from "./helpers.js";
describe("resolveTelegramForumThreadId", () => {
it("returns undefined for non-forum groups even with messageThreadId", () => {
// Reply threads in regular groups should not create separate sessions
expect(resolveTelegramForumThreadId({ isForum: false, messageThreadId: 42 })).toBeUndefined();
});
it("returns undefined for non-forum groups without messageThreadId", () => {
expect(
resolveTelegramForumThreadId({ isForum: false, messageThreadId: undefined }),
).toBeUndefined();
expect(
resolveTelegramForumThreadId({ isForum: undefined, messageThreadId: 99 }),
).toBeUndefined();
});
it("returns General topic (1) for forum groups without messageThreadId", () => {
expect(resolveTelegramForumThreadId({ isForum: true, messageThreadId: undefined })).toBe(1);
expect(resolveTelegramForumThreadId({ isForum: true, messageThreadId: null })).toBe(1);
});
it("returns the topic id for forum groups with messageThreadId", () => {
expect(resolveTelegramForumThreadId({ isForum: true, messageThreadId: 99 })).toBe(99);
});
});
describe("buildTelegramThreadParams", () => {
it("omits General topic thread id for message sends", () => {
expect(buildTelegramThreadParams(1)).toBeUndefined();
+13 -2
View File
@@ -13,14 +13,25 @@ import type {
const TELEGRAM_GENERAL_TOPIC_ID = 1;
/**
* Resolve the thread ID for Telegram forum topics.
* For non-forum groups, returns undefined even if messageThreadId is present
* (reply threads in regular groups should not create separate sessions).
* For forum groups, returns the topic ID (or General topic ID=1 if unspecified).
*/
export function resolveTelegramForumThreadId(params: {
isForum?: boolean;
messageThreadId?: number | null;
}) {
if (params.isForum && params.messageThreadId == null) {
// Non-forum groups: ignore message_thread_id (reply threads are not real topics)
if (!params.isForum) {
return undefined;
}
// Forum groups: use the topic ID, defaulting to General topic
if (params.messageThreadId == null) {
return TELEGRAM_GENERAL_TOPIC_ID;
}
return params.messageThreadId ?? undefined;
return params.messageThreadId;
}
/**
+19 -1
View File
@@ -74,6 +74,23 @@ const isGetUpdatesConflict = (err: unknown) => {
return haystack.includes("getupdates");
};
const NETWORK_ERROR_SNIPPETS = [
"fetch failed",
"network",
"timeout",
"socket",
"econnreset",
"econnrefused",
"undici",
];
const isNetworkRelatedError = (err: unknown) => {
if (!err) return false;
const message = formatErrorMessage(err).toLowerCase();
if (!message) return false;
return NETWORK_ERROR_SNIPPETS.some((snippet) => message.includes(snippet));
};
export async function monitorTelegramProvider(opts: MonitorTelegramOpts = {}) {
const cfg = opts.config ?? loadConfig();
const account = resolveTelegramAccount({
@@ -158,7 +175,8 @@ export async function monitorTelegramProvider(opts: MonitorTelegramOpts = {}) {
}
const isConflict = isGetUpdatesConflict(err);
const isRecoverable = isRecoverableTelegramNetworkError(err, { context: "polling" });
if (!isConflict && !isRecoverable) {
const isNetworkError = isNetworkRelatedError(err);
if (!isConflict && !isRecoverable && !isNetworkError) {
throw err;
}
restartAttempts += 1;
+12
View File
@@ -8,6 +8,13 @@ describe("isRecoverableTelegramNetworkError", () => {
expect(isRecoverableTelegramNetworkError(err)).toBe(true);
});
it("detects additional recoverable error codes", () => {
const aborted = Object.assign(new Error("aborted"), { code: "ECONNABORTED" });
const network = Object.assign(new Error("network"), { code: "ERR_NETWORK" });
expect(isRecoverableTelegramNetworkError(aborted)).toBe(true);
expect(isRecoverableTelegramNetworkError(network)).toBe(true);
});
it("detects AbortError names", () => {
const err = Object.assign(new Error("The operation was aborted"), { name: "AbortError" });
expect(isRecoverableTelegramNetworkError(err)).toBe(true);
@@ -19,6 +26,11 @@ describe("isRecoverableTelegramNetworkError", () => {
expect(isRecoverableTelegramNetworkError(err)).toBe(true);
});
it("detects expanded message patterns", () => {
expect(isRecoverableTelegramNetworkError(new Error("TypeError: fetch failed"))).toBe(true);
expect(isRecoverableTelegramNetworkError(new Error("Undici: socket failure"))).toBe(true);
});
it("skips message matches for send context", () => {
const err = new TypeError("fetch failed");
expect(isRecoverableTelegramNetworkError(err, { context: "send" })).toBe(false);
+4
View File
@@ -15,6 +15,8 @@ const RECOVERABLE_ERROR_CODES = new Set([
"UND_ERR_BODY_TIMEOUT",
"UND_ERR_SOCKET",
"UND_ERR_ABORTED",
"ECONNABORTED",
"ERR_NETWORK",
]);
const RECOVERABLE_ERROR_NAMES = new Set([
@@ -27,6 +29,8 @@ const RECOVERABLE_ERROR_NAMES = new Set([
const RECOVERABLE_MESSAGE_SNIPPETS = [
"fetch failed",
"typeerror: fetch failed",
"undici",
"network error",
"network request",
"client network socket disconnected",