Interactive Bot Actions

Wire button and select-menu clicks to your own handlers across Telegram, Discord, and Slack

Bots can wire button clicks and select-menu choices to your own async handlers — across Telegram, Discord, and Slack — through a small registry API.

python
from praisonaiagents import Agent
from praisonai.bots import Bot

agent = Agent(name="ShopBot", instructions="Help customers order")
bot = Bot("telegram", agent=agent)

The user taps a button or menu in chat; your registered handler runs and the bot replies.

Diagram

Interactive Bot Actions

šŸ‘¤ User clicks button

šŸ¤– Bot Adapter

šŸ“‹ InteractiveContext

āš™ļø InteractiveRegistry.dispatch

āœ… Your Handler

šŸ’¬ Bot Reply

Quick Start

  1. Use a built-in slash-command button (zero config)

    Buttons wired to slash commands fire automatically — no handler registration needed.

    python
    import os
    import asyncio
    from praisonaiagents import Agent
    from praisonaiagents.bots import (
        PresentationButton,
        PresentationAction,
        ActionType,
        MessagePresentation,
        PresentationBlock,
    )
    from praisonai.bots import TelegramBot
    
    agent = Agent(
        name="assistant",
        instructions="You are a helpful assistant. When asked for help, show a help button.",
    )
    
    bot = TelegramBot(
        token=os.environ["TELEGRAM_BOT_TOKEN"],
        agent=agent,
    )
    
    help_button = PresentationButton(
        label="šŸ“š Get Help",
        action=PresentationAction(type=ActionType.COMMAND, command="help"),
    )
    
    asyncio.run(bot.start())

    When the user taps Get Help, the existing /help handler fires automatically via the built-in command namespace.

  2. Register a custom namespace handler

    For your own button logic, create a registry and register an async handler.

    python
    import os
    import asyncio
    from praisonaiagents import Agent
    from praisonaiagents.bots import (
        InteractiveContext,
        create_registry,
        encode_action,
        PresentationAction,
        PresentationButton,
        ActionType,
    )
    from praisonai.bots import TelegramBot
    
    agent = Agent(
        name="assistant",
        instructions="You are a helpful assistant.",
    )
    
    registry = create_registry()
    
    async def on_approval(ctx: InteractiveContext) -> str | None:
        payload = ctx.platform_data.get("decoded_payload", {})
        choice = payload.get("value")
        if choice == "yes":
            return "approved"
        elif choice == "no":
            return "denied"
        return None
    
    registry.register("approval", on_approval)
    
    approve_button = PresentationButton(
        label="āœ… Approve",
        action=PresentationAction(type=ActionType.CALLBACK, value="yes"),
    )
    deny_button = PresentationButton(
        label="āŒ Deny",
        action=PresentationAction(type=ActionType.CALLBACK, value="no"),
    )
    
    approve_callback = encode_action("approval", approve_button.action)
    deny_callback = encode_action("approval", deny_button.action)
    
    bot = TelegramBot(
        token=os.environ["TELEGRAM_BOT_TOKEN"],
        agent=agent,
    )
    
    asyncio.run(bot.start())

    encode_action("approval", ...) produces "approval:yes" / "approval:no". When a user taps the button, registry.dispatch() routes the click to on_approval.


How It Works

Diagram
Fallback HandlerNamespace HandlerInteractiveRegistryBot AdapterUserFallback HandlerNamespace HandlerInteractiveRegistryBot AdapterUserExtracts namespace + payloadalt[Handler returns non-None][Handler returns None]Tap button (callback_data)dispatch(InteractiveContext)decode_callback(callback_data)handler(ctx)“handled”TrueBot replyNonefallback_handler(ctx)resultTrue / FalseReply or no-op
StepWhat happens
Button tapPlatform sends callback_data string to your bot
Adapter wrapsBuilds an InteractiveContext with user_id, message_id, chat_id, and platform-native objects
Registry decodesdecode_callback(callback_data) extracts namespace and payload
Handler runsAsync handler for matching namespace is called
FallbackIf no match or handler returns None, the fallback handler is tried

Decoding Rules

decode_callback(data) maps raw callback_data to a (namespace, payload) tuple:

