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.
This commit is contained in:
Rahul Bansal 2026-02-21 09:42:03 +05:30
parent 0dc21b649f
commit bac8e9d732
4 changed files with 229 additions and 0 deletions

View file

@ -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() enabledChannels := channelManager.GetEnabledChannels()
if len(enabledChannels) > 0 { if len(enabledChannels) > 0 {
fmt.Printf("✓ Channels enabled: %s\n", enabledChannels) fmt.Printf("✓ Channels enabled: %s\n", enabledChannels)

View file

@ -30,6 +30,7 @@ type TelegramChannel struct {
config *config.Config config *config.Config
chatIDs map[string]int64 chatIDs map[string]int64
transcriber *voice.GroqTranscriber transcriber *voice.GroqTranscriber
permManager *TelegramPermissionManager
placeholders sync.Map // chatID -> messageID placeholders sync.Map // chatID -> messageID
stopThinking sync.Map // chatID -> thinkingCancel stopThinking sync.Map // chatID -> thinkingCancel
} }
@ -81,6 +82,7 @@ func NewTelegramChannel(cfg *config.Config, bus *bus.MessageBus) (*TelegramChann
config: cfg, config: cfg,
chatIDs: make(map[string]int64), chatIDs: make(map[string]int64),
transcriber: nil, transcriber: nil,
permManager: NewTelegramPermissionManager(bot),
placeholders: sync.Map{}, placeholders: sync.Map{},
stopThinking: sync.Map{}, stopThinking: sync.Map{},
}, nil }, nil
@ -90,6 +92,11 @@ func (c *TelegramChannel) SetTranscriber(transcriber *voice.GroqTranscriber) {
c.transcriber = transcriber 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 { func (c *TelegramChannel) Start(ctx context.Context) error {
logger.InfoC("telegram", "Starting Telegram bot (polling mode)...") 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) return c.handleMessage(ctx, &message)
}, th.AnyMessage()) }, 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) c.setRunning(true)
logger.InfoCF("telegram", "Telegram bot connected", map[string]any{ logger.InfoCF("telegram", "Telegram bot connected", map[string]any{
"username": c.bot.Username(), "username": c.bot.Username(),

View file

@ -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)
}
}

View file

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