Skip to content

Commit 974016e

Browse files
committed
Phase 2: Telegram integration — session mgmt, config, network, CLI entry
internal/telegram/ (8 files, 1,888 LOC): - session.go: Chat-to-session mapping with in-memory cache + session.Store - config.go: Config from env vars (10 ODEK_TELEGRAM_* vars) + validation - network.go: FallbackTransport (RoundTrip failover), RetryWithBackoff cmd/odek/telegram.go (104 LOC): - CLI entry point: odek telegram - Wires bot, session manager, handler, poller with callbacks - Signal handling for graceful shutdown (SIGINT/SIGTERM) - Placeholder text handler ready for agent engine integration
1 parent ad5334f commit 974016e

5 files changed

Lines changed: 581 additions & 0 deletions

File tree

cmd/odek/main.go

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -148,6 +148,11 @@ func main() {
148148
fmt.Fprintf(os.Stderr, "odek: %v\n", err)
149149
os.Exit(1)
150150
}
151+
case "telegram":
152+
if err := telegramCmd(os.Args[2:]); err != nil {
153+
fmt.Fprintf(os.Stderr, "odek: %v\n", err)
154+
os.Exit(1)
155+
}
151156
default:
152157
fmt.Fprintf(os.Stderr, "odek: unknown command %q\n", os.Args[1])
153158
printUsage()
@@ -360,6 +365,7 @@ func printUsage() {
360365
odek init [--global | -g] [--force | -f]
361366
odek skill <list|view|save|delete|import|curate>
362367
odek mcp [--sandbox]
368+
odek telegram
363369
odek version
364370
365371
Commands:
@@ -380,6 +386,7 @@ Commands:
380386
skill Manage skills: list, view, save, delete, import, curate
381387
mcp Start MCP server (Model Context Protocol) over stdio
382388
Exposes all built-in tools for Claude Code, Cursor, etc.
389+
telegram Start Telegram bot (long-polling mode)
383390
init Create a config file (default: ./odek.json)
384391
version Print version and exit
385392

cmd/odek/telegram.go

Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
package main
2+
3+
import (
4+
"context"
5+
"fmt"
6+
"os"
7+
"os/signal"
8+
"syscall"
9+
"time"
10+
11+
"github.com/BackendStack21/kode/internal/session"
12+
"github.com/BackendStack21/kode/internal/telegram"
13+
)
14+
15+
// telegramCmd is the entry point for "odek telegram".
16+
func telegramCmd(args []string) error {
17+
// 1. Load and validate config.
18+
cfg := telegram.ConfigFromEnv()
19+
if err := telegram.ValidateConfig(cfg); err != nil {
20+
fmt.Fprintf(os.Stderr, "odek telegram: %v\n", err)
21+
return err
22+
}
23+
24+
// 2. Create bot client.
25+
bot := telegram.NewBot(cfg.Token)
26+
27+
// 3. Create session store on disk (~/.odek/sessions/).
28+
store, err := session.NewStore()
29+
if err != nil {
30+
fmt.Fprintf(os.Stderr, "odek telegram: session store: %v\n", err)
31+
return err
32+
}
33+
34+
// 4. Create session manager (per-chat Telegram session cache).
35+
sessionManager := telegram.NewSessionManager(store)
36+
_ = sessionManager // will be used when the agent engine is wired in
37+
38+
// 5. Create handler.
39+
handler := telegram.NewHandler(bot)
40+
41+
// 6. Set handler config from cfg.
42+
handler.Config = telegram.HandlerConfig{
43+
AllowedChats: cfg.AllowedChats,
44+
BotUsername: cfg.BotUsername,
45+
MaxMsgLength: cfg.MaxMsgLength,
46+
AllowedUsers: cfg.AllowedUsers,
47+
}
48+
49+
// 7. Wire handler callbacks.
50+
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
55+
}
56+
57+
handler.OnCommand = func(chatID int64, cmdName string, argsStr string) (string, error) {
58+
cmd := telegram.FindCommand(cmdName)
59+
if cmd == nil {
60+
return fmt.Sprintf("Unknown command: /%s", cmdName), nil
61+
}
62+
return cmd.Handler(argsStr)
63+
}
64+
65+
handler.OnCallbackQuery = func(chatID int64, data string) (string, error) {
66+
return fmt.Sprintf("Callback received: %s", data), nil
67+
}
68+
69+
handler.OnError = func(chatID int64, err error) {
70+
fmt.Fprintf(os.Stderr, "odek telegram: error for chat %d: %v\n", chatID, err)
71+
}
72+
73+
// 8. Print startup banner.
74+
fmt.Fprintf(os.Stderr, "odek telegram bot started\n")
75+
76+
// 9. Create poller.
77+
poller := telegram.NewPoller(bot)
78+
poller.Interval = time.Duration(cfg.PollInterval) * time.Second
79+
poller.Timeout = cfg.PollTimeout
80+
81+
// 10. Create cancellable context for graceful shutdown.
82+
ctx, cancel := context.WithCancel(context.Background())
83+
defer cancel()
84+
85+
// 11. Handle SIGINT/SIGTERM for graceful shutdown.
86+
sigCh := make(chan os.Signal, 1)
87+
signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM)
88+
go func() {
89+
<-sigCh
90+
fmt.Fprintf(os.Stderr, "\nodek telegram: shutting down...\n")
91+
cancel()
92+
}()
93+
94+
// 12. Start polling in a background goroutine.
95+
updates := make(chan telegram.Update, 100)
96+
go poller.Start(ctx, updates)
97+
98+
// 13. Process updates until the channel is closed (ctx cancelled).
99+
for upd := range updates {
100+
handler.HandleUpdate(upd)
101+
}
102+
103+
return nil
104+
}

