Skip to content

Commit 0872ba2

Browse files
committed
fix(telegram): scope command-sync hash cache by bot identity (#32059)
1 parent 836ad14 commit 0872ba2

File tree

3 files changed

+76
-15
lines changed

3 files changed

+76
-15
lines changed

src/telegram/bot-native-command-menu.test.ts

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,8 @@ describe("bot-native-command-menu", () => {
100100
} as unknown as Parameters<typeof syncTelegramMenuCommands>[0]["bot"],
101101
runtime: {} as Parameters<typeof syncTelegramMenuCommands>[0]["runtime"],
102102
commandsToRegister: [{ command: "cmd", description: "Command" }],
103+
accountId: `test-delete-${Date.now()}`,
104+
botIdentity: "bot-a",
103105
});
104106

105107
await vi.waitFor(() => {
@@ -143,6 +145,7 @@ describe("bot-native-command-menu", () => {
143145
>[0]["runtime"],
144146
commandsToRegister: commands,
145147
accountId,
148+
botIdentity: "bot-a",
146149
});
147150

148151
await vi.waitFor(() => {
@@ -159,6 +162,7 @@ describe("bot-native-command-menu", () => {
159162
>[0]["runtime"],
160163
commandsToRegister: commands,
161164
accountId,
165+
botIdentity: "bot-a",
162166
});
163167

164168
await vi.waitFor(() => {
@@ -169,6 +173,41 @@ describe("bot-native-command-menu", () => {
169173
expect(setMyCommands).toHaveBeenCalledTimes(1);
170174
});
171175

176+
it("does not reuse cached hash across different bot identities", async () => {
177+
const deleteMyCommands = vi.fn(async () => undefined);
178+
const setMyCommands = vi.fn(async () => undefined);
179+
const runtimeLog = vi.fn();
180+
const accountId = `test-bot-identity-${Date.now()}`;
181+
const commands = [{ command: "same", description: "Same" }];
182+
183+
syncTelegramMenuCommands({
184+
bot: { api: { deleteMyCommands, setMyCommands } } as unknown as Parameters<
185+
typeof syncTelegramMenuCommands
186+
>[0]["bot"],
187+
runtime: { log: runtimeLog, error: vi.fn(), exit: vi.fn() } as Parameters<
188+
typeof syncTelegramMenuCommands
189+
>[0]["runtime"],
190+
commandsToRegister: commands,
191+
accountId,
192+
botIdentity: "token-bot-a",
193+
});
194+
await vi.waitFor(() => expect(setMyCommands).toHaveBeenCalledTimes(1));
195+
196+
syncTelegramMenuCommands({
197+
bot: { api: { deleteMyCommands, setMyCommands } } as unknown as Parameters<
198+
typeof syncTelegramMenuCommands
199+
>[0]["bot"],
200+
runtime: { log: runtimeLog, error: vi.fn(), exit: vi.fn() } as Parameters<
201+
typeof syncTelegramMenuCommands
202+
>[0]["runtime"],
203+
commandsToRegister: commands,
204+
accountId,
205+
botIdentity: "token-bot-b",
206+
});
207+
await vi.waitFor(() => expect(setMyCommands).toHaveBeenCalledTimes(2));
208+
expect(runtimeLog).not.toHaveBeenCalledWith("telegram: command menu unchanged; skipping sync");
209+
});
210+
172211
it("retries with fewer commands on BOT_COMMANDS_TOO_MUCH", async () => {
173212
const deleteMyCommands = vi.fn(async () => undefined);
174213
const setMyCommands = vi
@@ -193,6 +232,8 @@ describe("bot-native-command-menu", () => {
193232
command: `cmd_${i}`,
194233
description: `Command ${i}`,
195234
})),
235+
accountId: `test-retry-${Date.now()}`,
236+
botIdentity: "bot-a",
196237
});
197238

198239
await vi.waitFor(() => {

src/telegram/bot-native-command-menu.ts

Lines changed: 28 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -112,25 +112,38 @@ export function hashCommandList(commands: TelegramMenuCommand[]): string {
112112
return createHash("sha256").update(JSON.stringify(sorted)).digest("hex").slice(0, 16);
113113
}
114114

115-
function resolveCommandHashPath(accountId?: string): string {
115+
function hashBotIdentity(botIdentity?: string): string {
116+
const normalized = botIdentity?.trim();
117+
if (!normalized) {
118+
return "no-bot";
119+
}
120+
return createHash("sha256").update(normalized).digest("hex").slice(0, 16);
121+
}
122+
123+
function resolveCommandHashPath(accountId?: string, botIdentity?: string): string {
116124
const stateDir = resolveStateDir(process.env, os.homedir);
117-
const normalized = accountId?.trim().replace(/[^a-z0-9._-]+/gi, "_") || "default";
118-
return path.join(stateDir, "telegram", `command-hash-${normalized}.txt`);
125+
const normalizedAccount = accountId?.trim().replace(/[^a-z0-9._-]+/gi, "_") || "default";
126+
const botHash = hashBotIdentity(botIdentity);
127+
return path.join(stateDir, "telegram", `command-hash-${normalizedAccount}-${botHash}.txt`);
119128
}
120129

121-
async function readCachedCommandHash(accountId?: string): Promise<string | null> {
130+
async function readCachedCommandHash(
131+
accountId?: string,
132+
botIdentity?: string,
133+
): Promise<string | null> {
122134
try {
123-
return (await fs.readFile(resolveCommandHashPath(accountId), "utf-8")).trim();
135+
return (await fs.readFile(resolveCommandHashPath(accountId, botIdentity), "utf-8")).trim();
124136
} catch {
125137
return null;
126138
}
127139
}
128140

129-
async function writeCachedCommandHash(accountId?: string, hash?: string): Promise<void> {
130-
if (!hash) {
131-
return;
132-
}
133-
const filePath = resolveCommandHashPath(accountId);
141+
async function writeCachedCommandHash(
142+
accountId: string | undefined,
143+
botIdentity: string | undefined,
144+
hash: string,
145+
): Promise<void> {
146+
const filePath = resolveCommandHashPath(accountId, botIdentity);
134147
try {
135148
await fs.mkdir(path.dirname(filePath), { recursive: true });
136149
await fs.writeFile(filePath, hash, "utf-8");
@@ -145,15 +158,16 @@ export function syncTelegramMenuCommands(params: {
145158
runtime: RuntimeEnv;
146159
commandsToRegister: TelegramMenuCommand[];
147160
accountId?: string;
161+
botIdentity?: string;
148162
}): void {
149-
const { bot, runtime, commandsToRegister, accountId } = params;
163+
const { bot, runtime, commandsToRegister, accountId, botIdentity } = params;
150164
const sync = async () => {
151165
// Skip sync if the command list hasn't changed since the last successful
152166
// sync. This prevents hitting Telegram's 429 rate limit when the gateway
153167
// is restarted several times in quick succession.
154168
// See: openclaw/openclaw#32017
155169
const currentHash = hashCommandList(commandsToRegister);
156-
const cachedHash = await readCachedCommandHash(accountId);
170+
const cachedHash = await readCachedCommandHash(accountId, botIdentity);
157171
if (cachedHash === currentHash) {
158172
runtime.log?.("telegram: command menu unchanged; skipping sync");
159173
return;
@@ -169,7 +183,7 @@ export function syncTelegramMenuCommands(params: {
169183
}
170184

171185
if (commandsToRegister.length === 0) {
172-
await writeCachedCommandHash(accountId, currentHash);
186+
await writeCachedCommandHash(accountId, botIdentity, currentHash);
173187
return;
174188
}
175189

@@ -181,7 +195,7 @@ export function syncTelegramMenuCommands(params: {
181195
runtime,
182196
fn: () => bot.api.setMyCommands(retryCommands),
183197
});
184-
await writeCachedCommandHash(accountId, currentHash);
198+
await writeCachedCommandHash(accountId, botIdentity, currentHash);
185199
return;
186200
} catch (err) {
187201
if (!isBotCommandsTooMuchError(err)) {

src/telegram/bot-native-commands.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -397,7 +397,13 @@ export const registerTelegramNativeCommands = ({
397397
}
398398
// Telegram only limits the setMyCommands payload (menu entries).
399399
// Keep hidden commands callable by registering handlers for the full catalog.
400-
syncTelegramMenuCommands({ bot, runtime, commandsToRegister, accountId });
400+
syncTelegramMenuCommands({
401+
bot,
402+
runtime,
403+
commandsToRegister,
404+
accountId,
405+
botIdentity: opts.token,
406+
});
401407

402408
const resolveCommandRuntimeContext = (params: {
403409
msg: NonNullable<TelegramNativeCommandContext["message"]>;

0 commit comments

Comments
 (0)