feat(telegram): auto-rename DM topics on first message (#51502)

* feat(telegram): auto-rename DM topics on first message

fix(telegram): use bot.api for topic rename to avoid SecretRef resolution

* fix(telegram): address security + test review feedback

- Fix test assertion: DEFAULT_PROMPT_SUBSTRING matches 'very short'
- Use RawBody instead of Body (no envelope metadata to LLM)
- Truncate user message to 500 chars for LLM prompt
- Remove user-derived content from verbose logs
- Remove redundant threadSpec.id null check
- Fix AutoTopicLabelParams type to match generateTopicLabel

* fix(telegram): use effective dm auto-topic config

* fix(telegram): detect direct auto-topic overrides

* fix: auto-rename Telegram DM topics on first message (#51502) (thanks @Lukavyi)

---------

Co-authored-by: Ayaan Zaidi <hi@obviy.us>
This commit is contained in:
Taras Lukavyi
2026-03-21 12:23:30 +01:00
committed by GitHub
parent cdf49f0b00
commit 466debb75c
12 changed files with 447 additions and 0 deletions

View File

@@ -442,6 +442,7 @@ export const buildTelegramMessageContext = async ({
msg,
chatId,
isGroup,
groupConfig,
resolvedThreadId,
threadSpec,
replyThreadId,

View File

@@ -40,11 +40,20 @@ const listSkillCommandsForAgents = vi.hoisted(() => vi.fn(() => []));
const wasSentByBot = vi.hoisted(() => vi.fn(() => false));
const loadSessionStore = vi.hoisted(() => vi.fn());
const resolveStorePath = vi.hoisted(() => vi.fn(() => "/tmp/sessions.json"));
const generateTopicLabel = vi.hoisted(() => vi.fn());
vi.mock("./draft-stream.js", () => ({
createTelegramDraftStream,
}));
vi.mock("openclaw/plugin-sdk/reply-runtime", async (importOriginal) => {
const actual = await importOriginal<typeof import("openclaw/plugin-sdk/reply-runtime")>();
return {
...actual,
generateTopicLabel,
};
});
vi.mock("./bot/delivery.js", () => ({
deliverReplies,
emitInternalMessageSentHook,
@@ -123,6 +132,7 @@ describe("dispatchTelegramMessage draft streaming", () => {
wasSentByBot.mockClear();
loadSessionStore.mockClear();
resolveStorePath.mockClear();
generateTopicLabel.mockClear();
loadConfig.mockReturnValue({});
dispatchReplyWithBufferedBlockDispatcher.mockResolvedValue({
queuedFinal: false,
@@ -130,6 +140,7 @@ describe("dispatchTelegramMessage draft streaming", () => {
});
resolveStorePath.mockReturnValue("/tmp/sessions.json");
loadSessionStore.mockReturnValue({});
generateTopicLabel.mockResolvedValue("Topic label");
});
const createDraftStream = (messageId?: number) => createTestDraftStream({ messageId });
@@ -156,6 +167,7 @@ describe("dispatchTelegramMessage draft streaming", () => {
},
chatId: 123,
isGroup: false,
groupConfig: undefined,
resolvedThreadId: undefined,
replyThreadId: 777,
threadSpec: { id: 777, scope: "dm" },
@@ -196,6 +208,7 @@ describe("dispatchTelegramMessage draft streaming", () => {
sendMessage: vi.fn(),
editMessageText: vi.fn(),
deleteMessage: vi.fn().mockResolvedValue(true),
editForumTopic: vi.fn().mockResolvedValue(true),
},
} as unknown as Bot;
}
@@ -2385,4 +2398,36 @@ describe("dispatchTelegramMessage draft streaming", () => {
statusReactionController.setThinking.mock.invocationCallOrder[1],
);
});
it("uses resolved DM config for auto-topic-label overrides", async () => {
dispatchReplyWithBufferedBlockDispatcher.mockResolvedValue({ queuedFinal: true });
loadSessionStore.mockReturnValue({ s1: {} });
const bot = createBot();
await dispatchWithContext({
bot,
context: createContext({
ctxPayload: {
SessionKey: "s1",
RawBody: "Need help with invoices",
} as TelegramMessageContext["ctxPayload"],
groupConfig: {
autoTopicLabel: false,
} as TelegramMessageContext["groupConfig"],
}),
telegramCfg: { autoTopicLabel: true },
cfg: {
channels: {
telegram: {
direct: {
"123": { autoTopicLabel: true },
},
},
},
},
});
expect(generateTopicLabel).not.toHaveBeenCalled();
expect(bot.api.editForumTopic).not.toHaveBeenCalled();
});
});

View File

@@ -22,12 +22,14 @@ import type {
OpenClawConfig,
ReplyToMode,
TelegramAccountConfig,
TelegramDirectConfig,
} from "openclaw/plugin-sdk/config-runtime";
import { getAgentScopedMediaLocalRoots } from "openclaw/plugin-sdk/media-runtime";
import { clearHistoryEntriesIfEnabled } from "openclaw/plugin-sdk/reply-history";
import { resolveSendableOutboundReplyParts } from "openclaw/plugin-sdk/reply-payload";
import { resolveChunkMode } from "openclaw/plugin-sdk/reply-runtime";
import type { ReplyPayload } from "openclaw/plugin-sdk/reply-runtime";
import { resolveAutoTopicLabelConfig, generateTopicLabel } from "openclaw/plugin-sdk/reply-runtime";
import { danger, logVerbose } from "openclaw/plugin-sdk/runtime-env";
import type { RuntimeEnv } from "openclaw/plugin-sdk/runtime-env";
import { defaultTelegramBotDeps, type TelegramBotDeps } from "./bot-deps.js";
@@ -160,6 +162,7 @@ export const dispatchTelegramMessage = async ({
msg,
chatId,
isGroup,
groupConfig,
threadSpec,
historyKey,
historyLimit,
@@ -532,6 +535,28 @@ export const dispatchTelegramMessage = async ({
let queuedFinal = false;
let hadErrorReplyFailureOrSkip = false;
// Determine if this is the first turn in session (for auto-topic-label).
const isDmTopic = !isGroup && threadSpec.scope === "dm" && threadSpec.id != null;
let isFirstTurnInSession = false;
if (isDmTopic) {
try {
const storePath = resolveStorePath(cfg.session?.store, { agentId: route.agentId });
const store = loadSessionStore(storePath, { skipCache: true });
const sessionKey = ctxPayload.SessionKey;
if (sessionKey) {
const entry = resolveSessionStoreEntry({ store, sessionKey }).existing;
isFirstTurnInSession = !entry?.systemSent;
} else {
logVerbose("auto-topic-label: SessionKey is absent, skipping first-turn detection");
}
} catch (err) {
logVerbose(
`auto-topic-label: session store error: ${err instanceof Error ? err.message : String(err)}`,
);
}
}
if (statusReactionController) {
void statusReactionController.setThinking();
}
@@ -859,6 +884,46 @@ export const dispatchTelegramMessage = async ({
return;
}
// Fire-and-forget: auto-rename DM topic on first message.
if (isDmTopic && isFirstTurnInSession) {
const userMessage = (ctxPayload.RawBody ?? ctxPayload.Body ?? "").slice(0, 500);
if (userMessage.trim()) {
const agentDir = resolveAgentDir(cfg, route.agentId);
const directConfig = !isGroup ? (groupConfig as TelegramDirectConfig | undefined) : undefined;
const directAutoTopicLabel = directConfig?.autoTopicLabel;
const accountAutoTopicLabel = telegramCfg?.autoTopicLabel;
const autoTopicConfig = resolveAutoTopicLabelConfig(
directAutoTopicLabel,
accountAutoTopicLabel,
);
if (autoTopicConfig) {
const topicThreadId = threadSpec.id!;
void (async () => {
try {
const label = await generateTopicLabel({
userMessage,
prompt: autoTopicConfig.prompt,
cfg,
agentId: route.agentId,
agentDir,
});
if (!label) {
logVerbose("auto-topic-label: LLM returned empty label");
return;
}
logVerbose(`auto-topic-label: generated label (len=${label.length})`);
await bot.api.editForumTopic(chatId, topicThreadId, { name: label });
logVerbose(`auto-topic-label: renamed topic ${chatId}/${topicThreadId}`);
} catch (err) {
logVerbose(
`auto-topic-label: failed: ${err instanceof Error ? err.message : String(err)}`,
);
}
})();
}
}
}
if (statusReactionController) {
void statusReactionController.setDone().catch((err) => {
logVerbose(`telegram: status reaction finalize failed: ${String(err)}`);