2026-02-14 02:02:44 +01:00
import { describe , expect , it , vi } from "vitest" ;
import {
buildCappedTelegramMenuCommands ,
buildPluginTelegramMenuCommands ,
2026-03-02 10:58:48 -08:00
hashCommandList ,
2026-02-14 02:02:44 +01:00
syncTelegramMenuCommands ,
} from "./bot-native-command-menu.js" ;
2026-03-02 21:31:26 +00:00
type SyncMenuOptions = {
deleteMyCommands : ReturnType < typeof vi.fn > ;
setMyCommands : ReturnType < typeof vi.fn > ;
commandsToRegister : Parameters < typeof syncTelegramMenuCommands > [ 0 ] [ "commandsToRegister" ] ;
accountId : string ;
botIdentity : string ;
runtimeLog? : ReturnType < typeof vi.fn > ;
2026-03-13 01:45:47 +00:00
runtimeError? : ReturnType < typeof vi.fn > ;
2026-03-02 21:31:26 +00:00
} ;
function syncMenuCommandsWithMocks ( options : SyncMenuOptions ) : void {
syncTelegramMenuCommands ( {
bot : {
api : { deleteMyCommands : options.deleteMyCommands , setMyCommands : options.setMyCommands } ,
} as unknown as Parameters < typeof syncTelegramMenuCommands > [ 0 ] [ "bot" ] ,
runtime : {
log : options.runtimeLog ? ? vi . fn ( ) ,
2026-03-13 01:45:47 +00:00
error : options.runtimeError ? ? vi . fn ( ) ,
2026-03-02 21:31:26 +00:00
exit : vi.fn ( ) ,
} as Parameters < typeof syncTelegramMenuCommands > [ 0 ] [ "runtime" ] ,
commandsToRegister : options.commandsToRegister ,
accountId : options.accountId ,
botIdentity : options.botIdentity ,
} ) ;
}
2026-02-14 02:02:44 +01:00
describe ( "bot-native-command-menu" , ( ) = > {
it ( "caps menu entries to Telegram limit" , ( ) = > {
const allCommands = Array . from ( { length : 105 } , ( _ , i ) = > ( {
command : ` cmd_ ${ i } ` ,
description : ` Command ${ i } ` ,
} ) ) ;
const result = buildCappedTelegramMenuCommands ( { allCommands } ) ;
expect ( result . commandsToRegister ) . toHaveLength ( 100 ) ;
expect ( result . totalCommands ) . toBe ( 105 ) ;
expect ( result . maxCommands ) . toBe ( 100 ) ;
expect ( result . overflowCount ) . toBe ( 5 ) ;
expect ( result . commandsToRegister [ 0 ] ) . toEqual ( { command : "cmd_0" , description : "Command 0" } ) ;
expect ( result . commandsToRegister [ 99 ] ) . toEqual ( {
command : "cmd_99" ,
description : "Command 99" ,
} ) ;
} ) ;
it ( "validates plugin command specs and reports conflicts" , ( ) = > {
const existingCommands = new Set ( [ "native" ] ) ;
const result = buildPluginTelegramMenuCommands ( {
specs : [
{ name : "valid" , description : " Works " } ,
{ name : "bad-name!" , description : "Bad" } ,
{ name : "native" , description : "Conflicts with native" } ,
{ name : "valid" , description : "Duplicate plugin name" } ,
{ name : "empty" , description : " " } ,
] ,
existingCommands ,
} ) ;
expect ( result . commands ) . toEqual ( [ { command : "valid" , description : "Works" } ] ) ;
expect ( result . issues ) . toContain (
'Plugin command "/bad-name!" is invalid for Telegram (use a-z, 0-9, underscore; max 32 chars).' ,
) ;
expect ( result . issues ) . toContain (
'Plugin command "/native" conflicts with an existing Telegram command.' ,
) ;
expect ( result . issues ) . toContain ( 'Plugin command "/valid" is duplicated.' ) ;
expect ( result . issues ) . toContain ( 'Plugin command "/empty" is missing a description.' ) ;
} ) ;
2026-02-18 09:16:31 +05:30
it ( "normalizes hyphenated plugin command names" , ( ) = > {
const result = buildPluginTelegramMenuCommands ( {
specs : [ { name : "agent-run" , description : "Run agent" } ] ,
existingCommands : new Set < string > ( ) ,
} ) ;
expect ( result . commands ) . toEqual ( [ { command : "agent_run" , description : "Run agent" } ] ) ;
expect ( result . issues ) . toEqual ( [ ] ) ;
} ) ;
2026-03-03 01:52:31 +08:00
it ( "ignores malformed plugin specs without crashing" , ( ) = > {
const malformedSpecs = [
{ name : "valid" , description : " Works " } ,
{ name : "missing-description" , description : undefined } ,
{ name : undefined , description : "Missing name" } ,
] as unknown as Parameters < typeof buildPluginTelegramMenuCommands > [ 0 ] [ "specs" ] ;
const result = buildPluginTelegramMenuCommands ( {
specs : malformedSpecs ,
existingCommands : new Set < string > ( ) ,
} ) ;
expect ( result . commands ) . toEqual ( [ { command : "valid" , description : "Works" } ] ) ;
expect ( result . issues ) . toContain (
'Plugin command "/missing_description" is missing a description.' ,
) ;
expect ( result . issues ) . toContain (
'Plugin command "/<unknown>" is invalid for Telegram (use a-z, 0-9, underscore; max 32 chars).' ,
) ;
} ) ;
2026-02-14 02:02:44 +01:00
it ( "deletes stale commands before setting new menu" , async ( ) = > {
const callOrder : string [ ] = [ ] ;
const deleteMyCommands = vi . fn ( async ( ) = > {
callOrder . push ( "delete" ) ;
} ) ;
const setMyCommands = vi . fn ( async ( ) = > {
callOrder . push ( "set" ) ;
} ) ;
2026-03-02 21:31:26 +00:00
syncMenuCommandsWithMocks ( {
deleteMyCommands ,
setMyCommands ,
2026-02-14 02:02:44 +01:00
commandsToRegister : [ { command : "cmd" , description : "Command" } ] ,
2026-03-02 19:24:16 +00:00
accountId : ` test-delete- ${ Date . now ( ) } ` ,
botIdentity : "bot-a" ,
2026-02-14 02:02:44 +01:00
} ) ;
await vi . waitFor ( ( ) = > {
expect ( setMyCommands ) . toHaveBeenCalled ( ) ;
} ) ;
expect ( callOrder ) . toEqual ( [ "delete" , "set" ] ) ;
} ) ;
2026-02-26 20:22:10 +08:00
2026-03-02 10:58:48 -08:00
it ( "produces a stable hash regardless of command order (#32017)" , ( ) = > {
const commands = [
{ command : "bravo" , description : "B" } ,
{ command : "alpha" , description : "A" } ,
] ;
const reversed = [ . . . commands ] . toReversed ( ) ;
expect ( hashCommandList ( commands ) ) . toBe ( hashCommandList ( reversed ) ) ;
} ) ;
it ( "produces different hashes for different command lists (#32017)" , ( ) = > {
const a = [ { command : "alpha" , description : "A" } ] ;
const b = [ { command : "alpha" , description : "Changed" } ] ;
expect ( hashCommandList ( a ) ) . not . toBe ( hashCommandList ( b ) ) ;
} ) ;
it ( "skips sync when command hash is unchanged (#32017)" , async ( ) = > {
const deleteMyCommands = vi . fn ( async ( ) = > undefined ) ;
const setMyCommands = vi . fn ( async ( ) = > undefined ) ;
const runtimeLog = vi . fn ( ) ;
// Use a unique accountId so cached hashes from other tests don't interfere.
const accountId = ` test-skip- ${ Date . now ( ) } ` ;
const commands = [ { command : "skip_test" , description : "Skip test command" } ] ;
// First sync — no cached hash, should call setMyCommands.
2026-03-02 21:31:26 +00:00
syncMenuCommandsWithMocks ( {
deleteMyCommands ,
setMyCommands ,
runtimeLog ,
2026-03-02 10:58:48 -08:00
commandsToRegister : commands ,
accountId ,
2026-03-02 19:24:16 +00:00
botIdentity : "bot-a" ,
2026-03-02 10:58:48 -08:00
} ) ;
await vi . waitFor ( ( ) = > {
expect ( setMyCommands ) . toHaveBeenCalledTimes ( 1 ) ;
} ) ;
// Second sync with the same commands — hash is cached, should skip.
2026-03-02 21:31:26 +00:00
syncMenuCommandsWithMocks ( {
deleteMyCommands ,
setMyCommands ,
runtimeLog ,
2026-03-02 10:58:48 -08:00
commandsToRegister : commands ,
accountId ,
2026-03-02 19:24:16 +00:00
botIdentity : "bot-a" ,
2026-03-02 10:58:48 -08:00
} ) ;
// setMyCommands should NOT have been called a second time.
expect ( setMyCommands ) . toHaveBeenCalledTimes ( 1 ) ;
} ) ;
2026-03-02 19:24:16 +00:00
it ( "does not reuse cached hash across different bot identities" , async ( ) = > {
const deleteMyCommands = vi . fn ( async ( ) = > undefined ) ;
const setMyCommands = vi . fn ( async ( ) = > undefined ) ;
const runtimeLog = vi . fn ( ) ;
const accountId = ` test-bot-identity- ${ Date . now ( ) } ` ;
const commands = [ { command : "same" , description : "Same" } ] ;
2026-03-02 21:31:26 +00:00
syncMenuCommandsWithMocks ( {
deleteMyCommands ,
setMyCommands ,
runtimeLog ,
2026-03-02 19:24:16 +00:00
commandsToRegister : commands ,
accountId ,
botIdentity : "token-bot-a" ,
} ) ;
await vi . waitFor ( ( ) = > expect ( setMyCommands ) . toHaveBeenCalledTimes ( 1 ) ) ;
2026-03-02 21:31:26 +00:00
syncMenuCommandsWithMocks ( {
deleteMyCommands ,
setMyCommands ,
runtimeLog ,
2026-03-02 19:24:16 +00:00
commandsToRegister : commands ,
accountId ,
botIdentity : "token-bot-b" ,
} ) ;
await vi . waitFor ( ( ) = > expect ( setMyCommands ) . toHaveBeenCalledTimes ( 2 ) ) ;
} ) ;
2026-03-02 19:54:58 +00:00
it ( "does not cache empty-menu hash when deleteMyCommands fails" , async ( ) = > {
const deleteMyCommands = vi
. fn ( )
. mockRejectedValueOnce ( new Error ( "transient failure" ) )
. mockResolvedValue ( undefined ) ;
const setMyCommands = vi . fn ( async ( ) = > undefined ) ;
const runtimeLog = vi . fn ( ) ;
const accountId = ` test-empty-delete-fail- ${ Date . now ( ) } ` ;
2026-03-02 21:31:26 +00:00
syncMenuCommandsWithMocks ( {
deleteMyCommands ,
setMyCommands ,
runtimeLog ,
2026-03-02 19:54:58 +00:00
commandsToRegister : [ ] ,
accountId ,
botIdentity : "bot-a" ,
} ) ;
await vi . waitFor ( ( ) = > expect ( deleteMyCommands ) . toHaveBeenCalledTimes ( 1 ) ) ;
2026-03-02 21:31:26 +00:00
syncMenuCommandsWithMocks ( {
deleteMyCommands ,
setMyCommands ,
runtimeLog ,
2026-03-02 19:54:58 +00:00
commandsToRegister : [ ] ,
accountId ,
botIdentity : "bot-a" ,
} ) ;
await vi . waitFor ( ( ) = > expect ( deleteMyCommands ) . toHaveBeenCalledTimes ( 2 ) ) ;
} ) ;
2026-02-26 20:22:10 +08:00
it ( "retries with fewer commands on BOT_COMMANDS_TOO_MUCH" , async ( ) = > {
const deleteMyCommands = vi . fn ( async ( ) = > undefined ) ;
const setMyCommands = vi
. fn ( )
. mockRejectedValueOnce ( new Error ( "400: Bad Request: BOT_COMMANDS_TOO_MUCH" ) )
. mockResolvedValue ( undefined ) ;
const runtimeLog = vi . fn ( ) ;
2026-03-13 01:45:47 +00:00
const runtimeError = vi . fn ( ) ;
2026-02-26 20:22:10 +08:00
2026-03-13 01:45:47 +00:00
syncMenuCommandsWithMocks ( {
deleteMyCommands ,
setMyCommands ,
runtimeLog ,
runtimeError ,
2026-02-26 20:22:10 +08:00
commandsToRegister : Array.from ( { length : 100 } , ( _ , i ) = > ( {
command : ` cmd_ ${ i } ` ,
description : ` Command ${ i } ` ,
} ) ) ,
2026-03-02 19:24:16 +00:00
accountId : ` test-retry- ${ Date . now ( ) } ` ,
botIdentity : "bot-a" ,
2026-02-26 20:22:10 +08:00
} ) ;
await vi . waitFor ( ( ) = > {
expect ( setMyCommands ) . toHaveBeenCalledTimes ( 2 ) ;
} ) ;
const firstPayload = setMyCommands . mock . calls [ 0 ] ? . [ 0 ] as Array < unknown > ;
const secondPayload = setMyCommands . mock . calls [ 1 ] ? . [ 0 ] as Array < unknown > ;
expect ( firstPayload ) . toHaveLength ( 100 ) ;
expect ( secondPayload ) . toHaveLength ( 80 ) ;
expect ( runtimeLog ) . toHaveBeenCalledWith (
"Telegram rejected 100 commands (BOT_COMMANDS_TOO_MUCH); retrying with 80." ,
) ;
2026-03-13 01:45:47 +00:00
expect ( runtimeLog ) . toHaveBeenCalledWith (
"Telegram accepted 80 commands after BOT_COMMANDS_TOO_MUCH (started with 100; omitted 20). Reduce plugin/skill/custom commands to expose more menu entries." ,
) ;
expect ( runtimeError ) . not . toHaveBeenCalled ( ) ;
2026-02-26 20:22:10 +08:00
} ) ;
2026-02-14 02:02:44 +01:00
} ) ;