fix(telegram): route native topic commands to the active session (#38871)

* fix(telegram): resolve session entry for /stop in forum topics

Fixes #38675

- Export normalizeStoreSessionKey from store.ts for reuse
- Use it in resolveSessionEntryForKey so topic session keys (lowercase
  in store) are found when handling /stop
- Add test for forum topic session key lookup

* fix(telegram): share native topic routing with inbound messages

* fix: land telegram topic routing follow-up (#38871)

---------

Co-authored-by: xialonglee <li.xialong@xydigit.com>
This commit is contained in:
Ayaan Zaidi
2026-03-07 19:01:16 +05:30
committed by GitHub
parent bfc36cc86d
commit 9e1de97a69
13 changed files with 335 additions and 172 deletions

View File

@@ -356,6 +356,20 @@ describe("abort detection", () => {
expect(resolveSessionEntryForKey(undefined, "session-1")).toEqual({});
});
it("resolves Telegram forum topic session when lookup key has different casing than store", () => {
// Store normalizes keys to lowercase; caller may pass mixed-case. /stop in topic must find entry.
const storeKey = "agent:main:telegram:group:-1001234567890:topic:99";
const lookupKey = "Agent:Main:Telegram:Group:-1001234567890:Topic:99";
const store = {
[storeKey]: { sessionId: "pi-topic-99", updatedAt: 0 },
} as Record<string, { sessionId: string; updatedAt: number }>;
// Direct lookup fails (store uses lowercase keys); normalization fallback must succeed.
expect(store[lookupKey]).toBeUndefined();
const result = resolveSessionEntryForKey(store, lookupKey);
expect(result.entry?.sessionId).toBe("pi-topic-99");
expect(result.key).toBe(storeKey);
});
it("fast-aborts even when text commands are disabled", async () => {
const { cfg } = await createAbortConfig({ commandsTextEnabled: false });

View File

@@ -12,6 +12,7 @@ import {
import type { OpenClawConfig } from "../../config/config.js";
import {
loadSessionStore,
resolveSessionStoreEntry,
resolveStorePath,
type SessionEntry,
updateSessionStore,
@@ -172,13 +173,22 @@ export function formatAbortReplyText(stoppedSubagents?: number): string {
export function resolveSessionEntryForKey(
store: Record<string, SessionEntry> | undefined,
sessionKey: string | undefined,
) {
): { entry?: SessionEntry; key?: string; legacyKeys?: string[] } {
if (!store || !sessionKey) {
return {};
}
const direct = store[sessionKey];
if (direct) {
return { entry: direct, key: sessionKey };
const resolved = resolveSessionStoreEntry({ store, sessionKey });
if (resolved.existing) {
return resolved.legacyKeys.length > 0
? {
entry: resolved.existing,
key: resolved.normalizedKey,
legacyKeys: resolved.legacyKeys,
}
: {
entry: resolved.existing,
key: resolved.normalizedKey,
};
}
return {};
}
@@ -301,7 +311,7 @@ export async function tryFastAbortFromMessage(params: {
if (targetKey) {
const storePath = resolveStorePath(cfg.session?.store, { agentId });
const store = loadSessionStore(storePath);
const { entry, key } = resolveSessionEntryForKey(store, targetKey);
const { entry, key, legacyKeys } = resolveSessionEntryForKey(store, targetKey);
const resolvedTargetKey = key ?? targetKey;
const acpManager = getAcpSessionManager();
const acpResolution = acpManager.resolveSession({
@@ -340,6 +350,11 @@ export async function tryFastAbortFromMessage(params: {
applyAbortCutoffToSessionEntry(entry, abortCutoff);
entry.updatedAt = Date.now();
store[key] = entry;
for (const legacyKey of legacyKeys ?? []) {
if (legacyKey !== key) {
delete store[legacyKey];
}
}
await updateSessionStore(storePath, (nextStore) => {
const nextEntry = nextStore[key] ?? entry;
if (!nextEntry) {
@@ -349,6 +364,11 @@ export async function tryFastAbortFromMessage(params: {
applyAbortCutoffToSessionEntry(nextEntry, abortCutoff);
nextEntry.updatedAt = Date.now();
nextStore[key] = nextEntry;
for (const legacyKey of legacyKeys ?? []) {
if (legacyKey !== key) {
delete nextStore[legacyKey];
}
}
});
} else if (abortKey) {
setAbortMemory(abortKey, true);

View File

@@ -1,6 +1,11 @@
import { resolveSessionAgentId } from "../../agents/agent-scope.js";
import type { OpenClawConfig } from "../../config/config.js";
import { loadSessionStore, resolveStorePath, type SessionEntry } from "../../config/sessions.js";
import {
loadSessionStore,
resolveSessionStoreEntry,
resolveStorePath,
type SessionEntry,
} from "../../config/sessions.js";
import { logVerbose } from "../../globals.js";
import { fireAndForgetHook } from "../../hooks/fire-and-forget.js";
import { createInternalHookEvent, triggerInternalHook } from "../../hooks/internal-hooks.js";
@@ -65,7 +70,7 @@ const isInboundAudioContext = (ctx: FinalizedMsgContext): boolean => {
return AUDIO_HEADER_RE.test(trimmed);
};
const resolveSessionStoreEntry = (
const resolveSessionStoreLookup = (
ctx: FinalizedMsgContext,
cfg: OpenClawConfig,
): {
@@ -84,7 +89,7 @@ const resolveSessionStoreEntry = (
const store = loadSessionStore(storePath);
return {
sessionKey,
entry: store[sessionKey.toLowerCase()] ?? store[sessionKey],
entry: resolveSessionStoreEntry({ store, sessionKey }).existing,
};
} catch {
return {
@@ -164,7 +169,7 @@ export async function dispatchReplyFromConfig(params: {
return { queuedFinal: false, counts: dispatcher.getQueuedCounts() };
}
const sessionStoreEntry = resolveSessionStoreEntry(ctx, cfg);
const sessionStoreEntry = resolveSessionStoreLookup(ctx, cfg);
const acpDispatchSessionKey = sessionStoreEntry.sessionKey ?? sessionKey;
const inboundAudio = isInboundAudioContext(ctx);
const sessionTtsAuto = normalizeTtsAutoMode(sessionStoreEntry.entry?.ttsAuto);