feat(telegram): support custom api_base_url for bot api

This commit is contained in:
Owen Wu 2026-02-28 20:12:36 -08:00
parent 1265655ef0
commit 12f352420c
7 changed files with 161 additions and 5 deletions

View file

@ -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": {

View file

@ -713,6 +713,7 @@ picoclaw agent -m "你好"
"telegram": {
"enabled": true,
"token": "123456:ABC...",
"base_url": "",
"allow_from": ["123456789"]
},
"discord": {

View file

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

View file

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

View file

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

View file

@ -50,6 +50,8 @@ func DefaultConfig() *Config {
Telegram: TelegramConfig{
Enabled: false,
Token: "",
BaseURL: "",
Proxy: "",
AllowFrom: FlexibleStringSlice{},
Typing: TypingConfig{Enabled: true},
Placeholder: PlaceholderConfig{