Skip to content

Commit aaa68a9

Browse files
committed
Phase 4: Telegram media support + inline keyboard approval
New files: - approver.go — TelegramApprover implements danger.Approver with inline keyboard buttons (Approve/Deny/Trust Session) via callback queries - approver_test.go — 13 tests, 100% coverage on 6/7 methods - download.go — DownloadVoice/DownloadPhoto helpers with media dir - download_test.go — 6 tests covering success, error paths, edge cases Modified: - bot.go — Added FileBaseURL field for testable file downloads - handler.go — Approver routing in handleCallback, async agent dispatch, voice/photo default handlers now download media instead of placeholders - handler_test.go — Updated default handler tests for new behavior - telegram.go — Full agent loop wiring: TelegramApprover per chat, RunWithMessages for multi-turn sessions, async goroutine dispatch All 16 packages pass. Coverage: 83.3% (+0.1% over baseline).
1 parent 0ffccbb commit aaa68a9

8 files changed

Lines changed: 1072 additions & 46 deletions

File tree

cmd/odek/telegram.go

Lines changed: 150 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -5,84 +5,132 @@ import (
55
"fmt"
66
"os"
77
"os/signal"
8+
"strings"
89
"syscall"
910
"time"
1011

12+
"github.com/BackendStack21/kode"
13+
"github.com/BackendStack21/kode/internal/config"
14+
"github.com/BackendStack21/kode/internal/llm"
15+
"github.com/BackendStack21/kode/internal/render"
1116
"github.com/BackendStack21/kode/internal/session"
1217
"github.com/BackendStack21/kode/internal/telegram"
1318
)
1419

