From bac8e9d732fcedf4e37d1bd23a48e6f745129501 Mon Sep 17 00:00:00 2001 From: Rahul Bansal Date: Sat, 21 Feb 2026 09:42:03 +0530 Subject: [PATCH] feat: add Telegram inline keyboard permission prompts TelegramPermissionManager sends inline keyboard buttons (Allow/Deny) and blocks until the user responds via callback query. HandleCallbackQuery registered in telegram bot handler. Gateway wires the permission factory so telegram channel uses inline buttons while other channels fall back to LLM-driven confirmation. --- cmd/picoclaw/cmd_gateway.go | 15 +++ pkg/channels/telegram.go | 12 +++ pkg/channels/telegram_permissions.go | 117 ++++++++++++++++++++++ pkg/channels/telegram_permissions_test.go | 85 ++++++++++++++++ 4 files changed, 229 insertions(+) create mode 100644 pkg/channels/telegram_permissions.go create mode 100644 pkg/channels/telegram_permissions_test.go diff --git a/cmd/picoclaw/cmd_gateway.go b/cmd/picoclaw/cmd_gateway.go index 9a3b6aa19..58b11f0fb 100644 --- a/cmd/picoclaw/cmd_gateway.go +++ b/cmd/picoclaw/cmd_gateway.go @@ -147,6 +147,21 @@ func gatewayCmd() { } } + // Set up permission factory for channel-specific permission prompts + var telegramPermManager *channels.TelegramPermissionManager + if telegramChannel, ok := channelManager.GetChannel("telegram"); ok { + if tc, ok := telegramChannel.(*channels.TelegramChannel); ok { + telegramPermManager = tc.PermissionManager() + } + } + + agentLoop.SetPermissionFuncFactory(func(channel, chatID string) tools.PermissionFunc { + if channel == "telegram" && telegramPermManager != nil { + return telegramPermManager.NewPermissionFunc(chatID) + } + return nil // Other channels fall back to LLM-driven flow + }) + enabledChannels := channelManager.GetEnabledChannels() if len(enabledChannels) > 0 { fmt.Printf("✓ Channels enabled: %s\n", enabledChannels) diff --git a/pkg/channels/telegram.go b/pkg/channels/telegram.go index a0a1c8d0a..d5536d1b7 100644 --- a/pkg/channels/telegram.go +++ b/pkg/channels/telegram.go @@ -30,6 +30,7 @@ type TelegramChannel struct { config *config.Config chatIDs map[string]int64 transcriber *voice.GroqTranscriber + permManager *TelegramPermissionManager placeholders sync.Map // chatID -> messageID stopThinking sync.Map // chatID -> thinkingCancel } @@ -81,6 +82,7 @@ func NewTelegramChannel(cfg *config.Config, bus *bus.MessageBus) (*TelegramChann config: cfg, chatIDs: make(map[string]int64), transcriber: nil, + permManager: NewTelegramPermissionManager(bot), placeholders: sync.Map{}, stopThinking: sync.Map{}, }, nil @@ -90,6 +92,11 @@ func (c *TelegramChannel) SetTranscriber(transcriber *voice.GroqTranscriber) { c.transcriber = transcriber } +// PermissionManager returns the Telegram permission manager for inline keyboard prompts. +func (c *TelegramChannel) PermissionManager() *TelegramPermissionManager { + return c.permManager +} + func (c *TelegramChannel) Start(ctx context.Context) error { logger.InfoC("telegram", "Starting Telegram bot (polling mode)...") @@ -125,6 +132,11 @@ func (c *TelegramChannel) Start(ctx context.Context) error { return c.handleMessage(ctx, &message) }, th.AnyMessage()) + bh.HandleCallbackQuery(func(ctx *th.Context, query telego.CallbackQuery) error { + c.permManager.HandleCallback(ctx, query) + return nil + }, telegohandler.CallbackDataContains("perm_")) + c.setRunning(true) logger.InfoCF("telegram", "Telegram bot connected", map[string]any{ "username": c.bot.Username(), diff --git a/pkg/channels/telegram_permissions.go b/pkg/channels/telegram_permissions.go new file mode 100644 index 000000000..7a395a39b --- /dev/null +++ b/pkg/channels/telegram_permissions.go @@ -0,0 +1,117 @@ +package channels + +import ( + "context" + "fmt" + "strconv" + "strings" + "sync" + "sync/atomic" + + "github.com/mymmrac/telego" + tu "github.com/mymmrac/telego/telegoutil" + "github.com/sipeed/picoclaw/pkg/logger" +) + +// TelegramPermissionManager handles inline keyboard permission prompts for outside-workspace access. +type TelegramPermissionManager struct { + bot *telego.Bot + pending sync.Map // callbackID -> chan bool + counter atomic.Int64 +} + +func NewTelegramPermissionManager(bot *telego.Bot) *TelegramPermissionManager { + return &TelegramPermissionManager{bot: bot} +} + +// AskPermission sends an inline keyboard to the user and blocks until they respond. +func (pm *TelegramPermissionManager) AskPermission(ctx context.Context, chatID int64, path string) (bool, error) { + callbackID := strconv.FormatInt(pm.counter.Add(1), 10) + + resultCh := make(chan bool, 1) + pm.pending.Store(callbackID, resultCh) + defer pm.pending.Delete(callbackID) + + keyboard := tu.InlineKeyboard( + tu.InlineKeyboardRow( + telego.InlineKeyboardButton{Text: "Allow", CallbackData: "perm_allow_" + callbackID}, + telego.InlineKeyboardButton{Text: "Deny", CallbackData: "perm_deny_" + callbackID}, + ), + ) + + text := fmt.Sprintf("Agent wants to access:\n%s\n\nAllow access to this directory?", path) + msg := tu.Message(tu.ID(chatID), text) + msg.ReplyMarkup = keyboard + + if _, err := pm.bot.SendMessage(ctx, msg); err != nil { + return false, fmt.Errorf("sending permission prompt: %w", err) + } + + select { + case approved := <-resultCh: + return approved, nil + case <-ctx.Done(): + return false, ctx.Err() + } +} + +// HandleCallback processes a callback query from an inline keyboard button press. +// Returns true if this was a permission callback, false otherwise. +func (pm *TelegramPermissionManager) HandleCallback(ctx context.Context, query telego.CallbackQuery) bool { + data := query.Data + + var callbackID string + var approved bool + + if strings.HasPrefix(data, "perm_allow_") { + callbackID = strings.TrimPrefix(data, "perm_allow_") + approved = true + } else if strings.HasPrefix(data, "perm_deny_") { + callbackID = strings.TrimPrefix(data, "perm_deny_") + approved = false + } else { + return false + } + + ch, ok := pm.pending.Load(callbackID) + if !ok { + // Expired or already handled + if pm.bot != nil { + _ = pm.bot.AnswerCallbackQuery(ctx, &telego.AnswerCallbackQueryParams{ + CallbackQueryID: query.ID, + Text: "Permission request expired", + }) + } + return true + } + + ch.(chan bool) <- approved + + label := "Denied" + if approved { + label = "Allowed" + } + if pm.bot != nil { + _ = pm.bot.AnswerCallbackQuery(ctx, &telego.AnswerCallbackQueryParams{ + CallbackQueryID: query.ID, + Text: label, + }) + } + + return true +} + +// NewPermissionFunc creates a PermissionFunc that uses Telegram inline keyboards. +func (pm *TelegramPermissionManager) NewPermissionFunc(chatIDStr string) func(ctx context.Context, path string) (bool, error) { + chatID, err := strconv.ParseInt(chatIDStr, 10, 64) + if err != nil { + logger.ErrorCF("telegram", "Invalid chat ID for permission func", map[string]interface{}{ + "chat_id": chatIDStr, + "error": err.Error(), + }) + return nil + } + return func(ctx context.Context, path string) (bool, error) { + return pm.AskPermission(ctx, chatID, path) + } +} diff --git a/pkg/channels/telegram_permissions_test.go b/pkg/channels/telegram_permissions_test.go new file mode 100644 index 000000000..19df250bb --- /dev/null +++ b/pkg/channels/telegram_permissions_test.go @@ -0,0 +1,85 @@ +package channels + +import ( + "context" + "testing" + "time" + + "github.com/mymmrac/telego" +) + +func TestTelegramPermissionManager_HandleCallback(t *testing.T) { + // Test the callback handling logic without a real bot + pm := &TelegramPermissionManager{} + + // Simulate a pending permission request + resultCh := make(chan bool, 1) + pm.pending.Store("42", resultCh) + + // Simulate allow callback + handled := pm.HandleCallback(context.Background(), telego.CallbackQuery{ + ID: "query-1", + Data: "perm_allow_42", + }) + if !handled { + t.Error("expected callback to be handled") + } + + select { + case approved := <-resultCh: + if !approved { + t.Error("expected approval") + } + case <-time.After(time.Second): + t.Fatal("timeout waiting for result") + } +} + +func TestTelegramPermissionManager_HandleCallback_Deny(t *testing.T) { + pm := &TelegramPermissionManager{} + resultCh := make(chan bool, 1) + pm.pending.Store("99", resultCh) + + handled := pm.HandleCallback(context.Background(), telego.CallbackQuery{ + ID: "query-2", + Data: "perm_deny_99", + }) + if !handled { + t.Error("expected callback to be handled") + } + + select { + case approved := <-resultCh: + if approved { + t.Error("expected denial") + } + case <-time.After(time.Second): + t.Fatal("timeout waiting for result") + } +} + +func TestTelegramPermissionManager_HandleCallback_Unknown(t *testing.T) { + pm := &TelegramPermissionManager{} + + // Non-permission callback should not be handled + handled := pm.HandleCallback(context.Background(), telego.CallbackQuery{ + ID: "query-3", + Data: "some_other_callback", + }) + if handled { + t.Error("expected non-permission callback to not be handled") + } +} + +func TestTelegramPermissionManager_HandleCallback_Expired(t *testing.T) { + pm := &TelegramPermissionManager{} + + // Permission callback with no pending request (expired) + handled := pm.HandleCallback(context.Background(), telego.CallbackQuery{ + ID: "query-4", + Data: "perm_allow_999", + }) + if !handled { + t.Error("expected expired permission callback to still be handled (return true)") + } +}