feat(channels/telegram): Telegram 渠道插件(Bot API 长轮询/Webhook + file_id 媒体 + 主动推送强)

This commit is contained in:
Aska committed 2026-09-06 06:38:13 +08:00
1 parent 0b036645bb
commit f9f5a78a02
12 files changed
+1983

No files matched your search

+244
View File
@@ -0,0 +1,244 @@
// 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
}
+205
View File
@@ -0,0 +1,205 @@
package telegram
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"strconv"
"strings"
)
// botResponse is the shared envelope Telegram returns for every method: ok plus
// either result or the error description/code.
type botResponse struct {
OK bool `json:"ok"`
Result json.RawMessage `json:"result"`
Description string `json:"description"`
ErrorCode int `json:"error_code"`
}
// doJSON posts a JSON payload to a Bot API method and decodes the envelope. The
// caller is responsible for decoding Result; a non-OK reply is returned as
// ErrBadResponse wrapped with the API description.
func (a *Adapter) doJSON(ctx context.Context, method string, payload any) (*botResponse, error) {
var buf bytes.Buffer
if payload != nil {
if err := json.NewEncoder(&buf).Encode(payload); err != nil {
return nil, fmt.Errorf("telegram: encode %s request: %w", method, err)
}
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, a.endpoint(method), &buf)
if err != nil {
return nil, fmt.Errorf("telegram: build %s request: %w", method, err)
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Accept", "application/json")
resp, err := a.client.Do(req)
if err != nil {
return nil, fmt.Errorf("telegram: %s call: %w", method, err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(io.LimitReader(resp.Body, 512))
desc := strings.TrimSpace(string(body))
if desc != "" {
return nil, fmt.Errorf("telegram: %s: status %d: %s: %w", method, resp.StatusCode, desc, ErrBadResponse)
}
return nil, fmt.Errorf("telegram: %s: status %d: %w", method, resp.StatusCode, ErrBadResponse)
}
var out botResponse
if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
return nil, fmt.Errorf("telegram: decode %s response: %w", method, err)
}
if !out.OK {
return nil, fmt.Errorf("telegram: %s: %w", method, ErrBadResponse)
}
return &out, nil
}
// getMe verifies the token and returns the bot's username.
func (a *Adapter) getMe(ctx context.Context) (string, error) {
out, err := a.doJSON(ctx, "getMe", nil)
if err != nil {
return "", err
}
var me struct {
Username string `json:"username"`
}
if err := json.Unmarshal(out.Result, &me); err != nil {
return "", fmt.Errorf("telegram: getMe result: %w", err)
}
return me.Username, nil
}
// setWebhook registers the webhook URL and disables getUpdates polling.
func (a *Adapter) setWebhook(ctx context.Context, url string) error {
_, err := a.doJSON(ctx, "setWebhook", map[string]any{"url": url})
return err
}
// getUpdates performs a long-poll for updates at the current offset. It returns
// a list of updates; the caller advances a.offset after acknowledging them.
func (a *Adapter) getUpdates(ctx context.Context) ([]telegramUpdate, error) {
a.mu.RLock()
offset := a.offset
a.mu.RUnlock()
payload := map[string]any{
"offset": offset,
"limit": maxUpdatesPerPoll,
"timeout": longPollTimeoutSec,
}
out, err := a.doJSON(ctx, "getUpdates", payload)
if err != nil {
return nil, err
}
var updates []telegramUpdate
if err := json.Unmarshal(out.Result, &updates); err != nil {
return nil, fmt.Errorf("telegram: getUpdates result: %w", err)
}
if updates == nil {
updates = []telegramUpdate{}
}
return updates, nil
}
// sendMessage sends a text message and returns the new message id.
func (a *Adapter) sendMessage(ctx context.Context, chatID, text string, replyTo int64, parseMode string) (int64, error) {
payload := map[string]any{
"chat_id": chatID,
"text": text,
}
if replyTo != 0 {
payload["reply_to_message_id"] = replyTo
}
if parseMode != "" {
payload["parse_mode"] = parseMode
}
return a.sendPayload(ctx, "sendMessage", payload)
}
// sendPhoto sends an existing photo by file_id and returns the new message id.
func (a *Adapter) sendPhoto(ctx context.Context, chatID, fileID string, replyTo int64, caption string) (int64, error) {
payload := map[string]any{
"chat_id": chatID,
"photo": fileID,
}
if caption != "" {
payload["caption"] = caption
payload["parse_mode"] = "Markdown"
}
if replyTo != 0 {
payload["reply_to_message_id"] = replyTo
}
return a.sendPayload(ctx, "sendPhoto", payload)
}
// sendVoice sends an existing voice clip by file_id and returns the new id.
func (a *Adapter) sendVoice(ctx context.Context, chatID, fileID string, replyTo int64, caption string) (int64, error) {
payload := map[string]any{
"chat_id": chatID,
"voice": fileID,
}
if caption != "" {
payload["caption"] = caption
payload["parse_mode"] = "Markdown"
}
if replyTo != 0 {
payload["reply_to_message_id"] = replyTo
}
return a.sendPayload(ctx, "sendVoice", payload)
}
// sendDocument sends an existing document by file_id and returns the new id.
func (a *Adapter) sendDocument(ctx context.Context, chatID, fileID, fileName string, replyTo int64, caption string) (int64, error) {
payload := map[string]any{
"chat_id": chatID,
"document": fileID,
}
if fileName != "" {
payload["file_name"] = fileName
}
if caption != "" {
payload["caption"] = caption
payload["parse_mode"] = "Markdown"
}
if replyTo != 0 {
payload["reply_to_message_id"] = replyTo
}
return a.sendPayload(ctx, "sendDocument", payload)
}
// sendPayload posts a media/message payload and returns the acknowledged
// message id from the Bot API result.
func (a *Adapter) sendPayload(ctx context.Context, method string, payload map[string]any) (int64, error) {
out, err := a.doJSON(ctx, method, payload)
if err != nil {
return 0, err
}
var res struct {
MessageID int64 `json:"message_id"`
}
if err := json.Unmarshal(out.Result, &res); err != nil {
return 0, fmt.Errorf("telegram: %s result: %w", method, err)
}
return res.MessageID, nil
}
// parseReplyToID converts a reply context identifier to a Telegram message id.
// A non-numeric or empty value yields 0 (no reply).
func parseReplyToID(s string) int64 {
if s == "" {
return 0
}
n, err := strconv.ParseInt(s, 10, 64)
if err != nil {
return 0
}
return n
}
+90
View File
@@ -0,0 +1,90 @@
package telegram
import (
"io"
"log/slog"
"net/http"
"time"
"git.yeij.top/AskaEth/Cyrene_v2/internal/channel/adapter"
"git.yeij.top/AskaEth/Cyrene_v2/internal/logger"
)
// Defaults and knob values for the Telegram Bot API integration.
const (
// defaultBaseURL is the Telegram API root for unconfigured adapters.
defaultBaseURL = "https://api.telegram.org"
// httpTimeout bounds a single Bot API request.
httpTimeout = 180 * time.Second
// longPollTimeoutSec is the getUpdates long-poll timeout in seconds.
longPollTimeoutSec = 50
// pollInterval is how long to sleep between getUpdates batches.
pollInterval = 1 * time.Second
// maxUpdatesPerPoll caps the number of updates fetched in one getUpdates call.
maxUpdatesPerPoll = 100
// maxMessageLen is Telegram's per-message text limit.
maxMessageLen = 4096
)
// Config holds the settings required to reach the Telegram Bot API.
type Config struct {
// BotToken is the secret token issued by BotFather. It is stored and
// surfaced through Config() but never written to logs.
BotToken string `json:"bot_token"`
// WebhookURL is the public HTTPS endpoint Telegram delivers updates to.
// When empty the adapter uses getUpdates long polling instead.
WebhookURL string `json:"webhook_url"`
}
// Option customizes an Adapter at construction time.
type Option func(*Adapter)
// WithHTTPClient injects the *http.Client used for Bot API requests. Tests pass
// the client of an httptest.Server so the mock endpoint answers calls.
func WithHTTPClient(c *http.Client) Option {
return func(a *Adapter) { a.client = c }
}
// WithBaseURL overrides the Telegram API root (default https://api.telegram.org).
// Tests point it at an httptest.Server URL.
func WithBaseURL(base string) Option {
return func(a *Adapter) {
if base != "" {
a.baseURL = base
}
}
}
// WithLogger injects the logger used by the adapter. When nil the adapter logs
// through a discard logger so a bare adapter stays silent; callers wire their
// production logger via this option.
func WithLogger(l *slog.Logger) Option {
return func(a *Adapter) {
if l == nil {
l = logger.New(logger.Options{Out: io.Discard})
}
a.log = logger.WithSource(l, "plugins/channels/telegram")
}
}
// New builds a Telegram adapter. It performs no network I/O; call Connect to
// establish the session.
func New(cfg Config, opts ...Option) *Adapter {
a := &Adapter{
cfg: cfg,
client: &http.Client{Timeout: httpTimeout},
baseURL: defaultBaseURL,
log: logger.WithSource(logger.New(logger.Options{Out: io.Discard}), "plugins/channels/telegram"),
chats: map[string]adapter.ChannelInfo{},
}
for _, opt := range opts {
opt(a)
}
return a
}
// endpoint returns the absolute Bot API URL for the given method, bundling the
// token into the path (never into query parameters or logs).
func (a *Adapter) endpoint(method string) string {
return a.baseURL + "/bot" + a.cfg.BotToken + "/" + method
}
+217
View File
@@ -0,0 +1,217 @@
package telegram
import (
"context"
"errors"
"net/http"
"sync"
"testing"
"git.yeij.top/AskaEth/Cyrene_v2/internal/channel/adapter"
)
func TestName(t *testing.T) {
t.Parallel()
a := New(Config{BotToken: "x"})
if got := a.Name(); got != "telegram" {
t.Fatalf("Name() = %q, want telegram", got)
}
}
func TestConfig(t *testing.T) {
t.Parallel()
a := New(Config{BotToken: "abc123", WebhookURL: "https://example.com/hook"})
got := a.Config()
if got["bot_token"] != "abc123" {
t.Fatalf("bot_token = %v, want abc123", got["bot_token"])
}
if got["webhook_url"] != "https://example.com/hook" {
t.Fatalf("webhook_url = %v, want https://example.com/hook", got["webhook_url"])
}
// Round-trip through the shared decoder to prove the map is decodable.
var round Config
if err := adapter.DecodeConfig(got, &round); err != nil {
t.Fatalf("DecodeConfig: %v", err)
}
if round.BotToken != "abc123" || round.WebhookURL != "https://example.com/hook" {
t.Fatalf("round trip mismatch: %+v", round)
}
}
func TestCapabilities(t *testing.T) {
t.Parallel()
a := New(Config{BotToken: "x"})
caps := a.Capabilities()
if !caps.Proactive {
t.Error("Proactive should be true for telegram")
}
if !caps.Markdown || !caps.Image || !caps.Voice {
t.Errorf("expected Markdown/Image/Voice supported, got %+v", caps)
}
if caps.Reaction || caps.TypingHint {
t.Errorf("expected Reaction/TypingHint unsupported, got %+v", caps)
}
if caps.MaxMessageLength != 4096 {
t.Errorf("MaxMessageLength = %d, want 4096", caps.MaxMessageLength)
}
if caps.RecommendBurstMax <= 0 {
t.Errorf("RecommendBurstMax should be positive, got %d", caps.RecommendBurstMax)
}
}
func TestListChannels_Empty(t *testing.T) {
t.Parallel()
a := New(Config{BotToken: "x"})
got, err := a.ListChannels(context.Background())
if err != nil {
t.Fatalf("ListChannels: %v", err)
}
if got == nil || len(got) != 0 {
t.Fatalf("expected empty non-nil slice, got %#v", got)
}
}
func TestConnect_MissingToken(t *testing.T) {
t.Parallel()
a := New(Config{})
err := a.Connect(context.Background())
if !errors.Is(err, ErrMissingToken) {
t.Fatalf("err = %v, want ErrMissingToken", err)
}
if a.IsConnected() {
t.Fatal("should not be connected")
}
}
func TestConnect_WebhookIdempotent(t *testing.T) {
t.Parallel()
var mu sync.Mutex
calls := 0
a, _ := newTestAdapter(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if methodOf(r) != "setWebhook" {
http.Error(w, "unexpected method", http.StatusNotFound)
return
}
mu.Lock()
calls++
mu.Unlock()
okResponse(w, map[string]any{"webhook_url": "https://example.com/hook"})
}))
a.cfg.WebhookURL = "https://example.com/hook"
if err := a.Connect(context.Background()); err != nil {
t.Fatalf("Connect: %v", err)
}
if !a.IsConnected() {
t.Fatal("expected connected")
}
if err := a.Connect(context.Background()); err != nil {
t.Fatalf("second Connect should be idempotent: %v", err)
}
mu.Lock()
if calls != 1 {
t.Fatalf("expected 1 setWebhook call, got %d", calls)
}
mu.Unlock()
}
func TestConnect_PollingMode(t *testing.T) {
t.Parallel()
a, _ := newTestAdapter(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
http.Error(w, "no api call expected in poll mode", http.StatusNotFound)
}))
if err := a.Connect(context.Background()); err != nil {
t.Fatalf("Connect: %v", err)
}
if !a.IsConnected() {
t.Fatal("expected connected")
}
if a.isWebhookMode() {
t.Fatal("expected polling mode")
}
}
func TestDisconnect_Idempotent(t *testing.T) {
t.Parallel()
a, _ := newTestAdapter(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
http.Error(w, "no api call expected", http.StatusNotFound)
}))
if err := a.Connect(context.Background()); err != nil {
t.Fatalf("Connect: %v", err)
}
if err := a.Disconnect(context.Background()); err != nil {
t.Fatalf("Disconnect: %v", err)
}
if a.IsConnected() {
t.Fatal("expected disconnected")
}
if err := a.Disconnect(context.Background()); err != nil {
t.Fatalf("second Disconnect should be idempotent: %v", err)
}
}
func TestClose_Terminal(t *testing.T) {
t.Parallel()
a, _ := newTestAdapter(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
http.Error(w, "no api call expected", http.StatusNotFound)
}))
if err := a.Connect(context.Background()); err != nil {
t.Fatalf("Connect: %v", err)
}
if err := a.Close(); err != nil {
t.Fatalf("Close: %v", err)
}
if a.IsConnected() {
t.Fatal("expected disconnected after close")
}
if err := a.Connect(context.Background()); !errors.Is(err, adapter.ErrClosed) {
t.Fatalf("Connect after close err = %v, want ErrClosed", err)
}
if err := a.HealthCheck(context.Background()); !errors.Is(err, adapter.ErrClosed) {
t.Fatalf("HealthCheck after close err = %v, want ErrClosed", err)
}
}
func TestHealthCheck_OK(t *testing.T) {
t.Parallel()
a, _ := newTestAdapter(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if methodOf(r) != "getMe" {
http.Error(w, "unexpected method", http.StatusNotFound)
return
}
okResponse(w, map[string]any{"id": 123, "username": "cyrene_bot"})
}))
if err := a.Connect(context.Background()); err != nil {
t.Fatalf("Connect: %v", err)
}
if err := a.HealthCheck(context.Background()); err != nil {
t.Fatalf("HealthCheck: %v", err)
}
}
func TestHealthCheck_NotConnected(t *testing.T) {
t.Parallel()
a := New(Config{BotToken: "x"})
err := a.HealthCheck(context.Background())
if !errors.Is(err, adapter.ErrNotConnected) {
t.Fatalf("err = %v, want ErrNotConnected", err)
}
}
func TestHealthCheck_APIError(t *testing.T) {
t.Parallel()
a, _ := newTestAdapter(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if methodOf(r) != "getMe" {
http.Error(w, "unexpected method", http.StatusNotFound)
return
}
failResponse(w, "Unauthorized", 401)
}))
if err := a.Connect(context.Background()); err != nil {
t.Fatalf("Connect: %v", err)
}
err := a.HealthCheck(context.Background())
if !errors.Is(err, ErrBadResponse) {
t.Fatalf("err = %v, want ErrBadResponse", err)
}
}
+17
View File
@@ -0,0 +1,17 @@
package telegram
import "errors"
// Sentinel errors returned by the Telegram adapter. Consumers test for them
// with errors.Is; failures that mean "not connected" wrap adapter.ErrNotConnected
// so the shared channel sentinel still matches.
var (
// ErrMissingToken is returned when no BotToken is configured.
ErrMissingToken = errors.New("telegram: missing bot token")
// ErrBadResponse is returned when the Bot API replies with OK=false or an
// unexpected payload shape.
ErrBadResponse = errors.New("telegram: bad bot api response")
// ErrNoChatTarget is returned when an outbound message carries no usable
// conversation/group identifier.
ErrNoChatTarget = errors.New("telegram: message has no target chat")
)
+61
View File
@@ -0,0 +1,61 @@
package telegram
import (
"encoding/json"
"io"
"log/slog"
"net/http"
"net/http/httptest"
"strings"
"testing"
"git.yeij.top/AskaEth/Cyrene_v2/internal/logger"
)
// discardLogger returns a silent, source-tagged logger for tests.
func discardLogger() *slog.Logger {
return logger.WithSource(logger.New(logger.Options{Out: io.Discard}), "plugins/channels/telegram")
}
// newTestAdapter wires a Telegram adapter to an httptest server whose handler
// answers the Bot API methods. The server is closed on test cleanup.
func newTestAdapter(t *testing.T, h http.Handler) (*Adapter, *httptest.Server) {
t.Helper()
srv := httptest.NewServer(h)
t.Cleanup(srv.Close)
a := New(
Config{BotToken: "123456:TESTTOKEN"},
WithBaseURL(srv.URL),
WithHTTPClient(srv.Client()),
WithLogger(discardLogger()),
)
return a, srv
}
// methodOf extracts the Bot API method name from the request path, which takes
// the form /bot<token>/<method>.
func methodOf(r *http.Request) string {
parts := strings.Split(r.URL.Path, "/")
return parts[len(parts)-1]
}
// decodeBody unmarshals an incoming JSON request body into a map for assertions.
func decodeBody(r *http.Request) map[string]any {
var m map[string]any
if err := json.NewDecoder(r.Body).Decode(&m); err != nil {
return nil
}
return m
}
// okResponse writes a generic Bot API success envelope with the given result.
func okResponse(w http.ResponseWriter, result any) {
body := map[string]any{"ok": true, "result": result}
_ = json.NewEncoder(w).Encode(body)
}
// failResponse writes a Bot API error envelope.
func failResponse(w http.ResponseWriter, desc string, code int) {
body := map[string]any{"ok": false, "description": desc, "error_code": code}
_ = json.NewEncoder(w).Encode(body)
}
+93
View File
@@ -0,0 +1,93 @@
package telegram
import (
"context"
"time"
"git.yeij.top/AskaEth/Cyrene_v2/internal/channel/adapter"
)
// ReadMessages runs a blocking getUpdates long-poll loop, emitting each
// normalized inbound message to emit until ctx is canceled or the adapter is
// disconnected. When running in webhook mode it does not poll; it simply waits
// for ctx to be canceled, since messages are delivered through the message
// handler via ProcessUpdate instead.
//
// The update offset is advanced only after a batch has been fully processed, so
// a canceled read does not lose the batch; acknowledgment is batched so the
// getUpdates call does not re-deliver the same update_id flood.
func (a *Adapter) ReadMessages(ctx context.Context, emit func(adapter.UnifiedMessage)) {
if emit == nil {
return
}
if a.isWebhookMode() {
<-ctx.Done()
return
}
if err := a.requireConnected(); err != nil {
a.log.Warn("read not started", "err", err)
return
}
pollCtx, cancel := context.WithCancel(ctx)
a.mu.Lock()
a.runID++
a.pollCancel = cancel
runID := a.runID
a.mu.Unlock()
defer func() {
a.mu.Lock()
if a.runID == runID {
a.pollCancel = nil
}
a.mu.Unlock()
cancel()
}()
for {
if err := pollCtx.Err(); err != nil {
return
}
updates, err := a.getUpdates(pollCtx)
if err != nil {
a.log.Warn("long poll failed", "err", err)
if !sleepWait(pollCtx, pollInterval) {
return
}
continue
}
if len(updates) == 0 {
if !sleepWait(pollCtx, pollInterval) {
return
}
continue
}
for _, u := range updates {
msg, ok := a.toUnifiedMessage(u)
if !ok {
continue
}
if err := msg.Validate(); err != nil {
a.log.Warn("skip invalid message", "err", err)
continue
}
emit(msg)
}
a.mu.Lock()
a.offset = updates[len(updates)-1].UpdateID + 1
a.mu.Unlock()
}
}
// sleepWait blocks for d or until ctx is canceled, returning false when the
// context fires first.
func sleepWait(ctx context.Context, d time.Duration) bool {
t := time.NewTimer(d)
defer t.Stop()
select {
case <-ctx.Done():
return false
case <-t.C:
return true
}
}
+171
View File
@@ -0,0 +1,171 @@
package telegram
import (
"context"
"net/http"
"sync"
"testing"
"time"
"git.yeij.top/AskaEth/Cyrene_v2/internal/channel/adapter"
)
// fakeUpdate builds a minimal telegramUpdate JSON object.
func fakeUpdate(id int, text string) map[string]any {
return map[string]any{
"update_id": id,
"message": map[string]any{
"message_id": id,
"from": map[string]any{"id": 1, "first_name": "Tester"},
"chat": map[string]any{"id": 10, "type": "private"},
"date": 1700000000,
"text": text,
},
}
}
func TestReadMessages_LongPollOffset(t *testing.T) {
t.Parallel()
var mu sync.Mutex
first := true
a, _ := newTestAdapter(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if methodOf(r) != "getUpdates" {
http.Error(w, "unexpected method", http.StatusNotFound)
return
}
mu.Lock()
firstCall := first
first = false
mu.Unlock()
if firstCall {
okResponse(w, []map[string]any{fakeUpdate(1, "one"), fakeUpdate(2, "two")})
return
}
okResponse(w, []map[string]any{})
}))
if err := a.Connect(context.Background()); err != nil {
t.Fatalf("Connect: %v", err)
}
ctx, cancel := context.WithCancel(context.Background())
emitted := make(chan adapter.UnifiedMessage, 4)
done := make(chan struct{})
go func() {
a.ReadMessages(ctx, func(m adapter.UnifiedMessage) { emitted <- m })
close(done)
}()
got := make([]adapter.UnifiedMessage, 0, 2)
for i := 0; i < 2; i++ {
select {
case m := <-emitted:
got = append(got, m)
case <-time.After(3 * time.Second):
t.Fatal("timeout waiting for emitted message")
}
}
cancel()
select {
case <-done:
case <-time.After(2 * time.Second):
t.Fatal("ReadMessages did not return after ctx cancel")
}
if len(got) != 2 {
t.Fatalf("emitted %d messages, want 2", len(got))
}
if got[0].Content != "one" || got[1].Content != "two" {
t.Errorf("contents = %q/%q, want one/two", got[0].Content, got[1].Content)
}
a.mu.RLock()
offset := a.offset
a.mu.RUnlock()
if offset != 3 {
t.Errorf("offset = %d, want 3", offset)
}
}
func TestReadMessages_CtxPreCanceled(t *testing.T) {
t.Parallel()
a, _ := newTestAdapter(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
http.Error(w, "no api call expected", http.StatusNotFound)
}))
ctx, cancel := context.WithCancel(context.Background())
cancel()
done := make(chan struct{})
go func() {
a.ReadMessages(ctx, func(m adapter.UnifiedMessage) {})
close(done)
}()
select {
case <-done:
case <-time.After(2 * time.Second):
t.Fatal("ReadMessages should return immediately on canceled ctx")
}
}
func TestReadMessages_WebhookWaits(t *testing.T) {
t.Parallel()
var calls int
var mu sync.Mutex
a, _ := newTestAdapter(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if methodOf(r) == "setWebhook" {
okResponse(w, map[string]any{"webhook_url": "https://example.com/hook"})
return
}
mu.Lock()
calls++
mu.Unlock()
http.Error(w, "no getUpdates expected in webhook mode", http.StatusNotFound)
}))
a.cfg.WebhookURL = "https://example.com/hook"
if err := a.Connect(context.Background()); err != nil {
t.Fatalf("Connect: %v", err)
}
ctx, cancel := context.WithCancel(context.Background())
done := make(chan struct{})
go func() {
a.ReadMessages(ctx, func(m adapter.UnifiedMessage) {})
close(done)
}()
time.Sleep(100 * time.Millisecond)
cancel()
select {
case <-done:
case <-time.After(2 * time.Second):
t.Fatal("ReadMessages should return on cancel in webhook mode")
}
mu.Lock()
if calls != 0 {
t.Errorf("getUpdates called %d times in webhook mode", calls)
}
mu.Unlock()
}
func TestReadMessages_ErrorRetriesThenCancel(t *testing.T) {
t.Parallel()
a, _ := newTestAdapter(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if methodOf(r) != "getUpdates" {
http.Error(w, "unexpected method", http.StatusNotFound)
return
}
failResponse(w, "Conflict", 409)
}))
if err := a.Connect(context.Background()); err != nil {
t.Fatalf("Connect: %v", err)
}
ctx, cancel := context.WithCancel(context.Background())
done := make(chan struct{})
go func() {
a.ReadMessages(ctx, func(m adapter.UnifiedMessage) {})
close(done)
}()
time.Sleep(150 * time.Millisecond)
cancel()
select {
case <-done:
case <-time.After(2 * time.Second):
t.Fatal("ReadMessages should return on cancel despite getUpdates errors")
}
}
+94
View File
@@ -0,0 +1,94 @@
package telegram
import (
"context"
"fmt"
"git.yeij.top/AskaEth/Cyrene_v2/internal/channel/adapter"
)
// markdownParseMode is the parse_mode sent with outgoing text/captions so the
// Markdown capability is actually exercised.
const markdownParseMode = "Markdown"
// SendMessage delivers the reply messages contained in resp to their target
// chats. Each message goes to its own ChannelID (or GroupID) conversation.
func (a *Adapter) SendMessage(ctx context.Context, resp adapter.UnifiedResponse) error {
if err := a.requireConnected(); err != nil {
return err
}
for _, m := range resp.Messages {
if err := a.sendOne(ctx, m, resp.ReplyTo); err != nil {
return fmt.Errorf("telegram: send message: %w", err)
}
}
return nil
}
// SendProactive pushes a message not triggered by a recent inbound message.
// Telegram permits arbitrary proactive push, so this is only gated by the
// connection state.
func (a *Adapter) SendProactive(ctx context.Context, msg adapter.UnifiedMessage) error {
if err := a.requireConnected(); err != nil {
return err
}
return a.sendOne(ctx, msg, "")
}
// sendOne delivers a single message to its conversation, choosing the Bot API
// method that matches the content type and the presence of a file_id payload.
func (a *Adapter) sendOne(ctx context.Context, m adapter.UnifiedMessage, replyTo string) error {
chatID := m.ChannelID
if chatID == "" {
chatID = m.GroupID
}
if chatID == "" {
return fmt.Errorf("telegram: %w", ErrNoChatTarget)
}
replyToID := parseReplyToID(replyTo)
fileID := mediaFileID(m.Media)
switch m.ContentType {
case adapter.ContentImage:
if fileID != "" {
_, err := a.sendPhoto(ctx, chatID, fileID, replyToID, m.Content)
return err
}
case adapter.ContentVoice:
if fileID != "" {
_, err := a.sendVoice(ctx, chatID, fileID, replyToID, m.Content)
return err
}
case adapter.ContentFile:
if fileID != "" {
_, err := a.sendDocument(ctx, chatID, fileID, mediaFileName(m.Media), replyToID, m.Content)
return err
}
}
if m.Content == "" {
return fmt.Errorf("telegram: %w: empty message content", ErrBadResponse)
}
_, err := a.sendMessage(ctx, chatID, m.Content, replyToID, markdownParseMode)
return err
}
// mediaFileID returns the first non-empty file_id carried by the media list.
func mediaFileID(media []adapter.Media) string {
for _, md := range media {
if md.URL != "" {
return md.URL
}
}
return ""
}
// mediaFileName returns the first non-empty file name carried by the media list.
func mediaFileName(media []adapter.Media) string {
for _, md := range media {
if md.Name != "" {
return md.Name
}
}
return ""
}
+324
View File
@@ -0,0 +1,324 @@
package telegram
import (
"context"
"errors"
"net/http"
"sync"
"testing"
"git.yeij.top/AskaEth/Cyrene_v2/internal/channel/adapter"
)
func TestSendMessage_TextConstruction(t *testing.T) {
t.Parallel()
var mu sync.Mutex
var body map[string]any
a, _ := newTestAdapter(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if methodOf(r) != "sendMessage" {
http.Error(w, "unexpected method: "+methodOf(r), http.StatusNotFound)
return
}
mu.Lock()
body = decodeBody(r)
mu.Unlock()
okResponse(w, map[string]any{"message_id": 100})
}))
if err := a.Connect(context.Background()); err != nil {
t.Fatalf("Connect: %v", err)
}
resp := adapter.UnifiedResponse{
ReplyTo: "42",
Messages: []adapter.UnifiedMessage{
{ChannelID: "200", ChannelType: adapter.ChannelDirect, Content: "hello", ContentType: adapter.ContentText},
},
}
if err := a.SendMessage(context.Background(), resp); err != nil {
t.Fatalf("SendMessage: %v", err)
}
mu.Lock()
defer mu.Unlock()
if body["chat_id"] != "200" {
t.Errorf("chat_id = %v, want 200", body["chat_id"])
}
if body["text"] != "hello" {
t.Errorf("text = %v, want hello", body["text"])
}
if body["parse_mode"] != "Markdown" {
t.Errorf("parse_mode = %v, want Markdown", body["parse_mode"])
}
if body["reply_to_message_id"] != float64(42) {
t.Errorf("reply_to_message_id = %v, want 42", body["reply_to_message_id"])
}
}
func TestSendMessage_ImageByFileID(t *testing.T) {
t.Parallel()
var mu sync.Mutex
var body map[string]any
var method string
a, _ := newTestAdapter(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
m := methodOf(r)
if m != "sendPhoto" {
http.Error(w, "unexpected method: "+m, http.StatusNotFound)
return
}
mu.Lock()
method = m
body = decodeBody(r)
mu.Unlock()
okResponse(w, map[string]any{"message_id": 101})
}))
if err := a.Connect(context.Background()); err != nil {
t.Fatalf("Connect: %v", err)
}
resp := adapter.UnifiedResponse{
Messages: []adapter.UnifiedMessage{
{
ChannelID: "200",
ChannelType: adapter.ChannelDirect,
Content: "a caption",
ContentType: adapter.ContentImage,
Media: []adapter.Media{{Kind: adapter.MediaImage, URL: "file-abc"}},
},
},
}
if err := a.SendMessage(context.Background(), resp); err != nil {
t.Fatalf("SendMessage: %v", err)
}
mu.Lock()
defer mu.Unlock()
if method != "sendPhoto" {
t.Errorf("method = %q, want sendPhoto", method)
}
if body["chat_id"] != "200" || body["photo"] != "file-abc" {
t.Errorf("body = %v, want chat_id 200 and photo file-abc", body)
}
if body["caption"] != "a caption" || body["parse_mode"] != "Markdown" {
t.Errorf("caption/parse_mode = %v/%v", body["caption"], body["parse_mode"])
}
}
func TestSendMessage_ImageNoFileIDFallsBackToText(t *testing.T) {
t.Parallel()
var mu sync.Mutex
var method string
a, _ := newTestAdapter(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
m := methodOf(r)
mu.Lock()
method = m
mu.Unlock()
if m != "sendMessage" {
http.Error(w, "unexpected method: "+m, http.StatusNotFound)
return
}
okResponse(w, map[string]any{"message_id": 102})
}))
if err := a.Connect(context.Background()); err != nil {
t.Fatalf("Connect: %v", err)
}
resp := adapter.UnifiedResponse{
Messages: []adapter.UnifiedMessage{
{ChannelID: "200", ContentType: adapter.ContentImage, Content: "no file_id so text"},
},
}
if err := a.SendMessage(context.Background(), resp); err != nil {
t.Fatalf("SendMessage: %v", err)
}
mu.Lock()
defer mu.Unlock()
if method != "sendMessage" {
t.Errorf("method = %q, want fallback sendMessage", method)
}
}
func TestSendMessage_VoiceAndDocument(t *testing.T) {
t.Parallel()
for _, tc := range []struct {
name string
ct adapter.ContentType
media adapter.Media
wantMethod string
wantField string
}{
{name: "voice", ct: adapter.ContentVoice, media: adapter.Media{Kind: adapter.MediaVoice, URL: "voice-1"}, wantMethod: "sendVoice", wantField: "voice"},
{name: "document", ct: adapter.ContentFile, media: adapter.Media{Kind: adapter.MediaFile, URL: "doc-1", Name: "x.pdf"}, wantMethod: "sendDocument", wantField: "document"},
} {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
var mu sync.Mutex
var body map[string]any
var method string
a, _ := newTestAdapter(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
m := methodOf(r)
mu.Lock()
method = m
body = decodeBody(r)
mu.Unlock()
if m != tc.wantMethod {
http.Error(w, "unexpected method: "+m, http.StatusNotFound)
return
}
okResponse(w, map[string]any{"message_id": 103})
}))
if err := a.Connect(context.Background()); err != nil {
t.Fatalf("Connect: %v", err)
}
resp := adapter.UnifiedResponse{
Messages: []adapter.UnifiedMessage{
{ChannelID: "200", ChannelType: adapter.ChannelDirect, ContentType: tc.ct, Media: []adapter.Media{tc.media}},
},
}
if err := a.SendMessage(context.Background(), resp); err != nil {
t.Fatalf("SendMessage: %v", err)
}
mu.Lock()
defer mu.Unlock()
if method != tc.wantMethod {
t.Errorf("method = %q, want %q", method, tc.wantMethod)
}
field := tc.media.URL
if body[tc.wantField] != field {
t.Errorf("%s = %v, want %v", tc.wantField, body[tc.wantField], field)
}
})
}
}
func TestSendProactive(t *testing.T) {
t.Parallel()
var mu sync.Mutex
var body map[string]any
a, _ := newTestAdapter(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if methodOf(r) != "sendMessage" {
http.Error(w, "unexpected method", http.StatusNotFound)
return
}
mu.Lock()
body = decodeBody(r)
mu.Unlock()
okResponse(w, map[string]any{"message_id": 104})
}))
if err := a.Connect(context.Background()); err != nil {
t.Fatalf("Connect: %v", err)
}
msg := adapter.UnifiedMessage{
ChannelID: "300", ChannelType: adapter.ChannelDirect,
Content: "reminder", ContentType: adapter.ContentText,
}
if err := a.SendProactive(context.Background(), msg); err != nil {
t.Fatalf("SendProactive: %v", err)
}
mu.Lock()
defer mu.Unlock()
if body["chat_id"] != "300" || body["text"] != "reminder" {
t.Errorf("body = %v, want chat_id 300 text reminder", body)
}
if _, ok := body["reply_to_message_id"]; ok {
t.Errorf("proactive message should not carry reply_to_message_id, got %v", body)
}
}
func TestSendMessage_NotConnected(t *testing.T) {
t.Parallel()
a := New(Config{BotToken: "x"})
err := a.SendMessage(context.Background(), adapter.UnifiedResponse{})
if !errors.Is(err, adapter.ErrNotConnected) {
t.Fatalf("err = %v, want ErrNotConnected", err)
}
if err := a.SendProactive(context.Background(), adapter.UnifiedMessage{}); !errors.Is(err, adapter.ErrNotConnected) {
t.Fatalf("SendProactive err = %v, want ErrNotConnected", err)
}
}
func TestSendMessage_NoTargetChat(t *testing.T) {
t.Parallel()
a, _ := newTestAdapter(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
http.Error(w, "unexpected method", http.StatusNotFound)
}))
if err := a.Connect(context.Background()); err != nil {
t.Fatalf("Connect: %v", err)
}
err := a.SendMessage(context.Background(), adapter.UnifiedResponse{
Messages: []adapter.UnifiedMessage{{ContentType: adapter.ContentText, Content: "hi"}},
})
if !errors.Is(err, ErrNoChatTarget) {
t.Fatalf("err = %v, want ErrNoChatTarget", err)
}
}
func TestSendMessage_APIError(t *testing.T) {
t.Parallel()
a, _ := newTestAdapter(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if methodOf(r) != "sendMessage" {
http.Error(w, "unexpected method", http.StatusNotFound)
return
}
failResponse(w, "Bad Request: chat not found", 400)
}))
if err := a.Connect(context.Background()); err != nil {
t.Fatalf("Connect: %v", err)
}
err := a.SendMessage(context.Background(), adapter.UnifiedResponse{
Messages: []adapter.UnifiedMessage{{ChannelID: "999", ContentType: adapter.ContentText, Content: "hi"}},
})
if !errors.Is(err, ErrBadResponse) {
t.Fatalf("err = %v, want ErrBadResponse", err)
}
}
func TestProcessUpdate_DeliversToHandler(t *testing.T) {
t.Parallel()
a, _ := newTestAdapter(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
http.Error(w, "unexpected method", http.StatusNotFound)
}))
if err := a.Connect(context.Background()); err != nil {
t.Fatalf("Connect: %v", err)
}
var mu sync.Mutex
var got *adapter.UnifiedMessage
a.SetMessageHandler(func(ctx context.Context, m adapter.UnifiedMessage) {
mu.Lock()
got = &m
mu.Unlock()
})
raw := []byte(`{"update_id":1,"message":{"message_id":5,"from":{"id":1,"first_name":"Ada"},"chat":{"id":10,"type":"private"},"date":1700000000,"text":"webhook hi"}}`)
n, err := a.ProcessUpdate(context.Background(), raw)
if err != nil {
t.Fatalf("ProcessUpdate: %v", err)
}
if n != 1 {
t.Errorf("delivered %d messages, want 1", n)
}
mu.Lock()
defer mu.Unlock()
if got == nil {
t.Fatal("handler was not called")
}
if got.Content != "webhook hi" || got.Platform != "telegram" {
t.Errorf("message = %+v", got)
}
}
func TestProcessUpdate_NonMessageIgnored(t *testing.T) {
t.Parallel()
a := New(Config{BotToken: "x"})
raw := []byte(`{"update_id":2,"callback_query":{"id":"cq","from":{"id":1},"data":"btn"}}`)
n, err := a.ProcessUpdate(context.Background(), raw)
if err != nil {
t.Fatalf("ProcessUpdate: %v", err)
}
if n != 0 {
t.Errorf("delivered %d messages, want 0 for callback query", n)
}
}
func TestProcessUpdate_InvalidJSON(t *testing.T) {
t.Parallel()
a := New(Config{BotToken: "x"})
if _, err := a.ProcessUpdate(context.Background(), []byte("{not json")); err == nil {
t.Fatal("expected error decoding invalid JSON")
}
}
+223
View File
@@ -0,0 +1,223 @@
package telegram
import (
"strconv"
"time"
"git.yeij.top/AskaEth/Cyrene_v2/internal/channel/adapter"
)
// telegramUpdate is the top-level object Telegram sends for every event.
type telegramUpdate struct {
UpdateID int64 `json:"update_id"`
Message *telegramMessage `json:"message"`
EditedMessage *telegramMessage `json:"edited_message"`
ChannelPost *telegramMessage `json:"channel_post"`
EditedChannelPost *telegramMessage `json:"edited_channel_post"`
}
// message returns the concrete chat message carried by the update, or nil.
func (u telegramUpdate) message() *telegramMessage {
switch {
case u.Message != nil:
return u.Message
case u.EditedMessage != nil:
return u.EditedMessage
case u.ChannelPost != nil:
return u.ChannelPost
case u.EditedChannelPost != nil:
return u.EditedChannelPost
default:
return nil
}
}
// telegramMessage is a chat message as delivered by the Bot API.
type telegramMessage struct {
MessageID int64 `json:"message_id"`
From *telegramUser `json:"from"`
Chat telegramChat `json:"chat"`
Date int64 `json:"date"`
Text string `json:"text"`
Caption string `json:"caption"`
Photo []telegramPhotoSize `json:"photo"`
Voice *telegramAudio `json:"voice"`
Audio *telegramAudio `json:"audio"`
Document *telegramDocument `json:"document"`
Video *telegramVideo `json:"video"`
ReplyToMessage *telegramMessage `json:"reply_to_message"`
MessageThreadID int64 `json:"message_thread_id"`
}
// telegramUser is the sender of a message.
type telegramUser struct {
ID int64 `json:"id"`
FirstName string `json:"first_name"`
LastName string `json:"last_name"`
Username string `json:"username"`
}
// telegramChat is the conversation a message belongs to.
type telegramChat struct {
ID int64 `json:"id"`
Type string `json:"type"` // private | group | supergroup | channel
Title string `json:"title"`
}
// telegramPhotoSize is one entry of a photo attachment.
type telegramPhotoSize struct {
FileID string `json:"file_id"`
FileSize int64 `json:"file_size"`
}
// telegramAudio describes a voice or audio attachment.
type telegramAudio struct {
FileID string `json:"file_id"`
FileSize int64 `json:"file_size"`
MIMEType string `json:"mime_type"`
}
// telegramDocument describes a document attachment.
type telegramDocument struct {
FileID string `json:"file_id"`
FileSize int64 `json:"file_size"`
FileName string `json:"file_name"`
MIMEType string `json:"mime_type"`
}
// telegramVideo describes a video attachment.
type telegramVideo struct {
FileID string `json:"file_id"`
FileSize int64 `json:"file_size"`
MIMEType string `json:"mime_type"`
}
// toUnifiedMessage converts an update to the normalized model. It returns ok
// false for updates that carry no deliverable chat message.
func (a *Adapter) toUnifiedMessage(update telegramUpdate) (adapter.UnifiedMessage, bool) {
m := update.message()
if m == nil {
return adapter.UnifiedMessage{}, false
}
msg := adapter.UnifiedMessage{
Platform: a.Name(),
ChannelID: strconv.FormatInt(m.Chat.ID, 10),
ChannelType: chatType(m.Chat.Type),
MessageID: strconv.FormatInt(m.MessageID, 10),
Timestamp: time.Unix(m.Date, 0),
}
if m.Chat.Type == "group" || m.Chat.Type == "supergroup" || m.Chat.Type == "channel" {
msg.GroupID = strconv.FormatInt(m.Chat.ID, 10)
msg.GroupName = m.Chat.Title
}
if m.MessageThreadID != 0 {
msg.ThreadID = strconv.FormatInt(m.MessageThreadID, 10)
}
content, contentType, media := extractPayload(m)
msg.Content = content
msg.ContentType = contentType
msg.Media = media
if m.From != nil {
msg.SenderID = strconv.FormatInt(m.From.ID, 10)
msg.SenderName = displayName(m.From)
msg.Sender = adapter.Actor{
Role: adapter.RoleUnknown,
UserID: msg.SenderID,
DisplayName: msg.SenderName,
GroupID: msg.GroupID,
GroupName: msg.GroupName,
}.Resolve()
}
if m.ReplyToMessage != nil {
msg.ReplyToID = strconv.FormatInt(m.ReplyToMessage.MessageID, 10)
msg.ReplyToText = m.ReplyToMessage.Text
if msg.ReplyToText == "" {
msg.ReplyToText = m.ReplyToMessage.Caption
}
}
a.recordChat(m.Chat, m.From)
return msg, true
}
// extractPayload picks the primary content and attachment list from a message.
func extractPayload(m *telegramMessage) (string, adapter.ContentType, []adapter.Media) {
media := make([]adapter.Media, 0, 2)
content := m.Caption
if m.Text != "" {
content = m.Text
}
ct := adapter.ContentText
if len(m.Photo) > 0 {
p := m.Photo[len(m.Photo)-1]
media = append(media, adapter.Media{Kind: adapter.MediaImage, URL: p.FileID, Size: p.FileSize, MIMEType: "image/jpeg"})
ct = adapter.ContentImage
}
if m.Voice != nil {
media = append(media, adapter.Media{Kind: adapter.MediaVoice, URL: m.Voice.FileID, Size: m.Voice.FileSize, MIMEType: m.Voice.MIMEType})
ct = adapter.ContentVoice
} else if m.Audio != nil {
media = append(media, adapter.Media{Kind: adapter.MediaVoice, URL: m.Audio.FileID, Size: m.Audio.FileSize, MIMEType: m.Audio.MIMEType})
ct = adapter.ContentVoice
}
if m.Document != nil {
media = append(media, adapter.Media{Kind: adapter.MediaFile, URL: m.Document.FileID, Size: m.Document.FileSize, Name: m.Document.FileName, MIMEType: m.Document.MIMEType})
ct = adapter.ContentFile
}
if m.Video != nil {
media = append(media, adapter.Media{Kind: adapter.MediaVideo, URL: m.Video.FileID, Size: m.Video.FileSize, MIMEType: m.Video.MIMEType})
ct = adapter.ContentFile
}
return content, ct, media
}
// chatType maps a Telegram chat type to the adapter's ChannelType.
func chatType(t string) adapter.ChannelType {
if t == "group" || t == "supergroup" || t == "channel" {
return adapter.ChannelGroup
}
return adapter.ChannelDirect
}
// displayName renders a sender's best human-readable name without leaking
// anything sensitive.
func displayName(u *telegramUser) string {
if u.FirstName != "" && u.LastName != "" {
return u.FirstName + " " + u.LastName
}
if u.FirstName != "" {
return u.FirstName
}
if u.Username != "" {
return u.Username
}
return strconv.FormatInt(u.ID, 10)
}
// recordChat caches a conversation so ListChannels can report it.
func (a *Adapter) recordChat(c telegramChat, from *telegramUser) {
info := adapter.ChannelInfo{
ChannelID: strconv.FormatInt(c.ID, 10),
ChannelType: chatType(c.Type),
ChannelName: c.Title,
}
if info.ChannelType == adapter.ChannelGroup {
info.GroupID = info.ChannelID
info.GroupName = c.Title
} else if from != nil {
info.ChannelName = displayName(from)
}
if info.ChannelName == "" {
info.ChannelName = info.ChannelID
}
a.mu.Lock()
a.chats[info.ChannelID] = info
a.mu.Unlock()
}
+244
View File
@@ -0,0 +1,244 @@
package telegram
import (
"context"
"testing"
"time"
"git.yeij.top/AskaEth/Cyrene_v2/internal/channel/adapter"
)
func TestToUnifiedMessage_PrivateText(t *testing.T) {
t.Parallel()
a := New(Config{BotToken: "x"})
u := telegramUpdate{
UpdateID: 1,
Message: &telegramMessage{
MessageID: 42,
From: &telegramUser{ID: 100, FirstName: "Alice", Username: "alice"},
Chat: telegramChat{ID: 200, Type: "private"},
Date: 1700000000,
Text: "hello world",
},
}
msg, ok := a.toUnifiedMessage(u)
if !ok {
t.Fatal("expected ok")
}
if msg.Platform != "telegram" {
t.Errorf("Platform = %q, want telegram", msg.Platform)
}
if msg.ChannelType != adapter.ChannelDirect {
t.Errorf("ChannelType = %q, want direct", msg.ChannelType)
}
if msg.ChannelID != "200" {
t.Errorf("ChannelID = %q, want 200", msg.ChannelID)
}
if msg.SenderID != "100" {
t.Errorf("SenderID = %q, want 100", msg.SenderID)
}
if msg.SenderName != "Alice" {
t.Errorf("SenderName = %q, want Alice", msg.SenderName)
}
if msg.Content != "hello world" || msg.ContentType != adapter.ContentText {
t.Errorf("content = %q/%q, want hello world/text", msg.Content, msg.ContentType)
}
if msg.Sender.UserID != "100" || msg.Sender.DisplayName != "Alice" {
t.Errorf("Sender actor = %+v", msg.Sender)
}
if msg.Sender.IsGroupChat() {
t.Error("private message should not be a group actor")
}
wantTS := time.Unix(1700000000, 0)
if !msg.Timestamp.Equal(wantTS) {
t.Errorf("Timestamp = %v, want %v", msg.Timestamp, wantTS)
}
}
func TestToUnifiedMessage_Group(t *testing.T) {
t.Parallel()
a := New(Config{BotToken: "x"})
u := telegramUpdate{
UpdateID: 2,
Message: &telegramMessage{
MessageID: 7,
From: &telegramUser{ID: 300, FirstName: "Bob"},
Chat: telegramChat{ID: -1000, Type: "supergroup", Title: "Team Chat"},
Date: 1700000001,
Text: "ping",
},
}
msg, ok := a.toUnifiedMessage(u)
if !ok {
t.Fatal("expected ok")
}
if msg.ChannelType != adapter.ChannelGroup {
t.Errorf("ChannelType = %q, want group", msg.ChannelType)
}
if msg.GroupID != "-1000" || msg.GroupName != "Team Chat" {
t.Errorf("group = %q/%q, want -1000/Team Chat", msg.GroupID, msg.GroupName)
}
if !msg.Sender.IsGroupChat() {
t.Error("Sender should be a group actor")
}
if msg.Sender.GroupID != "-1000" {
t.Errorf("Sender.GroupID = %q, want -1000", msg.Sender.GroupID)
}
}
func TestToUnifiedMessage_ImageFileID(t *testing.T) {
t.Parallel()
a := New(Config{BotToken: "x"})
u := telegramUpdate{
UpdateID: 3,
Message: &telegramMessage{
MessageID: 9,
From: &telegramUser{ID: 400, FirstName: "Cara"},
Chat: telegramChat{ID: 500, Type: "private"},
Date: 1700000002,
Caption: "look at this",
Photo: []telegramPhotoSize{{FileID: "photo-small", FileSize: 100}, {FileID: "photo-big", FileSize: 2000}},
},
}
msg, ok := a.toUnifiedMessage(u)
if !ok {
t.Fatal("expected ok")
}
if msg.ContentType != adapter.ContentImage {
t.Errorf("ContentType = %q, want image", msg.ContentType)
}
if msg.Content != "look at this" {
t.Errorf("Content = %q, want caption", msg.Content)
}
if len(msg.Media) != 1 {
t.Fatalf("media length = %d, want 1", len(msg.Media))
}
m := msg.Media[0]
if m.Kind != adapter.MediaImage || m.URL != "photo-big" || m.Size != 2000 {
t.Errorf("media = %+v, want image/photo-big/2000", m)
}
}
func TestToUnifiedMessage_VoiceFileID(t *testing.T) {
t.Parallel()
a := New(Config{BotToken: "x"})
u := telegramUpdate{
UpdateID: 4,
Message: &telegramMessage{
MessageID: 10,
From: &telegramUser{ID: 600, FirstName: "Dan"},
Chat: telegramChat{ID: 700, Type: "private"},
Date: 1700000003,
Voice: &telegramAudio{FileID: "voice-id", FileSize: 5000, MIMEType: "audio/ogg"},
},
}
msg, ok := a.toUnifiedMessage(u)
if !ok {
t.Fatal("expected ok")
}
if msg.ContentType != adapter.ContentVoice {
t.Errorf("ContentType = %q, want voice", msg.ContentType)
}
if len(msg.Media) != 1 || msg.Media[0].Kind != adapter.MediaVoice || msg.Media[0].URL != "voice-id" {
t.Errorf("media = %+v, want voice/voice-id", msg.Media)
}
}
func TestToUnifiedMessage_DocumentFileID(t *testing.T) {
t.Parallel()
a := New(Config{BotToken: "x"})
u := telegramUpdate{
UpdateID: 5,
Message: &telegramMessage{
MessageID: 11,
From: &telegramUser{ID: 800, FirstName: "Eve"},
Chat: telegramChat{ID: 900, Type: "private"},
Date: 1700000004,
Document: &telegramDocument{FileID: "doc-id", FileSize: 9000, FileName: "report.pdf", MIMEType: "application/pdf"},
},
}
msg, ok := a.toUnifiedMessage(u)
if !ok {
t.Fatal("expected ok")
}
if msg.ContentType != adapter.ContentFile {
t.Errorf("ContentType = %q, want file", msg.ContentType)
}
m := msg.Media[0]
if m.Kind != adapter.MediaFile || m.URL != "doc-id" || m.Name != "report.pdf" {
t.Errorf("media = %+v, want file/doc-id/report.pdf", m)
}
}
func TestToUnifiedMessage_ReplyAndThread(t *testing.T) {
t.Parallel()
a := New(Config{BotToken: "x"})
u := telegramUpdate{
UpdateID: 6,
Message: &telegramMessage{
MessageID: 20,
From: &telegramUser{ID: 1000, FirstName: "Finn"},
Chat: telegramChat{ID: -2000, Type: "group", Title: "Thread Group"},
Date: 1700000005,
Text: "a reply",
MessageThreadID: 33,
ReplyToMessage: &telegramMessage{MessageID: 19, Text: "original"},
},
}
msg, ok := a.toUnifiedMessage(u)
if !ok {
t.Fatal("expected ok")
}
if msg.ThreadID != "33" {
t.Errorf("ThreadID = %q, want 33", msg.ThreadID)
}
if msg.ReplyToID != "19" || msg.ReplyToText != "original" {
t.Errorf("reply = %q/%q, want 19/original", msg.ReplyToID, msg.ReplyToText)
}
}
func TestToUnifiedMessage_NoMessage(t *testing.T) {
t.Parallel()
a := New(Config{BotToken: "x"})
u := telegramUpdate{UpdateID: 7}
if _, ok := a.toUnifiedMessage(u); ok {
t.Fatal("expected ok=false for non-message update")
}
}
func TestListChannels_FromConversations(t *testing.T) {
t.Parallel()
a := New(Config{BotToken: "x"})
a.toUnifiedMessage(telegramUpdate{
UpdateID: 8,
Message: &telegramMessage{
MessageID: 1,
From: &telegramUser{ID: 1, FirstName: "Ada"},
Chat: telegramChat{ID: 10, Type: "private"},
Text: "hi",
},
})
a.toUnifiedMessage(telegramUpdate{
UpdateID: 9,
Message: &telegramMessage{
MessageID: 2,
From: &telegramUser{ID: 2, FirstName: "Bob"},
Chat: telegramChat{ID: -20, Type: "group", Title: "Gang"},
Text: "yo",
},
})
got, err := a.ListChannels(context.Background())
if err != nil {
t.Fatalf("ListChannels: %v", err)
}
if len(got) != 2 {
t.Fatalf("channels length = %d, want 2: %+v", len(got), got)
}
// Sort puts -20 (group) before 10 (private) as strings.
if got[0].ChannelID != "-20" || got[0].ChannelType != adapter.ChannelGroup || got[0].GroupName != "Gang" {
t.Errorf("channels[0] = %+v, want group -20", got[0])
}
if got[1].ChannelID != "10" || got[1].ChannelType != adapter.ChannelDirect || got[1].ChannelName != "Ada" {
t.Errorf("channels[1] = %+v, want direct 10", got[1])
}
}