From 12f352420cd6d70a60efcfb36a5135c7fec5cffd Mon Sep 17 00:00:00 2001 From: Owen Wu Date: Sat, 28 Feb 2026 20:12:36 -0800 Subject: [PATCH] feat(telegram): support custom api_base_url for bot api --- README.md | 4 + README.zh.md | 1 + config/config.example.json | 2 +- pkg/channels/telegram/telegram.go | 36 ++++++- .../telegram/telegram_baseurl_test.go | 93 +++++++++++++++++++ pkg/config/config_test.go | 28 ++++++ pkg/config/defaults.go | 2 + 7 files changed, 161 insertions(+), 5 deletions(-) create mode 100644 pkg/channels/telegram/telegram_baseurl_test.go diff --git a/README.md b/README.md index 6714ac6eb..7e29028b2 100644 --- a/README.md +++ b/README.md @@ -324,6 +324,7 @@ Talk to your picoclaw through Telegram, Discord, WhatsApp, DingTalk, LINE, or We "telegram": { "enabled": true, "token": "YOUR_BOT_TOKEN", + "base_url": "", "allow_from": ["YOUR_USER_ID"] } } @@ -331,6 +332,8 @@ Talk to your picoclaw through Telegram, Discord, WhatsApp, DingTalk, LINE, or We ``` > Get your user ID from `@userinfobot` on Telegram. +> +> Set `base_url` when you need a custom Telegram Bot API endpoint (for example, local Bot API server or reverse proxy). **3. Run** @@ -1188,6 +1191,7 @@ picoclaw agent -m "Hello" "telegram": { "enabled": true, "token": "123456:ABC...", + "base_url": "", "allow_from": ["123456789"] }, "discord": { diff --git a/README.zh.md b/README.zh.md index d3a49ee8d..4f3dfd033 100644 --- a/README.zh.md +++ b/README.zh.md @@ -713,6 +713,7 @@ picoclaw agent -m "你好" "telegram": { "enabled": true, "token": "123456:ABC...", + "base_url": "", "allow_from": ["123456789"] }, "discord": { diff --git a/config/config.example.json b/config/config.example.json index 3c84cfa9f..5836bcf57 100644 --- a/config/config.example.json +++ b/config/config.example.json @@ -337,4 +337,4 @@ "host": "127.0.0.1", "port": 18790 } -} \ No newline at end of file +} diff --git a/pkg/channels/telegram/telegram.go b/pkg/channels/telegram/telegram.go index f328f32b8..bf255aa14 100644 --- a/pkg/channels/telegram/telegram.go +++ b/pkg/channels/telegram/telegram.go @@ -53,6 +53,14 @@ func NewTelegramChannel(cfg *config.Config, bus *bus.MessageBus) (*TelegramChann var opts []telego.BotOption telegramCfg := cfg.Channels.Telegram + baseURL, err := normalizeTelegramBaseURL(telegramCfg.BaseURL) + if err != nil { + return nil, fmt.Errorf("invalid telegram base_url %q: %w", telegramCfg.BaseURL, err) + } + if baseURL != "" { + opts = append(opts, telego.WithAPIServer(baseURL)) + } + if telegramCfg.Proxy != "" { proxyURL, parseErr := url.Parse(telegramCfg.Proxy) if parseErr != nil { @@ -72,10 +80,6 @@ func NewTelegramChannel(cfg *config.Config, bus *bus.MessageBus) (*TelegramChann })) } - if baseURL := strings.TrimRight(strings.TrimSpace(telegramCfg.BaseURL), "/"); baseURL != "" { - opts = append(opts, telego.WithAPIServer(baseURL)) - } - bot, err := telego.NewBot(telegramCfg.Token, opts...) if err != nil { return nil, fmt.Errorf("failed to create telegram bot: %w", err) @@ -100,6 +104,30 @@ func NewTelegramChannel(cfg *config.Config, bus *bus.MessageBus) (*TelegramChann }, nil } +func normalizeTelegramBaseURL(raw string) (string, error) { + trimmed := strings.TrimSpace(raw) + if trimmed == "" { + return "", nil + } + + normalized := strings.TrimRight(trimmed, "/") + parsed, err := url.Parse(normalized) + if err != nil { + return "", err + } + if parsed.Scheme != "http" && parsed.Scheme != "https" { + return "", fmt.Errorf("scheme must be http or https") + } + if parsed.Host == "" { + return "", fmt.Errorf("host is required") + } + if parsed.RawQuery != "" || parsed.Fragment != "" { + return "", fmt.Errorf("query and fragment are not supported") + } + + return normalized, nil +} + func (c *TelegramChannel) Start(ctx context.Context) error { logger.InfoC("telegram", "Starting Telegram bot (polling mode)...") diff --git a/pkg/channels/telegram/telegram_baseurl_test.go b/pkg/channels/telegram/telegram_baseurl_test.go new file mode 100644 index 000000000..fe8c2af7a --- /dev/null +++ b/pkg/channels/telegram/telegram_baseurl_test.go @@ -0,0 +1,93 @@ +package telegram + +import ( + "strings" + "testing" + + "github.com/sipeed/picoclaw/pkg/bus" + "github.com/sipeed/picoclaw/pkg/config" +) + +func TestNormalizeTelegramBaseURL(t *testing.T) { + tests := []struct { + name string + input string + want string + wantErr bool + }{ + { + name: "empty value is allowed", + input: "", + want: "", + }, + { + name: "trim spaces and trailing slash", + input: " https://telegram-proxy.example.com/custom/ ", + want: "https://telegram-proxy.example.com/custom", + }, + { + name: "missing scheme", + input: "telegram-proxy.example.com", + wantErr: true, + }, + { + name: "unsupported scheme", + input: "ftp://telegram-proxy.example.com", + wantErr: true, + }, + { + name: "query not allowed", + input: "https://telegram-proxy.example.com/api?x=1", + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := normalizeTelegramBaseURL(tt.input) + if tt.wantErr { + if err == nil { + t.Fatalf("expected error, got nil") + } + return + } + if err != nil { + t.Fatalf("normalizeTelegramBaseURL error: %v", err) + } + if got != tt.want { + t.Fatalf("normalizeTelegramBaseURL() = %q, want %q", got, tt.want) + } + }) + } +} + +func TestNewTelegramChannel_UsesCustomBaseURL(t *testing.T) { + cfg := config.DefaultConfig() + cfg.Channels.Telegram.Token = "123456:ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghi" + cfg.Channels.Telegram.BaseURL = "https://telegram-proxy.example.com/custom/" + + channel, err := NewTelegramChannel(cfg, bus.NewMessageBus()) + if err != nil { + t.Fatalf("NewTelegramChannel error: %v", err) + } + + got := channel.bot.FileDownloadURL("photos/abc.jpg") + wantPrefix := "https://telegram-proxy.example.com/custom/file/bot123456:ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghi/" + if !strings.HasPrefix(got, wantPrefix) { + t.Fatalf("FileDownloadURL prefix = %q, want prefix %q", got, wantPrefix) + } +} + +func TestNewTelegramChannel_InvalidCustomBaseURLErrors(t *testing.T) { + cfg := config.DefaultConfig() + cfg.Channels.Telegram.Token = "123456:ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghi" + cfg.Channels.Telegram.BaseURL = "telegram-proxy.example.com" + + _, err := NewTelegramChannel(cfg, bus.NewMessageBus()) + if err == nil { + t.Fatal("expected error, got nil") + } + if !strings.Contains(err.Error(), "invalid telegram base_url") { + t.Fatalf("error = %q, expected invalid base_url message", err.Error()) + } +} diff --git a/pkg/config/config_test.go b/pkg/config/config_test.go index 6af7c209e..6aaa60167 100644 --- a/pkg/config/config_test.go +++ b/pkg/config/config_test.go @@ -189,6 +189,31 @@ func TestConfig_BackwardCompat_NoAgentsList(t *testing.T) { } } +func TestTelegramConfig_BaseURLParse(t *testing.T) { + jsonData := `{ + "channels": { + "telegram": { + "enabled": true, + "token": "123456:ABC", + "base_url": "https://telegram-proxy.example.com/custom" + } + } + }` + + cfg := DefaultConfig() + if err := json.Unmarshal([]byte(jsonData), cfg); err != nil { + t.Fatalf("unmarshal: %v", err) + } + + if cfg.Channels.Telegram.BaseURL != "https://telegram-proxy.example.com/custom" { + t.Errorf( + "Telegram.BaseURL = %q, want %q", + cfg.Channels.Telegram.BaseURL, + "https://telegram-proxy.example.com/custom", + ) + } +} + // TestDefaultConfig_HeartbeatEnabled verifies heartbeat is enabled by default func TestDefaultConfig_HeartbeatEnabled(t *testing.T) { cfg := DefaultConfig() @@ -277,6 +302,9 @@ func TestDefaultConfig_Channels(t *testing.T) { if cfg.Channels.Telegram.Enabled { t.Error("Telegram should be disabled by default") } + if cfg.Channels.Telegram.BaseURL != "" { + t.Errorf("Telegram BaseURL should be empty by default, got %q", cfg.Channels.Telegram.BaseURL) + } if cfg.Channels.Discord.Enabled { t.Error("Discord should be disabled by default") } diff --git a/pkg/config/defaults.go b/pkg/config/defaults.go index 9fc09c5f1..83ab4dafc 100644 --- a/pkg/config/defaults.go +++ b/pkg/config/defaults.go @@ -50,6 +50,8 @@ func DefaultConfig() *Config { Telegram: TelegramConfig{ Enabled: false, Token: "", + BaseURL: "", + Proxy: "", AllowFrom: FlexibleStringSlice{}, Typing: TypingConfig{Enabled: true}, Placeholder: PlaceholderConfig{