From 0f69cf1b74f4b73e0fdc9e0e4bde4084e11154bd Mon Sep 17 00:00:00 2001 From: Secret297 <73541046+Secret297-CODER-SOURCE@users.noreply.github.com> Date: Tue, 10 Mar 2026 14:43:12 +0200 Subject: [PATCH 1/9] Add per-agent workspace tools and tool-calls Introduce scoped WorkspaceTools for Telegram agents (createWorkspaceTools) with explicit readable/writable file lists and Anthropic tool definitions. Add an Anthropic tool-calling loop (runAnthropicWithTools) to AiReplyEngine and accept an optional workspaceTools parameter in aiReply; when ANTHROPIC_API_KEY is present, use the tool-calling path (up to 5 iterations) and otherwise fall back to the existing adapter. Update BotAgent and UserBotAgent to create and pass per-agent workspace tools into aiReply so agents can safely read/write their own workspace files. Also adjust the UI file loader to always reload Telegram agent file contents to avoid showing stale/shared cache entries. --- .../telegram-manager/src/agents/BotAgent.ts | 8 +- .../src/agents/UserBotAgent.ts | 9 +- .../src/behaviors/AiReplyEngine.ts | 98 +++++++++++++++++- .../src/tools/TelegramTools.ts | 99 ++++++++++++++++++- ui/src/ui/app-render.ts | 9 +- 5 files changed, 213 insertions(+), 10 deletions(-) diff --git a/extensions/telegram-manager/src/agents/BotAgent.ts b/extensions/telegram-manager/src/agents/BotAgent.ts index 606503f663885..2712d22a9b70b 100644 --- a/extensions/telegram-manager/src/agents/BotAgent.ts +++ b/extensions/telegram-manager/src/agents/BotAgent.ts @@ -3,6 +3,7 @@ import { Bot, Context } from "grammy"; import cron from "node-cron"; import { aiReply } from "../behaviors/AiReplyEngine"; import { TelegramStorage } from "../storage/TelegramStorage"; +import { createWorkspaceTools } from "../tools/TelegramTools"; import { AgentRecord, BotCredentials, @@ -271,13 +272,16 @@ export class BotAgent extends BaseAgent { taskSession = byUsername; } } + // Tools scoped to this agent's workspace — cannot access other agents' files. + const workspaceTools = createWorkspaceTools(this.storage.getAgentWorkspaceDir(this.id)); + if (taskSession) { await ctx.replyWithChatAction("typing"); // Use custom system prompt if provided, otherwise build from workspace files. const systemPrompt = taskSession.systemPrompt ?? (await this.buildRichSystemPrompt(taskSession.task, chatKey)); try { - const reply = await aiReply(text, chatKey, systemPrompt, this.storage); + const reply = await aiReply(text, chatKey, systemPrompt, this.storage, workspaceTools); if (reply) { await ctx.reply(reply); this.trackMessage("out", reply, chatId); @@ -299,7 +303,7 @@ export class BotAgent extends BaseAgent { const systemPrompt = cfg.aiSystemPrompt ?? (await this.buildRichSystemPrompt(undefined, chatKey)); try { - reply = await aiReply(text, chatKey, systemPrompt, this.storage); + reply = await aiReply(text, chatKey, systemPrompt, this.storage, workspaceTools); } catch (e) { const errMsg = e instanceof Error ? e.message : String(e); this.logger.warn(`[TG:${this.name}] auto_reply AI failed: ${errMsg}`); diff --git a/extensions/telegram-manager/src/agents/UserBotAgent.ts b/extensions/telegram-manager/src/agents/UserBotAgent.ts index 89b9087baa9df..e596aafa6d699 100644 --- a/extensions/telegram-manager/src/agents/UserBotAgent.ts +++ b/extensions/telegram-manager/src/agents/UserBotAgent.ts @@ -5,6 +5,7 @@ import { NewMessage } from "telegram/events"; import { StringSession } from "telegram/sessions"; import { aiReply } from "../behaviors/AiReplyEngine"; import { TelegramStorage } from "../storage/TelegramStorage"; +import { createWorkspaceTools } from "../tools/TelegramTools"; import { AgentRecord, UserbotCredentials, BehaviorConfig, ILogger } from "../types"; import { BaseAgent } from "./BaseAgent"; @@ -300,8 +301,10 @@ export class UserBotAgent extends BaseAgent { // Pass chatKey so the prompt includes a brief recap of prior exchanges. const systemPrompt = taskSession.systemPrompt ?? (await this.buildRichSystemPrompt(taskSession.task, chatKey)); + // Tools scoped to this agent's workspace — cannot access other agents' files. + const workspaceTools = createWorkspaceTools(this.storage.getAgentWorkspaceDir(this.id)); try { - const reply = await aiReply(text, chatKey, systemPrompt, this.storage); + const reply = await aiReply(text, chatKey, systemPrompt, this.storage, workspaceTools); if (reply) { await this.sendWithFloodGuard(chatId, reply, msg.id); this.trackMessage("out", reply, chatId); @@ -344,8 +347,10 @@ export class UserBotAgent extends BaseAgent { // Use configured system prompt if present, otherwise build from workspace files. const systemPrompt = cfg.aiSystemPrompt ?? (await this.buildRichSystemPrompt(undefined, key)); + // Tools scoped to this agent's workspace — cannot access other agents' files. + const workspaceTools = createWorkspaceTools(this.storage.getAgentWorkspaceDir(this.id)); try { - reply = await aiReply(text, key, systemPrompt, this.storage); + reply = await aiReply(text, key, systemPrompt, this.storage, workspaceTools); } catch (e) { const errMsg = e instanceof Error ? e.message : String(e); this.logger.warn(`[TG:${this.name}] auto_reply AI failed: ${errMsg}`); diff --git a/extensions/telegram-manager/src/behaviors/AiReplyEngine.ts b/extensions/telegram-manager/src/behaviors/AiReplyEngine.ts index 5fd229a387232..b100241c7776c 100644 --- a/extensions/telegram-manager/src/behaviors/AiReplyEngine.ts +++ b/extensions/telegram-manager/src/behaviors/AiReplyEngine.ts @@ -1,5 +1,11 @@ // plugins/telegram/src/behaviors/AiReplyEngine.ts import Anthropic from "@anthropic-ai/sdk"; +import { + WorkspaceTools, + workspaceToolDefs, + ReadableFile, + WritableFile, +} from "../tools/TelegramTools"; export type ModelMessage = { role: "user" | "assistant"; content: string }; @@ -135,6 +141,76 @@ function resolveAdapter(): ModelAdapter { ); } +// ─── Workspace tool-calling loop (Anthropic only) ───────────────────────────── + +/** + * Run an agentic Anthropic call that allows the agent to read/write its own + * workspace files. Loops up to 5 iterations while the model issues tool calls, + * then returns the final text response. + * + * Only available when ANTHROPIC_API_KEY is set; callers must check this first. + */ +async function runAnthropicWithTools( + messages: Anthropic.MessageParam[], + systemPrompt: string, + tools: WorkspaceTools, +): Promise { + const client = getAnthropicClient(); + const model = process.env.TG_AI_MODEL?.trim() || "claude-3-5-sonnet-20241022"; + // Work on a copy so we don't mutate the caller's history during the loop. + const msgs: Anthropic.MessageParam[] = [...messages]; + + for (let i = 0; i < 5; i++) { + const res = await client.messages.create({ + model, + max_tokens: 2048, + system: systemPrompt, + messages: msgs, + tools: workspaceToolDefs as unknown as Anthropic.Tool[], + }); + + if (res.stop_reason !== "tool_use") { + // No tool calls — return the text content. + const text = res.content + .filter((b): b is Anthropic.TextBlock => b.type === "text") + .map((b) => b.text) + .join("") + .trim(); + return text || "…"; + } + + // Append the assistant turn (includes both text and tool_use blocks). + msgs.push({ role: "assistant", content: res.content }); + + // Execute each tool call and collect results. + const toolResults: Anthropic.ToolResultBlockParam[] = []; + for (const block of res.content) { + if (block.type !== "tool_use") continue; + let output: string; + try { + if (block.name === "read_workspace_file") { + const { filename } = block.input as { filename: ReadableFile }; + output = (await tools.readFile(filename)) || "(empty)"; + } else if (block.name === "write_workspace_file") { + const { filename, content } = block.input as { filename: WritableFile; content: string }; + await tools.writeFile(filename, content); + output = `Updated ${filename}`; + } else { + output = `Unknown tool: ${block.name}`; + } + } catch (e) { + output = `Error: ${e instanceof Error ? e.message : String(e)}`; + } + toolResults.push({ type: "tool_result", tool_use_id: block.id, content: output }); + } + + // Feed results back for the next iteration. + msgs.push({ role: "user", content: toolResults }); + } + + return "…"; // fallback after max iterations +} + // ─── Conversation history ────────────────────────────────────────────────────── // In-memory cache for fast access; backed by optional persistent storage. @@ -154,6 +230,7 @@ export async function aiReply( chatKey: string, // agentId + chatId — unique per conversation systemPrompt = "You are a helpful Telegram assistant. Be concise and friendly.", storage?: ConversationStorage, + workspaceTools?: WorkspaceTools, ): Promise { // Warm the in-memory cache from persistent storage on the first message // for this chat key (e.g. after an agent restart). @@ -168,10 +245,23 @@ export async function aiReply( hist.push({ role: "user", content: text }); if (hist.length > MAX_HISTORY) hist.splice(0, hist.length - MAX_HISTORY); - const adapter = resolveAdapter(); - // Pass chatKey as sessionKey so adapters that support it (e.g. the gateway - // adapter) can maintain per-conversation sessions on the server side. - const reply = await adapter(hist, systemPrompt, chatKey); + let reply: string; + + // Use the Anthropic tool-calling path when workspace tools are provided and + // a direct Anthropic key is configured. Falls back to the generic adapter + // (gateway / OpenAI-compatible) which does not support tool calling. + if (workspaceTools && process.env.ANTHROPIC_API_KEY?.trim()) { + const msgs: Anthropic.MessageParam[] = hist.map((m) => ({ + role: m.role, + content: m.content, + })); + reply = await runAnthropicWithTools(msgs, systemPrompt, workspaceTools); + } else { + const adapter = resolveAdapter(); + // Pass chatKey as sessionKey so adapters that support it (e.g. the gateway + // adapter) can maintain per-conversation sessions on the server side. + reply = await adapter(hist, systemPrompt, chatKey); + } hist.push({ role: "assistant", content: reply }); histories.set(chatKey, hist); diff --git a/extensions/telegram-manager/src/tools/TelegramTools.ts b/extensions/telegram-manager/src/tools/TelegramTools.ts index fc42ddea45e15..98ffd8b8b97c2 100644 --- a/extensions/telegram-manager/src/tools/TelegramTools.ts +++ b/extensions/telegram-manager/src/tools/TelegramTools.ts @@ -1 +1,98 @@ -// OpenClaw Tool API placeholder +// Workspace file tools — each agent instance is scoped to its own directory, +// preventing cross-agent file access. +import fs from "fs"; +import path from "path"; + +// Files the agent may read. +export const READABLE_FILES = [ + "AGENTS.md", + "SOUL.md", + "TOOLS.md", + "IDENTITY.md", + "USER.md", + "HEARTBEAT.md", + "BOOTSTRAP.md", + "MEMORY.md", +] as const; + +// Files the agent may write (memory/context only — not behavioral instructions). +export const WRITABLE_FILES = ["MEMORY.md", "USER.md"] as const; + +export type ReadableFile = (typeof READABLE_FILES)[number]; +export type WritableFile = (typeof WRITABLE_FILES)[number]; + +export interface WorkspaceTools { + workspaceDir: string; + readFile(filename: ReadableFile): Promise; + writeFile(filename: WritableFile, content: string): Promise; +} + +/** + * Create a WorkspaceTools instance scoped to a single agent's workspace directory. + * All read/write operations are validated and confined to this directory — an agent + * cannot reach another agent's files through these tools. + */ +export function createWorkspaceTools(workspaceDir: string): WorkspaceTools { + return { + workspaceDir, + + async readFile(filename: ReadableFile): Promise { + if (!(READABLE_FILES as readonly string[]).includes(filename)) return ""; + try { + return await fs.promises.readFile(path.join(workspaceDir, filename), "utf-8"); + } catch { + return ""; + } + }, + + async writeFile(filename: WritableFile, content: string): Promise { + if (!(WRITABLE_FILES as readonly string[]).includes(filename)) { + throw new Error( + `Write access denied for "${filename}"; allowed: ${WRITABLE_FILES.join(", ")}`, + ); + } + fs.mkdirSync(workspaceDir, { recursive: true }); + await fs.promises.writeFile(path.join(workspaceDir, filename), content, "utf-8"); + }, + }; +} + +/** Anthropic tool definitions for workspace file access. */ +export const workspaceToolDefs = [ + { + name: "read_workspace_file", + description: + "Read one of your workspace files (e.g. MEMORY.md, USER.md) to recall stored context from previous sessions.", + input_schema: { + type: "object" as const, + properties: { + filename: { + type: "string" as const, + enum: READABLE_FILES as unknown as string[], + description: "The workspace file to read.", + }, + }, + required: ["filename"], + }, + }, + { + name: "write_workspace_file", + description: + "Update MEMORY.md (to remember facts across sessions) or USER.md (to record context about the person you are talking with). Use this to persist important information so it is available in future conversations.", + input_schema: { + type: "object" as const, + properties: { + filename: { + type: "string" as const, + enum: WRITABLE_FILES as unknown as string[], + description: "Which workspace file to update (MEMORY.md or USER.md).", + }, + content: { + type: "string" as const, + description: "Full new content for the file.", + }, + }, + required: ["filename", "content"], + }, + }, +] as const; diff --git a/ui/src/ui/app-render.ts b/ui/src/ui/app-render.ts index 4b53083b1e56f..7cc68bcd2bed5 100644 --- a/ui/src/ui/app-render.ts +++ b/ui/src/ui/app-render.ts @@ -759,7 +759,14 @@ export function renderApp(state: AppViewState) { onSelectFile: (name) => { state.agentFileActive = name; const agentId = state.telegramSelectedId; - if (agentId && !state.agentFileContents[name]) { + if (agentId) { + // Always reload — do NOT reuse the shared cache here. + // agentFileContents is shared with the main Agents tab, so a + // stale entry from a different agent (e.g. main's AGENTS.md) + // would be shown instead of this Telegram agent's own file. + const fresh = { ...state.agentFileContents }; + delete fresh[name]; + state.agentFileContents = fresh; void loadTelegramAgentFileContent(state, agentId, name); } }, From 7e3e1e2cd1ddabf43253cd850caa6606cebd5ca1 Mon Sep 17 00:00:00 2001 From: Secret297 <73541046+Secret297-CODER-SOURCE@users.noreply.github.com> Date: Tue, 10 Mar 2026 15:05:30 +0200 Subject: [PATCH 2/9] Improve agent files loading UX and remove TG_ID MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Remove the TG_ID pattern from PII_PATTERNS to avoid treating short numeric Telegram IDs as PII. Improve Telegram agent files loading UX: set agentFilesLoading=true when initiating file load to prevent a flash of the "click to load" callout, auto-load files when returning to an already-active Files panel, and import loadTelegramAgentFiles where needed. Update the Files panel UI to show a "Loading files…" state and replace the static callout with a clickable callout button that invokes the provided onLoadFiles handler. --- src/pii-guard/patterns.ts | 9 --------- ui/src/ui/app-render.ts | 1 + ui/src/ui/app-settings.ts | 11 ++++++++++- ui/src/ui/views/agents-panels-status-files.ts | 16 ++++++++++++---- 4 files changed, 23 insertions(+), 14 deletions(-) diff --git a/src/pii-guard/patterns.ts b/src/pii-guard/patterns.ts index 946e560ba0f3d..59663e2c9aba5 100644 --- a/src/pii-guard/patterns.ts +++ b/src/pii-guard/patterns.ts @@ -358,15 +358,6 @@ export const PII_PATTERNS: PiiPattern[] = [ regex: /(?:ул\.|улица|пр-т\.?|проспект|пер\.|переулок|бульвар|б-р\.?|шоссе|ш\.?|набережная|наб\.|площадь|пл\.)\s+[«"]?[А-ЯЁа-яёA-Za-z][А-ЯЁа-яё\s-]{1,60}[»"]?(?:\s*,\s*(?:д\.?\s*)?\d+[а-яА-ЯёЁ]?)?/gi, }, - { - tokenName: "TG_ID", - group: "safe_tg", - priority: 0, - description: "Telegram User/Chat ID (7–13 цифр) — НЕ является PII", - // Telegram user IDs: 7–10 цифр. Channel IDs: до 13 цифр. - // Negative lookbehind/lookahead — не брать числа внутри более длинных последовательностей. - regex: /(? - Load the agent workspace files to edit core instructions. - + ? params.agentFilesLoading + ? html` +
Loading files…
+ ` + : html` + ` : html`
From 04025133e7086086544c2840790a5f8ab6950ca5 Mon Sep 17 00:00:00 2001 From: Secret297 <73541046+Secret297-CODER-SOURCE@users.noreply.github.com> Date: Tue, 10 Mar 2026 15:17:47 +0200 Subject: [PATCH 3/9] Clear agent file cache and load active file Import loadTelegramAgentFileContent and, when returning to the Telegram Files panel, clear the shared agentFileContents cache and reload the agent's file list. If an active file is selected, also fetch its content so the Files panel doesn't show stale data left over from the main Agents tab. Adds a clarifying comment about the shared cache behavior. --- ui/src/ui/app-settings.ts | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/ui/src/ui/app-settings.ts b/ui/src/ui/app-settings.ts index 908ead195a457..b1182cad09c61 100644 --- a/ui/src/ui/app-settings.ts +++ b/ui/src/ui/app-settings.ts @@ -29,6 +29,7 @@ import { loadSkills } from "./controllers/skills.ts"; import { loadTelegramAgents, loadTelegramAgentFiles, + loadTelegramAgentFileContent, loadTelegramConfig, } from "./controllers/telegram.ts"; import { @@ -208,9 +209,16 @@ export async function refreshActiveTab(host: SettingsHost) { await loadTelegramConfig(host as unknown as OpenClawApp); await loadTelegramAgents(host as unknown as OpenClawApp); const app = host as unknown as OpenClawApp; - // Auto-load files when navigating back to an already-active Files panel + // Auto-load files when navigating back to an already-active Files panel. + // Also clear the content cache — it may be stale from the main Agents tab + // (agentFileContents is shared and gets overwritten when the user views the + // main agent's files, then returns here). if (app.telegramActivePanel === "files" && app.telegramSelectedId) { + app.agentFileContents = {}; void loadTelegramAgentFiles(app, app.telegramSelectedId); + if (app.agentFileActive) { + void loadTelegramAgentFileContent(app, app.telegramSelectedId, app.agentFileActive); + } } } if (host.tab === "skills") { From c389a56c63037b608eb2908a7b054ebeea94d888 Mon Sep 17 00:00:00 2001 From: Secret297 <73541046+Secret297-CODER-SOURCE@users.noreply.github.com> Date: Tue, 10 Mar 2026 20:21:54 +0200 Subject: [PATCH 4/9] feat(telegram-manager): mimic partner style and add goal to auto-reply --- .../telegram-manager/src/agents/BaseAgent.ts | 21 +++++++-- .../src/agents/UserBotAgent.ts | 47 ++++++++++++++++++- extensions/telegram-manager/src/types.ts | 2 + 3 files changed, 63 insertions(+), 7 deletions(-) diff --git a/extensions/telegram-manager/src/agents/BaseAgent.ts b/extensions/telegram-manager/src/agents/BaseAgent.ts index 8a4b238ee1b61..84c3e4bd40f0f 100644 --- a/extensions/telegram-manager/src/agents/BaseAgent.ts +++ b/extensions/telegram-manager/src/agents/BaseAgent.ts @@ -176,12 +176,18 @@ export abstract class BaseAgent extends EventEmitter { * (SOUL.md, AGENTS.md, IDENTITY.md, USER.md, MEMORY.md). Falls back to a * minimal default when no workspace files exist. * - * @param task Optional task description appended as a "## Task" section. - * @param chatKey Optional per-user chat key; when provided, injects the - * last N turns of that user's conversation history so the - * agent can reference prior exchanges in its replies. + * @param task Optional task description appended as a "## Task" section. + * @param chatKey Optional per-user chat key; when provided, injects the + * last N turns of that user's conversation history so the + * agent can reference prior exchanges in its replies. + * @param extraContext Optional extra section to append (e.g. writing-style + * examples fetched from real Telegram message history). */ - protected async buildRichSystemPrompt(task?: string, chatKey?: string): Promise { + protected async buildRichSystemPrompt( + task?: string, + chatKey?: string, + extraContext?: string, + ): Promise { const workspaceDir = this.storage.getAgentWorkspaceDir(this.id); const sections: string[] = []; @@ -212,6 +218,11 @@ export abstract class BaseAgent extends EventEmitter { } } + // Inject extra context supplied by the caller (e.g. partner's writing style). + if (extraContext) { + sections.push(extraContext); + } + if (task) { sections.push( `## Task\n${task}\n\n` + diff --git a/extensions/telegram-manager/src/agents/UserBotAgent.ts b/extensions/telegram-manager/src/agents/UserBotAgent.ts index e596aafa6d699..a4c01bf8adb19 100644 --- a/extensions/telegram-manager/src/agents/UserBotAgent.ts +++ b/extensions/telegram-manager/src/agents/UserBotAgent.ts @@ -297,10 +297,14 @@ export class UserBotAgent extends BaseAgent { // Use a stable per-chat key so history is shared with auto_reply — // continuous dialogue is preserved when the task session ends. const chatKey = `${this.id}:${chatId}`; + // Fetch the partner's writing style from real Telegram history for new chats. + const storedHistory = this.storage.loadConversationHistory(chatKey); + const styleContext = storedHistory.length < 4 ? await this.fetchStyleContext(chatId) : ""; // Use custom system prompt if provided, otherwise build from workspace files. // Pass chatKey so the prompt includes a brief recap of prior exchanges. const systemPrompt = - taskSession.systemPrompt ?? (await this.buildRichSystemPrompt(taskSession.task, chatKey)); + taskSession.systemPrompt ?? + (await this.buildRichSystemPrompt(taskSession.task, chatKey, styleContext)); // Tools scoped to this agent's workspace — cannot access other agents' files. const workspaceTools = createWorkspaceTools(this.storage.getAgentWorkspaceDir(this.id)); try { @@ -344,9 +348,13 @@ export class UserBotAgent extends BaseAgent { let reply = ""; if (cfg.replyMode === "ai") { + // Fetch partner's writing style from Telegram history for new conversations. + const storedHistory = this.storage.loadConversationHistory(key); + const styleContext = storedHistory.length < 4 ? await this.fetchStyleContext(chatId) : ""; // Use configured system prompt if present, otherwise build from workspace files. + // Pass goal (if set) so the agent has a clear objective in every conversation. const systemPrompt = - cfg.aiSystemPrompt ?? (await this.buildRichSystemPrompt(undefined, key)); + cfg.aiSystemPrompt ?? (await this.buildRichSystemPrompt(cfg.goal, key, styleContext)); // Tools scoped to this agent's workspace — cannot access other agents' files. const workspaceTools = createWorkspaceTools(this.storage.getAgentWorkspaceDir(this.id)); try { @@ -566,6 +574,41 @@ export class UserBotAgent extends BaseAgent { } } + /** + * Fetch recent messages from a Telegram chat and build a style-context section + * describing how the conversation partner writes. Used to help the AI mimic + * the other person's tone, vocabulary, and sentence structure. + * + * Only called when the stored conversation history is short (new chat) so we + * don't redundantly re-fetch once the AI already has enough context. + */ + private async fetchStyleContext(chatId: string): Promise { + if (!this.client) return ""; + try { + const msgs = await this.client.getMessages(chatId, { limit: 25 }); + const me = (await this.client.getMe()) as unknown as { id?: bigint | number }; + const myId = me?.id?.toString(); + // Collect the other person's messages only (exclude our own outbound messages). + const theirTexts = ( + msgs as unknown as Array<{ message?: string; senderId?: { toString(): string } }> + ) + .filter((m) => m.message && m.senderId?.toString() !== myId) + .map((m) => m.message!.trim()) + .filter((t) => t.length > 0) + .slice(0, 15); + if (theirTexts.length === 0) return ""; + return ( + `## Conversation partner's writing style\n` + + `Study how this person writes based on their recent messages:\n` + + theirTexts.map((t) => `- "${t}"`).join("\n") + + `\n\nMimic their style: match their vocabulary, sentence length, formality level, ` + + `emoji usage, and punctuation habits. Write as naturally as they do.` + ); + } catch { + return ""; + } + } + private postWebhook(url: string, body: unknown) { fetch(url, { method: "POST", diff --git a/extensions/telegram-manager/src/types.ts b/extensions/telegram-manager/src/types.ts index 605d337130828..c4648c044aba4 100644 --- a/extensions/telegram-manager/src/types.ts +++ b/extensions/telegram-manager/src/types.ts @@ -72,6 +72,8 @@ export interface AutoReplyBehavior { enabled: boolean; replyMode: "ai" | "template"; aiSystemPrompt?: string; + /** Persistent objective for the agent across all auto-reply conversations. */ + goal?: string; triggerKeywords?: string[]; templates?: { trigger: string; response: string }[]; onlyInChats?: string[]; From 487e6b5bf61b34b799e9ba5016ad7a089343d412 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 12 Mar 2026 13:16:56 +0000 Subject: [PATCH 5/9] Initial plan From ba9a2d2d3c72125195e8c9917bdab96917eeb01f Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 12 Mar 2026 13:52:06 +0000 Subject: [PATCH 6/9] fix(telegram): enforce description length limits and fix double-logging on setMyCommands failure Co-authored-by: Secret297-CODER-SOURCE <73541046+Secret297-CODER-SOURCE@users.noreply.github.com> --- .../config.telegram-custom-commands.test.ts | 59 +++++++++++++++++++ src/config/telegram-custom-commands.ts | 17 +++++- src/telegram/bot-native-command-menu.test.ts | 53 +++++++++++++++++ src/telegram/bot-native-command-menu.ts | 18 ++++-- 4 files changed, 142 insertions(+), 5 deletions(-) diff --git a/src/config/config.telegram-custom-commands.test.ts b/src/config/config.telegram-custom-commands.test.ts index 27ff0450220cf..9638bfc395b20 100644 --- a/src/config/config.telegram-custom-commands.test.ts +++ b/src/config/config.telegram-custom-commands.test.ts @@ -1,4 +1,9 @@ import { describe, expect, it } from "vitest"; +import { + normalizeTelegramCommandDescription, + resolveTelegramCustomCommands, + TELEGRAM_COMMAND_DESCRIPTION_MAX_LENGTH, +} from "./telegram-custom-commands.js"; import { OpenClawSchema } from "./zod-schema.js"; describe("telegram custom commands schema", () => { @@ -40,3 +45,57 @@ describe("telegram custom commands schema", () => { ]); }); }); + +describe("normalizeTelegramCommandDescription", () => { + it("trims whitespace", () => { + expect(normalizeTelegramCommandDescription(" Hello world ")).toBe("Hello world"); + }); + + it("returns empty string for whitespace-only input", () => { + expect(normalizeTelegramCommandDescription(" ")).toBe(""); + }); + + it("truncates descriptions longer than 256 characters with ellipsis", () => { + const long = "A".repeat(300); + const result = normalizeTelegramCommandDescription(long); + expect(result).toHaveLength(TELEGRAM_COMMAND_DESCRIPTION_MAX_LENGTH); + expect(result.endsWith("…")).toBe(true); + }); + + it("does not truncate descriptions at exactly 256 characters", () => { + const exact = "A".repeat(TELEGRAM_COMMAND_DESCRIPTION_MAX_LENGTH); + expect(normalizeTelegramCommandDescription(exact)).toBe(exact); + }); +}); + +describe("resolveTelegramCustomCommands", () => { + it("rejects commands with descriptions shorter than 3 characters", () => { + const result = resolveTelegramCustomCommands({ + commands: [{ command: "cmd", description: "ab" }], + }); + + expect(result.commands).toHaveLength(0); + expect(result.issues).toHaveLength(1); + expect(result.issues[0]?.message).toContain("too short"); + }); + + it("accepts descriptions with exactly 3 characters", () => { + const result = resolveTelegramCustomCommands({ + commands: [{ command: "cmd", description: "abc" }], + }); + + expect(result.commands).toHaveLength(1); + expect(result.issues).toHaveLength(0); + }); + + it("truncates descriptions longer than 256 characters", () => { + const long = "A".repeat(300); + const result = resolveTelegramCustomCommands({ + commands: [{ command: "cmd", description: long }], + }); + + expect(result.commands).toHaveLength(1); + expect(result.commands[0]?.description).toHaveLength(TELEGRAM_COMMAND_DESCRIPTION_MAX_LENGTH); + expect(result.issues).toHaveLength(0); + }); +}); diff --git a/src/config/telegram-custom-commands.ts b/src/config/telegram-custom-commands.ts index e7c316791d785..046f33369cb46 100644 --- a/src/config/telegram-custom-commands.ts +++ b/src/config/telegram-custom-commands.ts @@ -1,4 +1,6 @@ export const TELEGRAM_COMMAND_NAME_PATTERN = /^[a-z0-9_]{1,32}$/; +export const TELEGRAM_COMMAND_DESCRIPTION_MAX_LENGTH = 256; +export const TELEGRAM_COMMAND_DESCRIPTION_MIN_LENGTH = 3; export type TelegramCustomCommandInput = { command?: string | null; @@ -21,7 +23,12 @@ export function normalizeTelegramCommandName(value: string): string { } export function normalizeTelegramCommandDescription(value: string): string { - return value.trim(); + const trimmed = value.trim(); + if (trimmed.length > TELEGRAM_COMMAND_DESCRIPTION_MAX_LENGTH) { + // Truncate to fit Telegram's 256-character limit, appending an ellipsis. + return trimmed.slice(0, TELEGRAM_COMMAND_DESCRIPTION_MAX_LENGTH - 1) + "…"; + } + return trimmed; } export function resolveTelegramCustomCommands(params: { @@ -85,6 +92,14 @@ export function resolveTelegramCustomCommands(params: { }); continue; } + if (description.length < TELEGRAM_COMMAND_DESCRIPTION_MIN_LENGTH) { + issues.push({ + index, + field: "description", + message: `Telegram custom command "/${normalized}" description is too short (minimum ${TELEGRAM_COMMAND_DESCRIPTION_MIN_LENGTH} characters).`, + }); + continue; + } if (checkDuplicates) { seen.add(normalized); } diff --git a/src/telegram/bot-native-command-menu.test.ts b/src/telegram/bot-native-command-menu.test.ts index cabea3132d546..1b6eddf299f03 100644 --- a/src/telegram/bot-native-command-menu.test.ts +++ b/src/telegram/bot-native-command-menu.test.ts @@ -50,6 +50,34 @@ describe("bot-native-command-menu", () => { expect(result.issues).toContain('Plugin command "/empty" is missing a description.'); }); + it("rejects plugin commands with descriptions that are too short", () => { + const result = buildPluginTelegramMenuCommands({ + specs: [ + { name: "short", description: "ab" }, + { name: "ok", description: "abc" }, + ], + existingCommands: new Set(), + }); + + expect(result.commands).toEqual([{ command: "ok", description: "abc" }]); + expect(result.issues).toContain( + 'Plugin command "/short" description is too short (minimum 3 characters).', + ); + }); + + it("truncates plugin command descriptions longer than 256 characters", () => { + const longDescription = "A".repeat(300); + const result = buildPluginTelegramMenuCommands({ + specs: [{ name: "longdesc", description: longDescription }], + existingCommands: new Set(), + }); + + expect(result.commands).toHaveLength(1); + expect(result.commands[0]?.description).toHaveLength(256); + expect(result.commands[0]?.description.endsWith("…")).toBe(true); + expect(result.issues).toHaveLength(0); + }); + it("normalizes hyphenated plugin command names", () => { const result = buildPluginTelegramMenuCommands({ specs: [{ name: "agent-run", description: "Run agent" }], @@ -86,4 +114,29 @@ describe("bot-native-command-menu", () => { expect(callOrder).toEqual(["delete", "set"]); }); + + it("logs setMyCommands failure once without rethrowing", async () => { + const runtimeError = vi.fn(); + const setMyCommands = vi.fn().mockRejectedValue(new Error("BOT_COMMANDS_TOO_MUCH")); + + syncTelegramMenuCommands({ + bot: { + api: { setMyCommands }, + } as unknown as Parameters[0]["bot"], + runtime: { + error: runtimeError, + } as unknown as Parameters[0]["runtime"], + commandsToRegister: [{ command: "cmd", description: "Command" }], + }); + + // syncTelegramMenuCommands fires void sync() which is async; waitFor lets the + // microtask queue drain so withTelegramApiErrorLogging can log the rejection. + await vi.waitFor(() => { + expect(runtimeError).toHaveBeenCalled(); + }); + + // Error must be logged exactly once (no double-logging). + expect(runtimeError).toHaveBeenCalledTimes(1); + expect(runtimeError.mock.calls[0]?.[0]).toContain("setMyCommands"); + }); }); diff --git a/src/telegram/bot-native-command-menu.ts b/src/telegram/bot-native-command-menu.ts index 5528fd06ff77e..d1d0cb11e41a1 100644 --- a/src/telegram/bot-native-command-menu.ts +++ b/src/telegram/bot-native-command-menu.ts @@ -1,6 +1,8 @@ import type { Bot } from "grammy"; import { + normalizeTelegramCommandDescription, normalizeTelegramCommandName, + TELEGRAM_COMMAND_DESCRIPTION_MIN_LENGTH, TELEGRAM_COMMAND_NAME_PATTERN, } from "../config/telegram-custom-commands.js"; import type { RuntimeEnv } from "../runtime.js"; @@ -35,11 +37,17 @@ export function buildPluginTelegramMenuCommands(params: { ); continue; } - const description = spec.description.trim(); + const description = normalizeTelegramCommandDescription(spec.description); if (!description) { issues.push(`Plugin command "/${normalized}" is missing a description.`); continue; } + if (description.length < TELEGRAM_COMMAND_DESCRIPTION_MIN_LENGTH) { + issues.push( + `Plugin command "/${normalized}" description is too short (minimum ${TELEGRAM_COMMAND_DESCRIPTION_MIN_LENGTH} characters).`, + ); + continue; + } if (existingCommands.has(normalized)) { if (pluginCommandNames.has(normalized)) { issues.push(`Plugin command "/${normalized}" is duplicated.`); @@ -97,10 +105,12 @@ export function syncTelegramMenuCommands(params: { operation: "setMyCommands", runtime, fn: () => bot.api.setMyCommands(commandsToRegister), + }).catch(() => { + // withTelegramApiErrorLogging already logged the error above. + // Catch here to prevent the outer void sync().catch from logging a + // second "command sync failed" message for the same failure. }); }; - void sync().catch((err) => { - runtime.error?.(`Telegram command sync failed: ${String(err)}`); - }); + void sync().catch(() => {}); } From dd0b980654c0a074abd16f4d877547b403246ed9 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 12 Mar 2026 14:07:48 +0000 Subject: [PATCH 7/9] fix(telegram): reduce TELEGRAM_MAX_COMMANDS from 100 to 99 to prevent BOT_COMMANDS_TOO_MUCH Co-authored-by: Secret297-CODER-SOURCE <73541046+Secret297-CODER-SOURCE@users.noreply.github.com> --- src/telegram/bot-native-command-menu.test.ts | 12 ++++++------ src/telegram/bot-native-command-menu.ts | 4 +++- src/telegram/bot-native-commands.plugin-auth.test.ts | 2 +- src/telegram/bot-native-commands.test.ts | 8 ++++---- 4 files changed, 14 insertions(+), 12 deletions(-) diff --git a/src/telegram/bot-native-command-menu.test.ts b/src/telegram/bot-native-command-menu.test.ts index 1b6eddf299f03..63a4eb847bdc3 100644 --- a/src/telegram/bot-native-command-menu.test.ts +++ b/src/telegram/bot-native-command-menu.test.ts @@ -14,14 +14,14 @@ describe("bot-native-command-menu", () => { const result = buildCappedTelegramMenuCommands({ allCommands }); - expect(result.commandsToRegister).toHaveLength(100); + expect(result.commandsToRegister).toHaveLength(99); expect(result.totalCommands).toBe(105); - expect(result.maxCommands).toBe(100); - expect(result.overflowCount).toBe(5); + expect(result.maxCommands).toBe(99); + expect(result.overflowCount).toBe(6); expect(result.commandsToRegister[0]).toEqual({ command: "cmd_0", description: "Command 0" }); - expect(result.commandsToRegister[99]).toEqual({ - command: "cmd_99", - description: "Command 99", + expect(result.commandsToRegister[98]).toEqual({ + command: "cmd_98", + description: "Command 98", }); }); diff --git a/src/telegram/bot-native-command-menu.ts b/src/telegram/bot-native-command-menu.ts index d1d0cb11e41a1..d0bafc50ca267 100644 --- a/src/telegram/bot-native-command-menu.ts +++ b/src/telegram/bot-native-command-menu.ts @@ -8,7 +8,9 @@ import { import type { RuntimeEnv } from "../runtime.js"; import { withTelegramApiErrorLogging } from "./api-logging.js"; -export const TELEGRAM_MAX_COMMANDS = 100; +// Telegram's documented limit is 100, but the API rejects with BOT_COMMANDS_TOO_MUCH +// at exactly 100 in practice. Cap at 99 to stay safely under the enforced threshold. +export const TELEGRAM_MAX_COMMANDS = 99; export type TelegramMenuCommand = { command: string; diff --git a/src/telegram/bot-native-commands.plugin-auth.test.ts b/src/telegram/bot-native-commands.plugin-auth.test.ts index f6f6d16c2fc4d..b7528d898b95a 100644 --- a/src/telegram/bot-native-commands.plugin-auth.test.ts +++ b/src/telegram/bot-native-commands.plugin-auth.test.ts @@ -71,7 +71,7 @@ describe("registerTelegramNativeCommands (plugin auth)", () => { }); expect(setMyCommands).not.toHaveBeenCalled(); - expect(log).not.toHaveBeenCalledWith(expect.stringContaining("registering first 100")); + expect(log).not.toHaveBeenCalledWith(expect.stringContaining("registering first 99")); expect(Object.keys(handlers)).toHaveLength(101); }); diff --git a/src/telegram/bot-native-commands.test.ts b/src/telegram/bot-native-commands.test.ts index 080fb5b85ce17..3ce6b3fb3d908 100644 --- a/src/telegram/bot-native-commands.test.ts +++ b/src/telegram/bot-native-commands.test.ts @@ -115,7 +115,7 @@ describe("registerTelegramNativeCommands", () => { }); }); - it("truncates Telegram command registration to 100 commands", () => { + it("truncates Telegram command registration to 99 commands", () => { const cfg: OpenClawConfig = { commands: { native: false }, }; @@ -145,10 +145,10 @@ describe("registerTelegramNativeCommands", () => { command: string; description: string; }>; - expect(registeredCommands).toHaveLength(100); - expect(registeredCommands).toEqual(customCommands.slice(0, 100)); + expect(registeredCommands).toHaveLength(99); + expect(registeredCommands).toEqual(customCommands.slice(0, 99)); expect(runtimeLog).toHaveBeenCalledWith( - "Telegram limits bots to 100 commands. 120 configured; registering first 100. Use channels.telegram.commands.native: false to disable, or reduce plugin/skill/custom commands.", + "Telegram limits bots to 99 commands. 120 configured; registering first 99. Use channels.telegram.commands.native: false to disable, or reduce plugin/skill/custom commands.", ); }); From f1ae93d1b4e2fe090ca093060f1374787b7eb216 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 12 Mar 2026 19:02:04 +0000 Subject: [PATCH 8/9] fix(telegram): log unexpected errors in outer sync catch; import danger in menu module Co-authored-by: Secret297-CODER-SOURCE <73541046+Secret297-CODER-SOURCE@users.noreply.github.com> --- src/telegram/bot-native-command-menu.ts | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/telegram/bot-native-command-menu.ts b/src/telegram/bot-native-command-menu.ts index d0bafc50ca267..1483929de632e 100644 --- a/src/telegram/bot-native-command-menu.ts +++ b/src/telegram/bot-native-command-menu.ts @@ -5,6 +5,7 @@ import { TELEGRAM_COMMAND_DESCRIPTION_MIN_LENGTH, TELEGRAM_COMMAND_NAME_PATTERN, } from "../config/telegram-custom-commands.js"; +import { danger } from "../globals.js"; import type { RuntimeEnv } from "../runtime.js"; import { withTelegramApiErrorLogging } from "./api-logging.js"; @@ -114,5 +115,10 @@ export function syncTelegramMenuCommands(params: { }); }; - void sync().catch(() => {}); + void sync().catch((err) => { + // API errors are already logged by withTelegramApiErrorLogging and silently + // swallowed by the inner .catch() above. This outer catch only fires for + // unexpected non-API errors (e.g. programming errors inside sync()). + runtime.error?.(danger(`telegram command sync failed: ${String(err)}`)); + }); } From 5dff0200f0467425baac1a356de785f5966cedf2 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 12 Mar 2026 19:20:27 +0000 Subject: [PATCH 9/9] fix(telegram): skip setMyCommands when deleteMyCommands fails with 5xx (504 Gateway Timeout) Co-authored-by: Secret297-CODER-SOURCE <73541046+Secret297-CODER-SOURCE@users.noreply.github.com> --- src/telegram/bot-native-command-menu.test.ts | 52 ++++++++++++++++++++ src/telegram/bot-native-command-menu.ts | 25 +++++++++- 2 files changed, 75 insertions(+), 2 deletions(-) diff --git a/src/telegram/bot-native-command-menu.test.ts b/src/telegram/bot-native-command-menu.test.ts index 63a4eb847bdc3..b2456c60914d9 100644 --- a/src/telegram/bot-native-command-menu.test.ts +++ b/src/telegram/bot-native-command-menu.test.ts @@ -139,4 +139,56 @@ describe("bot-native-command-menu", () => { expect(runtimeError).toHaveBeenCalledTimes(1); expect(runtimeError.mock.calls[0]?.[0]).toContain("setMyCommands"); }); + + it("skips setMyCommands when deleteMyCommands fails with a 5xx server error", async () => { + const serverError = Object.assign(new Error("Gateway Timeout"), { error_code: 504 }); + const deleteMyCommands = vi.fn().mockRejectedValue(serverError); + const setMyCommands = vi.fn(); + const runtimeError = vi.fn(); + + syncTelegramMenuCommands({ + bot: { + api: { deleteMyCommands, setMyCommands }, + } as unknown as Parameters[0]["bot"], + runtime: { + error: runtimeError, + } as unknown as Parameters[0]["runtime"], + commandsToRegister: [{ command: "cmd", description: "Command" }], + }); + + await vi.waitFor(() => { + expect(deleteMyCommands).toHaveBeenCalled(); + }); + + // setMyCommands must NOT be attempted — the API is unreachable. + expect(setMyCommands).not.toHaveBeenCalled(); + // deleteMyCommands failure is still logged once. + expect(runtimeError).toHaveBeenCalledTimes(1); + expect(runtimeError.mock.calls[0]?.[0]).toContain("deleteMyCommands"); + }); + + it("still calls setMyCommands when deleteMyCommands fails with a 4xx client error", async () => { + const clientError = Object.assign(new Error("Bad Request"), { error_code: 400 }); + const deleteMyCommands = vi.fn().mockRejectedValue(clientError); + const setMyCommands = vi.fn().mockResolvedValue(undefined); + const runtimeError = vi.fn(); + + syncTelegramMenuCommands({ + bot: { + api: { deleteMyCommands, setMyCommands }, + } as unknown as Parameters[0]["bot"], + runtime: { + error: runtimeError, + } as unknown as Parameters[0]["runtime"], + commandsToRegister: [{ command: "cmd", description: "Command" }], + }); + + await vi.waitFor(() => { + expect(setMyCommands).toHaveBeenCalled(); + }); + + // deleteMyCommands failure logged, setMyCommands succeeded (no additional error). + expect(runtimeError).toHaveBeenCalledTimes(1); + expect(runtimeError.mock.calls[0]?.[0]).toContain("deleteMyCommands"); + }); }); diff --git a/src/telegram/bot-native-command-menu.ts b/src/telegram/bot-native-command-menu.ts index 1483929de632e..e891817c3e35f 100644 --- a/src/telegram/bot-native-command-menu.ts +++ b/src/telegram/bot-native-command-menu.ts @@ -9,6 +9,19 @@ import { danger } from "../globals.js"; import type { RuntimeEnv } from "../runtime.js"; import { withTelegramApiErrorLogging } from "./api-logging.js"; +/** + * Returns true when the error is a Telegram server-side failure (HTTP 5xx). + * GrammyError surfaces the HTTP status code in the `error_code` field, so a + * 504 Gateway Timeout appears as `{ error_code: 504, ... }`. + */ +function isTelegramServerError(err: unknown): boolean { + if (!err || typeof err !== "object") { + return false; + } + const code = (err as { error_code?: unknown }).error_code; + return typeof code === "number" && code >= 500 && code < 600; +} + // Telegram's documented limit is 100, but the API rejects with BOT_COMMANDS_TOO_MUCH // at exactly 100 in practice. Cap at 99 to stay safely under the enforced threshold. export const TELEGRAM_MAX_COMMANDS = 99; @@ -92,15 +105,23 @@ export function syncTelegramMenuCommands(params: { const { bot, runtime, commandsToRegister } = params; const sync = async () => { // Keep delete -> set ordering to avoid stale deletions racing after fresh registrations. + let deleteServerError = false; if (typeof bot.api.deleteMyCommands === "function") { await withTelegramApiErrorLogging({ operation: "deleteMyCommands", runtime, fn: () => bot.api.deleteMyCommands(), - }).catch(() => {}); + }).catch((err) => { + // If Telegram returned a 5xx server error (e.g. 504 Gateway Timeout), + // the API is temporarily unreachable. Skip setMyCommands to avoid + // waiting a second full timeout for a call that will also fail. + if (isTelegramServerError(err)) { + deleteServerError = true; + } + }); } - if (commandsToRegister.length === 0) { + if (deleteServerError || commandsToRegister.length === 0) { return; }