// Package telegram implements the Telegram Bot API as a channel adapter for // Cyrene v2. It conforms to internal/channel/adapter.PlatformAdapter, translating // Telegram updates (getUpdates long polling, or a registered webhook) into the // normalized UnifiedMessage model and delivering outbound replies through the // Bot API send* methods. // // Media is carried by Telegram file_id values: inbound attachments populate // UnifiedMessage.Media with URL set to the file_id (plus size/name/mime when the // update provides them), and outbound image/voice/documents are sent back by // file_id via sendPhoto/sendVoice/sendDocument. The design authority is // DevDocs/design/08-渠道接入.md section 4; the contract is defined in sections // 0-2. Proactive push is enabled (design: Telegram is a strong push channel). // // The adapter is self-contained: it depends only on the standard library plus // the project's internal adapter, config-independent logger, and channel/actor // model. Tokens are never logged. package telegram import ( "context" "encoding/json" "fmt" "log/slog" "net/http" "sort" "sync" "git.yeij.top/AskaEth/Cyrene_v2/internal/channel/adapter" ) // Compile-time assertion that Adapter satisfies the shared contract. var _ adapter.PlatformAdapter = (*Adapter)(nil) // Adapter is a Telegram Bot API channel adapter. It is safe for concurrent use. // The zero use is via New; do not copy it after first use. type Adapter struct { mu sync.RWMutex cfg Config client *http.Client baseURL string log *slog.Logger connected bool closed bool webhook bool handler adapter.MessageHandler pollCancel context.CancelFunc runID uint64 offset int64 chats map[string]adapter.ChannelInfo } // Name returns the platform identifier. func (a *Adapter) Name() string { return "telegram" } // Config returns a snapshot of the adapter's configuration. The BotToken is the // sensitive field; callers that persist this map must treat it as a secret. func (a *Adapter) Config() map[string]any { a.mu.RLock() defer a.mu.RUnlock() return map[string]any{ "bot_token": a.cfg.BotToken, "webhook_url": a.cfg.WebhookURL, } } // Connect establishes the session. It is idempotent. When WebhookURL is // configured the webhook is registered with the Bot API and long polling is // disabled; otherwise the adapter prepares for getUpdates polling. func (a *Adapter) Connect(ctx context.Context) error { a.mu.Lock() if a.connected { a.mu.Unlock() return nil } if a.closed { a.mu.Unlock() return fmt.Errorf("telegram: %w", adapter.ErrClosed) } if a.cfg.BotToken == "" { a.mu.Unlock() return fmt.Errorf("telegram: %w", ErrMissingToken) } token := a.cfg.BotToken webhookURL := a.cfg.WebhookURL a.mu.Unlock() webhookMode := false if webhookURL != "" { if err := a.setWebhook(ctx, webhookURL); err != nil { return fmt.Errorf("telegram: register webhook: %w", err) } webhookMode = true } a.log.Info("connected", "webhook", webhookMode, "token_len", len(token)) a.mu.Lock() a.connected = true a.webhook = webhookMode a.offset = 0 a.mu.Unlock() return nil } // Disconnect tears down the session and stops an in-flight poll loop. It is // idempotent. func (a *Adapter) Disconnect(ctx context.Context) error { a.mu.Lock() if !a.connected { a.mu.Unlock() return nil } a.connected = false cancel := a.pollCancel a.mu.Unlock() if cancel != nil { cancel() } a.log.Info("disconnected") return nil } // Close permanently shuts the adapter down, stopping any polling loop and // marking it closed. It is idempotent; subsequent operations fail with // adapter.ErrClosed. func (a *Adapter) Close() error { a.mu.Lock() if a.closed { a.mu.Unlock() return nil } a.closed = true a.connected = false cancel := a.pollCancel a.mu.Unlock() if cancel != nil { cancel() } a.log.Info("closed") return nil } // IsConnected reports whether the session is currently live. func (a *Adapter) IsConnected() bool { a.mu.RLock() defer a.mu.RUnlock() return a.connected } // HealthCheck verifies the session is usable by calling getMe against the Bot // API. It returns an error wrapping adapter.ErrNotConnected when not connected. func (a *Adapter) HealthCheck(ctx context.Context) error { if err := a.requireConnected(); err != nil { return err } bot, err := a.getMe(ctx) if err != nil { return fmt.Errorf("telegram: health check: %w", err) } a.log.Debug("health check ok", "bot", bot) return nil } // Capabilities declares what the Telegram platform supports. Proactive push is // enabled because Telegram permits arbitrary outbound messages. func (a *Adapter) Capabilities() adapter.Capabilities { return adapter.Capabilities{ MaxMessageLength: maxMessageLen, Markdown: true, Image: true, Voice: true, Reaction: false, TypingHint: false, RecommendBurstMax: 1, Proactive: true, }.Normalize() } // SetMessageHandler installs the push-style inbound callback used by the webhook // delivery path (see ProcessUpdate). A nil fn clears the handler. func (a *Adapter) SetMessageHandler(fn adapter.MessageHandler) { a.mu.Lock() defer a.mu.Unlock() a.handler = fn } // ListChannels returns the conversations observed so far, ordered by ChannelID. func (a *Adapter) ListChannels(ctx context.Context) ([]adapter.ChannelInfo, error) { a.mu.RLock() defer a.mu.RUnlock() out := make([]adapter.ChannelInfo, 0, len(a.chats)) for _, c := range a.chats { out = append(out, c) } sort.Slice(out, func(i, j int) bool { return out[i].ChannelID < out[j].ChannelID }) return out, nil } // ProcessUpdate decodes a raw webhook payload (a single Telegram Update) and // delivers the normalized message through the installed message handler. It is // the push-side counterpart to ReadMessages and returns the number of messages // delivered. Non-message updates (for example callback queries) are ignored. func (a *Adapter) ProcessUpdate(ctx context.Context, raw []byte) (int, error) { var update telegramUpdate if err := json.Unmarshal(raw, &update); err != nil { return 0, fmt.Errorf("telegram: decode webhook update: %w", err) } msg, ok := a.toUnifiedMessage(update) if !ok { return 0, nil } if err := msg.Validate(); err != nil { return 0, fmt.Errorf("telegram: validate webhook update: %w", err) } a.mu.RLock() handler := a.handler a.mu.RUnlock() if handler != nil { handler(ctx, msg) } return 1, nil } // requireConnected guards operations that need a live session. func (a *Adapter) requireConnected() error { a.mu.RLock() defer a.mu.RUnlock() if a.closed { return fmt.Errorf("telegram: %w", adapter.ErrClosed) } if !a.connected { return fmt.Errorf("telegram: %w", adapter.ErrNotConnected) } return nil } // isWebhookMode reports whether the adapter is serving via webhook rather than // polling. func (a *Adapter) isWebhookMode() bool { a.mu.RLock() defer a.mu.RUnlock() return a.webhook }