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)") + } +}