diff --git a/internal/channels/telegram/channel.go b/internal/channels/telegram/channel.go index 6a3fca46..bba37e41 100644 --- a/internal/channels/telegram/channel.go +++ b/internal/channels/telegram/channel.go @@ -58,6 +58,10 @@ func (c *thinkingCancel) Cancel() { func New(cfg config.TelegramConfig, msgBus *bus.MessageBus, pairingSvc store.PairingStore, agentStore store.AgentStore, teamStore store.TeamStore, pendingStore store.PendingMessageStore) (*Channel, error) { var opts []telego.BotOption + if cfg.APIServer != "" { + opts = append(opts, telego.WithAPIServer(cfg.APIServer)) + } + if cfg.Proxy != "" { proxyURL, parseErr := url.Parse(cfg.Proxy) if parseErr != nil { diff --git a/internal/channels/telegram/factory.go b/internal/channels/telegram/factory.go index fdd6582a..0bfcb4ae 100644 --- a/internal/channels/telegram/factory.go +++ b/internal/channels/telegram/factory.go @@ -12,12 +12,15 @@ import ( // telegramCreds maps the credentials JSON from the channel_instances table. type telegramCreds struct { - Token string `json:"token"` - Proxy string `json:"proxy,omitempty"` + Token string `json:"token"` + Proxy string `json:"proxy,omitempty"` + APIServer string `json:"api_server,omitempty"` } // telegramInstanceConfig maps the non-secret config JSONB from the channel_instances table. type telegramInstanceConfig struct { + APIServer string `json:"api_server,omitempty"` + Proxy string `json:"proxy,omitempty"` DMPolicy string `json:"dm_policy,omitempty"` GroupPolicy string `json:"group_policy,omitempty"` RequireMention *bool `json:"require_mention,omitempty"` @@ -25,7 +28,8 @@ type telegramInstanceConfig struct { DMStream *bool `json:"dm_stream,omitempty"` GroupStream *bool `json:"group_stream,omitempty"` ReactionLevel string `json:"reaction_level,omitempty"` - MediaMaxBytes int64 `json:"media_max_bytes,omitempty"` + MediaMaxMB int64 `json:"media_max_mb,omitempty"` + MediaMaxBytes int64 `json:"media_max_bytes,omitempty"` // deprecated: use media_max_mb LinkPreview *bool `json:"link_preview,omitempty"` BlockReply *bool `json:"block_reply,omitempty"` AllowFrom []string `json:"allow_from,omitempty"` @@ -65,10 +69,21 @@ func buildChannel(name string, creds json.RawMessage, cfg json.RawMessage, } } + // Prefer config values; fall back to credentials for backward compat. + proxy := ic.Proxy + if proxy == "" { + proxy = c.Proxy + } + apiServer := ic.APIServer + if apiServer == "" { + apiServer = c.APIServer + } + tgCfg := config.TelegramConfig{ Enabled: true, Token: c.Token, - Proxy: c.Proxy, + Proxy: proxy, + APIServer: apiServer, AllowFrom: ic.AllowFrom, DMPolicy: ic.DMPolicy, GroupPolicy: ic.GroupPolicy, @@ -77,7 +92,7 @@ func buildChannel(name string, creds json.RawMessage, cfg json.RawMessage, DMStream: ic.DMStream, GroupStream: ic.GroupStream, ReactionLevel: ic.ReactionLevel, - MediaMaxBytes: ic.MediaMaxBytes, + MediaMaxBytes: resolveMediaMaxBytes(ic), LinkPreview: ic.LinkPreview, BlockReply: ic.BlockReply, } @@ -97,3 +112,12 @@ func buildChannel(name string, creds json.RawMessage, cfg json.RawMessage, ch.SetName(name) return ch, nil } + +// resolveMediaMaxBytes converts media_max_mb (preferred) to bytes, +// falling back to the deprecated media_max_bytes for backward compat. +func resolveMediaMaxBytes(ic telegramInstanceConfig) int64 { + if ic.MediaMaxMB > 0 { + return ic.MediaMaxMB * 1024 * 1024 + } + return ic.MediaMaxBytes +} diff --git a/internal/channels/telegram/media.go b/internal/channels/telegram/media.go index 18f41434..68a86029 100644 --- a/internal/channels/telegram/media.go +++ b/internal/channels/telegram/media.go @@ -17,9 +17,14 @@ import ( ) const ( - // defaultMediaMaxBytes is the default max download size (20MB, Telegram Bot API limit). + // defaultMediaMaxBytes is the default max download size for the official Bot API (20 MB). defaultMediaMaxBytes int64 = 20 * 1024 * 1024 + // localAPIDefaultMaxBytes is the default max download size when a local Bot API server + // is configured. The local server supports up to 2 GB; we default to 200 MB and let + // downstream providers enforce their own limits. + localAPIDefaultMaxBytes int64 = 200 * 1024 * 1024 + // downloadMaxRetries is the number of download retry attempts. downloadMaxRetries = 3 ) @@ -34,7 +39,11 @@ func (c *Channel) resolveMedia(ctx context.Context, msg *telego.Message) []Media maxBytes := c.config.MediaMaxBytes if maxBytes == 0 { - maxBytes = defaultMediaMaxBytes + if c.config.APIServer != "" { + maxBytes = localAPIDefaultMaxBytes + } else { + maxBytes = defaultMediaMaxBytes + } } // Photo: take highest resolution (last element) @@ -160,6 +169,10 @@ func (c *Channel) resolveMedia(ctx context.Context, msg *telego.Message) []Media // downloadMedia downloads a file from Telegram by file_id with retry logic. // Returns the local file path. +// +// When a local Bot API server is configured (api_server), the download URL +// points to that server instead of the official api.telegram.org, removing the +// standard 20 MB file size limit. Downstream providers enforce their own limits. func (c *Channel) downloadMedia(ctx context.Context, fileID string, maxBytes int64) (string, error) { var file *telego.File var err error @@ -187,13 +200,32 @@ func (c *Channel) downloadMedia(ctx context.Context, fileID string, maxBytes int return "", fmt.Errorf("empty file path for file_id %s", fileID) } - // Check file size before downloading - if int64(file.FileSize) > maxBytes { + // Check file size before downloading (FileSize may be 0 for large files on local Bot API). + if file.FileSize > 0 && int64(file.FileSize) > maxBytes { return "", fmt.Errorf("file too large: %d bytes (max %d)", file.FileSize, maxBytes) } - // Build download URL - downloadURL := fmt.Sprintf("https://api.telegram.org/file/bot%s/%s", c.config.Token, file.FilePath) + // Local Bot API (--local mode) returns absolute filesystem paths and does NOT + // serve files over HTTP (/file/ endpoint returns 501). When the path is absolute, + // copy directly from the filesystem (requires the data dir to be mounted). + if c.config.APIServer != "" && filepath.IsAbs(file.FilePath) { + if _, statErr := os.Stat(file.FilePath); statErr == nil { + slog.Debug("telegram media: copying from local filesystem", + "file_id", fileID, "path", file.FilePath, "size", file.FileSize) + return copyLocalFile(file.FilePath, maxBytes) + } + return "", fmt.Errorf("local bot api file not accessible (mount the data dir into the container): %s", file.FilePath) + } + + // Download over HTTP: use custom API server if configured (non-local mode), + // otherwise the official Telegram API. + var downloadURL string + if c.config.APIServer != "" { + downloadURL = fmt.Sprintf("%s/file/bot%s/%s", + strings.TrimRight(c.config.APIServer, "/"), c.config.Token, file.FilePath) + } else { + downloadURL = fmt.Sprintf("https://api.telegram.org/file/bot%s/%s", c.config.Token, file.FilePath) + } resp, err := http.Get(downloadURL) if err != nil { @@ -231,6 +263,41 @@ func (c *Channel) downloadMedia(ctx context.Context, fileID string, maxBytes int return tmpFile.Name(), nil } +// copyLocalFile copies a file from the local Bot API data directory to a temp file. +func copyLocalFile(srcPath string, maxBytes int64) (string, error) { + src, err := os.Open(srcPath) + if err != nil { + return "", fmt.Errorf("open local file: %w", err) + } + defer src.Close() + + info, err := src.Stat() + if err != nil { + return "", fmt.Errorf("stat local file: %w", err) + } + if info.Size() > maxBytes { + return "", fmt.Errorf("file too large: %d bytes (max %d)", info.Size(), maxBytes) + } + + ext := filepath.Ext(srcPath) + if ext == "" { + ext = ".bin" + } + + tmpFile, err := os.CreateTemp("", "goclaw_media_*"+ext) + if err != nil { + return "", fmt.Errorf("create temp file: %w", err) + } + defer tmpFile.Close() + + if _, err := io.Copy(tmpFile, src); err != nil { + os.Remove(tmpFile.Name()) + return "", fmt.Errorf("copy local file: %w", err) + } + + return tmpFile.Name(), nil +} + // buildMediaTags delegates to the shared media package. func buildMediaTags(mediaList []MediaInfo) string { return media.BuildMediaTags(mediaList) diff --git a/internal/config/config_channels.go b/internal/config/config_channels.go index e0afb410..1df49d87 100644 --- a/internal/config/config_channels.go +++ b/internal/config/config_channels.go @@ -27,6 +27,7 @@ type TelegramConfig struct { Enabled bool `json:"enabled"` Token string `json:"token"` Proxy string `json:"proxy,omitempty"` + APIServer string `json:"api_server,omitempty"` // custom Telegram Bot API server URL (e.g. "http://localhost:8081") AllowFrom FlexibleStringSlice `json:"allow_from"` DMPolicy string `json:"dm_policy,omitempty"` // "pairing" (default), "allowlist", "open", "disabled" GroupPolicy string `json:"group_policy,omitempty"` // "open" (default), "allowlist", "disabled" diff --git a/ui/web/src/pages/channels/channel-schemas.ts b/ui/web/src/pages/channels/channel-schemas.ts index 4d62914c..1247603c 100644 --- a/ui/web/src/pages/channels/channel-schemas.ts +++ b/ui/web/src/pages/channels/channel-schemas.ts @@ -39,7 +39,6 @@ export const groupPolicyOptions = [ export const credentialsSchema: Record = { telegram: [ { key: "token", label: "Bot Token", type: "password", required: true, placeholder: "123456:ABC-DEF...", help: "From @BotFather" }, - { key: "proxy", label: "HTTP Proxy", type: "text", placeholder: "http://proxy:8080" }, ], discord: [ { key: "token", label: "Bot Token", type: "password", required: true, placeholder: "Discord bot token" }, @@ -69,6 +68,8 @@ export const credentialsSchema: Record = { export const configSchema: Record = { telegram: [ + { key: "api_server", label: "API Server URL", type: "text", placeholder: "http://127.0.0.1:8081", help: "Custom Telegram Bot API server for large file uploads (up to 2GB). Leave empty for default." }, + { key: "proxy", label: "HTTP Proxy", type: "text", placeholder: "http://proxy:8080", help: "Route bot traffic through an HTTP proxy" }, { key: "dm_policy", label: "DM Policy", type: "select", options: dmPolicyOptions, defaultValue: "pairing" }, { key: "group_policy", label: "Group Policy", type: "select", options: groupPolicyOptions, defaultValue: "pairing" }, { key: "require_mention", label: "Require @mention in groups", type: "boolean", defaultValue: true }, @@ -76,7 +77,7 @@ export const configSchema: Record = { { key: "dm_stream", label: "DM Streaming", type: "boolean", defaultValue: false, help: "Edit placeholder progressively as LLM generates" }, { key: "group_stream", label: "Group Streaming", type: "boolean", defaultValue: false, help: "Send & edit message progressively in groups" }, { key: "reaction_level", label: "Reaction Level", type: "select", options: [{ value: "off", label: "Off" }, { value: "minimal", label: "Minimal" }, { value: "full", label: "Full" }], defaultValue: "full" }, - { key: "media_max_bytes", label: "Max Media Size (bytes)", type: "number", defaultValue: 20971520, help: "Default: 20MB" }, + { key: "media_max_mb", label: "Max Media Size (MB)", type: "number", defaultValue: 20, help: "Default: 20 MB (cloud API). Increase when using local Bot API server." }, { key: "link_preview", label: "Link Preview", type: "boolean", defaultValue: true }, { key: "allow_from", label: "Allowed Users", type: "tags", help: "User IDs or @usernames, one per line" }, { key: "block_reply", label: "Block Reply", type: "select", options: blockReplyOptions, defaultValue: "inherit", help: "Deliver intermediate text during tool iterations" },