1520
// telegramCmd is the entry point for "odek telegram".
1621
func telegramCmd(args []string) error {
17-
// 1. Load and validate config.
22+
// 1. Load config from all sources (file → env).
23+
resolved := config.LoadConfig(config.CLIFlags{})
24+
25+
// 2. Validate API key presence.
26+
if resolved.APIKey == "" {
27+
return fmt.Errorf("no API key configured — set ODEK_API_KEY, DEEPSEEK_API_KEY, or configure in odek.json")
28+
}
29+
30+
// 3. Load and validate Telegram config.
1831
cfg := telegram.ConfigFromEnv()
1932
if err := telegram.ValidateConfig(cfg); err != nil {
2033
fmt.Fprintf(os.Stderr, "odek telegram: %v\n", err)
2134
return err
2235
}
2336

24-
// 2. Create bot client.
37+
// 4. Create bot client.
2538
bot := telegram.NewBot(cfg.Token)
2639

27-
// 3. Create session store on disk (~/.odek/sessions/).
40+
// 5. Create session store on disk (~/.odek/sessions/).
2841
store, err := session.NewStore()
2942
if err != nil {
3043
fmt.Fprintf(os.Stderr, "odek telegram: session store: %v\n", err)
3144
return err
3245
}
3346

34-
// 4. Create session manager (per-chat Telegram session cache).
47+
// 6. Create session manager (per-chat Telegram session cache).
3548
sessionManager := telegram.NewSessionManager(store)
36-
_ = sessionManager // will be used when the agent engine is wired in
3749

38-
// 5. Create handler.
50+
// 7. Create handler.
3951
handler := telegram.NewHandler(bot)
4052

41-
// 6. Set handler config from cfg.
53+
// 8. Set handler config from cfg.
4254
handler.Config = telegram.HandlerConfig{
4355
AllowedChats: cfg.AllowedChats,
4456
BotUsername: cfg.BotUsername,
4557
MaxMsgLength: cfg.MaxMsgLength,
4658
AllowedUsers: cfg.AllowedUsers,
4759
}
4860

49-
// 7. Wire handler callbacks.
61+
// 9. Resolve system message.
62+
systemMessage := resolved.System
63+
if systemMessage == "" {
64+
systemMessage = defaultSystem
65+
}
66+
67+
// 10. Wire handler callbacks.
68+
//
69+
// Important: OnTextMessage processes in a background goroutine so it doesn't
70+
// block the main update processing loop. The TelegramApprover blocks waiting
71+
// for inline keyboard callbacks, which arrive via the main loop — only async
72+
// dispatch prevents deadlock.
5073
handler.OnTextMessage = func(chatID int64, text string) (string, error) {
51-
// Placeholder: load session, append user message, run agent,
52-
// save session, return response.
53-
// For now, just echo the text back as a placeholder.
54-
return fmt.Sprintf("Echo: %s", text), nil
74+
go handleChatMessage(chatID, text, bot, handler, sessionManager,
75+
resolved, systemMessage)
76+
return "", nil
5577
}
5678

5779
handler.OnCommand = func(chatID int64, cmdName string, argsStr string) (string, error) {
5880
cmd := telegram.FindCommand(cmdName)
5981
if cmd == nil {
6082
return fmt.Sprintf("Unknown command: /%s", cmdName), nil
6183
}
84+
85+
// Handle /new — clear session and reset trust in the approver.
86+
if cmdName == "new" {
87+
sessionManager.Delete(chatID)
88+
if handler.Approver != nil {
89+
handler.Approver.ResetTrust()
90+
}
91+
}
92+
6293
return cmd.Handler(argsStr)
6394
}
6495

6596
handler.OnCallbackQuery = func(chatID int64, data string) (string, error) {
66-
return fmt.Sprintf("Callback received: %s", data), nil
97+
return "", nil // approval callbacks are routed by the approver
98+
}
99+
100+
handler.OnVoiceMessage = func(chatID int64, fileID string) (string, error) {
101+
go handleChatMessage(chatID, "[voice message: "+fileID+"]",
102+
bot, handler, sessionManager, resolved, systemMessage)
103+
return "", nil
104+
}
105+
106+
handler.OnPhotoMessage = func(chatID int64, fileIDs []string) (string, error) {
107+
go handleChatMessage(chatID, "[photo message: "+strings.Join(fileIDs, ",")+"]",
108+
bot, handler, sessionManager, resolved, systemMessage)
109+
return "", nil
67110
}
68111

69112
handler.OnError = func(chatID int64, err error) {
70113
fmt.Fprintf(os.Stderr, "odek telegram: error for chat %d: %v\n", chatID, err)
71114
}
72115

73-
// 8. Print startup banner.
116+
// 11. Set command list via Telegram API.
117+
if err := bot.SetMyCommands(telegram.CommandDescriptors()); err != nil {
118+
fmt.Fprintf(os.Stderr, "odek telegram: warning: set commands failed: %v\n", err)
119+
}
120+
121+
// 12. Print startup banner.
74122
fmt.Fprintf(os.Stderr, "odek telegram bot started\n")
75123

76-
// 9. Create poller.
124+
// 13. Create poller.
77125
poller := telegram.NewPoller(bot)
78126
poller.Interval = time.Duration(cfg.PollInterval) * time.Second
79127
poller.Timeout = cfg.PollTimeout
80128

81-
// 10. Create cancellable context for graceful shutdown.
129+
// 14. Create cancellable context for graceful shutdown.
82130
ctx, cancel := context.WithCancel(context.Background())
83131
defer cancel()
84132

85-
// 11. Handle SIGINT/SIGTERM for graceful shutdown.
133+
// 15. Handle SIGINT/SIGTERM for graceful shutdown.
86134
sigCh := make(chan os.Signal, 1)
87135
signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM)
88136
go func() {
@@ -91,14 +139,97 @@ func telegramCmd(args []string) error {
91139
cancel()
92140
}()
93141

94-
// 12. Start polling in a background goroutine.
142+
// 16. Start polling in a background goroutine.
95143
updates := make(chan telegram.Update, 100)
96144
go poller.Start(ctx, updates)
97145

98-
// 13. Process updates until the channel is closed (ctx cancelled).
146+
// 17. Process updates until the channel is closed (ctx cancelled).
99147
for upd := range updates {
100148
handler.HandleUpdate(upd)
101149
}
102150

103151
return nil
104152
}
153+
154+
// handleChatMessage processes a user message from Telegram in a background
155+
// goroutine. It creates or loads the chat session, creates a TelegramApprover
156+
// for approval prompts, runs the agent loop with RunWithMessages, and sends
157+
// back the response. Each chat gets its own TelegramApprover instance.
158+
func handleChatMessage(
159+
chatID int64,
160+
text string,
161+
bot *telegram.Bot,
162+
handler *telegram.Handler,
163+
sessionManager *telegram.SessionManager,
164+
resolved config.ResolvedConfig,
165+
systemMessage string,
166+
) {
167+
// Create a per-chat TelegramApprover for inline keyboard approval.
168+
approver := telegram.NewTelegramApprover(bot, chatID)
169+
handler.Approver = approver
170+
171+
// Get or create the session for this chat.
172+
cs, err := sessionManager.GetOrCreate(chatID)
173+
if err != nil {
174+
reportError(bot, chatID, "Failed to create session: "+err.Error())
175+
return
176+
}
177+
178+
// Append user message to session.
179+
cs.Messages = append(cs.Messages, llm.Message{Role: "user", Content: text})
180+
cs.LastActive = time.Now()
181+
182+
// Build the agent with Telegram approver.
183+
tools := builtinTools(resolved.Dangerous, nil, approver, resolved.MaxConcurrency)
184+
185+
modelLabel := odek.ProfileLabel(resolved.Model)
186+
if modelLabel == "" {
187+
modelLabel = "deepseek-chat"
188+
}
189+
190+
rend := render.New(os.Stderr, false).WithModel(modelLabel)
191+
192+
agent, err := odek.New(odek.Config{
193+
Model: resolved.Model,
194+
BaseURL: resolved.BaseURL,
195+
APIKey: resolved.APIKey,
196+
MaxIterations: resolved.MaxIter,
197+
SystemMessage: systemMessage,
198+
NoProjectFile: resolved.NoAgents,
199+
Thinking: resolved.Thinking,
200+
Tools: tools,
201+
Renderer: rend,
202+
})
203+
if err != nil {
204+
reportError(bot, chatID, "Failed to create agent: "+err.Error())
205+
return
206+
}
207+
defer agent.Close()
208+
209+
// Run the agent with the full message history (multi-turn).
210+
response, updatedMessages, err := agent.RunWithMessages(context.Background(), cs.Messages)
211+
if err != nil {
212+
reportError(bot, chatID, "Agent error: "+err.Error())
213+
return
214+
}
215+
216+
// Save the updated session messages.
217+
cs.Messages = updatedMessages
218+
cs.TurnCount++
219+
if err := sessionManager.Save(chatID, cs.Messages); err != nil {
220+
fmt.Fprintf(os.Stderr, "odek telegram: session save: %v\n", err)
221+
}
222+
223+
// Send the response.
224+
if response != "" {
225+
handler.SendResponse(chatID, response)
226+
}
227+
}
228+
229+
// reportError sends an error message to the given chat and logs to stderr.
230+
func reportError(bot *telegram.Bot, chatID int64, msg string) {
231+
fmt.Fprintf(os.Stderr, "odek telegram: %s\n", msg)
232+
if _, err := bot.SendMessage(chatID, "❌ "+msg, nil); err != nil {
233+
fmt.Fprintf(os.Stderr, "odek telegram: send error message: %v\n", err)
234+
}
235+
}

0 commit comments

Comments
 (0)