owpengram-server/cmd/telesrv/main.go

1575 lines
74 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"
"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"
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"
ratingapp "telesrv/internal/app/rating"
secretchatapp "telesrv/internal/app/secretchat"
"telesrv/internal/app/stargifts"
"telesrv/internal/app/stars"
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"
"telesrv/internal/botapi"
"telesrv/internal/config"
"telesrv/internal/domain"
"telesrv/internal/mtprotoedge"
obsmetrics "telesrv/internal/observability/metrics"
"telesrv/internal/officialgifts"
"telesrv/internal/otpdelivery"
otpsmtp "telesrv/internal/otpdelivery/smtp"
otpwebhook "telesrv/internal/otpdelivery/webhook"
"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/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 退出", zap.Error(err))
_ = logger.Sync() // os.Exit 跳过 defer缓冲写需显式 flush 错误日志
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 控制日志可见延迟上界。
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(zapcore.NewConsoleEncoder(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("未知 Business automation AI provider回退 quick reply 模板", 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 已跳过", zap.String("provider", pc.Name), zap.String("kind", pc.Kind), zap.Error(err))
continue
}
providers = append(providers, provider)
logger.Info("AI compose provider 已启用", 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 已跳过", zap.String("provider", pc.Name), zap.Error(err))
continue
}
providers = append(providers, translationapp.NewAIProvider(provider))
logger.Info("translation provider 已启用", 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 已启用但没有远程 providermessages.translateText 将返回 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 调试端点已启用", 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 端点退出", 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)
return []obsmetrics.GaugeSample{
{Name: "telesrv_go_goroutines", Value: float64(runtime.NumGoroutine())},
{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()},
}
}
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_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_result_owners", Value: float64(snapshot.RPCResultOwners)},
{Name: "telesrv_mtproto_rpc_result_receipts", Value: float64(snapshot.RPCResultReceipts)},
{Name: "telesrv_mtproto_rpc_result_receipt_bytes", Value: float64(snapshot.RPCResultReceiptBytes)},
{Name: "telesrv_mtproto_rpc_result_subscribers", Value: float64(snapshot.RPCResultSubscribers)},
}
}
// 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)
}
users storepkg.UserCache
log *zap.Logger
}
func (n rpcProjectionVerificationNotifier) NotifyPeerVerified(ctx context.Context, peer domain.Peer) error {
if n.invalidator == nil {
return nil
}
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)
}
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 启动",
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 已迁移",
zap.Uint("schema_version", migrationStatus.Version),
zap.Bool("schema_dirty", migrationStatus.Dirty),
zap.Bool("schema_empty", migrationStatus.Empty),
)
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("持久化依赖就绪", zap.String("redis", cfg.RedisAddr))
if cfg.TelegramLoginEnabled {
telegramLoginHTTPHandler, err = telegramloginhttp.NewHandler(telegramloginhttp.Config{
Service: telegramLoginService, Tokens: telegramLoginIDTokens,
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)
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)
readModelVersionStore := storepkg.NewCachedReadModelVersionStore(postgres.NewReadModelVersionStore(pool), 0, 0)
dispatchOutboxStore := postgres.NewDispatchOutboxStore(pool, postgres.WithLeaseTimeout(cfg.OutboxLeaseTimeout))
bootstrapUpdateStore := postgres.NewBootstrapUpdateJobStore(pool)
botAPIUpdateStore := postgres.NewBotAPIUpdateStore(pool)
botCallbackStore := redisstore.NewBotCallbackRegistryStore(rdb)
ephemeralStore := redisstore.NewEphemeralMessageStore(rdb)
ephemeralReportStore := postgres.NewEphemeralReportStore(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))
secretChatIDAllocator := redisstore.NewSecretChatIDAllocator(rdb, postgres.NewSecretChatIDCounterSource(pool))
contactStore := userprojection.NewCachedContactStore(postgres.NewContactStore(pool), 0)
dialogStore := postgres.NewDialogStore(pool)
chatlistStore := postgres.NewChatlistStore(pool)
messageStore := postgres.NewMessageStore(pool,
postgres.WithMessageAllocators(boxIDAllocator),
postgres.WithMessageLogger(logger.Named("store").Named("messages")))
// 共享频道行/成员缓存 + 统一 read-model LISTEN/NOTIFY 实时失效:消除高频「逐 RPC
// 解析频道/成员」在客户端重连同步突发里重复读同一行的放大。
channelRowCache := postgres.NewChannelRowCache(cfg.ChannelRowCacheMaxEntries)
channelMemberCache := postgres.NewChannelMemberCache(cfg.ChannelMemberCacheMaxEntries)
channelDialogCache := postgres.NewChannelDialogCache(cfg.ChannelDialogCacheMaxEntries)
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.WithChannelMemberCache(channelMemberCache),
postgres.WithChannelDialogCache(channelDialogCache),
postgres.WithChannelBoostCache(channelBoostCache))
communityStore := postgres.NewCommunityStore(pool, channelIDAllocator, channelMessageIDAllocator)
pollStore := postgres.NewPollStore(pool)
mediaStore := postgres.NewMediaStore(pool)
// 头像投影缓存:所有 projector 共用一层短 TTL owner→头像缓存消除高频「返回用户」RPC
// 每次投影对每批 owner 固定 2 次的 CurrentProfilePhotosKind PG 查询。
cachedPhotos := userprojection.NewCachedPhotoProvider(mediaStore, userprojection.DefaultPhotoCacheTTL)
privacyStore := privacyapp.NewCachedPrivacyStore(postgres.NewPrivacyStore(pool), 0)
storyStore := postgres.NewStoryStore(pool)
blobBackend, err := filesapp.NewLocalFS(cfg.BlobDir)
if err != nil {
return fmt.Errorf("init blob backend: %w", err)
}
logger.Info("blob backend 就绪",
zap.String("backend", "localfs"),
zap.String("dir", cfg.BlobDir),
)
filesService := filesapp.NewService(mediaStore, blobBackend, cfg.DC,
filesapp.WithLogger(logger),
filesapp.WithUploadPartQuota(domain.UploadPartQuota{
MaxBytes: cfg.UploadInFlightMaxBytes,
MaxParts: cfg.UploadInFlightMaxParts,
MaxFiles: cfg.UploadInFlightMaxFiles,
}),
filesapp.WithMapboxMapTiles(cfg.MapboxToken, cfg.MapTileCacheDir),
externalMediaOption(cfg),
webPagePreviewOption(cfg),
)
if cfg.MapboxToken != "" {
logger.Info("地图缩略图代理已启用", zap.String("provider", "mapbox"), zap.String("cache_dir", cfg.MapTileCacheDir))
}
if cfg.ExternalMediaEnable {
logger.Info("外链媒体抓取已启用", zap.Int64("max_bytes", cfg.ExternalMediaMaxBytes), zap.Int("rate_per_min", cfg.ExternalMediaRatePerMin))
}
if cfg.WebPagePreviewEnable {
logger.Info("链接预览抓取已启用", 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("媒体种子导入完成",
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.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("外观种子导入完成",
zap.String("source", "default-seed"),
zap.Int("wallpapers", stats.Wallpapers),
zap.Int("documents", stats.Documents),
zap.Int("blobs", stats.Blobs),
)
}
if stats, err := filesService.WarmCaches(ctx); err != nil {
logger.Warn("媒体资源缓存预热失败", zap.Error(err))
} else if stats.StickerSets > 0 || stats.Documents > 0 || stats.Blobs > 0 {
logger.Info("媒体资源缓存预热完成",
zap.Int("sticker_sets", stats.StickerSets),
zap.Int("documents", stats.Documents),
zap.Int("blobs", stats.Blobs),
)
}
// 默认 emoji status 系统集:从 animated_emoji 精选合成(幂等,已 seed 的存量
// 库重启后自动补上);缺失时 premium 用户的 status 选择器会是空的。
if count, created, err := filesService.EnsureDefaultEmojiStatusSet(ctx); err != nil {
logger.Warn("默认 emoji status 系统集合成失败", zap.Error(err))
} else if created {
logger.Info("默认 emoji status 系统集已合成", 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,
OfficialGifts: officialgifts.New(cfg.OfficialGiftsDir),
})
go 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).
Run(ctx)
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(adminService),
contacts.WithReadModelVersions(readModelVersionStore),
)
if seeded, err := langPackService.SeedDirectory(ctx, cfg.LangPackSeedDir); err != nil {
return fmt.Errorf("seed langpack: %w", err)
} else if seeded > 0 {
logger.Info("语言包种子导入完成", zap.String("dir", cfg.LangPackSeedDir), zap.Int("strings", seeded))
}
// 国家区号目录:把 catalog 固化的官方全量(~235 国)幂等 upsert 进 PG,覆盖迁移里仅
// seed 的 2 国(US/CN)默认值。否则 countries 表非空,ListCountries 返回那 2 行就会
// 绕过 catalog,登录页/号码格式只显示 2 国。upsert 失败仅告警不阻断启动(回退旧 2 行)。
if cs := catalog.Countries().Countries; len(cs) > 0 {
if err := helpStore.UpsertCountries(ctx, cs); err != nil {
logger.Warn("国家区号种子导入失败", zap.Error(err))
} else {
logger.Info("国家区号种子导入完成", 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),
}
var webhookSender otpdelivery.Sender
if cfg.PhoneCodeDeliveryProvider == "webhook" ||
(cfg.LoginEmailEnable && 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 投递已启用",
zap.Bool("phone", cfg.PhoneCodeDeliveryProvider == "webhook"),
zap.Bool("email", cfg.LoginEmailEnable && 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 {
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,
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.WithUserStickerSets(accountService),
botsapp.WithTelegramLogin(telegramLoginService),
botsapp.WithDialogRateLimiter(rateLimiter, cfg.VerificationBotRateLimit, cfg.VerificationBotRateWindow),
botsapp.WithPublicBaseURL(cfg.PublicBaseURL))
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
}
// 服务端重启恢复SFU 状态全失,把全部活跃通话的参与者批量置 leftversion++
// 客户端经 checkGroupCall 发现自己 ssrc 消失后自动 rejoin。
if calls, err := groupCallsService.ResetAllParticipants(ctx, int(time.Now().Unix())); err != nil {
logger.Warn("重启清理群通话参与者失败", zap.Error(err))
} else if len(calls) > 0 {
logger.Info("重启清理群通话参与者", 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, secretChatIDAllocator)
starsStore := postgres.NewStarsStore(pool)
starsPurchaseStore := postgres.NewStarsPurchaseStore(pool, messageStore, channelStore)
starsService := stars.NewService(starsStore,
stars.WithStartingGrant(cfg.StarsStartingGrant),
stars.WithPurchaseStore(starsPurchaseStore))
starGiftStore := postgres.NewStarGiftStore(pool)
starGiftUpgradeStore := postgres.NewStarGiftUpgradeStore(pool, messageStore, postgres.WithStarGiftLifecyclePolicy(domain.StarGiftLifecyclePolicy{
TransferStars: cfg.StarGiftTransferStars, DropOriginalDetailsStars: cfg.StarGiftDropOriginalDetailsStars,
OfferMinStars: cfg.StarGiftOfferMinStars,
ExportDelaySeconds: int(cfg.StarGiftExportDelay / time.Second), TransferDelaySeconds: int(cfg.StarGiftTransferDelay / time.Second),
ResellDelaySeconds: int(cfg.StarGiftResellDelay / time.Second), CraftDelaySeconds: int(cfg.StarGiftCraftDelay / time.Second),
CraftChancePermille: cfg.StarGiftCraftChancePermille,
}))
starGiftLifecycleStore := postgres.NewStarGiftLifecycleStore(pool, messageStore, cfg.StarGiftTONStartingGrant,
postgres.WithStarGiftMarketPolicy(domain.StarGiftMarketPolicy{
StarsProceedsPermille: cfg.StarGiftStarsProceedsPermille,
TONProceedsPermille: cfg.StarGiftTONProceedsPermille,
}))
starGiftWithdrawalProvider, err := stargifts.NewLocalWithdrawalProvider(cfg.PublicBaseURL)
if err != nil {
return fmt.Errorf("init local star gift withdrawal provider: %w", err)
}
giftsService := stargifts.NewService(starGiftStore, blobBackend, cfg.DC,
stargifts.WithUpgradeStore(starGiftUpgradeStore),
stargifts.WithLifecycleStore(starGiftLifecycleStore),
stargifts.WithWithdrawalProvider(starGiftWithdrawalProvider))
// 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(adminService))
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(adminService),
dialogs.WithPremiumChecker(usersService.PremiumActive),
dialogs.WithReadModelVersions(readModelVersionStore),
)
// 编译期保证 *users.Service 满足 channel fan-out 跨 viewer 投影预热的可选能力;签名漂移会在
// 这里立刻断编译,而非在运行时静默退化回 O(viewer) 逐 viewer 投影。
var _ rpc.BatchViewerUsersResolver = usersService
channelsService := channelapp.NewService(channelStore,
channelapp.WithBotProfileResolver(botsService),
channelapp.WithReadModelVersions(readModelVersionStore),
channelapp.WithSendPermissionChecker(adminService),
)
communitiesService := communitiesapp.NewService(communityStore)
ephemeralService := ephemeralapp.NewService(ephemeralStore, channelsService, usersService, botsService)
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(adminService),
messageapp.WithReadModelVersions(readModelVersionStore),
messageapp.WithBotResponder(botsService),
messageapp.WithSendPermissionChecker(adminService),
messageapp.WithBusinessAutomation(passwordStore, businessAutomationOptions...),
)
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, authKeyStore, tempAuthKeyStore, cfg.DevAuthCode,
auth.WithLoginMessages(messageStore, dialogStore),
auth.WithLoginCodeDelivery(messageStore),
auth.WithPasswords(passwordStore),
auth.WithBotLogin(botStore),
auth.WithPremiumGrant(cfg.PremiumGrantMonths),
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("附加 OTP provider 投递失败777000 App-code 保持有效",
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,
}))
// Collectible (NFT) usernames and the gramsrv composite account rating are
// optional read models projected at the protocol edge. The rating worker
// computes and persists scores; profile reads never recompute them.
collectibleUsernameStore := postgres.NewCollectibleUsernameStore(pool)
accountRatingStore := postgres.NewAccountRatingStore(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")),
)
ratingService := ratingapp.NewService(
ratingapp.WithStore(accountRatingStore),
ratingapp.WithEnabled(cfg.RatingEnabled),
ratingapp.WithWeights(cfg.AccountRatingWeights()),
ratingapp.WithPendingDelay(cfg.RatingPendingDelay),
ratingapp.WithStaleAfter(cfg.RatingStaleAfter),
ratingapp.WithLogger(logger.Named("app").Named("rating")),
)
// 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")),
)
// @verifierbot files applications with the operator and reports decisions back.
botsService.SetCustomVerification(botVerificationService)
botVerificationService.SetApplicantNotifier(botsService)
updatesService := updates.NewService(updateStateStore, updateEventStore, updates.WithLogger(logger.Named("app").Named("updates")))
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,
PublicAppScheme: cfg.PublicAppScheme,
PublicAppLinkBase: cfg.PublicAppLinkBase,
// PFS temp→perm 解析缓存显式撤销会清缓存并断开连接re-bind 即时失效;
// 配置 TTL 只承担跨进程/异常失效兜底,避免大连接数周期性打满 PG。
TempKeyResolveCacheTTL: cfg.TempKeyResolveCacheTTL,
TempKeyResolveCacheMaxEntries: cfg.TempKeyResolveCacheMaxEntries,
}, rpc.Deps{
Auth: authService,
AuthDeliveryReports: authDeliveryReportService,
ClientTelemetry: clientTelemetryService,
AuthKeySessionLayers: authKeyStore,
Account: accountService,
Privacy: privacyService,
Help: help.NewService(helpStore, helpStore, help.WithMapboxToken(cfg.MapboxToken), help.WithAccountFreezeProvider(adminService)),
AccountFreeze: adminService,
AICompose: aiComposeService,
Ephemeral: ephemeralService,
EphemeralPush: ephemeralStore,
Moderation: moderationService,
Users: usersService,
Usernames: usernamesService,
AccountRatings: ratingService,
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,
Polls: pollsapp.NewService(pollStore),
Stories: storiesService,
Phone: phoneService,
SecretChats: secretChatService,
Stars: starsService,
Gifts: giftsService,
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,
ChannelMembers: channelMemberCache,
ChannelDialogs: channelDialogCache,
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,
BaseUsers: userCache,
BotProfiles: botsService,
StarGifts: giftsService,
AccountSettings: router,
}, logger.Named("store").Named("read-model-listener"))
go readModelListener.Run(ctx)
activeSessions.SetLifecycleObserver(router)
adminService.Configure(adminapp.Dependencies{
Auth: authService,
Revoker: router,
Users: usersService,
Stars: starsService,
StarsNotifier: router,
UserNotifier: router,
UserModerationNotifier: router,
FreezeNotifier: router,
Channels: channelsService,
ChannelNotifier: router,
Messages: messagesService,
Gifts: giftsService,
GiftGranter: router,
Bots: botsService,
Emoji: filesService,
Moderation: moderationService,
Usernames: usernamesService,
Rating: ratingService,
Verification: verificationService,
BotVerification: botVerificationService,
})
// 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,
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"))
}
go ratingapp.NewRecomputeWorker(ratingService, logger.Named("rating").Named("recompute"),
cfg.RatingRecomputeInterval, cfg.RatingRecomputeBatch).Run(ctx)
// 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)
moderationActionOptions := []moderationapp.ActionExecutorOption{}
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.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.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 func() {
interval := cfg.StarGiftSweepInterval
if interval <= 0 {
interval = 15 * time.Second
}
batch := cfg.StarGiftSweepBatch
if batch <= 0 {
batch = 1000
}
run := func() {
if err := giftsService.SweepLifecycle(ctx, int(time.Now().Unix()), batch); err != nil && ctx.Err() == nil {
logger.Warn("star_gift_lifecycle_sweep_failed", zap.Error(err))
}
}
run()
ticker := time.NewTicker(interval)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
run()
}
}
}()
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,
StickerSets: filesService,
Users: userStore,
Channels: channelStore,
Privacy: privacyService,
Photos: filesService,
UniqueGifts: giftsService,
GiftWithdrawals: giftsService,
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,
LayerRPC: router,
AuthKeys: authKeyStore,
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,
RPCResultCacheMaxEntries: cfg.MTProtoRPCResultCacheMaxEntries,
RPCResultCacheAuthMaxEntries: cfg.MTProtoRPCResultCacheAuthMaxEntries,
RPCResultCacheSessionMaxEntries: cfg.MTProtoRPCResultCacheSessionMaxEntries,
RPCResultPendingPerAuth: cfg.MTProtoRPCResultPendingPerAuth,
InboundFrameGlobalMaxBytes: cfg.MTProtoInboundFrameGlobalMaxBytes,
OutboundQueueSize: cfg.MTProtoOutboundQueueSize,
OutboundControlQueueSize: cfg.MTProtoOutboundControlQueueSize,
OutboundTrackedGlobalMaxBytes: cfg.MTProtoOutboundTrackedGlobalMaxBytes,
OutboundWriteGlobalMaxBytes: cfg.MTProtoOutboundWriteGlobalMaxBytes,
OnServing: func(_ net.Addr) {
logger.Info("telesrv 服务就绪",
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"),
)
},
})
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()
}
}
}