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:
parent
0dc21b649f
commit
bac8e9d732
4 changed files with 229 additions and 0 deletions
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
117
pkg/channels/telegram_permissions.go
Normal file
117
pkg/channels/telegram_permissions.go
Normal 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)
|
||||
}
|
||||
}
|
||||
85
pkg/channels/telegram_permissions_test.go
Normal file
85
pkg/channels/telegram_permissions_test.go
Normal 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)")
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue