mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-10-19 16:15:45 +00:00
feat(telegram): support custom Bot API server URL (#157)
* feat(telegram): support custom Bot API server URL Add api_server credential field for Telegram channels to allow using a self-hosted Telegram Bot API server (e.g. for 2GB upload limits). Uses telego.WithAPIServer() option. * feat(telegram): local Bot API media download + config improvements - Support local Bot API --local mode: detect absolute file paths and copy directly from filesystem (requires shared volume mount) - Fall back to HTTP download via custom API server URL for non-local mode - Move api_server/proxy from credentials to config (non-secret fields); credentials still accepted for backward compat - Add media_max_mb config field (friendlier than media_max_bytes); deprecated media_max_bytes still works - Default to 200 MB max when local Bot API is configured (vs 20 MB cloud) - UI: move api_server/proxy to config section, use MB for media size * fix: use 127.0.0.1 instead of localhost in API server placeholder Docker containers cannot resolve localhost to the host machine. 127.0.0.1 works reliably with Docker's default bridge network. --------- Co-authored-by: Luvu182 <208665161+Luvu182@users.noreply.github.com>
This commit is contained in:
1 parent
ace07509b7
commit
e5eca5d0f0
5 files changed
+110
-13
No files matched your search
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -39,7 +39,6 @@ export const groupPolicyOptions = [
|
||||
export const credentialsSchema: Record<string, FieldDef[]> = {
|
||||
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<string, FieldDef[]> = {
|
||||
|
||||
export const configSchema: Record<string, FieldDef[]> = {
|
||||
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<string, FieldDef[]> = {
|
||||
{ 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" },
|
||||
|
||||
Reference in new issue
Block a user