internal/telegram/config.go

Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,130 @@
1+
package telegram
2+
3+
import (
4+
"fmt"
5+
"os"
6+
"strconv"
7+
"strings"
8+
)
9+
10+
// TelegramConfig holds all configuration for the Telegram bot.
11+
type TelegramConfig struct {
12+
Token string `json:"bot_token"`
13+
AllowedChats []int64 `json:"allowed_chats"`
14+
AllowedUsers []int64 `json:"allowed_users"`
15+
BotUsername string `json:"bot_username"`
16+
PollInterval int `json:"poll_interval"` // seconds, default 1
17+
PollTimeout int `json:"poll_timeout"` // seconds, default 30
18+
MaxMsgLength int `json:"max_msg_length"` // default 4096
19+
DailyTokenBudget int64 `json:"daily_token_budget"` // default 1000000
20+
SessionTTL int `json:"session_ttl_hours"` // hours, default 24
21+
FallbackURLs []string `json:"fallback_urls"`
22+
}
23+
24+
// DefaultConfig returns a TelegramConfig with sensible defaults.
25+
func DefaultConfig() TelegramConfig {
26+
return TelegramConfig{
27+
PollInterval: 1,
28+
PollTimeout: 30,
29+
MaxMsgLength: 4096,
30+
DailyTokenBudget: 1000000,
31+
SessionTTL: 24,
32+
}
33+
}
34+
35+
// ConfigFromEnv reads configuration from environment variables, starting with
36+
// DefaultConfig and overriding any values that are set in the environment.
37+
func ConfigFromEnv() TelegramConfig {
38+
cfg := DefaultConfig()
39+
40+
if v := os.Getenv("ODEK_TELEGRAM_BOT_TOKEN"); v != "" {
41+
cfg.Token = v
42+
}
43+
if v := os.Getenv("ODEK_TELEGRAM_ALLOWED_CHATS"); v != "" {
44+
cfg.AllowedChats = parseInt64List(v)
45+
}
46+
if v := os.Getenv("ODEK_TELEGRAM_ALLOWED_USERS"); v != "" {
47+
cfg.AllowedUsers = parseInt64List(v)
48+
}
49+
if v := os.Getenv("ODEK_TELEGRAM_BOT_USERNAME"); v != "" {
50+
cfg.BotUsername = v
51+
}
52+
if v := os.Getenv("ODEK_TELEGRAM_POLL_INTERVAL"); v != "" {
53+
if n, err := strconv.Atoi(v); err == nil {
54+
cfg.PollInterval = n
55+
}
56+
}
57+
if v := os.Getenv("ODEK_TELEGRAM_POLL_TIMEOUT"); v != "" {
58+
if n, err := strconv.Atoi(v); err == nil {
59+
cfg.PollTimeout = n
60+
}
61+
}
62+
if v := os.Getenv("ODEK_TELEGRAM_MAX_MSG_LENGTH"); v != "" {
63+
if n, err := strconv.Atoi(v); err == nil {
64+
cfg.MaxMsgLength = n
65+
}
66+
}
67+
if v := os.Getenv("ODEK_TELEGRAM_DAILY_TOKEN_BUDGET"); v != "" {
68+
if n, err := strconv.ParseInt(v, 10, 64); err == nil {
69+
cfg.DailyTokenBudget = n
70+
}
71+
}
72+
if v := os.Getenv("ODEK_TELEGRAM_SESSION_TTL_HOURS"); v != "" {
73+
if n, err := strconv.Atoi(v); err == nil {
74+
cfg.SessionTTL = n
75+
}
76+
}
77+
if v := os.Getenv("ODEK_TELEGRAM_FALLBACK_URLS"); v != "" {
78+
cfg.FallbackURLs = splitAndTrim(v)
79+
}
80+
81+
return cfg
82+
}
83+
84+
// ValidateConfig checks that the configuration values are within acceptable
85+
// ranges and returns an error describing the first problem found.
86+
func ValidateConfig(cfg TelegramConfig) error {
87+
if cfg.Token == "" {
88+
return fmt.Errorf("telegram: bot_token must not be empty")
89+
}
90+
if cfg.PollInterval < 1 {
91+
return fmt.Errorf("telegram: poll_interval must be >= 1, got %d", cfg.PollInterval)
92+
}
93+
if cfg.PollTimeout < 1 || cfg.PollTimeout > 60 {
94+
return fmt.Errorf("telegram: poll_timeout must be between 1 and 60, got %d", cfg.PollTimeout)
95+
}
96+
if cfg.MaxMsgLength < 1 || cfg.MaxMsgLength > 4096 {
97+
return fmt.Errorf("telegram: max_msg_length must be between 1 and 4096, got %d", cfg.MaxMsgLength)
98+
}
99+
if cfg.SessionTTL < 1 {
100+
return fmt.Errorf("telegram: session_ttl_hours must be >= 1, got %d", cfg.SessionTTL)
101+
}
102+
return nil
103+
}
104+
105+
// parseInt64List parses a comma-separated string of integers into a slice of int64.
106+
func parseInt64List(s string) []int64 {
107+
parts := splitAndTrim(s)
108+
result := make([]int64, 0, len(parts))
109+
for _, p := range parts {
110+
n, err := strconv.ParseInt(p, 10, 64)
111+
if err != nil {
112+
continue
113+
}
114+
result = append(result, n)
115+
}
116+
return result
117+
}
118+
119+
// splitAndTrim splits a string on commas and trims whitespace from each part.
120+
func splitAndTrim(s string) []string {
121+
parts := strings.Split(s, ",")
122+
result := make([]string, 0, len(parts))
123+
for _, p := range parts {
124+
trimmed := strings.TrimSpace(p)
125+
if trimmed != "" {
126+
result = append(result, trimmed)
127+
}
128+
}
129+
return result
130+
}

0 commit comments

Comments
 (0)