InputNamespacePayload
"cmd:help""command"{"command": "help"}
"approval:yes""approval"{"value": "yes"}
"pair:approve:telegram:123:abc:deadbeef""pair"{"value": "approve:telegram:123:abc:deadbeef"}
"plain""plain"{}
"""unknown"{}

After dispatch(), the registry also writes decoded_namespace and decoded_payload into ctx.platform_data before calling your handler.


Choosing a Namespace Style

Diagram

Yes

No

Yes

No

šŸ¤– Which button type?

Runs a slash command?

Use built-in cmd: namespace\nencode_action with ActionType.COMMAND\n→ fires existing /cmd handler

Is it approve/deny pairing?

Use built-in pair namespace\n(already wired automatically)

Pick a custom namespace\ne.g. ‘approval’, ‘menu’, ‘vote’

registry.register(namespace, handler)


Durable callback payload store

When a reply / select value overflows the channel’s inline-callback byte-cap (Telegram’s 64 UTF-8 bytes), the framework persists it under a short reference so the exact value round-trips back to your handler.

TelegramBot wires this for you — it shares one store between its renderer and the inbound registry. To do it yourself, pass a store to create_registry:

python
from praisonaiagents.bots import create_registry, InMemoryCallbackPayloadStore

store = InMemoryCallbackPayloadStore()
registry = create_registry(store=store)

Without a store, overflowing values fall back to the lossy reply:#<sha1[:16]> hash and the handler is skipped. See Interactive Callback Payload Store for the full flow, configuration, and custom backends.


Handling poll results

A poll block collects a group vote; its aggregated tally comes back through the same registry under the reserved poll namespace. Register make_poll_result_handler and read the typed PollResult:

python
from praisonaiagents.bots import (
    create_registry,
    make_poll_result_handler,
    POLL_NAMESPACE,
)

async def on_result(result, context):
    if result.winner() is None:
        return "It's a tie — I'll pick one at random."
    return f"Booking {result.winner()}."

registry = create_registry()
registry.register(POLL_NAMESPACE, make_poll_result_handler(on_result))

The handler reads context.platform_data["poll_result"] (accepting a PollResult or its dict form) and forwards it to on_result. See Poll Blocks for authoring polls and the full PollResult API.


Platform-Specific Context

Each adapter populates ctx.platform_data with native objects so you can access full platform functionality inside your handler.

Telegram
KeyTypeDescription
updatetelegram.UpdateFull update object
contexttelegram.ext.ContextTypes.DEFAULT_TYPEPTB context
querytelegram.CallbackQueryThe callback query that triggered the action

Access example:

python
async def my_handler(ctx: InteractiveContext) -> str | None:
    query = ctx.platform_data["query"]
    await query.answer("Processing...")
    return "handled"
Discord
KeyTypeDescription
interactiondiscord.InteractionThe interaction object

Access example:

python
async def my_handler(ctx: InteractiveContext) -> str | None:
    interaction = ctx.platform_data["interaction"]
    await interaction.edit_original_response(content="Done!")
    return "handled"
Slack
KeyTypeDescription
bodydictFull Block Kit action payload
actiondictThe specific action that was triggered

Access example:

python
async def my_handler(ctx: InteractiveContext) -> str | None:
    body = ctx.platform_data["body"]
    action = ctx.platform_data["action"]
    channel = body.get("channel", {}).get("id")
    return "handled"

Common Patterns

Slash-command button

A button that fires an existing slash command — no handler registration needed.

python
import os
import asyncio
from praisonaiagents import Agent
from praisonaiagents.bots import (
    PresentationButton,
    PresentationAction,
    ActionType,
)
from praisonai.bots import SlackBot

agent = Agent(
    name="assistant",
    instructions="You are a helpful assistant.",
)

status_button = PresentationButton(
    label="šŸ“Š Check Status",
    action=PresentationAction(type=ActionType.COMMAND, command="status"),
)

bot = SlackBot(
    token=os.environ["SLACK_BOT_TOKEN"],
    app_token=os.environ["SLACK_APP_TOKEN"],
    agent=agent,
)

asyncio.run(bot.start())

Clicking Check Status triggers the built-in /status command automatically.

Yes / No approval

Route yes/no button clicks to your own async handler using a custom namespace.

python
import os
import asyncio
from praisonaiagents import Agent
from praisonaiagents.bots import (
    InteractiveContext,
    create_registry,
    encode_action,
    PresentationAction,
    ActionType,
)
from praisonai.bots import DiscordBot

agent = Agent(
    name="assistant",
    instructions="You are a helpful assistant.",
)

registry = create_registry()

async def on_vote(ctx: InteractiveContext) -> str | None:
    choice = ctx.platform_data.get("decoded_payload", {}).get("value")
    user = ctx.user_id
    if choice == "yes":
        return f"vote:yes:{user}"
    return f"vote:no:{user}"

registry.register("vote", on_vote)

yes_callback = encode_action("vote", PresentationAction(type=ActionType.CALLBACK, value="yes"))
no_callback = encode_action("vote", PresentationAction(type=ActionType.CALLBACK, value="no"))

bot = DiscordBot(
    token=os.environ["DISCORD_BOT_TOKEN"],
    agent=agent,
)

asyncio.run(bot.start())

Multi-step menu

A single handler dispatches different actions based on the button value.

python
import os
import asyncio
from praisonaiagents import Agent
from praisonaiagents.bots import (
    InteractiveContext,
    create_registry,
    encode_action,
    PresentationAction,
    ActionType,
)
from praisonai.bots import TelegramBot

agent = Agent(
    name="assistant",
    instructions="You are a menu-driven assistant.",
)

registry = create_registry()

async def on_menu(ctx: InteractiveContext) -> str | None:
    choice = ctx.platform_data.get("decoded_payload", {}).get("value", "")
    if choice == "report":
        return "menu:report"
    elif choice == "settings":
        return "menu:settings"
    elif choice == "help":
        return "menu:help"
    return None

registry.register("menu", on_menu)

report_cb = encode_action("menu", PresentationAction(type=ActionType.CALLBACK, value="report"))
settings_cb = encode_action("menu", PresentationAction(type=ActionType.CALLBACK, value="settings"))
help_cb = encode_action("menu", PresentationAction(type=ActionType.CALLBACK, value="help"))

bot = TelegramBot(
    token=os.environ["TELEGRAM_BOT_TOKEN"],
    agent=agent,
)

asyncio.run(bot.start())

API Reference

InteractiveContext

Passed to every handler by the registry’s dispatch() method.

FieldTypeDefaultDescription
callback_datastrrequiredRaw callback data from the platform
user_idstrrequiredPlatform-native user ID
message_idOptional[str]NoneID of the message containing the button
chat_idOptional[str]NoneID of the chat where the click happened
bot_adapterOptional[BotAdapter]NoneThe bot adapter handling this interaction
platform_dataDict[str, Any]{}Platform-specific objects + decoded fields after dispatch

After dispatch(), platform_data also contains:

  • decoded_namespace — the decoded namespace string
  • decoded_payload — the decoded payload dict

InteractiveHandler

python
InteractiveHandler = Callable[[InteractiveContext], Awaitable[Optional[str]]]

Return a non-None string to mark the click as handled (stops the chain). Return None to fall through to the fallback handler.

InteractiveRegistry

MethodDescription
register(namespace, handler)Register an async handler for a namespace. Re-registering warns and overwrites.
unregister(namespace)Remove a handler. No-op if not registered.
set_fallback(handler)Register a fallback called when no namespace matches or the handler returns None.
dispatch(context)Decode callback_data, invoke the matching handler, try fallback. Returns True if handled.
has_handler(namespace)Check if a namespace has a registered handler.
list_namespaces()Return all registered namespace names.

Functions

FunctionSignatureDescription
encode_action(namespace, action)(str, PresentationAction) -> strEncode an action for callback_data. CALLBACK → "ns:value", COMMAND → "cmd:name", URL/WEB_APP → namespace.
decode_callback(data)(str) -> Tuple[str, Dict]Decode callback_data into (namespace, payload).
create_registry(store=None)(Optional[CallbackPayloadStoreProtocol]) -> InteractiveRegistryPreferred. Create a fresh per-adapter registry. Pass a store to resolve reply:@<ref> / select:<action_id>:@<ref> payloads back to their exact value.

Built-in Namespaces

Every platform adapter (Telegram, Discord, Slack) registers these automatically:

NamespaceWhat it does
commandRoutes cmd:<name> button clicks to the registered /name slash-command handler. Telegram also enforces command_policy.
pairHandles approve/deny buttons for unknown-user pairing (same behavior as before, now routed through InteractiveRegistry).

Best Practices

Use create_registry() per adapter

Each bot adapter should have its own registry to prevent namespace collisions when running multiple bots in the same process.

python
from praisonaiagents.bots import create_registry

registry = create_registry()
registry.register("approval", on_approval)
Sharing the callback secret across multiple hosts

Inline-button callbacks are HMAC-signed. The signing key is the persisted gateway secret at ~/.praisonai/gateway/.gateway_secret (mode 0600, auto-created on first use) — no configuration needed on a single-host deploy. Buttons survive gateway restarts by default.

Set PRAISONAI_CALLBACK_SECRET to a shared value when running the gateway on multiple hosts so every replica verifies the same signatures:

bash
export PRAISONAI_CALLBACK_SECRET="$(openssl rand -hex 32)"
Diagram

Yes

No

missing

exists

Callback fired

PRAISONAI_CALLBACK_SECRET set?

Use env value

Load ~/.praisonai/gateway/.gateway_secret

Create 0600, use new value

Use file value

HMAC-verify callback

Return non-None only when you handled the click

Returning None lets the fallback handler run. This is useful when one namespace handler is shared across multiple button types and you only want to handle specific values.

python
async def on_menu(ctx: InteractiveContext) -> str | None:
    choice = ctx.platform_data.get("decoded_payload", {}).get("value", "")
    if choice in ("report", "settings"):
        return f"menu:{choice}"
    return None
Catch your own exceptions inside the handler

Unhandled exceptions inside a handler are logged, but the registry will still try the fallback. Users won’t see a useful error message unless you handle it yourself.

python
async def on_approval(ctx: InteractiveContext) -> str | None:
    try:
        result = await process_approval(ctx)
        return result
    except Exception as e:
        query = ctx.platform_data.get("query")
        if query:
            await query.answer(f"Error: {e}")
        return "error"

Related