Button-driven bot management: paginated picker, per-bot API token / revoke, Edit Bot (name/description/about/commands/botpic), Bot Settings toggles (inline/groups/privacy), and delete. Navigation edits the menu message in place via a new editServiceBotMessage helper. Edit Botpic accepts a photo the user sends to @BotFather and sets it as the bot's profile photo (new files.SetAvatarFromExistingPhoto, wired through bots.SetBotUserpic / WithBotAvatarStore); photos.uploadProfilePhoto does not accept a bot target so this is the only route.
1982 lines
94 KiB
Go
1982 lines
94 KiB
Go
// Command telesrv 是基于 gotd/td 的 Telegram-like server(第一兼容目标:Telegram Desktop)。
|
||
package main
|
||
|
||
import (
|
||
"context"
|
||
"fmt"
|
||
"net"
|
||
"net/http"
|
||
"net/http/pprof"
|
||
"os"
|
||
"os/signal"
|
||
"runtime"
|
||
runtimemetrics "runtime/metrics"
|
||
"strconv"
|
||
"strings"
|
||
"syscall"
|
||
"time"
|
||
|
||
"go.uber.org/zap"
|
||
"go.uber.org/zap/zapcore"
|
||
|
||
"github.com/iamxvbaba/td/clock"
|
||
"github.com/iamxvbaba/td/exchange"
|
||
"github.com/iamxvbaba/td/tg"
|
||
|
||
adminapp "telesrv/internal/admin"
|
||
"telesrv/internal/adminapi"
|
||
"telesrv/internal/app/account"
|
||
aiapp "telesrv/internal/app/ai"
|
||
"telesrv/internal/app/auth"
|
||
authdiagnosticsapp "telesrv/internal/app/authdiagnostics"
|
||
botsapp "telesrv/internal/app/bots"
|
||
botverificationapp "telesrv/internal/app/botverification"
|
||
broadcastapp "telesrv/internal/app/broadcast"
|
||
channelapp "telesrv/internal/app/channels"
|
||
chatlistsapp "telesrv/internal/app/chatlists"
|
||
clienttelemetryapp "telesrv/internal/app/clienttelemetry"
|
||
communitiesapp "telesrv/internal/app/communities"
|
||
"telesrv/internal/app/contacts"
|
||
"telesrv/internal/app/dialogs"
|
||
ephemeralapp "telesrv/internal/app/ephemeral"
|
||
filesapp "telesrv/internal/app/files"
|
||
groupcallsapp "telesrv/internal/app/groupcalls"
|
||
"telesrv/internal/app/help"
|
||
"telesrv/internal/app/langpack"
|
||
"telesrv/internal/app/livestream"
|
||
"telesrv/internal/app/maintenance"
|
||
messageapp "telesrv/internal/app/messages"
|
||
moderationapp "telesrv/internal/app/moderation"
|
||
passkeyapp "telesrv/internal/app/passkey"
|
||
phoneapp "telesrv/internal/app/phone"
|
||
pollsapp "telesrv/internal/app/polls"
|
||
privacyapp "telesrv/internal/app/privacy"
|
||
secretchatapp "telesrv/internal/app/secretchat"
|
||
storiesapp "telesrv/internal/app/stories"
|
||
telegramloginapp "telesrv/internal/app/telegramlogin"
|
||
themesapp "telesrv/internal/app/themes"
|
||
translationapp "telesrv/internal/app/translation"
|
||
"telesrv/internal/app/updates"
|
||
usernamesapp "telesrv/internal/app/usernames"
|
||
"telesrv/internal/app/userprojection"
|
||
"telesrv/internal/app/users"
|
||
verificationapp "telesrv/internal/app/verification"
|
||
welcomemessagesapp "telesrv/internal/app/welcomemessages"
|
||
"telesrv/internal/botapi"
|
||
"telesrv/internal/config"
|
||
"telesrv/internal/domain"
|
||
"telesrv/internal/identity"
|
||
"telesrv/internal/mtprotoedge"
|
||
obsmetrics "telesrv/internal/observability/metrics"
|
||
"telesrv/internal/otpdelivery"
|
||
otpsmtp "telesrv/internal/otpdelivery/smtp"
|
||
otpwebhook "telesrv/internal/otpdelivery/webhook"
|
||
"telesrv/internal/procctl"
|
||
"telesrv/internal/rpc"
|
||
"telesrv/internal/seed/catalog"
|
||
"telesrv/internal/sfu"
|
||
storepkg "telesrv/internal/store"
|
||
"telesrv/internal/store/memory"
|
||
"telesrv/internal/store/postgres"
|
||
"telesrv/internal/store/redisstore"
|
||
"telesrv/internal/telegramloginhttp"
|
||
"telesrv/internal/turnsrv"
|
||
"telesrv/internal/updatecdn"
|
||
"telesrv/internal/web"
|
||
)
|
||
|
||
func main() {
|
||
logger, err := newLogger()
|
||
if err != nil {
|
||
fmt.Fprintln(os.Stderr, "init logger:", err)
|
||
os.Exit(1)
|
||
}
|
||
defer func() { _ = logger.Sync() }()
|
||
|
||
if err := run(logger); err != nil {
|
||
logger.Error("telesrv exiting", zap.Error(err))
|
||
_ = logger.Sync() // os.Exit skips defer; flush buffered log writes explicitly
|
||
os.Exit(1)
|
||
}
|
||
}
|
||
|
||
// newLogger 构建运行日志器。三项关键改造(相对旧的 zap.NewDevelopment):
|
||
// - 级别可配(TELESRV_LOG_LEVEL,默认 info):旧版固定 Debug,热路径 65 处 Debug(含
|
||
// mtprotoedge 每帧一条)在连接洪峰会刷爆日志。生产/压测用 info 即可,需要时设 debug。
|
||
// - 缓冲异步写(BufferedWriteSyncer):旧版每条日志一次 stderr 同步写 + 全局锁,高并发下
|
||
// 在日志锁上串行累积——实测连接时 12 个并发 RPC 的 client_info 阶段被拖成 ~1s 惊群
|
||
// (mutex profile 91% 竞争在 zap 写)。缓冲后写入批量化,锁持有时间从「每条一次系统调用」
|
||
// 降到「攒一批刷一次」。FlushInterval 控制日志可见延迟上界。
|
||
// - compactEncoder(见 log_encoder.go)取代 zap 自带的 console encoder:后者把结构化字段
|
||
// 整体序列化成一坨 JSON 挂在行尾,人眼扫描困难;compactEncoder 直接把字段写成
|
||
// `key=value` 平铺在一行,短时间戳,同时不经 map/interface{} 装箱,性能开销接近原 encoder。
|
||
func newLogger() (*zap.Logger, error) {
|
||
level := zapcore.InfoLevel
|
||
if v := strings.TrimSpace(os.Getenv("TELESRV_LOG_LEVEL")); v != "" {
|
||
if err := level.UnmarshalText([]byte(strings.ToLower(v))); err != nil {
|
||
return nil, fmt.Errorf("parse TELESRV_LOG_LEVEL %q: %w", v, err)
|
||
}
|
||
}
|
||
ws := &zapcore.BufferedWriteSyncer{
|
||
WS: zapcore.AddSync(os.Stderr),
|
||
FlushInterval: 500 * time.Millisecond,
|
||
}
|
||
core := zapcore.NewCore(newCompactEncoder(zap.NewDevelopmentEncoderConfig()), ws, level)
|
||
return zap.New(core,
|
||
zap.AddCaller(),
|
||
zap.AddStacktrace(zapcore.ErrorLevel),
|
||
zap.ErrorOutput(zapcore.AddSync(os.Stderr)),
|
||
), nil
|
||
}
|
||
|
||
func newBusinessAutomationOptions(cfg config.Config, online messageapp.BusinessAutomationOnlineChecker, generator messageapp.BusinessAITextGenerator, logger *zap.Logger) []messageapp.BusinessAutomationOption {
|
||
opts := []messageapp.BusinessAutomationOption{
|
||
messageapp.WithBusinessAutomationOnlineChecker(online),
|
||
}
|
||
provider := strings.ToLower(strings.TrimSpace(cfg.BusinessAIProvider))
|
||
switch provider {
|
||
case "", "echo":
|
||
opts = append(opts, messageapp.WithBusinessAutomationReplyProvider(messageapp.NewEchoBusinessAutomationProvider()))
|
||
logger.Info("Business automation reply provider", zap.String("provider", "echo"))
|
||
case "template", "quick_reply", "quick-reply":
|
||
logger.Info("Business automation reply provider", zap.String("provider", "template"))
|
||
case "ai", "compose_ai", "ai_compose", "aicompose", "kimi":
|
||
if generator == nil {
|
||
logger.Warn("Business automation AI provider requested but AI generator is unavailable", zap.String("provider", cfg.BusinessAIProvider))
|
||
return opts
|
||
}
|
||
opts = append(opts, messageapp.WithBusinessAutomationReplyProvider(messageapp.NewAIBusinessAutomationProvider(generator)))
|
||
logger.Info("Business automation reply provider", zap.String("provider", "ai"))
|
||
default:
|
||
logger.Warn("unknown Business automation AI provider, falling back to quick reply template", zap.String("provider", cfg.BusinessAIProvider))
|
||
}
|
||
return opts
|
||
}
|
||
|
||
func newAIComposeOptions(cfg config.Config, limiter aiapp.RateLimiter, premium aiapp.PremiumChecker, logger *zap.Logger) []aiapp.Option {
|
||
opts := []aiapp.Option{
|
||
aiapp.WithEnabled(cfg.AIEnabled),
|
||
aiapp.WithTimeout(cfg.AITimeout),
|
||
aiapp.WithRateLimiter(limiter, cfg.AIRateLimit, cfg.AIRateWindow),
|
||
aiapp.WithPremiumChecker(premium),
|
||
aiapp.WithLogger(logger.Named("app").Named("ai")),
|
||
aiapp.WithPrivacyLogContent(cfg.AIPrivacyLogContent),
|
||
}
|
||
providers := make([]aiapp.Provider, 0, len(cfg.AIProviders))
|
||
for _, pc := range cfg.AIProviders {
|
||
provider, err := aiapp.NewProviderFromConfig(aiapp.ProviderConfig{
|
||
Name: pc.Name,
|
||
Kind: aiapp.ProviderKind(pc.Kind),
|
||
BaseURL: pc.BaseURL,
|
||
APIKey: pc.APIKey,
|
||
Model: pc.Model,
|
||
Timeout: cfg.AITimeout,
|
||
MaxOutputTokens: pc.MaxOutputTokens,
|
||
Temperature: pc.Temperature,
|
||
OmitTemperature: pc.OmitTemperature,
|
||
Thinking: pc.Thinking,
|
||
})
|
||
if err != nil {
|
||
logger.Warn("AI compose provider skipped", zap.String("provider", pc.Name), zap.String("kind", pc.Kind), zap.Error(err))
|
||
continue
|
||
}
|
||
providers = append(providers, provider)
|
||
logger.Info("AI compose provider enabled", zap.String("provider", provider.Name()), zap.String("kind", pc.Kind))
|
||
}
|
||
if len(providers) > 0 {
|
||
opts = append(opts, aiapp.WithProviders(providers...))
|
||
}
|
||
return opts
|
||
}
|
||
|
||
func newTranslationOptions(cfg config.Config, limiter translationapp.RateLimiter, logger *zap.Logger) []translationapp.Option {
|
||
opts := []translationapp.Option{
|
||
translationapp.WithEnabled(cfg.TranslationEnabled),
|
||
translationapp.WithTimeout(cfg.TranslationTimeout),
|
||
translationapp.WithRateLimiter(limiter, cfg.TranslationRateLimit, cfg.TranslationRateWindow),
|
||
}
|
||
selected := make(map[string]struct{}, len(cfg.TranslationProviders))
|
||
for _, name := range cfg.TranslationProviders {
|
||
selected[strings.ToLower(strings.TrimSpace(name))] = struct{}{}
|
||
}
|
||
providers := make([]translationapp.Provider, 0, len(cfg.AIProviders))
|
||
for _, pc := range cfg.AIProviders {
|
||
if aiapp.ProviderKind(pc.Kind) == aiapp.ProviderKindLocal {
|
||
continue
|
||
}
|
||
if len(selected) > 0 {
|
||
if _, ok := selected[strings.ToLower(pc.Name)]; !ok {
|
||
continue
|
||
}
|
||
}
|
||
provider, err := aiapp.NewProviderFromConfig(aiapp.ProviderConfig{
|
||
Name: pc.Name, Kind: aiapp.ProviderKind(pc.Kind), BaseURL: pc.BaseURL,
|
||
APIKey: pc.APIKey, Model: pc.Model, Timeout: cfg.TranslationTimeout,
|
||
MaxOutputTokens: max(pc.MaxOutputTokens, 8192), Temperature: pc.Temperature,
|
||
OmitTemperature: pc.OmitTemperature, Thinking: pc.Thinking,
|
||
})
|
||
if err != nil {
|
||
logger.Warn("translation provider skipped", zap.String("provider", pc.Name), zap.Error(err))
|
||
continue
|
||
}
|
||
providers = append(providers, translationapp.NewAIProvider(provider))
|
||
logger.Info("translation provider enabled", zap.String("provider", provider.Name()), zap.String("kind", pc.Kind))
|
||
}
|
||
if len(providers) > 0 {
|
||
opts = append(opts, translationapp.WithProviders(providers...))
|
||
} else if cfg.TranslationEnabled {
|
||
logger.Warn("translation is enabled but has no remote provider; messages.translateText will return TRANSLATIONS_DISABLED")
|
||
}
|
||
return opts
|
||
}
|
||
|
||
// startDebugServer 在 addr 上挂起 net/http/pprof 调试端点(addr 为空则关闭)。
|
||
// 用独立 mux(不污染 http.DefaultServeMux),仅注册 pprof 路由:
|
||
// - /debug/pprof/profile CPU 剖析(?seconds=30)
|
||
// - /debug/pprof/heap 堆内存快照
|
||
// - /debug/pprof/goroutine goroutine 栈(排查泄漏/阻塞)
|
||
// - /debug/pprof/mutex 锁竞争(需 SetMutexProfileFraction)
|
||
// - /debug/pprof/block 阻塞剖析(需 SetBlockProfileRate)
|
||
// - /debug/pprof/allocs 累计分配(带宽/序列化热点常与之相关)
|
||
//
|
||
// mutex/block 采样在低流量测试环境开销可忽略;高流量生产如担心扰动,置空 DebugAddr 关闭整端点。
|
||
func startDebugServer(ctx context.Context, addr string, metricsHandler http.Handler, logger *zap.Logger) {
|
||
if addr == "" {
|
||
return
|
||
}
|
||
runtime.SetMutexProfileFraction(5) // 采样 1/5 的锁竞争事件
|
||
runtime.SetBlockProfileRate(10000) // 每阻塞约 10µs 采一次样
|
||
|
||
mux := http.NewServeMux()
|
||
mux.HandleFunc("/debug/pprof/", pprof.Index) // 含 heap/goroutine/mutex/block/allocs 等命名 profile
|
||
mux.HandleFunc("/debug/pprof/cmdline", pprof.Cmdline)
|
||
mux.HandleFunc("/debug/pprof/profile", pprof.Profile)
|
||
mux.HandleFunc("/debug/pprof/symbol", pprof.Symbol)
|
||
mux.HandleFunc("/debug/pprof/trace", pprof.Trace)
|
||
if metricsHandler != nil {
|
||
mux.Handle("/metrics", metricsHandler)
|
||
}
|
||
|
||
srv := &http.Server{Addr: addr, Handler: mux}
|
||
go func() {
|
||
logger.Info("pprof debug endpoint enabled", zap.String("addr", addr),
|
||
zap.String("hint", "go tool pprof http://"+addr+"/debug/pprof/profile?seconds=30"))
|
||
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
|
||
logger.Warn("pprof endpoint exited", zap.Error(err))
|
||
}
|
||
}()
|
||
go func() {
|
||
<-ctx.Done()
|
||
shutdownCtx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
|
||
defer cancel()
|
||
_ = srv.Shutdown(shutdownCtx)
|
||
}()
|
||
}
|
||
|
||
func goRuntimeGaugeSamples() []obsmetrics.GaugeSample {
|
||
var mem runtime.MemStats
|
||
runtime.ReadMemStats(&mem)
|
||
samples := []obsmetrics.GaugeSample{
|
||
{Name: "telesrv_go_goroutines", Value: float64(runtime.NumGoroutine())},
|
||
{Name: "telesrv_go_scheduler_busy_seconds", Value: goSchedulerBusySeconds()},
|
||
{Name: "telesrv_go_heap_alloc_bytes", Value: float64(mem.HeapAlloc)},
|
||
{Name: "telesrv_go_heap_inuse_bytes", Value: float64(mem.HeapInuse)},
|
||
{Name: "telesrv_go_heap_objects", Value: float64(mem.HeapObjects)},
|
||
{Name: "telesrv_go_stack_inuse_bytes", Value: float64(mem.StackInuse)},
|
||
{Name: "telesrv_go_sys_bytes", Value: float64(mem.Sys)},
|
||
{Name: "telesrv_go_gc_cycles", Value: float64(mem.NumGC)},
|
||
{Name: "telesrv_go_gc_pause_seconds", Value: time.Duration(mem.PauseTotalNs).Seconds()},
|
||
}
|
||
if value, ok := processCPUSeconds(); ok {
|
||
samples = append(samples, obsmetrics.GaugeSample{Name: "telesrv_process_cpu_seconds", Value: value})
|
||
}
|
||
return samples
|
||
}
|
||
|
||
// goSchedulerBusySeconds is a Go scheduler-class estimate. The runtime
|
||
// documentation explicitly warns that CPU-class values are overestimates and
|
||
// are not comparable to operating-system process CPU time, so capacity reports
|
||
// use telesrv_process_cpu_seconds instead.
|
||
func goSchedulerBusySeconds() float64 {
|
||
samples := []runtimemetrics.Sample{
|
||
{Name: "/cpu/classes/total:cpu-seconds"},
|
||
{Name: "/cpu/classes/idle:cpu-seconds"},
|
||
}
|
||
runtimemetrics.Read(samples)
|
||
total := samples[0].Value.Float64()
|
||
idle := samples[1].Value.Float64()
|
||
if total <= idle {
|
||
return 0
|
||
}
|
||
return total - idle
|
||
}
|
||
|
||
func mtprotoRuntimeGaugeSamples(snapshot mtprotoedge.RuntimeSnapshot) []obsmetrics.GaugeSample {
|
||
return []obsmetrics.GaugeSample{
|
||
{Name: "telesrv_mtproto_raw_connections", Value: float64(snapshot.RawConnections)},
|
||
{Name: "telesrv_mtproto_raw_connection_limit", Value: float64(snapshot.RawConnectionLimit)},
|
||
{Name: "telesrv_mtproto_handshakes_active", Value: float64(snapshot.Handshakes)},
|
||
{Name: "telesrv_mtproto_handshake_limit", Value: float64(snapshot.HandshakeLimit)},
|
||
{Name: "telesrv_mtproto_sessions", Labels: []obsmetrics.Label{{Name: "state", Value: "active"}}, Value: float64(snapshot.ActiveSessions)},
|
||
{Name: "telesrv_mtproto_sessions", Labels: []obsmetrics.Label{{Name: "state", Value: "provisional"}}, Value: float64(snapshot.ProvisionalSessions)},
|
||
{Name: "telesrv_mtproto_logical_sessions", Labels: []obsmetrics.Label{{Name: "state", Value: "retained"}}, Value: float64(snapshot.LogicalSessions)},
|
||
{Name: "telesrv_mtproto_logical_sessions", Labels: []obsmetrics.Label{{Name: "state", Value: "offline"}}, Value: float64(snapshot.OfflineLogicalSessions)},
|
||
{Name: "telesrv_mtproto_logical_outbox_frames", Value: float64(snapshot.LogicalOutboxFrames)},
|
||
{Name: "telesrv_mtproto_logical_outbox_bytes", Value: float64(snapshot.LogicalOutboxBytes)},
|
||
{Name: "telesrv_mtproto_pending_push_bytes", Value: float64(snapshot.PendingPushBytes)},
|
||
{Name: "telesrv_mtproto_inbound_rpc_tasks", Value: float64(snapshot.InboundRPCTasks)},
|
||
{Name: "telesrv_mtproto_inbound_rpc_bytes", Value: float64(snapshot.InboundRPCBytes)},
|
||
{Name: "telesrv_mtproto_inbound_rpc_ready_connections", Value: float64(snapshot.InboundRPCReadyConnections)},
|
||
{Name: "telesrv_mtproto_inbound_rpc_task_limit", Value: float64(snapshot.InboundRPCMaxTasks)},
|
||
{Name: "telesrv_mtproto_inbound_rpc_byte_limit", Value: float64(snapshot.InboundRPCMaxBytes)},
|
||
{Name: "telesrv_mtproto_rpc_delivery_hook_workers", Value: float64(snapshot.RPCDeliveryHookWorkers)},
|
||
{Name: "telesrv_mtproto_rpc_delivery_hook_capacity", Value: float64(snapshot.RPCDeliveryHookCapacity)},
|
||
{Name: "telesrv_mtproto_rpc_delivery_hook_reserved", Value: float64(snapshot.RPCDeliveryHookReserved)},
|
||
{Name: "telesrv_mtproto_rpc_delivery_hook_queued", Value: float64(snapshot.RPCDeliveryHookQueued)},
|
||
{Name: "telesrv_mtproto_rpc_delivery_hook_running", Value: float64(snapshot.RPCDeliveryHookRunning)},
|
||
{Name: "telesrv_mtproto_rpc_delivery_hook_completed_total", Value: float64(snapshot.RPCDeliveryHookCompleted)},
|
||
{Name: "telesrv_mtproto_rpc_delivery_hook_rejected_total", Value: float64(snapshot.RPCDeliveryHookRejected)},
|
||
{Name: "telesrv_mtproto_rpc_delivery_hook_panics_total", Value: float64(snapshot.RPCDeliveryHookPanics)},
|
||
{Name: "telesrv_mtproto_rpc_delivery_hook_duration_seconds_total", Value: snapshot.RPCDeliveryHookDurationSeconds},
|
||
{Name: "telesrv_mtproto_inbound_frame_bytes", Value: float64(snapshot.InboundFrameBytes)},
|
||
{Name: "telesrv_mtproto_inbound_frame_byte_limit", Value: float64(snapshot.InboundFrameMaxBytes)},
|
||
{Name: "telesrv_mtproto_outbound_tracked_bytes", Labels: []obsmetrics.Label{{Name: "kind", Value: "body"}}, Value: float64(snapshot.OutboundTrackedBytes)},
|
||
{Name: "telesrv_mtproto_outbound_tracked_bytes", Labels: []obsmetrics.Label{{Name: "kind", Value: "control"}}, Value: float64(snapshot.OutboundControlBytes)},
|
||
{Name: "telesrv_mtproto_outbound_tracked_byte_limit", Labels: []obsmetrics.Label{{Name: "kind", Value: "body"}}, Value: float64(snapshot.OutboundTrackedMaxBytes)},
|
||
{Name: "telesrv_mtproto_outbound_tracked_byte_limit", Labels: []obsmetrics.Label{{Name: "kind", Value: "control"}}, Value: float64(snapshot.OutboundControlMaxBytes)},
|
||
{Name: "telesrv_mtproto_outbound_write_bytes", Value: float64(snapshot.OutboundWriteBytes)},
|
||
{Name: "telesrv_mtproto_outbound_write_byte_limit", Value: float64(snapshot.OutboundWriteMaxBytes)},
|
||
{Name: "telesrv_mtproto_rpc_execution_owners", Value: float64(snapshot.RPCExecutionOwners)},
|
||
{Name: "telesrv_mtproto_rpc_execution_reserved_entries", Value: float64(snapshot.RPCExecutionReservedEntries)},
|
||
{Name: "telesrv_mtproto_rpc_execution_receipts", Value: float64(snapshot.RPCExecutionReceipts)},
|
||
{Name: "telesrv_mtproto_rpc_execution_receipt_budget_bytes", Value: float64(snapshot.RPCExecutionReceiptBudgetBytes)},
|
||
{Name: "telesrv_mtproto_rpc_execution_subscribers", Value: float64(snapshot.RPCExecutionSubscribers)},
|
||
}
|
||
}
|
||
|
||
// externalMediaOption 按配置启用外链媒体抓取;禁用时返回 nil(NewService 跳过 nil option)。
|
||
// liveStreamDep 把可能为 nil 的 *livestream.Service 转成 rpc.LiveStreamsService,
|
||
// 避免 typed-nil interface(nil 具体指针装进接口后 != nil 的坑)。
|
||
func liveStreamDep(s *livestream.Service) rpc.LiveStreamsService {
|
||
if s == nil {
|
||
return nil
|
||
}
|
||
return s
|
||
}
|
||
|
||
// verificationPeerVerifier writes the platform verification flag onto the peer
|
||
// record for app/verification.
|
||
//
|
||
// It is called from *inside* the store transaction that decides the application,
|
||
// which is the whole point of the port: "approved" and "target carries the badge"
|
||
// must commit together. That is why the transaction is taken from the context
|
||
// (postgres.VerificationTxFromContext) and written through — a write on a separate
|
||
// pool connection would survive a rollback of the decision and leave a peer
|
||
// wearing a badge no approved application backs.
|
||
//
|
||
// The app-service path is only the fallback for a context that carries no
|
||
// transaction (a non-postgres store, or a direct call): there is nothing to join
|
||
// then, and going through the services keeps their cache refresh behaviour.
|
||
type verificationPeerVerifier struct {
|
||
users interface {
|
||
SetVerified(ctx context.Context, userID int64, verified bool) (domain.User, error)
|
||
}
|
||
channels interface {
|
||
SetVerified(ctx context.Context, channelID int64, verified bool) (domain.Channel, error)
|
||
}
|
||
// channelRowCache is handed to the transaction-scoped channel store so the
|
||
// cached channel row is dropped on the flag write, exactly as the pooled store
|
||
// does it.
|
||
channelRowCache *postgres.ChannelRowCache
|
||
}
|
||
|
||
func (v verificationPeerVerifier) SetUserVerified(ctx context.Context, userID int64, verified bool) error {
|
||
if tx, ok := postgres.VerificationTxFromContext(ctx); ok {
|
||
_, err := postgres.NewUserStore(tx).SetVerified(ctx, userID, verified)
|
||
return err
|
||
}
|
||
if v.users == nil {
|
||
return fmt.Errorf("verification peer verifier: user service is not wired")
|
||
}
|
||
_, err := v.users.SetVerified(ctx, userID, verified)
|
||
return err
|
||
}
|
||
|
||
func (v verificationPeerVerifier) SetChannelVerified(ctx context.Context, channelID int64, verified bool) error {
|
||
if tx, ok := postgres.VerificationTxFromContext(ctx); ok {
|
||
opts := []postgres.ChannelStoreOption(nil)
|
||
if v.channelRowCache != nil {
|
||
opts = append(opts, postgres.WithChannelRowCache(v.channelRowCache))
|
||
}
|
||
_, err := postgres.NewChannelStore(tx, opts...).SetChannelVerified(ctx, channelID, verified)
|
||
return err
|
||
}
|
||
if v.channels == nil {
|
||
return fmt.Errorf("verification peer verifier: channel service is not wired")
|
||
}
|
||
_, err := v.channels.SetVerified(ctx, channelID, verified)
|
||
return err
|
||
}
|
||
|
||
var _ verificationapp.PeerVerifier = verificationPeerVerifier{}
|
||
|
||
// botVerificationMarkApplier writes a third-party mark on the decision's own
|
||
// transaction when there is one.
|
||
//
|
||
// postgres.DecideCustomVerificationRequest hands its callback a context carrying
|
||
// the transaction, and the pooled store would open a second, independently
|
||
// committing one -- so an approval whose mark write failed would leave the request
|
||
// approved with no mark. This adapter is what makes "approved implies mark exists"
|
||
// survive a rollback, exactly as verificationPeerVerifier does for the official flag.
|
||
type botVerificationMarkApplier struct {
|
||
store storepkg.BotVerificationStore
|
||
}
|
||
|
||
func (a botVerificationMarkApplier) GrantCustomVerification(ctx context.Context, mark domain.CustomVerification) (domain.CustomVerification, bool, error) {
|
||
if tx, ok := postgres.VerificationTxFromContext(ctx); ok {
|
||
return postgres.NewBotVerificationStore(tx).GrantCustomVerification(ctx, mark)
|
||
}
|
||
return a.store.GrantCustomVerification(ctx, mark)
|
||
}
|
||
|
||
func (a botVerificationMarkApplier) RevokeCustomVerification(ctx context.Context, verifierBotID int64, peer domain.Peer) (bool, error) {
|
||
if tx, ok := postgres.VerificationTxFromContext(ctx); ok {
|
||
return postgres.NewBotVerificationStore(tx).RevokeCustomVerification(ctx, verifierBotID, peer)
|
||
}
|
||
return a.store.RevokeCustomVerification(ctx, verifierBotID, peer)
|
||
}
|
||
|
||
var _ botverificationapp.MarkApplier = botVerificationMarkApplier{}
|
||
|
||
// compositeBotVerificationNotifier drops the cached peer projections before the
|
||
// edge rebuilds and pushes the peer, so a mark change cannot be pushed with a
|
||
// stale badge.
|
||
type compositeBotVerificationNotifier struct {
|
||
cache rpcProjectionVerificationNotifier
|
||
edge botverificationapp.PeerNotifier
|
||
}
|
||
|
||
func (n compositeBotVerificationNotifier) NotifyPeerBotVerification(ctx context.Context, peer domain.Peer) error {
|
||
if err := n.cache.NotifyPeerVerified(ctx, peer); err != nil && n.cache.log != nil {
|
||
n.cache.log.Warn("invalidate peer caches after third-party verification change",
|
||
zap.String("peer_type", string(peer.Type)), zap.Int64("peer_id", peer.ID), zap.Error(err))
|
||
}
|
||
if n.edge == nil {
|
||
return nil
|
||
}
|
||
return n.edge.NotifyPeerBotVerification(ctx, peer)
|
||
}
|
||
|
||
var _ botverificationapp.PeerNotifier = compositeBotVerificationNotifier{}
|
||
|
||
// rpcProjectionVerificationNotifier is the fallback badge-change hook, the same
|
||
// shape and for the same reason as rpcProjectionUsernameNotifier: the RPC edge
|
||
// owns both the cached peer projections and the tg.* push, and until it exposes
|
||
// NotifyPeerVerified only the invalidation half can be wired here. Invalidation is
|
||
// the half that must not be skipped — a decided application whose peer projection
|
||
// still says "not verified" would keep showing the old badge state to every client
|
||
// that reads from cache.
|
||
type rpcProjectionVerificationNotifier struct {
|
||
invalidator interface {
|
||
InvalidateRPCProjectionReadModelForUser(userID int64)
|
||
InvalidateRPCProjectionReadModelForChannel(channelID int64)
|
||
InvalidatePeerIdentityReadModel(domain.Peer)
|
||
}
|
||
users storepkg.UserCache
|
||
peerIdentity bool
|
||
log *zap.Logger
|
||
}
|
||
|
||
func (n rpcProjectionVerificationNotifier) NotifyPeerVerified(ctx context.Context, peer domain.Peer) error {
|
||
if n.invalidator == nil {
|
||
return nil
|
||
}
|
||
if n.peerIdentity {
|
||
n.invalidator.InvalidatePeerIdentityReadModel(peer)
|
||
}
|
||
switch peer.Type {
|
||
case domain.PeerTypeUser:
|
||
n.invalidator.InvalidateRPCProjectionReadModelForUser(peer.ID)
|
||
// The shared user:base cache is the source the projection rebuilds from, so
|
||
// dropping only the projection would let it rebuild from a stale row.
|
||
if n.users != nil {
|
||
if err := n.users.Delete(ctx, []int64{peer.ID}); err != nil && n.log != nil {
|
||
n.log.Warn("invalidate base user cache after verification change",
|
||
zap.Int64("user_id", peer.ID), zap.Error(err))
|
||
}
|
||
}
|
||
case domain.PeerTypeChannel:
|
||
n.invalidator.InvalidateRPCProjectionReadModelForChannel(peer.ID)
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// compositeVerificationNotifier drops the cached peer projections first and only
|
||
// then lets the protocol edge push the change, so the pushed peer is rebuilt from
|
||
// the committed row rather than from a cache entry written before the decision.
|
||
// A cache failure must not swallow the push: the push is what online clients see.
|
||
type compositeVerificationNotifier struct {
|
||
cache rpcProjectionVerificationNotifier
|
||
edge verificationapp.PeerNotifier
|
||
}
|
||
|
||
func (n compositeVerificationNotifier) NotifyPeerVerified(ctx context.Context, peer domain.Peer) error {
|
||
if err := n.cache.NotifyPeerVerified(ctx, peer); err != nil && n.cache.log != nil {
|
||
n.cache.log.Warn("invalidate peer caches after verification change",
|
||
zap.String("peer_type", string(peer.Type)), zap.Int64("peer_id", peer.ID), zap.Error(err))
|
||
}
|
||
if n.edge == nil {
|
||
return nil
|
||
}
|
||
return n.edge.NotifyPeerVerified(ctx, peer)
|
||
}
|
||
|
||
var _ verificationapp.PeerNotifier = compositeVerificationNotifier{}
|
||
|
||
var _ verificationapp.PeerNotifier = rpcProjectionVerificationNotifier{}
|
||
|
||
func externalMediaOption(cfg config.Config) filesapp.Option {
|
||
if !cfg.ExternalMediaEnable {
|
||
return nil
|
||
}
|
||
return filesapp.WithExternalMedia(cfg.ExternalMediaMaxBytes, cfg.ExternalMediaRatePerMin)
|
||
}
|
||
|
||
// webPagePreviewOption 按配置启用链接预览抓取;禁用时返回 nil(NewService 跳过 nil option)。
|
||
func webPagePreviewOption(cfg config.Config) filesapp.Option {
|
||
if !cfg.WebPagePreviewEnable {
|
||
return nil
|
||
}
|
||
return filesapp.WithWebPagePreview(cfg.WebPagePreviewMaxBytes, cfg.WebPagePreviewRatePerMin)
|
||
}
|
||
|
||
// fastestPositiveDuration returns the smaller of a and b, treating a
|
||
// non-positive value as "not configured" rather than as the smallest
|
||
// possible duration -- 0 only when both are non-positive (nothing
|
||
// configured). Used to derive the storage retention sweep's ticker cadence
|
||
// from whichever of the shared default age and its per-category overrides
|
||
// asks to run soonest.
|
||
func fastestPositiveDuration(a, b time.Duration) time.Duration {
|
||
if a <= 0 {
|
||
return b
|
||
}
|
||
if b <= 0 {
|
||
return a
|
||
}
|
||
if a < b {
|
||
return a
|
||
}
|
||
return b
|
||
}
|
||
|
||
func run(logger *zap.Logger) error {
|
||
cfg, err := config.Load()
|
||
if err != nil {
|
||
return fmt.Errorf("load config: %w", err)
|
||
}
|
||
buildMeta := currentBuildMetadata()
|
||
|
||
rsaKey, err := mtprotoedge.LoadOrGenerateRSAKey(cfg.RSAKeyPath)
|
||
if err != nil {
|
||
return fmt.Errorf("server rsa key: %w", err)
|
||
}
|
||
fingerprint := exchange.PrivateKey{RSA: rsaKey}.Fingerprint()
|
||
|
||
_, portStr, err := net.SplitHostPort(cfg.ListenAddr)
|
||
if err != nil {
|
||
return fmt.Errorf("parse listen addr %q: %w", cfg.ListenAddr, err)
|
||
}
|
||
port, err := strconv.Atoi(portStr)
|
||
if err != nil {
|
||
return fmt.Errorf("parse listen port %q: %w", portStr, err)
|
||
}
|
||
|
||
// tg.Layer 由当前导入的 canonical schema 生成;纳入未来 Layer 后无需
|
||
// 在 telesrv 另维护一份常量。
|
||
logger.Info("telesrv starting",
|
||
zap.String("listen", cfg.ListenAddr),
|
||
zap.Int("dc", cfg.DC),
|
||
zap.String("default_country_code", cfg.DefaultCountryCode),
|
||
zap.String("advertise", net.JoinHostPort(cfg.AdvertiseIP, portStr)),
|
||
zap.Int("tl_layer", tg.Layer),
|
||
zap.String("git_commit", buildMeta.Commit),
|
||
zap.String("git_branch", buildMeta.Branch),
|
||
zap.String("git_tree_state", buildMeta.TreeState),
|
||
zap.String("build_time", buildMeta.BuildTime),
|
||
zap.String("go_version", buildMeta.GoVersion),
|
||
zap.String("rsa_key", cfg.RSAKeyPath),
|
||
zap.Int64("rsa_fingerprint", fingerprint),
|
||
)
|
||
|
||
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
|
||
defer stop()
|
||
metricRegistry := obsmetrics.New()
|
||
metricRegistry.AddGaugeProvider(goRuntimeGaugeSamples)
|
||
|
||
// pprof 调试端点:telesrv 是宿主进程(不在 docker 内,docker stats 看不到它),CPU/内存/
|
||
// goroutine/锁竞争的定位全靠此端点。早于重负载初始化启动,连 seed/预热阶段也可剖析。
|
||
startDebugServer(ctx, cfg.DebugAddr, metricRegistry, logger)
|
||
|
||
// 持久化依赖:先迁移 schema,再建立连接。auth key 与业务事实落 PostgreSQL,
|
||
// Redis 只承载可重建的短 TTL 状态、缓存、计数器和限流。
|
||
// 依赖由 deploy/docker-compose.yml 启动;连不上则启动失败(开发期须先 docker compose up)。
|
||
migrationStatus, err := postgres.MigrateAndStatus(cfg.PostgresDSN)
|
||
if err != nil {
|
||
return fmt.Errorf("postgres migrate: %w", err)
|
||
}
|
||
logger.Info("PostgreSQL schema migrated",
|
||
zap.Uint("schema_version", migrationStatus.Version),
|
||
zap.Bool("schema_dirty", migrationStatus.Dirty),
|
||
zap.Bool("schema_empty", migrationStatus.Empty),
|
||
)
|
||
blobRuntimeLock, err := postgres.AcquireBlobRuntimeLock(ctx, cfg.PostgresDSN)
|
||
if err != nil {
|
||
return fmt.Errorf("acquire blob runtime lock: %w", err)
|
||
}
|
||
defer func() {
|
||
if err := blobRuntimeLock.Close(); err != nil {
|
||
logger.Error("release blob runtime lock", zap.Error(err))
|
||
}
|
||
}()
|
||
pool, err := postgres.Open(ctx, cfg.PostgresDSN,
|
||
postgres.WithMaxConns(cfg.PostgresMaxConns),
|
||
postgres.WithMinConns(cfg.PostgresMinConns),
|
||
)
|
||
if err != nil {
|
||
return fmt.Errorf("connect postgres: %w", err)
|
||
}
|
||
defer pool.Close()
|
||
metricRegistry.AddGaugeProvider(func() []obsmetrics.GaugeSample {
|
||
stat := pool.Stat()
|
||
return []obsmetrics.GaugeSample{
|
||
{Name: "telesrv_postgres_pool_connections", Labels: []obsmetrics.Label{{Name: "state", Value: "total"}}, Value: float64(stat.TotalConns())},
|
||
{Name: "telesrv_postgres_pool_connections", Labels: []obsmetrics.Label{{Name: "state", Value: "acquired"}}, Value: float64(stat.AcquiredConns())},
|
||
{Name: "telesrv_postgres_pool_connections", Labels: []obsmetrics.Label{{Name: "state", Value: "idle"}}, Value: float64(stat.IdleConns())},
|
||
{Name: "telesrv_postgres_pool_connections", Labels: []obsmetrics.Label{{Name: "state", Value: "constructing"}}, Value: float64(stat.ConstructingConns())},
|
||
{Name: "telesrv_postgres_pool_max_connections", Value: float64(stat.MaxConns())},
|
||
{Name: "telesrv_postgres_pool_acquire_count", Value: float64(stat.AcquireCount())},
|
||
{Name: "telesrv_postgres_pool_acquire_wait_seconds", Value: stat.AcquireDuration().Seconds()},
|
||
{Name: "telesrv_postgres_pool_empty_acquire_count", Value: float64(stat.EmptyAcquireCount())},
|
||
{Name: "telesrv_postgres_pool_canceled_acquire_count", Value: float64(stat.CanceledAcquireCount())},
|
||
}
|
||
})
|
||
|
||
var telegramLoginService *telegramloginapp.Service
|
||
var telegramLoginIDTokens *telegramloginapp.IDTokenIssuer
|
||
var telegramLoginHTTPHandler http.Handler
|
||
if cfg.TelegramLoginEnabled {
|
||
codeSealer, err := telegramloginapp.LoadCodeSealer(cfg.TelegramLoginCodeKeysFile)
|
||
if err != nil {
|
||
return fmt.Errorf("load telegram login code keys: %w", err)
|
||
}
|
||
clientSecretPepper, err := telegramloginapp.LoadClientSecretPepper(cfg.TelegramLoginSecretPepperFile)
|
||
if err != nil {
|
||
return fmt.Errorf("load telegram login client-secret pepper: %w", err)
|
||
}
|
||
signingKeys, err := telegramloginapp.LoadSigningKeyRing(cfg.TelegramLoginSigningKeysFile, time.Now)
|
||
if err != nil {
|
||
return fmt.Errorf("load telegram login signing keys: %w", err)
|
||
}
|
||
telegramLoginService, err = telegramloginapp.NewService(postgres.NewTelegramLoginStore(pool), codeSealer, telegramloginapp.Config{
|
||
Issuer: cfg.TelegramLoginIssuer, AppScheme: cfg.PublicAppScheme, AppLinkBase: cfg.PublicAppLinkBase,
|
||
AllowHTTP: cfg.TelegramLoginAllowHTTP,
|
||
ClientSecretPepper: clientSecretPepper,
|
||
SupportedSigningAlgorithms: signingKeys.ActiveAlgorithms(),
|
||
RequestTTL: cfg.TelegramLoginRequestTTL, CodeTTL: cfg.TelegramLoginCodeTTL,
|
||
})
|
||
if err != nil {
|
||
return fmt.Errorf("initialize telegram login service: %w", err)
|
||
}
|
||
telegramLoginIDTokens, err = telegramloginapp.NewIDTokenIssuer(signingKeys, telegramloginapp.IDTokenIssuerConfig{
|
||
Issuer: cfg.TelegramLoginIssuer, TTL: cfg.TelegramLoginIDTokenTTL, AllowHTTP: cfg.TelegramLoginAllowHTTP,
|
||
})
|
||
if err != nil {
|
||
return fmt.Errorf("initialize telegram login ID-token issuer: %w", err)
|
||
}
|
||
}
|
||
|
||
rdb, err := redisstore.Open(ctx, cfg.RedisAddr, cfg.RedisPassword, cfg.RedisDB)
|
||
if err != nil {
|
||
return fmt.Errorf("connect redis: %w", err)
|
||
}
|
||
defer func() { _ = rdb.Close() }()
|
||
metricRegistry.AddGaugeProvider(func() []obsmetrics.GaugeSample {
|
||
stat := rdb.PoolStats()
|
||
return []obsmetrics.GaugeSample{
|
||
{Name: "telesrv_redis_pool_connections", Labels: []obsmetrics.Label{{Name: "state", Value: "total"}}, Value: float64(stat.TotalConns)},
|
||
{Name: "telesrv_redis_pool_connections", Labels: []obsmetrics.Label{{Name: "state", Value: "idle"}}, Value: float64(stat.IdleConns)},
|
||
{Name: "telesrv_redis_pool_pending_requests", Value: float64(stat.PendingRequests)},
|
||
{Name: "telesrv_redis_pool_hits", Value: float64(stat.Hits)},
|
||
{Name: "telesrv_redis_pool_misses", Value: float64(stat.Misses)},
|
||
{Name: "telesrv_redis_pool_timeouts", Value: float64(stat.Timeouts)},
|
||
{Name: "telesrv_redis_pool_wait_count", Value: float64(stat.WaitCount)},
|
||
{Name: "telesrv_redis_pool_wait_seconds", Value: time.Duration(stat.WaitDurationNs).Seconds()},
|
||
}
|
||
})
|
||
logger.Info("persistence dependencies ready", zap.String("redis", cfg.RedisAddr))
|
||
if cfg.TelegramLoginEnabled {
|
||
telegramLoginHTTPHandler, err = telegramloginhttp.NewHandler(telegramloginhttp.Config{
|
||
Service: telegramLoginService, Tokens: telegramLoginIDTokens,
|
||
BotUsernames: postgres.NewUserStore(pool),
|
||
Limiter: redisstore.NewRateLimiter(rdb), AppName: cfg.PublicAppName,
|
||
Logger: logger.Named("telegram-login-http"), TrustedProxyCIDRs: cfg.TelegramLoginTrustedProxyCIDRs,
|
||
AllowHTTP: cfg.TelegramLoginAllowHTTP,
|
||
})
|
||
if err != nil {
|
||
return fmt.Errorf("initialize telegram login HTTP provider: %w", err)
|
||
}
|
||
logger.Info("Telegram Login/OIDC provider enabled",
|
||
zap.String("issuer", telegramLoginIDTokens.Issuer()),
|
||
zap.Strings("signing_algorithms", telegramLoginIDTokens.SupportedAlgorithms()))
|
||
}
|
||
|
||
authKeyStore := postgres.NewAuthKeyStore(pool)
|
||
authKeyGetBatchStore, err := postgres.NewBatchedAuthKeyStore(
|
||
authKeyStore,
|
||
postgres.AuthKeyGetBatchConfig{
|
||
MaxSize: cfg.AuthKeyGetBatchMax, MaxWait: cfg.AuthKeyGetBatchWait,
|
||
QueueSize: cfg.AuthKeyGetBatchQueue, QueryTimeout: cfg.AuthKeyGetBatchTimeout,
|
||
},
|
||
)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
defer authKeyGetBatchStore.Close()
|
||
authKeySessionLayerStore, err := postgres.NewBatchedAuthKeySessionLayerStore(
|
||
authKeyStore,
|
||
postgres.AuthKeySessionLayerBatchConfig{
|
||
MaxSize: cfg.LayerAdvanceBatchMax, MaxWait: cfg.LayerAdvanceBatchWait,
|
||
QueueSize: cfg.LayerAdvanceBatchQueue, QueryTimeout: cfg.LayerAdvanceBatchTimeout,
|
||
},
|
||
)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
defer authKeySessionLayerStore.Close()
|
||
userStore := postgres.NewUserStore(pool)
|
||
authzStore := postgres.NewAuthorizationStore(pool)
|
||
adminStore := postgres.NewAdminStore(pool)
|
||
updateStateStore := postgres.NewUpdateStateStore(pool)
|
||
updateEventStore := postgres.NewUpdateEventStore(pool, postgres.WithUpdateEventLogger(logger.Named("store").Named("updates")))
|
||
phoneChangeStore := postgres.NewPhoneChangeStore(pool)
|
||
readModelVersionBatchStore, err := storepkg.NewBatchedReadModelVersionStore(
|
||
postgres.NewReadModelVersionStore(pool),
|
||
storepkg.ReadModelVersionBatchConfig{
|
||
MaxKeys: cfg.ReadModelVersionBatchMaxKeys, MaxWait: cfg.ReadModelVersionBatchWait,
|
||
QueueSize: cfg.ReadModelVersionBatchQueue, QueryTimeout: cfg.ReadModelVersionBatchTimeout,
|
||
},
|
||
)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
defer readModelVersionBatchStore.Close()
|
||
readModelVersionStore := storepkg.NewCachedReadModelVersionStore(
|
||
readModelVersionBatchStore,
|
||
0,
|
||
cfg.ReadModelVersionCacheMaxEntries,
|
||
)
|
||
dialogListSnapshotCache := redisstore.NewDialogListSnapshotCache(rdb, cfg.DialogListSnapshotRedisTTL)
|
||
activeChannelIDsPageCache := redisstore.NewActiveChannelIDsPageCache(rdb, cfg.ActiveChannelIDsRedisTTL)
|
||
dispatchOutboxStore := postgres.NewDispatchOutboxStore(pool, postgres.WithLeaseTimeout(cfg.OutboxLeaseTimeout))
|
||
bootstrapUpdateStore, err := postgres.NewBatchedBootstrapUpdateJobStore(
|
||
postgres.NewBootstrapUpdateJobStore(pool),
|
||
postgres.BootstrapReadyBatchConfig{
|
||
MaxSize: cfg.BootstrapReadyBatchMax, MaxWait: cfg.BootstrapReadyBatchWait,
|
||
QueueSize: cfg.BootstrapReadyBatchQueue, QueryTimeout: cfg.BootstrapReadyBatchTimeout,
|
||
Metrics: metricRegistry,
|
||
},
|
||
)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
defer bootstrapUpdateStore.Close()
|
||
botAPIUpdateStore := postgres.NewBotAPIUpdateStore(pool)
|
||
botCallbackStore := redisstore.NewBotCallbackRegistryStore(rdb)
|
||
ephemeralStore := redisstore.NewEphemeralMessageStore(rdb)
|
||
ephemeralReportStore := postgres.NewEphemeralReportStore(pool)
|
||
welcomeMessageStore := postgres.NewWelcomeMessageStore(pool)
|
||
moderationReportStore := postgres.NewModerationReportStore(pool)
|
||
authDeliveryReportStore := postgres.NewAuthDeliveryReportStore(pool)
|
||
clientTelemetryStore := postgres.NewClientTelemetryStore(pool)
|
||
boxIDAllocator := redisstore.NewBoxIDAllocator(rdb, postgres.NewMessageBoxCounterSource(pool))
|
||
channelIDAllocator := redisstore.NewChannelIDAllocator(rdb, postgres.NewChannelIDCounterSource(pool))
|
||
channelMessageIDAllocator := redisstore.NewChannelMessageIDAllocator(rdb, postgres.NewChannelMessageIDCounterSource(pool))
|
||
reverseContactStore, err := storepkg.NewBatchedReverseContactStore(
|
||
postgres.NewContactStore(pool),
|
||
storepkg.ReverseContactBatchConfig{
|
||
MaxPairs: cfg.ContactReverseBatchMaxPairs, MaxWait: cfg.ContactReverseBatchWait,
|
||
QueueSize: cfg.ContactReverseBatchQueue, QueryTimeout: cfg.ContactReverseBatchTimeout,
|
||
},
|
||
)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
defer reverseContactStore.Close()
|
||
contactStore := userprojection.NewCachedContactStoreWithMaxViewers(
|
||
reverseContactStore,
|
||
0,
|
||
cfg.ContactSnapshotCacheMaxViewers,
|
||
)
|
||
dialogStore := postgres.NewDialogStore(pool)
|
||
chatlistStore := postgres.NewChatlistStore(pool)
|
||
messageStore := postgres.NewMessageStore(pool,
|
||
postgres.WithMessageAllocators(boxIDAllocator),
|
||
postgres.WithMessageLogger(logger.Named("store").Named("messages")))
|
||
broadcastStore := postgres.NewBroadcastStore(pool)
|
||
broadcastService := broadcastapp.NewService(broadcastStore,
|
||
broadcastapp.WithMessageSender(messageStore),
|
||
broadcastapp.WithLogger(logger.Named("broadcast")))
|
||
// 共享频道行/成员缓存 + 统一 read-model LISTEN/NOTIFY 实时失效:消除高频「逐 RPC
|
||
// 解析频道/成员」在客户端重连同步突发里重复读同一行的放大。
|
||
channelRowCache := postgres.NewChannelRowCache(cfg.ChannelRowCacheMaxEntries)
|
||
channelTopMessageCache := postgres.NewChannelTopMessageCache(cfg.ChannelTopMessageCacheMaxEntries)
|
||
channelMemberCache := postgres.NewChannelMemberCache(cfg.ChannelMemberCacheMaxEntries)
|
||
channelDialogCache := postgres.NewChannelDialogCache(cfg.ChannelDialogCacheMaxEntries)
|
||
channelDifferenceCache := postgres.NewChannelDifferenceBaseCache(
|
||
cfg.ChannelDifferenceCacheMaxEntries,
|
||
cfg.ChannelDifferenceCacheMaxBytes,
|
||
cfg.ChannelDifferenceCacheTTL,
|
||
)
|
||
channelBoostCache := postgres.NewChannelBoostCache(cfg.ChannelBoostCacheMaxEntries, cfg.ChannelBoostCacheTTL)
|
||
channelStore := postgres.NewChannelStore(pool,
|
||
postgres.WithChannelAllocators(channelIDAllocator, channelMessageIDAllocator),
|
||
postgres.WithChannelLogger(logger.Named("store").Named("channels")),
|
||
postgres.WithChannelRowCache(channelRowCache),
|
||
postgres.WithChannelTopMessageCache(channelTopMessageCache),
|
||
postgres.WithChannelMemberCache(channelMemberCache),
|
||
postgres.WithChannelDialogCache(channelDialogCache),
|
||
postgres.WithChannelDifferenceBaseCache(channelDifferenceCache),
|
||
postgres.WithChannelBoostCache(channelBoostCache))
|
||
activeChannelIDsPageBatcher, err := postgres.NewActiveChannelIDsPageBatcher(
|
||
channelStore,
|
||
postgres.ActiveChannelIDsBatchConfig{
|
||
MaxSize: cfg.ActiveChannelIDsBatchMax, MaxWait: cfg.ActiveChannelIDsBatchWait,
|
||
QueueSize: cfg.ActiveChannelIDsBatchQueue, QueryTimeout: cfg.ActiveChannelIDsBatchTimeout,
|
||
Metrics: metricRegistry,
|
||
},
|
||
)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
defer activeChannelIDsPageBatcher.Close()
|
||
metricRegistry.AddGaugeProvider(func() []obsmetrics.GaugeSample {
|
||
snapshot := channelDifferenceCache.Snapshot()
|
||
return []obsmetrics.GaugeSample{
|
||
{Name: "telesrv_channel_difference_cache_entries", Value: float64(snapshot.Entries)},
|
||
{Name: "telesrv_channel_difference_cache_weight_bytes", Value: float64(snapshot.Weight)},
|
||
{Name: "telesrv_channel_difference_cache_hits", Value: float64(snapshot.Hits)},
|
||
{Name: "telesrv_channel_difference_cache_misses", Value: float64(snapshot.Misses)},
|
||
{Name: "telesrv_channel_difference_cache_loads", Value: float64(snapshot.Loads)},
|
||
{Name: "telesrv_channel_difference_cache_load_errors", Value: float64(snapshot.LoadErrors)},
|
||
}
|
||
})
|
||
communityCatalogCache := postgres.NewCommunityCatalogCache()
|
||
communityStore := postgres.NewCommunityStore(pool, channelIDAllocator, channelMessageIDAllocator,
|
||
postgres.WithCommunityCatalogCache(communityCatalogCache))
|
||
pollStore := postgres.NewPollStore(pool)
|
||
mediaStore := postgres.NewMediaStore(pool)
|
||
// 头像投影缓存:所有 projector 共用 owner→头像正/负 LRU。profile_photo NOTIFY
|
||
// 精确失效负责正常新鲜度,长 TTL 只覆盖漏通知,避免登录 ramp 周期性重查稳定负值。
|
||
cachedPhotos := userprojection.NewCachedPhotoProviderWithMaxEntries(
|
||
mediaStore,
|
||
cfg.ProfilePhotoCacheTTL,
|
||
cfg.ProfilePhotoCacheMaxEntries,
|
||
)
|
||
privacyStore := privacyapp.NewCachedPrivacyStore(postgres.NewPrivacyStore(pool), 0)
|
||
storyStore := postgres.NewStoryStore(pool)
|
||
// Transient upload-part scratch storage always stays on local disk
|
||
// (TELESRV_BLOB_DIR) regardless of the permanent blob backend below --
|
||
// one S3 round trip per ~512KB chunk isn't worth it for data deleted
|
||
// within minutes of assembly.
|
||
localBlobFS, err := filesapp.NewLocalFS(cfg.BlobDir)
|
||
if err != nil {
|
||
return fmt.Errorf("init local blob dir: %w", err)
|
||
}
|
||
// Construct the s3 backend whenever it's configured, even if it isn't
|
||
// the active one right now: this is what keeps files readable/deletable
|
||
// after switching TELESRV_BLOB_BACKEND away from s3, as long as the old
|
||
// bucket/credentials are still reachable. Only a genuinely active s3
|
||
// backend failing to initialize is fatal to boot.
|
||
var s3Backend *filesapp.S3FS
|
||
if cfg.S3Endpoint != "" && cfg.S3Bucket != "" {
|
||
b, err := filesapp.NewS3FS(ctx, cfg.S3Endpoint, cfg.S3AccessKeyID, cfg.S3SecretAccessKey, cfg.S3Bucket, cfg.S3Region, cfg.S3UseSSL, cfg.S3PathStyle)
|
||
switch {
|
||
case err != nil && cfg.BlobBackendKind == "s3":
|
||
return fmt.Errorf("init s3 blob backend: %w", err)
|
||
case err != nil:
|
||
logger.Warn("s3 blob backend configured but failed to initialize; files previously written to s3 will be unreadable until this is fixed",
|
||
zap.String("endpoint", cfg.S3Endpoint), zap.String("bucket", cfg.S3Bucket), zap.Error(err))
|
||
default:
|
||
s3Backend = b
|
||
}
|
||
}
|
||
var blobBackend filesapp.BlobBackend = localBlobFS
|
||
var additionalBlobBackend filesapp.BlobBackend
|
||
var spaceGuard filesapp.SpaceGuard = filesapp.NoopSpaceGuard{}
|
||
if cfg.BlobBackendKind == "s3" {
|
||
if s3Backend == nil {
|
||
return fmt.Errorf("s3 blob backend not initialized (check TELESRV_S3_* config)")
|
||
}
|
||
blobBackend = s3Backend
|
||
additionalBlobBackend = localBlobFS
|
||
logger.Info("blob backend ready", zap.String("backend", "s3"), zap.String("bucket", cfg.S3Bucket), zap.String("endpoint", cfg.S3Endpoint))
|
||
if cfg.StorageLowSpaceGuardEnable && cfg.StorageMaxTotalBytes > 0 {
|
||
s3Guard := filesapp.NewS3BudgetSpaceGuard(cfg.StorageMaxTotalBytes)
|
||
spaceGuard = s3Guard
|
||
go filesapp.NewS3DiskUsageWorker(s3Guard, mediaStore, cfg.StorageUsageRefreshInterval, logger.Named("files").Named("diskusage")).Run(ctx)
|
||
}
|
||
} else {
|
||
logger.Info("blob backend ready", zap.String("backend", "localfs"), zap.String("dir", cfg.BlobDir))
|
||
if s3Backend != nil {
|
||
additionalBlobBackend = s3Backend
|
||
logger.Info("s3 blob backend also configured; kept reachable for files written while it was the active backend",
|
||
zap.String("bucket", cfg.S3Bucket))
|
||
}
|
||
if cfg.StorageLowSpaceGuardEnable && cfg.StorageMinFreeBytes > 0 {
|
||
localGuard := filesapp.NewLocalDiskSpaceGuard(cfg.StorageMinFreeBytes)
|
||
spaceGuard = localGuard
|
||
go filesapp.NewLocalDiskUsageWorker(localGuard, cfg.BlobDir, cfg.StorageUsageRefreshInterval, logger.Named("files").Named("diskusage")).Run(ctx)
|
||
}
|
||
}
|
||
gifCatalogStore := postgres.NewGifCatalogStore(pool)
|
||
filesService := filesapp.NewService(mediaStore, blobBackend, cfg.DC,
|
||
filesapp.WithLogger(logger),
|
||
filesapp.WithGifCatalog(gifCatalogStore),
|
||
filesapp.WithGifSeedDir(cfg.GifSeedDir),
|
||
filesapp.WithUploadPartQuota(domain.UploadPartQuota{
|
||
MaxBytes: cfg.UploadInFlightMaxBytes,
|
||
MaxParts: cfg.UploadInFlightMaxParts,
|
||
MaxFiles: cfg.UploadInFlightMaxFiles,
|
||
}),
|
||
filesapp.WithUploadPartBackend(localBlobFS),
|
||
filesapp.WithAdditionalBlobBackend(additionalBlobBackend),
|
||
filesapp.WithSpaceGuard(spaceGuard),
|
||
filesapp.WithMaxUploadFileBytes(cfg.StorageMaxUploadFileBytes),
|
||
filesapp.WithStorageRetentionAges(cfg.StorageRetentionMaxAge, cfg.StorageRetentionMaxAgeByCategory, cfg.StorageRetentionMaxAgeAvatar),
|
||
filesapp.WithStorageMaxTotalBytes(cfg.StorageMaxTotalBytes),
|
||
filesapp.WithSecretChatDeleteFileAfterDownload(cfg.SecretChatDeleteFileAfterDownload),
|
||
filesapp.WithMapboxMapTiles(cfg.MapboxToken, cfg.MapTileCacheDir),
|
||
externalMediaOption(cfg),
|
||
webPagePreviewOption(cfg),
|
||
)
|
||
if cfg.MapboxToken != "" {
|
||
logger.Info("map tile thumbnail proxy enabled", zap.String("provider", "mapbox"), zap.String("cache_dir", cfg.MapTileCacheDir))
|
||
}
|
||
if cfg.ExternalMediaEnable {
|
||
logger.Info("external link media fetching enabled", zap.Int64("max_bytes", cfg.ExternalMediaMaxBytes), zap.Int("rate_per_min", cfg.ExternalMediaRatePerMin))
|
||
}
|
||
if cfg.WebPagePreviewEnable {
|
||
logger.Info("link preview fetching enabled", zap.Int64("max_bytes", cfg.WebPagePreviewMaxBytes), zap.Int("rate_per_min", cfg.WebPagePreviewRatePerMin))
|
||
}
|
||
if stats, err := filesService.SeedMedia(ctx, cfg.StickerSeedDir, cfg.StickerSeedMaxSets); err != nil {
|
||
return fmt.Errorf("seed media: %w", err)
|
||
} else if !stats.Skipped {
|
||
logger.Info("media seed import complete",
|
||
zap.String("dir", cfg.StickerSeedDir),
|
||
zap.Int("reactions", stats.Reactions),
|
||
zap.Int("sticker_sets", stats.StickerSets),
|
||
zap.Int("effects", stats.Effects),
|
||
zap.Int("documents", stats.Documents),
|
||
zap.Int("blobs", stats.Blobs),
|
||
)
|
||
}
|
||
if stats, err := filesService.SeedGifs(ctx, cfg.GifSeedDir); err != nil {
|
||
return fmt.Errorf("seed gifs: %w", err)
|
||
} else if stats.Imported > 0 || stats.Failed > 0 {
|
||
logger.Info("gif catalog seed import complete",
|
||
zap.String("dir", cfg.GifSeedDir),
|
||
zap.Int("imported", stats.Imported),
|
||
zap.Int("skipped", stats.Skipped),
|
||
zap.Int("failed", stats.Failed),
|
||
)
|
||
}
|
||
if stats, err := filesService.SeedPremiumPromo(ctx, cfg.PremiumPromoSeedDir); err != nil {
|
||
return fmt.Errorf("seed premium promo: %w", err)
|
||
} else if !stats.Skipped {
|
||
logger.Info("Premium promo 视频种子导入完成",
|
||
zap.String("dir", cfg.PremiumPromoSeedDir),
|
||
zap.Int("videos", stats.Videos),
|
||
zap.Int("blobs", stats.Blobs),
|
||
)
|
||
}
|
||
if stats, err := filesService.SeedAppearance(ctx); err != nil {
|
||
return fmt.Errorf("seed appearance: %w", err)
|
||
} else if !stats.Skipped {
|
||
logger.Info("appearance seed import complete",
|
||
zap.String("source", "default-seed"),
|
||
zap.Int("wallpapers", stats.Wallpapers),
|
||
zap.Int("documents", stats.Documents),
|
||
zap.Int("blobs", stats.Blobs),
|
||
)
|
||
}
|
||
// The official system account (777000) mirrors the operator's own Server
|
||
// Settings -> Server identity, when set: same "default unless
|
||
// configured" contract as the client-facing /owpengram/server-info
|
||
// endpoint, just applied to this one built-in account's display name
|
||
// and avatar instead of what's shown to a client adding the server.
|
||
identityStore := identity.NewStore(cfg.IdentityDir)
|
||
serverIdentity, err := identityStore.Get()
|
||
if err != nil {
|
||
return fmt.Errorf("read server identity: %w", err)
|
||
}
|
||
domain.SetOfficialSystemUserDisplayName(serverIdentity.Name)
|
||
var customSystemIcon []byte
|
||
if iconData, _, ok := identityStore.Icon(); ok {
|
||
customSystemIcon = iconData
|
||
}
|
||
if usingCustom, err := filesService.SeedOfficialSystemAvatar(ctx, customSystemIcon); err != nil {
|
||
return fmt.Errorf("seed official system avatar: %w", err)
|
||
} else if usingCustom {
|
||
logger.Info("official system account avatar seed import complete (custom Server identity icon)", zap.Int64("photo_id", domain.OfficialSystemUserPhotoID))
|
||
}
|
||
if seeded, err := filesService.SeedBotFatherAvatar(ctx); err != nil {
|
||
return fmt.Errorf("seed botfather avatar: %w", err)
|
||
} else if seeded {
|
||
logger.Info("BotFather avatar seed import complete", zap.Int64("photo_id", domain.BotFatherUserPhotoID))
|
||
}
|
||
if seeded, err := filesService.SeedStickersBotAvatar(ctx); err != nil {
|
||
return fmt.Errorf("seed stickers bot avatar: %w", err)
|
||
} else if seeded {
|
||
logger.Info("@Stickers avatar seed import complete", zap.Int64("photo_id", domain.StickersBotUserPhotoID))
|
||
}
|
||
if seeded, err := filesService.SeedChatBotAvatar(ctx); err != nil {
|
||
return fmt.Errorf("seed chatbot avatar: %w", err)
|
||
} else if seeded {
|
||
logger.Info("@ChatBot avatar seed import complete", zap.Int64("photo_id", domain.ChatBotUserPhotoID))
|
||
}
|
||
if seeded, err := filesService.SeedVerifyBotAvatar(ctx); err != nil {
|
||
return fmt.Errorf("seed verifybot avatar: %w", err)
|
||
} else if seeded {
|
||
logger.Info("@verifybot avatar seed import complete", zap.Int64("photo_id", domain.VerifyBotUserPhotoID))
|
||
}
|
||
if stats, err := filesService.WarmCaches(ctx); err != nil {
|
||
logger.Warn("media resource cache warmup failed", zap.Error(err))
|
||
} else if stats.StickerSets > 0 || stats.Documents > 0 || stats.Blobs > 0 {
|
||
logger.Info("media resource cache warmup complete",
|
||
zap.Int("sticker_sets", stats.StickerSets),
|
||
zap.Int("documents", stats.Documents),
|
||
zap.Int("blobs", stats.Blobs),
|
||
)
|
||
}
|
||
// Default emoji status system set: curated from animated_emoji (idempotent —
|
||
// an already-seeded install picks this back up automatically after restart);
|
||
// without it, premium users' status picker would be empty.
|
||
if count, created, err := filesService.EnsureDefaultEmojiStatusSet(ctx); err != nil {
|
||
logger.Warn("default emoji status system set assembly failed", zap.Error(err))
|
||
} else if created {
|
||
logger.Info("default emoji status system set assembled", zap.Int("documents", count))
|
||
}
|
||
langPackStore := postgres.NewLangPackStore(pool)
|
||
passwordStore := postgres.NewPasswordStore(pool)
|
||
helpStore := postgres.NewHelpStore(pool)
|
||
aiComposeStore := postgres.NewAIComposeStore(pool)
|
||
tempAuthKeyStore := postgres.NewTempAuthKeyBindingStore(pool)
|
||
inlineRegistryStore := redisstore.NewInlineRegistryStore(rdb)
|
||
codeStore := redisstore.NewCodeStore(rdb)
|
||
authDeliveryReportService := authdiagnosticsapp.NewService(codeStore, authDeliveryReportStore)
|
||
clientTelemetryService := clienttelemetryapp.NewService(clientTelemetryStore)
|
||
rateLimiter := redisstore.NewRateLimiter(rdb)
|
||
activeSessions := mtprotoedge.NewSessionManager(logger.Named("mtprotoedge").Named("sessions"))
|
||
adminService := adminapp.NewService(adminapp.Dependencies{
|
||
Commands: adminStore,
|
||
Restrictions: adminStore,
|
||
})
|
||
userProjectionFacts := userprojection.NewDurableUserProjectionFacts(
|
||
adminService,
|
||
readModelVersionStore,
|
||
cfg.UserProjectionFactCacheMaxEntries,
|
||
)
|
||
retentionWorker := maintenance.NewRetentionWorker(dispatchOutboxStore, tempAuthKeyStore, logger.Named("maintenance").Named("retention"),
|
||
cfg.UpdateEventRetention,
|
||
cfg.RetentionInterval,
|
||
cfg.RetentionBatch,
|
||
).WithDispatchOutboxPoisonPolicy(cfg.OutboxPoisonRetention, cfg.OutboxPoisonCleanupInterval).
|
||
WithBotAPIUpdateRetention(botAPIUpdateStore, cfg.BotAPIUpdateRetention).
|
||
WithAuthKeySessionLayerRetention(authKeyStore).
|
||
WithLoginCodeDeliveryRetention(messageStore).
|
||
WithClientTelemetryRetention(clientTelemetryStore, 30*24*time.Hour).
|
||
WithAuthDeliveryReportRetention(authDeliveryReportStore, 30*24*time.Hour).
|
||
WithModerationRetention(moderationReportStore).
|
||
WithUserUpdateRetention(updateEventStore).
|
||
WithChannelUpdateRetention(channelStore).
|
||
WithOrphanAuthKeyRetention(authKeyStore, activeSessions, cfg.OrphanAuthKeyRetention)
|
||
// TELESRV_STORAGE_RETENTION_MODE is a single 3-way switch: at most one of
|
||
// the orphan-only (safe) and hard (age-based, ignores live references)
|
||
// media sweeps is ever wired in, matching "off"/"orphan"/"hard".
|
||
// The worker's own maxAge parameter only drives how often the sweep
|
||
// ticks (internal/app/maintenance.RetentionWorker.mediaRetentionInterval)
|
||
// -- the real per-category cutoff math lives entirely in
|
||
// files.Service (WithStorageRetentionAges above). Passing the raw shared
|
||
// default here would tick as slowly as a 30-day default even when a
|
||
// TELESRV_STORAGE_RETENTION_MAX_AGE_<CATEGORY> override asks for a much
|
||
// shorter age (or, if the shared default is 0 -- "disabled by default,
|
||
// only specific categories opt in" -- would disable the sweep outright,
|
||
// since a 0 maxAge here used to gate the whole sweep off). Use the
|
||
// fastest positive age across the shared default and every override
|
||
// instead, so the ticker -- and the sweep-enabled gate -- reflect
|
||
// whatever is actually configured to run soonest.
|
||
fastestRetentionAge := fastestPositiveDuration(cfg.StorageRetentionMaxAge, cfg.StorageRetentionMaxAgeAvatar)
|
||
for _, age := range cfg.StorageRetentionMaxAgeByCategory {
|
||
fastestRetentionAge = fastestPositiveDuration(fastestRetentionAge, age)
|
||
}
|
||
switch cfg.StorageRetentionMode {
|
||
case config.StorageRetentionModeOrphan:
|
||
retentionWorker = retentionWorker.WithOrphanedMediaRetention(filesService, fastestRetentionAge)
|
||
case config.StorageRetentionModeHard:
|
||
retentionWorker = retentionWorker.WithHardMediaRetention(filesService, fastestRetentionAge)
|
||
}
|
||
// Active eviction is independent of TELESRV_STORAGE_RETENTION_MODE (can
|
||
// run even when that's "off") and reuses the same media sweep ticker.
|
||
retentionWorker = retentionWorker.WithStorageEviction(filesService, cfg.StorageEvictionEnable)
|
||
// retentionWorker.Run itself isn't started here -- see the
|
||
// filesService.SetRetentionPurgeNotifier call below, which must happen
|
||
// first so the worker's very first (synchronous) sweep tick can't purge
|
||
// blobs before there's anywhere to send the purge notice.
|
||
go filesapp.NewUploadPartGCWorker(filesService, logger.Named("files").Named("upload_gc"),
|
||
cfg.UploadPartTTL,
|
||
cfg.UploadPartGCInterval,
|
||
cfg.UploadPartGCBatch,
|
||
).Run(ctx)
|
||
langPackService := langpack.NewService(langPackStore, langpack.WithPublicBaseURL(cfg.PublicBaseURL))
|
||
privacyService := privacyapp.NewService(privacyStore, contactStore)
|
||
contactsService := contacts.NewService(contactStore, userStore).Configure(
|
||
contacts.WithPhotoProvider(cachedPhotos),
|
||
contacts.WithPrivacyEvaluator(privacyService),
|
||
contacts.WithAccountFreezeProvider(userProjectionFacts),
|
||
contacts.WithReadModelVersions(readModelVersionStore),
|
||
contacts.WithHideThirdPartyVerification(cfg.HideThirdPartyVerification),
|
||
)
|
||
if seeded, err := langPackService.SeedDirectory(ctx, cfg.LangPackSeedDir); err != nil {
|
||
return fmt.Errorf("seed langpack: %w", err)
|
||
} else if seeded > 0 {
|
||
logger.Info("langpack seed import complete", zap.String("dir", cfg.LangPackSeedDir), zap.Int("strings", seeded))
|
||
}
|
||
// Country dialing code catalog: idempotently upsert the full official set
|
||
// (~235 countries) baked into the catalog into PG, overriding the migration's
|
||
// 2-country (US/CN) seed default. Otherwise the countries table is non-empty,
|
||
// ListCountries returns just those 2 rows and bypasses the catalog, so the
|
||
// login page/number formatting would only ever show 2 countries. An upsert
|
||
// failure only warns and doesn't block startup (falls back to the old 2 rows).
|
||
if cs := catalog.Countries().Countries; len(cs) > 0 {
|
||
if err := helpStore.UpsertCountries(ctx, cs); err != nil {
|
||
logger.Warn("country dialing code seed import failed", zap.Error(err))
|
||
} else {
|
||
logger.Info("country dialing code seed import complete", zap.Int("countries", len(cs)))
|
||
}
|
||
}
|
||
|
||
botStore := postgres.NewBotStore(pool)
|
||
// userCache 与 users 服务共享同一实例:bot 元数据写入(version bump)后必须
|
||
// 失效缓存,否则 TTL 内 getUsers 回旧 first_name/旧 bot_info_version。
|
||
userCache := redisstore.NewUserCache(rdb, redisstore.DefaultUserCacheTTL)
|
||
accountLifecycleStore := postgres.NewAccountLifecycleStore(pool)
|
||
accountOptions := []account.ServiceOption{
|
||
account.WithReactionSettings(passwordStore),
|
||
account.WithAccountSettings(passwordStore),
|
||
account.WithNotifySettings(passwordStore),
|
||
account.WithStickerCollections(passwordStore),
|
||
account.WithUserStickerSets(passwordStore),
|
||
account.WithSavedMusic(passwordStore),
|
||
account.WithBusinessAutomation(passwordStore),
|
||
account.WithUsers(userStore),
|
||
account.WithPhoneChange(phoneChangeStore, authzStore, codeStore, userCache, cfg.DevAuthCode, cfg.AuthCodeTTL, cfg.AuthCodeMaxAttempts),
|
||
account.WithAccountLifecycle(accountLifecycleStore),
|
||
account.WithPublicBaseURL(cfg.PublicBaseURL),
|
||
account.WithEmailSignup(cfg.EmailSignupEnable),
|
||
account.WithEmailSignupPhonePrefixes(cfg.EmailSignupPhonePrefixes),
|
||
}
|
||
var webhookSender otpdelivery.Sender
|
||
// EmailSignupEnable also needs a sender: "email as identity" sign-up/login
|
||
// codes go out on the same loginEmailSender channel as LoginEmailEnable
|
||
// (see auth.Service.emailSignupEnabled / account.sendChangePhoneCodeByEmail),
|
||
// so it must gate provider construction identically or those flows silently
|
||
// get a nil sender when LoginEmailEnable itself is off.
|
||
if cfg.PhoneCodeDeliveryProvider == "webhook" ||
|
||
((cfg.LoginEmailEnable || cfg.EmailSignupEnable) && cfg.EmailCodeDeliveryProvider == "webhook") {
|
||
configured, err := otpwebhook.New(otpwebhook.Config{
|
||
URL: cfg.OTPWebhookURL,
|
||
Secret: cfg.OTPWebhookSecret,
|
||
Timeout: cfg.OTPWebhookTimeout,
|
||
Logger: logger.Named("otp").Named("webhook"),
|
||
})
|
||
if err != nil {
|
||
return fmt.Errorf("configure OTP webhook: %w", err)
|
||
}
|
||
webhookSender = configured
|
||
logger.Info("OTP webhook delivery enabled",
|
||
zap.Bool("phone", cfg.PhoneCodeDeliveryProvider == "webhook"),
|
||
zap.Bool("email", (cfg.LoginEmailEnable || cfg.EmailSignupEnable) && cfg.EmailCodeDeliveryProvider == "webhook"))
|
||
}
|
||
var phoneCodeSender otpdelivery.Sender
|
||
if cfg.PhoneCodeDeliveryProvider == "webhook" {
|
||
phoneCodeSender = webhookSender
|
||
accountOptions = append(accountOptions, account.WithPhoneCodeDelivery(phoneCodeSender, cfg.PhoneCodeLength))
|
||
}
|
||
var loginEmailSender otpdelivery.Sender
|
||
if cfg.LoginEmailEnable || cfg.EmailSignupEnable {
|
||
switch cfg.EmailCodeDeliveryProvider {
|
||
case "webhook":
|
||
loginEmailSender = webhookSender
|
||
default:
|
||
loginEmailSender = otpsmtp.New(otpsmtp.Config{
|
||
Host: cfg.SMTPHost,
|
||
Port: cfg.SMTPPort,
|
||
Username: cfg.SMTPUsername,
|
||
Password: cfg.SMTPPassword,
|
||
From: cfg.SMTPFrom,
|
||
FromName: cfg.SMTPFromName,
|
||
AppName: cfg.PublicAppName,
|
||
TLSMode: cfg.SMTPTLSMode,
|
||
Timeout: cfg.SMTPTimeout,
|
||
})
|
||
}
|
||
accountOptions = append(accountOptions,
|
||
account.WithLoginEmailVerification(codeStore, loginEmailSender, cfg.AuthCodeTTL, cfg.AuthCodeMaxAttempts, cfg.LoginEmailCodeLength))
|
||
}
|
||
accountService := account.NewService(passwordStore, accountOptions...)
|
||
botsService := botsapp.NewService(userStore, botStore, messageStore,
|
||
botsapp.WithLogger(logger.Named("bots")),
|
||
botsapp.WithBlockChecker(contactStore),
|
||
botsapp.WithPublicChannelUsernameResolver(channelStore),
|
||
botsapp.WithUserCache(userCache),
|
||
botsapp.WithStickerSetCreator(filesService),
|
||
botsapp.WithGifCatalogSource(filesService),
|
||
botsapp.WithBotAvatarStore(filesService),
|
||
botsapp.WithUserStickerSets(accountService),
|
||
botsapp.WithTelegramLogin(telegramLoginService),
|
||
botsapp.WithDialogRateLimiter(rateLimiter, cfg.VerificationBotRateLimit, cfg.VerificationBotRateWindow),
|
||
botsapp.WithPublicBaseURL(cfg.PublicBaseURL),
|
||
botsapp.WithHideThirdPartyVerification(cfg.HideThirdPartyVerification))
|
||
// The built-in ChatBot and StickersBot are seeded with the default product
|
||
// name in their bio (users.about) and description (bots.description). Align
|
||
// them with the active branding on startup so the seeded "telesrv" text is
|
||
// replaced. SetBotInfo writes both fields; the sync is a no-op when the text
|
||
// already matches.
|
||
for _, botID := range []int64{domain.ChatBotUserID, domain.StickersBotUserID} {
|
||
var wantAbout, wantDesc string
|
||
switch botID {
|
||
case domain.ChatBotUserID:
|
||
wantAbout = domain.ChatBotDescription()
|
||
wantDesc = wantAbout
|
||
case domain.StickersBotUserID:
|
||
wantAbout = domain.StickersBotDescription()
|
||
wantDesc = wantAbout
|
||
}
|
||
if _, curAbout, curDesc, err := botsService.GetBotInfo(ctx, botID); err == nil && curAbout == wantAbout && curDesc == wantDesc {
|
||
continue
|
||
}
|
||
if _, err := botsService.SetBotInfo(ctx, botID, domain.BotInfoUpdate{
|
||
SetAbout: true,
|
||
About: wantAbout,
|
||
SetDescription: true,
|
||
Description: wantDesc,
|
||
}); err != nil {
|
||
logger.Warn("sync bot branding", zap.Int64("bot", botID), zap.Error(err))
|
||
}
|
||
}
|
||
groupCallStore := postgres.NewGroupCallStore(pool)
|
||
groupCallsService := groupcallsapp.NewService(groupCallStore, groupcallsapp.WithPublicBaseURL(cfg.PublicBaseURL))
|
||
// 群通话媒体面:内嵌 pion SFU(M1+)。SFU 的 liveness reporter 把媒体面存活
|
||
// 回报给信令侧保活水位(sweeper 双过期判据的实现);未启用则退化为纯信令(M0)。
|
||
sfuService := sfu.Service(sfu.Disabled())
|
||
if cfg.SFUEnable {
|
||
sfuAdvertise := cfg.SFUAdvertiseIP
|
||
if sfuAdvertise == "" {
|
||
sfuAdvertise = cfg.AdvertiseIP
|
||
}
|
||
pionSFU, err := sfu.NewPion(sfu.PionConfig{
|
||
UDPPort: cfg.SFUUDPPort,
|
||
AdvertiseIP: sfuAdvertise,
|
||
Logger: logger.Named("sfu"),
|
||
Touch: func(callID, userID int64) {
|
||
if _, _, err := groupCallsService.Touch(context.Background(), callID, userID, int(time.Now().Unix())); err != nil {
|
||
logger.Debug("sfu liveness touch", zap.Int64("call_id", callID), zap.Int64("user_id", userID), zap.Error(err))
|
||
}
|
||
},
|
||
})
|
||
if err != nil {
|
||
return fmt.Errorf("init sfu: %w", err)
|
||
}
|
||
sfuService = pionSFU
|
||
}
|
||
// 频道 RTMP 直播媒体面(Live Stream):内嵌 RTMP ingest(OBS 推流)+ ffmpeg
|
||
// 切段。未启用时信令仍可用,观众停留在"等待推流"占位。
|
||
var liveStreamService *livestream.Service
|
||
if cfg.LiveStreamEnable {
|
||
liveStreamService = livestream.NewService(livestream.Config{
|
||
ListenAddr: cfg.LiveStreamRtmpAddr,
|
||
FFmpegPath: cfg.LiveStreamFFmpegPath,
|
||
WorkDir: cfg.LiveStreamWorkDir,
|
||
SegmentKeep: cfg.LiveStreamSegmentKeep,
|
||
}, groupCallsService, logger.Named("livestream"))
|
||
if err := liveStreamService.Start(); err != nil {
|
||
return fmt.Errorf("init live stream: %w", err)
|
||
}
|
||
defer liveStreamService.Close()
|
||
}
|
||
// 私聊通话中继(P3):内嵌 TURN/STUN,phoneCall.connections 经 phoneConnectionWebrtc
|
||
// 下发。未启用时退回 P1 的纯信令 LAN 直连。
|
||
turnService := turnsrv.Service(turnsrv.Disabled())
|
||
if cfg.TURNEnable {
|
||
turnAdvertise := cfg.TURNAdvertiseIP
|
||
if turnAdvertise == "" {
|
||
turnAdvertise = cfg.SFUAdvertiseIP
|
||
}
|
||
if turnAdvertise == "" {
|
||
turnAdvertise = cfg.AdvertiseIP
|
||
}
|
||
t, err := turnsrv.New(turnsrv.Config{
|
||
UDPPort: cfg.TURNUDPPort,
|
||
AdvertiseIP: turnAdvertise,
|
||
SharedSecret: cfg.TURNSecret,
|
||
RelayMinPort: cfg.TURNRelayMinPort,
|
||
RelayMaxPort: cfg.TURNRelayMaxPort,
|
||
CredentialTTL: cfg.CallTURNCredentialTTL,
|
||
Logger: logger.Named("turn"),
|
||
})
|
||
if err != nil {
|
||
return fmt.Errorf("init turn: %w", err)
|
||
}
|
||
defer t.Close()
|
||
turnService = t
|
||
}
|
||
// Restart recovery: SFU state is entirely lost, so mark every active call's
|
||
// participants left in bulk (version++); clients notice their own ssrc is
|
||
// gone via checkGroupCall and rejoin automatically.
|
||
if calls, err := groupCallsService.ResetAllParticipants(ctx, int(time.Now().Unix())); err != nil {
|
||
logger.Warn("clearing group call participants on restart failed", zap.Error(err))
|
||
} else if len(calls) > 0 {
|
||
logger.Info("cleared group call participants on restart", zap.Int("calls", len(calls)))
|
||
}
|
||
phoneService := phoneapp.NewService(phoneapp.Config{
|
||
RingTimeout: cfg.CallRingTimeout,
|
||
TombstoneTTL: cfg.CallTombstoneTTL,
|
||
MaxActivePerUser: cfg.CallMaxActivePerUser,
|
||
MaxRegistryEntries: cfg.CallRegistryMaxEntries,
|
||
SignalingRatePerSecond: cfg.CallSignalingRate,
|
||
})
|
||
// 私聊端对端加密(Secret Chat)握手状态机 + qts 投递队列(盲中继)。
|
||
secretChatStore := postgres.NewSecretChatStore(pool)
|
||
encryptedQueueStore := postgres.NewEncryptedQueueStore(pool)
|
||
secretChatService := secretchatapp.NewService(secretChatStore, encryptedQueueStore)
|
||
// Passkey:凭据持久化走 postgres;一次性挑战走进程内内存(短 TTL,与 QR 登录 token
|
||
// 同属进程内一次性凭据,不跨实例)。
|
||
passkeyStore := postgres.NewPasskeyStore(pool)
|
||
passkeyChallengeStore := memory.NewPasskeyChallengeStore()
|
||
passkeyService := passkeyapp.NewService(passkeyStore, passkeyChallengeStore, cfg.PasskeyRPID, cfg.DC,
|
||
passkeyapp.WithAllowedOrigins(cfg.PasskeyAllowedOrigins))
|
||
// 自定义云主题(Create a New Theme):主题目录与每用户已安装列表均持久化到 postgres。
|
||
themeService := themesapp.NewService(postgres.NewThemeStore(pool))
|
||
usersService := users.NewService(userStore, users.WithBaseUserCache(userCache), users.WithContactStore(contactStore), users.WithPhotoProvider(cachedPhotos), users.WithPrivacyEvaluator(privacyService), users.WithAccountFreezeProvider(userProjectionFacts), users.WithHideThirdPartyVerification(cfg.HideThirdPartyVerification), users.WithReservedUsernames(cfg.ReservedUsernames))
|
||
privacyService.ConfigureReadModels(usersService, channelStore)
|
||
aiComposeService := aiapp.NewService(aiComposeStore, newAIComposeOptions(cfg, rateLimiter, usersService.PremiumActive, logger)...)
|
||
botsService.SetAIChatGenerator(aiComposeService)
|
||
dialogsService := dialogs.NewService(dialogStore, channelStore).Configure(
|
||
dialogs.WithContactStore(contactStore),
|
||
dialogs.WithPhotoProvider(cachedPhotos),
|
||
dialogs.WithPrivacyEvaluator(privacyService),
|
||
dialogs.WithAccountFreezeProvider(userProjectionFacts),
|
||
dialogs.WithPremiumChecker(usersService.PremiumActive),
|
||
dialogs.WithReadModelVersions(readModelVersionStore),
|
||
dialogs.WithDialogHydrationCaches(
|
||
cfg.DialogPrivatePeerCacheMaxEntries,
|
||
cfg.DialogPrivatePeerCacheMaxBytes,
|
||
cfg.DialogDraftCacheMaxEntries,
|
||
cfg.DialogDraftCacheMaxBytes,
|
||
),
|
||
dialogs.WithDialogListSnapshotCache(
|
||
cfg.DialogListSnapshotCacheMaxEntries,
|
||
cfg.DialogListSnapshotCacheMaxHeaders,
|
||
cfg.DialogListSnapshotCacheTTL,
|
||
),
|
||
dialogs.WithSharedDialogListSnapshotCache(dialogListSnapshotCache),
|
||
)
|
||
// 编译期保证 *users.Service 满足 channel fan-out 跨 viewer 投影预热的可选能力;签名漂移会在
|
||
// 这里立刻断编译,而非在运行时静默退化回 O(viewer) 逐 viewer 投影。
|
||
var _ rpc.BatchViewerUsersResolver = usersService
|
||
channelsService := channelapp.NewService(channelStore,
|
||
channelapp.WithBotProfileResolver(botsService),
|
||
channelapp.WithReadModelVersions(readModelVersionStore),
|
||
channelapp.WithActiveChannelIDsReadModel(
|
||
activeChannelIDsPageCache,
|
||
activeChannelIDsPageBatcher,
|
||
cfg.ActiveChannelIDsCacheMaxEntries,
|
||
cfg.ActiveChannelIDsCacheTTL,
|
||
metricRegistry,
|
||
),
|
||
channelapp.WithSendPermissionChecker(adminService),
|
||
channelapp.WithReservedUsernames(cfg.ReservedUsernames),
|
||
)
|
||
communitiesService := communitiesapp.NewService(communityStore)
|
||
ephemeralService := ephemeralapp.NewService(ephemeralStore, channelsService, usersService, botsService)
|
||
welcomeMessageService := welcomemessagesapp.NewService(welcomeMessageStore, channelsService)
|
||
storiesService := storiesapp.NewService(storyStore, storiesapp.WithChannelStoryAccess(channelsService))
|
||
chatlistsService := chatlistsapp.NewService(
|
||
chatlistStore,
|
||
dialogStore,
|
||
chatlistsapp.WithChannels(channelsService),
|
||
chatlistsapp.WithPremiumChecker(usersService.PremiumActive),
|
||
)
|
||
businessAutomationOptions := newBusinessAutomationOptions(cfg, activeSessions, aiComposeService, logger)
|
||
messagesService := messageapp.NewService(messageStore, dialogStore,
|
||
messageapp.WithContactStore(contactStore),
|
||
messageapp.WithPhotoProvider(cachedPhotos),
|
||
messageapp.WithPrivacyEvaluator(privacyService),
|
||
messageapp.WithAccountFreezeProvider(userProjectionFacts),
|
||
messageapp.WithReadModelVersions(readModelVersionStore),
|
||
messageapp.WithBotResponder(botsService),
|
||
messageapp.WithSendPermissionChecker(adminService),
|
||
messageapp.WithBusinessAutomation(passwordStore, businessAutomationOptions...),
|
||
)
|
||
// Wires the storage retention sweep's purge-notice capability now that
|
||
// both edit-capable app services exist -- filesService was constructed
|
||
// earlier, before either was available. Must happen before
|
||
// retentionWorker.Run starts below: that call's first sweep tick runs
|
||
// synchronously (maintenance.RetentionWorker.Run -> runOnce), and once a
|
||
// document/photo's blob bytes are purged it never again matches the
|
||
// hard-retention candidate query (see ListDocumentIDsForHardRetentionOlderThan's
|
||
// doc comment) -- so a tick that raced ahead of this call wouldn't just
|
||
// delay the notice, it would permanently lose it (files.SetRetentionPurgeNotifier).
|
||
filesService.SetRetentionPurgeNotifier(messagesService, channelsService)
|
||
go retentionWorker.Run(ctx)
|
||
moderationService := moderationapp.NewService(
|
||
moderationReportStore,
|
||
moderationapp.WithMessageReaders(messagesService, channelsService),
|
||
moderationapp.WithStoryReader(storiesService),
|
||
moderationapp.WithPeerReaders(usersService, channelsService),
|
||
moderationapp.WithProfilePhotoReader(filesService),
|
||
)
|
||
legacyReportsMigrated, err := moderationService.MigrateLegacyEphemeralReports(ctx, ephemeralReportStore, 500)
|
||
if err != nil {
|
||
return fmt.Errorf("migrate legacy ephemeral reports: %w", err)
|
||
}
|
||
if legacyReportsMigrated > 0 {
|
||
logger.Info("旧 ephemeral 举报已迁移到统一审核管线",
|
||
zap.Int("reports", legacyReportsMigrated))
|
||
}
|
||
translationService := translationapp.NewService(
|
||
messagesService,
|
||
channelsService,
|
||
dialogStore,
|
||
newTranslationOptions(cfg, rateLimiter, logger)...,
|
||
)
|
||
authService := auth.NewService(userStore, authzStore, codeStore, authKeyGetBatchStore, tempAuthKeyStore, cfg.DevAuthCode,
|
||
auth.WithLoginMessages(messageStore, dialogStore),
|
||
auth.WithLoginWelcomeMessages(identityStore, cfg.WelcomeMessagePhoneTemplate, cfg.WelcomeMessageEmailTemplate),
|
||
auth.WithLoginCodeMessageTemplate(identityStore, cfg.LoginCodeMessageTemplate),
|
||
auth.WithLoginCodeDelivery(messageStore),
|
||
auth.WithPasswords(passwordStore),
|
||
auth.WithBotLogin(botStore),
|
||
auth.WithPremiumGrant(cfg.PremiumGrantMonths),
|
||
auth.WithDefaultStickerSet(passwordStore, cfg.DefaultStickerSetID),
|
||
auth.WithCodeTTL(cfg.AuthCodeTTL),
|
||
auth.WithCodeMaxAttempts(cfg.AuthCodeMaxAttempts),
|
||
auth.WithPhoneCodeDelivery(phoneCodeSender, cfg.PhoneCodeLength),
|
||
auth.WithOTPDeliveryFailureObserver(func(_ context.Context, request otpdelivery.Request, err error) {
|
||
logger.Named("otp").Warn("supplementary OTP provider delivery failed, 777000 app-code login remains valid",
|
||
zap.String("delivery_id", request.DeliveryID),
|
||
zap.String("purpose", string(request.Purpose)),
|
||
zap.String("channel", string(request.Channel)),
|
||
zap.Error(err))
|
||
}),
|
||
auth.WithLoginEmail(auth.LoginEmailOptions{
|
||
Enabled: cfg.LoginEmailEnable,
|
||
RequireSetup: cfg.LoginEmailRequireSetup,
|
||
CodeLength: cfg.LoginEmailCodeLength,
|
||
Store: accountService,
|
||
Sender: loginEmailSender,
|
||
}),
|
||
auth.WithEmailSignup(cfg.EmailSignupEnable),
|
||
auth.WithEmailSignupPhonePrefixes(cfg.EmailSignupPhonePrefixes))
|
||
// Collectible (NFT) usernames are an optional read model projected at the
|
||
// protocol edge.
|
||
collectibleUsernameStore := postgres.NewCollectibleUsernameStore(pool)
|
||
usernamesService := usernamesapp.NewService(
|
||
usernamesapp.WithRegistryStore(collectibleUsernameStore),
|
||
usernamesapp.WithCollectibleStore(collectibleUsernameStore),
|
||
usernamesapp.WithURLTemplate(cfg.CollectibleUsernameURLTemplate),
|
||
usernamesapp.WithPublicBaseURL(cfg.PublicBaseURL),
|
||
usernamesapp.WithLogger(logger.Named("app").Named("usernames")),
|
||
)
|
||
// Official platform verification: applications are filed through the built-in
|
||
// @verifybot and decided in the admin panel. Every eligibility rule lives in
|
||
// this service; the bot and the panel are only its two surfaces.
|
||
verificationStore := postgres.NewVerificationStore(pool)
|
||
verificationLogger := logger.Named("app").Named("verification")
|
||
verificationService := verificationapp.NewService(
|
||
verificationapp.WithStore(verificationStore),
|
||
verificationapp.WithUserDirectory(usersService),
|
||
verificationapp.WithBotDirectory(botsService),
|
||
verificationapp.WithChannelDirectory(channelsService),
|
||
verificationapp.WithAccountFreezeProvider(adminService),
|
||
verificationapp.WithPeerVerifier(verificationPeerVerifier{
|
||
users: usersService,
|
||
channels: channelsService,
|
||
channelRowCache: channelRowCache,
|
||
}),
|
||
verificationapp.WithRateLimiter(rateLimiter, cfg.VerificationApplyRateLimit, cfg.VerificationApplyRateWindow),
|
||
verificationapp.WithEnabled(cfg.VerificationEnabled),
|
||
verificationapp.WithAllowUserTargets(cfg.VerificationAllowUserTargets),
|
||
verificationapp.WithRejectCooldown(cfg.VerificationRejectCooldown),
|
||
verificationapp.WithMaxActivePerUser(cfg.VerificationMaxActivePerUser),
|
||
verificationapp.WithLogger(verificationLogger),
|
||
)
|
||
// @verifybot is the applicant surface, and the notifier that carries decisions
|
||
// back to the applicant as ordinary messages. Both directions are deferred
|
||
// injections because the bots service is built before the peer directories the
|
||
// verification service needs.
|
||
botsService.SetVerification(verificationService)
|
||
verificationService.SetApplicantNotifier(botsService)
|
||
// Third-party verification is a SEPARATE mechanism: a verifier bot marks peers
|
||
// with its own custom-emoji icon and description, which clients render before the
|
||
// name. It shares no state with the official badge above -- different tables,
|
||
// different rights, different TL fields (bot_verification_icon / bot_verification
|
||
// versus verified).
|
||
botVerificationStore := postgres.NewBotVerificationStore(pool)
|
||
botVerificationService := botverificationapp.NewService(
|
||
botverificationapp.WithStore(botVerificationStore),
|
||
botverificationapp.WithUserDirectory(usersService),
|
||
botverificationapp.WithBotDirectory(botsService),
|
||
botverificationapp.WithChannelDirectory(channelsService),
|
||
// The icon must be a real custom emoji document: an id no client can fetch
|
||
// renders as nothing, so the badge would be silently invisible.
|
||
botverificationapp.WithIconResolver(filesService),
|
||
botverificationapp.WithMarkApplier(botVerificationMarkApplier{store: botVerificationStore}),
|
||
botverificationapp.WithRateLimiter(rateLimiter, cfg.BotVerificationRequestRateLimit, cfg.BotVerificationRequestRateWindow),
|
||
botverificationapp.WithEnabled(cfg.BotVerificationEnabled),
|
||
botverificationapp.WithMaxPerVerifier(cfg.BotVerificationMaxPerVerifier),
|
||
botverificationapp.WithLogger(logger.Named("app").Named("botverification")),
|
||
)
|
||
// @marksbot files applications with the operator and reports decisions back.
|
||
botsService.SetCustomVerification(botVerificationService)
|
||
botVerificationService.SetApplicantNotifier(botsService)
|
||
if granted, err := botVerificationService.SeedDefaultVerifier(ctx); err != nil {
|
||
return fmt.Errorf("seed default verifier: %w", err)
|
||
} else if granted {
|
||
logger.Info("default verifier seed complete", zap.Int64("bot_id", domain.VerifierBotUserID))
|
||
}
|
||
updatesService := updates.NewService(updateStateStore, updateEventStore, updates.WithLogger(logger.Named("app").Named("updates")))
|
||
var appUpdateResolver updatecdn.Resolver
|
||
if cfg.UpdateServiceURL != "" {
|
||
client, err := updatecdn.NewClient(cfg.UpdateServiceURL, cfg.UpdateRequestTimeout)
|
||
if err != nil {
|
||
return fmt.Errorf("initialize update service client: %w", err)
|
||
}
|
||
appUpdateResolver = client
|
||
}
|
||
router := rpc.New(rpc.Config{
|
||
DC: cfg.DC,
|
||
DefaultCountryCode: cfg.DefaultCountryCode,
|
||
IP: cfg.AdvertiseIP,
|
||
Port: port,
|
||
OutboundPushTimeout: cfg.OutboundPushTimeout,
|
||
SendRateLimit: cfg.SendRateLimit,
|
||
SendRateWindow: cfg.SendRateWindow,
|
||
AuthCodePhoneRateLimit: cfg.AuthCodePhoneRateLimit,
|
||
AuthCodeAuthKeyRateLimit: cfg.AuthCodeAuthKeyRateLimit,
|
||
AuthCodeRateWindow: cfg.AuthCodeRateWindow,
|
||
CatchupRateLimit: cfg.CatchupRateLimit,
|
||
CatchupRateWindow: cfg.CatchupRateWindow,
|
||
ChannelNudgeMaxTargets: cfg.ChannelNudgeMaxTargets,
|
||
CallSignalingMaxBytes: cfg.CallSignalingMaxBytes,
|
||
CallForceRelay: cfg.CallForceRelay,
|
||
GroupCallMaxParticipants: cfg.GroupCallMaxParticipants,
|
||
RtmpIngestURL: cfg.LiveStreamRtmpURL,
|
||
PublicBaseURL: cfg.PublicBaseURL,
|
||
UpdatePublicURL: cfg.UpdatePublicURL,
|
||
PublicAppScheme: cfg.PublicAppScheme,
|
||
PublicAppLinkBase: cfg.PublicAppLinkBase,
|
||
// PFS temp→perm 解析缓存:显式撤销会清缓存并断开连接,re-bind 即时失效;
|
||
// 配置 TTL 只承担跨进程/异常失效兜底,避免大连接数周期性打满 PG。
|
||
TempKeyResolveCacheTTL: cfg.TempKeyResolveCacheTTL,
|
||
TempKeyResolveCacheMaxEntries: cfg.TempKeyResolveCacheMaxEntries,
|
||
PeerIdentityCacheMaxEntries: cfg.PeerIdentityCacheMaxEntries,
|
||
StoryActivePeerCacheMaxEntries: cfg.StoryActivePeerCacheMaxEntries,
|
||
StoryHiddenListCacheMaxEntries: cfg.StoryHiddenListCacheMaxEntries,
|
||
StoryHiddenListCacheMaxBytes: cfg.StoryHiddenListCacheMaxBytes,
|
||
PresenceLastSeenBatchMax: cfg.PresenceLastSeenBatchMax,
|
||
PresenceLastSeenBatchWait: cfg.PresenceLastSeenBatchWait,
|
||
PresenceLastSeenBatchQueue: cfg.PresenceLastSeenBatchQueue,
|
||
PresenceLastSeenBatchTimeout: cfg.PresenceLastSeenBatchTimeout,
|
||
PresenceLastSeenDrainTimeout: cfg.PresenceLastSeenDrainTimeout,
|
||
}, rpc.Deps{
|
||
Auth: authService,
|
||
AuthDeliveryReports: authDeliveryReportService,
|
||
ClientTelemetry: clientTelemetryService,
|
||
AuthKeySessionLayers: authKeySessionLayerStore,
|
||
ReadModelVersions: readModelVersionStore,
|
||
UserProjectionFacts: userProjectionFacts,
|
||
Account: accountService,
|
||
Privacy: privacyService,
|
||
Help: help.NewService(helpStore, helpStore,
|
||
help.WithMapboxToken(cfg.MapboxToken),
|
||
help.WithEmailSignupEnable(cfg.EmailSignupEnable),
|
||
help.WithEmailSignupPhonePrefixes(cfg.EmailSignupPhonePrefixes),
|
||
help.WithMaxUploadFileBytes(cfg.StorageMaxUploadFileBytes),
|
||
help.WithAccountFreezeProvider(adminService),
|
||
),
|
||
AppUpdates: appUpdateResolver,
|
||
AccountFreeze: userProjectionFacts,
|
||
AccountFreezeNotifications: adminService,
|
||
AICompose: aiComposeService,
|
||
Ephemeral: ephemeralService,
|
||
EphemeralPush: ephemeralStore,
|
||
WelcomeMessages: welcomeMessageService,
|
||
Moderation: moderationService,
|
||
Users: usersService,
|
||
Usernames: usernamesService,
|
||
BotVerifications: botVerificationService,
|
||
TelegramLogin: telegramLoginRPCDependency(telegramLoginService),
|
||
Updates: updatesService,
|
||
BootstrapUpdates: bootstrapUpdateStore,
|
||
BotAPIUpdates: botAPIUpdateStore,
|
||
BotCallbacks: botCallbackStore,
|
||
Contacts: contactsService,
|
||
Dialogs: dialogsService,
|
||
Chatlists: chatlistsService,
|
||
Messages: messagesService,
|
||
Translation: translationService,
|
||
Channels: channelsService,
|
||
Communities: communitiesService,
|
||
Files: filesService,
|
||
PremiumPromo: filesService,
|
||
Bots: botsService,
|
||
ServiceBotCallbacks: botsService,
|
||
ServiceBotInlineResults: botsService,
|
||
Polls: pollsapp.NewService(pollStore),
|
||
Stories: storiesService,
|
||
Phone: phoneService,
|
||
SecretChats: secretChatService,
|
||
Passkey: passkeyService,
|
||
Themes: themeService,
|
||
GroupCalls: groupCallsService,
|
||
LiveStreams: liveStreamDep(liveStreamService),
|
||
SFU: sfuService,
|
||
TURN: turnService,
|
||
LangPack: langPackService,
|
||
Sessions: activeSessions,
|
||
Metrics: metricRegistry,
|
||
Inline: inlineRegistryStore,
|
||
Limiter: rateLimiter,
|
||
}, logger.Named("rpc"), clock.System)
|
||
readModelListener := postgres.NewReadModelChangeListener(cfg.PostgresDSN, postgres.ReadModelCacheSet{
|
||
ReadModelVersions: readModelVersionStore,
|
||
ChannelRows: channelRowCache,
|
||
ChannelTopMessages: channelTopMessageCache,
|
||
CommunityCatalog: communityCatalogCache,
|
||
ChannelMembers: channelMemberCache,
|
||
ChannelDialogs: channelDialogCache,
|
||
ChannelDifferences: channelDifferenceCache,
|
||
ChannelBoosts: channelBoostCache,
|
||
Contacts: postgres.ContactReadModelCaches{contactStore, contactsService},
|
||
Dialogs: dialogsService,
|
||
Privacy: privacyService,
|
||
ProfilePhotos: cachedPhotos,
|
||
Stories: router,
|
||
ChannelFullBots: router,
|
||
ChannelBotMembers: channelsService,
|
||
ChannelMediaCounts: channelsService,
|
||
PrivateMediaCounts: messagesService,
|
||
RPCProjections: router,
|
||
PeerIdentities: router,
|
||
BaseUsers: userCache,
|
||
BotProfiles: botsService,
|
||
AccountSettings: router,
|
||
UserProjectionFacts: userProjectionFacts,
|
||
}, logger.Named("store").Named("read-model-listener"))
|
||
go readModelListener.Run(ctx)
|
||
activeSessions.SetLifecycleObserver(router)
|
||
adminService.Configure(adminapp.Dependencies{
|
||
Auth: authService,
|
||
Revoker: router,
|
||
Users: usersService,
|
||
UserNotifier: router,
|
||
UserModerationNotifier: router,
|
||
FreezeNotifier: router,
|
||
Channels: channelsService,
|
||
ChannelNotifier: router,
|
||
Messages: messagesService,
|
||
Photos: filesService,
|
||
StickerSets: filesService,
|
||
GifCatalog: filesService,
|
||
Storage: filesService,
|
||
Bots: botsService,
|
||
Emoji: filesService,
|
||
Moderation: moderationService,
|
||
Usernames: usernamesService,
|
||
Verification: verificationService,
|
||
BotVerification: botVerificationService,
|
||
Account: accountService,
|
||
Broadcast: broadcastService,
|
||
})
|
||
// The RPC edge owns the tg.* projection cache and the standard non-PTS
|
||
// updateUser/updateChannel refresh, so committed registry mutations are
|
||
// visible to online viewers immediately.
|
||
usernamesService.SetPeerUsernameNotifier(router)
|
||
// The badge change is a peer fact the protocol edge caches and pushes, so the
|
||
// verification service gets the same hook the username registry uses. The
|
||
// assertion is deliberately dynamic: NotifyPeerVerified lands with the edge
|
||
// agent, and until then only projection invalidation is wired — a decision can
|
||
// then never be masked by a stale projection, and clients converge on their next
|
||
// authoritative peer read.
|
||
if notifier, ok := any(router).(verificationapp.PeerNotifier); ok {
|
||
// Compose rather than choose: the decision writes users.verified inside the
|
||
// verification transaction (through postgres.VerificationTxFromContext), so it
|
||
// bypasses users.Service and its cache refresh. Dropping the shared user:base
|
||
// entry before the edge builds the pushed tg.User is what keeps the badge in
|
||
// that push from being one beat stale; the cross-instance read-model listener
|
||
// would otherwise only catch up asynchronously.
|
||
verificationService.SetPeerNotifier(compositeVerificationNotifier{
|
||
cache: rpcProjectionVerificationNotifier{
|
||
invalidator: router,
|
||
users: userCache,
|
||
log: verificationLogger,
|
||
},
|
||
edge: notifier,
|
||
})
|
||
} else {
|
||
verificationService.SetPeerNotifier(rpcProjectionVerificationNotifier{
|
||
invalidator: router,
|
||
users: userCache,
|
||
log: verificationLogger,
|
||
})
|
||
logger.Warn("verification badge update push is not implemented by the RPC edge; only projection invalidation is wired",
|
||
zap.String("expected_hook", "rpc.Router.NotifyPeerVerified"))
|
||
}
|
||
// The third-party mark lives on the same peer projections as the official flag,
|
||
// so it needs the same edge hook. Composed with the cache drop for the same reason:
|
||
// the mark can be written on the decision's own transaction, bypassing the app
|
||
// services that would otherwise refresh the shared user:base entry.
|
||
if notifier, ok := any(router).(botverificationapp.PeerNotifier); ok {
|
||
botVerificationService.SetPeerNotifier(compositeBotVerificationNotifier{
|
||
cache: rpcProjectionVerificationNotifier{
|
||
invalidator: router,
|
||
users: userCache,
|
||
peerIdentity: true,
|
||
log: verificationLogger,
|
||
},
|
||
edge: notifier,
|
||
})
|
||
} else {
|
||
logger.Warn("third-party verification push is not implemented by the RPC edge",
|
||
zap.String("expected_hook", "rpc.Router.NotifyPeerBotVerification"))
|
||
}
|
||
// Applicant notifications are delivered from a durable outbox, never inside the
|
||
// decision transaction: @verifybot may be blocked and the panel must not wait on
|
||
// a message send.
|
||
go verificationapp.NewNotificationWorker(verificationService, logger.Named("verification").Named("notify"),
|
||
cfg.VerificationNotifyInterval, cfg.VerificationNotifyBatch).Run(ctx)
|
||
// System broadcasts (admin panel "Broadcasts" -- a message from 777000 to
|
||
// all/selected users) are delivered from the same kind of durable outbox as
|
||
// applicant notifications above: an admin creating one for every user must
|
||
// not wait on however long sending to all of them takes.
|
||
go broadcastapp.NewWorker(broadcastService, broadcastapp.WorkerConfig{
|
||
Interval: cfg.BroadcastWorkerInterval,
|
||
Lease: cfg.BroadcastWorkerLease,
|
||
MaterializeBatch: cfg.BroadcastWorkerMaterializeBatch,
|
||
DeliveryBatch: cfg.BroadcastWorkerBatch,
|
||
}, logger.Named("broadcast").Named("delivery")).Run(ctx)
|
||
moderationActionOptions := []moderationapp.ActionExecutorOption{
|
||
moderationapp.WithAccountDeletionNotifier(router),
|
||
}
|
||
if cfg.PublicLinkWebAddr != "" {
|
||
moderationActionOptions = append(
|
||
moderationActionOptions,
|
||
moderationapp.WithAppealLinks(moderationService, cfg.PublicBaseURL),
|
||
)
|
||
}
|
||
moderationActionExecutor := moderationapp.NewActionExecutor(
|
||
adminService, channelsService, router, accountLifecycleStore,
|
||
moderationActionOptions...,
|
||
)
|
||
go moderationapp.NewActionWorker(
|
||
moderationReportStore,
|
||
moderationActionExecutor,
|
||
logger.Named("moderation").Named("actions"),
|
||
).Run(ctx)
|
||
// bot session 撤销、在线通知与 @ChatBot 流式草稿推送经 router 实现(需 tg.* 边界),
|
||
// router 创建后注入。
|
||
botsService.SetRouterHooks(router)
|
||
botsService.SetTextDraftPusher(router)
|
||
go rpc.NewOutboxDispatcher(updateEventStore, dispatchOutboxStore, activeSessions, logger.Named("rpc").Named("outbox"),
|
||
rpc.WithOutboxWorkers(cfg.OutboxWorkers),
|
||
rpc.WithOutboxBatch(cfg.OutboxBatch),
|
||
rpc.WithOutboxInterval(cfg.OutboxInterval),
|
||
rpc.WithOutboxPushTimeout(cfg.OutboundPushTimeout),
|
||
rpc.WithOutboxMetrics(metricRegistry),
|
||
rpc.WithOutboxUpdateBuilder(router.BuildOutboxUpdates),
|
||
).Run(ctx)
|
||
go rpc.NewBootstrapUpdateDispatcher(router, logger.Named("rpc").Named("bootstrap")).Run(ctx)
|
||
go rpc.NewWelcomeDeliveryDispatcher(router, welcomeMessageStore, logger.Named("rpc").Named("welcome-delivery")).Run(ctx)
|
||
go rpc.NewScheduledDispatcher(router, logger.Named("rpc").Named("scheduled")).Run(ctx)
|
||
go rpc.NewSuggestedPostDispatcher(router, logger.Named("rpc").Named("suggested-post")).Run(ctx)
|
||
go rpc.NewExpiryDispatcher(router, logger.Named("rpc").Named("expiry")).Run(ctx)
|
||
go rpc.NewPhoneExpiryDispatcher(router, logger.Named("rpc").Named("phone-expiry"), cfg.CallExpiryInterval).Run(ctx)
|
||
go rpc.NewGroupCallSweepDispatcher(router, logger.Named("rpc").Named("groupcall-sweep"), cfg.GroupCallSweepInterval, cfg.GroupCallCheckTTL).Run(ctx)
|
||
go router.RunChannelFanout(ctx)
|
||
go router.RunBotAPIEnqueue(ctx)
|
||
go router.RunPresenceLastSeenBatch(ctx)
|
||
go router.RunPresenceSweeper(ctx, time.Minute)
|
||
go activeSessions.RunPendingSweeper(ctx, time.Minute)
|
||
go router.RunPremiumSweeper(ctx, cfg.PremiumSweepInterval, cfg.PremiumSweepBatch)
|
||
go router.RunAccountLifecycle(ctx, time.Minute, 500)
|
||
go router.RunAccountFreezeNotifications(ctx, time.Minute, 500)
|
||
if telegramLoginService != nil {
|
||
go runTelegramLoginRetention(ctx, telegramLoginService, cfg.TelegramLoginRetention, cfg.TelegramLoginSweepInterval, cfg.TelegramLoginSweepBatch, logger.Named("telegram-login-retention"))
|
||
}
|
||
go router.RunInlineBotPushSubscriber(ctx)
|
||
go router.RunBotCallbackAnswerSubscriber(ctx)
|
||
go router.RunEphemeralPushSubscriber(ctx)
|
||
if _, err := botapi.Start(ctx, cfg.BotAPIAddr, botsService, usersService, router, router, logger.Named("botapi")); err != nil {
|
||
return fmt.Errorf("start bot api: %w", err)
|
||
}
|
||
// Scoped tokens carry a bounded permission set; the master token stays
|
||
// unrestricted, so a deployment that configures none behaves exactly as before.
|
||
adminScopedTokens := make([]adminapi.ScopedToken, 0, len(cfg.AdminScopedTokens))
|
||
for _, item := range cfg.AdminScopedTokens {
|
||
adminScopedTokens = append(adminScopedTokens, adminapi.ScopedToken{
|
||
Name: item.Name,
|
||
Token: item.Token,
|
||
Permissions: item.Permissions,
|
||
})
|
||
}
|
||
if _, err := adminapi.Start(ctx, adminapi.Config{
|
||
Addr: cfg.AdminAPIAddr,
|
||
Token: cfg.AdminAPIToken,
|
||
ScopedTokens: adminScopedTokens,
|
||
}, adminService, logger.Named("adminapi")); err != nil {
|
||
return fmt.Errorf("start admin api: %w", err)
|
||
}
|
||
if _, err := web.Start(ctx, web.Config{
|
||
Addr: cfg.PublicLinkWebAddr,
|
||
PublicBaseURL: cfg.PublicBaseURL,
|
||
AppScheme: cfg.PublicAppScheme,
|
||
AppLinkBase: cfg.PublicAppLinkBase,
|
||
WebBaseURL: cfg.PublicWebBaseURL,
|
||
AppName: cfg.PublicAppName,
|
||
DownloadURL: cfg.PublicDownloadURL,
|
||
StickerSets: filesService,
|
||
Users: userStore,
|
||
Channels: channelStore,
|
||
Privacy: privacyService,
|
||
Photos: filesService,
|
||
ModerationAppeals: moderationService,
|
||
TelegramLogin: telegramLoginHTTPHandler,
|
||
}, logger.Named("public-web")); err != nil {
|
||
return fmt.Errorf("start public Web: %w", err)
|
||
}
|
||
|
||
srv := mtprotoedge.New(mtprotoedge.Options{
|
||
Logger: logger.Named("mtprotoedge"),
|
||
DC: cfg.DC,
|
||
StrictDC: cfg.StrictDCCheck,
|
||
RSAKey: rsaKey,
|
||
IdentityDir: cfg.IdentityDir,
|
||
LayerRPC: router,
|
||
AuthKeys: authKeyGetBatchStore,
|
||
ActiveSessions: activeSessions,
|
||
Metrics: metricRegistry,
|
||
ObfuscatedTCP: true,
|
||
WebSocket: cfg.WebSocketEnable,
|
||
WebSocketAllowedOrigins: cfg.WebSocketAllowedOrigins,
|
||
MaxConnections: cfg.MTProtoMaxConnections,
|
||
MaxConnectionsPerIP: cfg.MTProtoMaxConnectionsPerIP,
|
||
MaxConcurrentHandshakes: cfg.MTProtoMaxConcurrentHandshakes,
|
||
RPCMaxInflight: cfg.MTProtoRPCMaxInflight,
|
||
RPCQueueSize: cfg.MTProtoRPCQueueSize,
|
||
RPCTimeout: cfg.MTProtoRPCTimeout,
|
||
RPCGlobalWorkers: cfg.MTProtoRPCGlobalWorkers,
|
||
RPCGlobalMaxTasks: cfg.MTProtoRPCGlobalMaxTasks,
|
||
RPCGlobalMaxBytes: cfg.MTProtoRPCGlobalMaxBytes,
|
||
RPCDeliveryHookWorkers: cfg.MTProtoRPCDeliveryHookWorkers,
|
||
RPCDeliveryHookMaxPending: cfg.MTProtoRPCDeliveryHookMaxPending,
|
||
RPCExecutionMaxEntries: cfg.MTProtoRPCExecutionMaxEntries,
|
||
RPCExecutionAuthMaxEntries: cfg.MTProtoRPCExecutionAuthMaxEntries,
|
||
RPCExecutionSessionMaxEntries: cfg.MTProtoRPCExecutionSessionMaxEntries,
|
||
RPCExecutionPendingPerAuth: cfg.MTProtoRPCExecutionPendingPerAuth,
|
||
InboundFrameGlobalMaxBytes: cfg.MTProtoInboundFrameGlobalMaxBytes,
|
||
OutboundQueueSize: cfg.MTProtoOutboundQueueSize,
|
||
OutboundControlQueueSize: cfg.MTProtoOutboundControlQueueSize,
|
||
OutboundTrackedGlobalMaxBytes: cfg.MTProtoOutboundTrackedGlobalMaxBytes,
|
||
OutboundWriteGlobalMaxBytes: cfg.MTProtoOutboundWriteGlobalMaxBytes,
|
||
OnServing: func(_ net.Addr) {
|
||
logger.Info("telesrv service ready",
|
||
zap.String("listen", cfg.ListenAddr),
|
||
zap.String("advertise", net.JoinHostPort(cfg.AdvertiseIP, portStr)),
|
||
zap.Int("pid", os.Getpid()),
|
||
zap.String("git_commit", buildMeta.Commit),
|
||
zap.Uint("schema_version", migrationStatus.Version),
|
||
zap.String("blob_backend", "localfs"),
|
||
)
|
||
// Picks up a pending "please bounce the admin panel" request left
|
||
// by cmd/telesrv-admin's Restart/Update (internal/procctl) --
|
||
// see HandlePendingAdminRestart's doc comment for why this
|
||
// process (the new one, already up) is the safe place to do
|
||
// that from, not the admin panel doing it to itself. Repo root
|
||
// is cwd, matching cmd/telesrv-admin's own convention; a no-op,
|
||
// not an error, when no restart was requested or the repo
|
||
// layout (bin/, .server_panel.json) isn't present.
|
||
go func() {
|
||
if root, err := os.Getwd(); err == nil {
|
||
if restarted, err := procctl.NewManager(root).HandlePendingAdminRestart(ctx); err != nil {
|
||
logger.Warn("admin panel auto-restart failed", zap.Error(err))
|
||
} else if restarted {
|
||
logger.Info("admin panel restarted after server restart/update")
|
||
}
|
||
}
|
||
}()
|
||
},
|
||
})
|
||
metricRegistry.AddGaugeProvider(func() []obsmetrics.GaugeSample {
|
||
return mtprotoRuntimeGaugeSamples(srv.RuntimeSnapshot())
|
||
})
|
||
// This is intentionally the final startup operation. ListenAndServe owns the
|
||
// public listener so no seed/prewarm work can run after port 2398 is exposed.
|
||
return srv.ListenAndServe(ctx, cfg.ListenAddr)
|
||
}
|
||
|
||
// telegramLoginRPCDependency preserves a disabled Telegram Login service as a
|
||
// nil interface. Assigning the nil *Service directly to rpc.Deps would create a
|
||
// non-nil interface with a nil concrete pointer and bypass Router availability
|
||
// checks.
|
||
func telegramLoginRPCDependency(service *telegramloginapp.Service) rpc.TelegramLoginService {
|
||
if service == nil {
|
||
return nil
|
||
}
|
||
return service
|
||
}
|
||
|
||
func runTelegramLoginRetention(ctx context.Context, service *telegramloginapp.Service, retention, interval time.Duration, batch int, logger *zap.Logger) {
|
||
run := func() {
|
||
var total int64
|
||
// Bound one tick even when a deployment accumulated years of stale data;
|
||
// subsequent ticks continue without monopolizing the database pool.
|
||
for range 10 {
|
||
deleted, err := service.DeleteExpiredArtifacts(ctx, time.Now().UTC().Add(-retention), batch)
|
||
if err != nil {
|
||
if ctx.Err() == nil {
|
||
logger.Warn("telegram_login_retention_failed", zap.Error(err))
|
||
}
|
||
return
|
||
}
|
||
total += deleted
|
||
if deleted < int64(batch) {
|
||
break
|
||
}
|
||
}
|
||
if total > 0 {
|
||
logger.Info("telegram_login_retention_completed", zap.Int64("deleted", total))
|
||
}
|
||
}
|
||
run()
|
||
ticker := time.NewTicker(interval)
|
||
defer ticker.Stop()
|
||
for {
|
||
select {
|
||
case <-ctx.Done():
|
||
return
|
||
case <-ticker.C:
|
||
run()
|
||
}
|
||
}
|
||
}
|