- Add Telegram integration support by introducing a dispatcher for handling Telegram events and messages. - Implement event notifications for robot configuration changes (creation, update, deletion) to facilitate integration with external services. - Refactor the robot initialization process to load robots into cache and start the dispatcher, improving the overall system setup. - Update the delivery event structure to include additional metadata for better context during message handling. - Enhance logging capabilities for better observability during robot execution and event processing.
280 lines
7.5 KiB
Go
280 lines
7.5 KiB
Go
package telegram
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"net"
|
|
"net/http"
|
|
"os"
|
|
"sync"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
// TestE2E_Webhook requires a local telegram-bot-api server (TELEGRAM_TEST_HOST)
|
|
// because the official Telegram API only accepts HTTPS webhooks on public IPs.
|
|
// A local Bot API server can deliver webhooks to http://127.0.0.1.
|
|
//
|
|
// Flow:
|
|
// 1. Start a local HTTP server on a random port
|
|
// 2. SetWebhook to http://127.0.0.1:{port}/webhook with a secret token
|
|
// 3. Seed a message via MTProto
|
|
// 4. Wait for the webhook to deliver the Update
|
|
// 5. Verify: ParseWebhookPayload succeeds, secret header correct, Update fields valid
|
|
// 6. DeleteWebhook to restore polling mode
|
|
func TestE2E_Webhook(t *testing.T) {
|
|
skipIfNoToken(t)
|
|
|
|
host := os.Getenv("TELEGRAM_TEST_HOST")
|
|
if host == "" {
|
|
t.Skip("TELEGRAM_TEST_HOST not set — need local bot-api server for webhook test")
|
|
}
|
|
|
|
const secret = "e2e-webhook-test-secret"
|
|
b := testBot(WithAPIBase(host))
|
|
bWithSecret := NewBot(testBotToken, secret, WithAPIBase(host))
|
|
|
|
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
|
|
defer cancel()
|
|
|
|
// --- 1. Start local webhook receiver ---
|
|
var (
|
|
mu sync.Mutex
|
|
received []webhookHit
|
|
wrongSecret int
|
|
)
|
|
|
|
mux := http.NewServeMux()
|
|
mux.HandleFunc("/webhook", func(w http.ResponseWriter, r *http.Request) {
|
|
headerSec := r.Header.Get("X-Telegram-Bot-Api-Secret-Token")
|
|
cm, err := bWithSecret.ParseWebhookPayload(r.Context(), r, nil)
|
|
|
|
mu.Lock()
|
|
defer mu.Unlock()
|
|
|
|
if err != nil {
|
|
wrongSecret++
|
|
t.Logf("webhook: rejected request (secret=%q err=%v)", headerSec, err)
|
|
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
|
return
|
|
}
|
|
|
|
received = append(received, webhookHit{
|
|
secret: headerSec,
|
|
cm: cm,
|
|
})
|
|
if cm != nil {
|
|
t.Logf("webhook: accepted update_id=%d secret=%q", cm.UpdateID, headerSec)
|
|
} else {
|
|
t.Logf("webhook: accepted (no processable message) secret=%q", headerSec)
|
|
}
|
|
w.WriteHeader(http.StatusOK)
|
|
})
|
|
|
|
listener, err := net.Listen("tcp", "127.0.0.1:0")
|
|
if err != nil {
|
|
t.Fatalf("listen: %v", err)
|
|
}
|
|
port := listener.Addr().(*net.TCPAddr).Port
|
|
server := &http.Server{Handler: mux}
|
|
go server.Serve(listener)
|
|
defer server.Close()
|
|
t.Logf("webhook server listening on 127.0.0.1:%d", port)
|
|
|
|
// --- 2. SetWebhook ---
|
|
webhookURL := fmt.Sprintf("http://127.0.0.1:%d/webhook", port)
|
|
if err := bWithSecret.SetWebhook(ctx, webhookURL, []string{"message"}); err != nil {
|
|
t.Fatalf("SetWebhook: %v", err)
|
|
}
|
|
t.Logf("SetWebhook -> %s", webhookURL)
|
|
|
|
defer func() {
|
|
cleanCtx, cleanCancel := context.WithTimeout(context.Background(), 10*time.Second)
|
|
defer cleanCancel()
|
|
if err := b.DeleteWebhook(cleanCtx, true); err != nil {
|
|
t.Logf("warning: DeleteWebhook failed: %v", err)
|
|
} else {
|
|
t.Log("DeleteWebhook -> ok (polling restored)")
|
|
}
|
|
}()
|
|
|
|
// --- 3. Seed a message ---
|
|
seedBotMessages(t)
|
|
|
|
// --- 4. Wait for webhook delivery ---
|
|
deadline := time.After(30 * time.Second)
|
|
ticker := time.NewTicker(500 * time.Millisecond)
|
|
defer ticker.Stop()
|
|
|
|
for {
|
|
select {
|
|
case <-deadline:
|
|
mu.Lock()
|
|
count := len(received)
|
|
mu.Unlock()
|
|
if count == 0 {
|
|
t.Fatal("timeout: no webhook updates received within 30s")
|
|
}
|
|
goto verify
|
|
case <-ticker.C:
|
|
mu.Lock()
|
|
count := len(received)
|
|
mu.Unlock()
|
|
if count >= 2 {
|
|
time.Sleep(2 * time.Second)
|
|
goto verify
|
|
}
|
|
}
|
|
}
|
|
|
|
verify:
|
|
mu.Lock()
|
|
defer mu.Unlock()
|
|
|
|
if len(received) == 0 {
|
|
t.Fatal("no webhook updates received")
|
|
}
|
|
t.Logf("total webhook hits: %d (rejected: %d)", len(received), wrongSecret)
|
|
|
|
// --- 5. Validate received updates ---
|
|
for i, hit := range received {
|
|
t.Run(fmt.Sprintf("update_%d", i), func(t *testing.T) {
|
|
if hit.secret != secret {
|
|
t.Errorf("secret header = %q, want %q", hit.secret, secret)
|
|
}
|
|
|
|
cm := hit.cm
|
|
if cm == nil {
|
|
t.Log("webhook hit has no processable message (cm nil)")
|
|
return
|
|
}
|
|
|
|
if cm.UpdateID == 0 {
|
|
t.Error("ConvertedMessage.UpdateID should not be 0")
|
|
}
|
|
if cm.MessageID == 0 {
|
|
t.Error("ConvertedMessage.MessageID should not be 0")
|
|
}
|
|
if cm.ChatID == 0 {
|
|
t.Error("ConvertedMessage.ChatID should not be 0")
|
|
}
|
|
if cm.Date == 0 {
|
|
t.Error("ConvertedMessage.Date should not be 0")
|
|
}
|
|
if cm.SenderID == 0 {
|
|
t.Error("ConvertedMessage.SenderID should not be 0")
|
|
}
|
|
if cm.SenderName == "" {
|
|
t.Error("ConvertedMessage.SenderName should not be empty")
|
|
}
|
|
if !cm.HasText() && !cm.HasMedia() {
|
|
t.Error("message has neither text nor media")
|
|
}
|
|
|
|
for j, mi := range cm.MediaItems {
|
|
if mi.FileID == "" {
|
|
t.Errorf("media[%d].FileID should not be empty", j)
|
|
}
|
|
if mi.FileUniqueID == "" {
|
|
t.Errorf("media[%d].FileUniqueID should not be empty", j)
|
|
}
|
|
if mi.MimeType == "" {
|
|
t.Errorf("media[%d].MimeType should not be empty", j)
|
|
}
|
|
if mi.Type == "" {
|
|
t.Errorf("media[%d].Type should not be empty", j)
|
|
}
|
|
}
|
|
|
|
t.Logf("webhook update[%d] id=%d msg=%d chat=%d sender=%q text=%q media=%d",
|
|
i, cm.UpdateID, cm.MessageID, cm.ChatID, cm.SenderName,
|
|
truncate(cm.Text, 40), len(cm.MediaItems))
|
|
})
|
|
}
|
|
|
|
// --- 6. Compare with GetUpdates ---
|
|
// Delete webhook first, then seed + poll to verify ConvertUpdate consistency.
|
|
t.Run("convert_consistency", func(t *testing.T) {
|
|
delCtx, delCancel := context.WithTimeout(context.Background(), 10*time.Second)
|
|
defer delCancel()
|
|
if err := b.DeleteWebhook(delCtx, true); err != nil {
|
|
t.Fatalf("DeleteWebhook for polling: %v", err)
|
|
}
|
|
time.Sleep(time.Second)
|
|
|
|
seedBotMessages(t)
|
|
time.Sleep(2 * time.Second)
|
|
|
|
pollCtx, pollCancel := context.WithTimeout(context.Background(), 15*time.Second)
|
|
defer pollCancel()
|
|
polled := fetchUpdates(t, b, pollCtx)
|
|
if len(polled) == 0 {
|
|
t.Skip("no polled updates to compare")
|
|
}
|
|
|
|
polledCM := polled[0]
|
|
if polledCM == nil {
|
|
t.Skip("polled update has no message")
|
|
}
|
|
|
|
var webhookCM *ConvertedMessage
|
|
for _, hit := range received {
|
|
if hit.cm != nil {
|
|
webhookCM = hit.cm
|
|
break
|
|
}
|
|
}
|
|
if webhookCM == nil {
|
|
t.Skip("no webhook update with message")
|
|
}
|
|
|
|
if webhookCM.ChatID == 0 || polledCM.ChatID == 0 {
|
|
t.Error("both sources should have non-zero ChatID")
|
|
}
|
|
if webhookCM.SenderID == 0 || polledCM.SenderID == 0 {
|
|
t.Error("both sources should have non-zero SenderID")
|
|
}
|
|
if webhookCM.Date == 0 || polledCM.Date == 0 {
|
|
t.Error("both sources should have non-zero Date")
|
|
}
|
|
if webhookCM.ChatType != polledCM.ChatType {
|
|
t.Errorf("ChatType mismatch: webhook=%q polled=%q", webhookCM.ChatType, polledCM.ChatType)
|
|
}
|
|
|
|
webhookHasContent := webhookCM.HasText() || webhookCM.HasMedia()
|
|
polledHasContent := polledCM.HasText() || polledCM.HasMedia()
|
|
if !webhookHasContent {
|
|
t.Error("webhook ConvertUpdate produced no content")
|
|
}
|
|
if !polledHasContent {
|
|
t.Error("polled ConvertUpdate produced no content")
|
|
}
|
|
|
|
t.Logf("consistency OK: webhook(text=%v media=%d) polled(text=%v media=%d) chat_type=%s",
|
|
webhookCM.HasText(), len(webhookCM.MediaItems),
|
|
polledCM.HasText(), len(polledCM.MediaItems),
|
|
polledCM.ChatType)
|
|
})
|
|
|
|
// Verify ParseWebhookPayload rejects wrong secrets
|
|
t.Run("wrong_secret_rejected", func(t *testing.T) {
|
|
fakeReq, _ := http.NewRequest("POST", "/webhook", nil)
|
|
fakeReq.Header.Set("X-Telegram-Bot-Api-Secret-Token", "wrong-secret")
|
|
_, err := bWithSecret.ParseWebhookPayload(fakeReq.Context(), fakeReq, nil)
|
|
if err == nil {
|
|
t.Error("expected error for wrong secret, got nil")
|
|
}
|
|
})
|
|
}
|
|
|
|
type webhookHit struct {
|
|
secret string
|
|
cm *ConvertedMessage
|
|
}
|
|
|
|
func min(a, b int) int {
|
|
if a < b {
|
|
return a
|
|
}
|
|
return b
|
|
}
|