owpengram-server/cmd/telesrv/main.go
Astra f33e25af8d admin: add account spam restriction (join/message gate)
Adds a narrower spam sanction alongside the existing account freeze: a
restricted account keeps every existing membership and conversation, but
cannot join new channels/groups (public join or invite link) and cannot
start a new conversation with a non-contact. Reachable both as a standalone
admin action and as a decision on a reported user's moderation case, with
the same idempotent-supersession and appeal wiring freeze already has.
2026-09-16 14:03:15 +01:00

1990 lines
94 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// 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 按配置启用外链媒体抓取;禁用时返回 nilNewService 跳过 nil option
// liveStreamDep 把可能为 nil 的 *livestream.Service 转成 rpc.LiveStreamsService
// 避免 typed-nil interfacenil 具体指针装进接口后 != 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 按配置启用链接预览抓取;禁用时返回 nilNewService 跳过 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.WithAccountRestrictionProvider(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...)
reservedUsernameStore := postgres.NewReservedUsernameStore(pool)
botsService := botsapp.NewService(userStore, botStore, messageStore,
botsapp.WithLogger(logger.Named("bots")),
botsapp.WithBlockChecker(contactStore),
botsapp.WithPublicChannelUsernameResolver(channelStore),
botsapp.WithReservedUsernames(reservedUsernameStore),
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 SFUM1+。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 ingestOBS 推流)+ 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/STUNphoneCall.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)
channelParticipantQueueStore := postgres.NewChannelParticipantQueueStore(pool)
secretChatService := secretchatapp.NewService(secretChatStore, encryptedQueueStore, channelParticipantQueueStore)
// 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.WithAccountRestrictionProvider(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.WithAccountRestrictionProvider(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.WithAccountRestrictionProvider(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,
AccountRestriction: userProjectionFacts,
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,
ReservedUsernames: reservedUsernameStore,
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()
}
}
}