Files
2026-06-02 23:14:41 +08:00

967 lines
25 KiB
Go

package telegram
import (
"context"
"errors"
"fmt"
"net/http"
"net/http/httptest"
"strings"
"sync"
"sync/atomic"
"testing"
"time"
"unicode/utf8"
"github.com/chenhg5/cc-connect/core"
tgbot "github.com/go-telegram/bot"
"github.com/go-telegram/bot/models"
)
type testLifecycleHandler struct {
onReady func(core.Platform)
onUnavailable func(core.Platform, error)
}
func (h testLifecycleHandler) OnPlatformReady(p core.Platform) {
if h.onReady != nil {
h.onReady(p)
}
}
func (h testLifecycleHandler) OnPlatformUnavailable(p core.Platform, err error) {
if h.onUnavailable != nil {
h.onUnavailable(p, err)
}
}
type stubBackoffTimer struct {
ch chan time.Time
}
func immediateTimer(time.Duration) backoffTimer {
ch := make(chan time.Time)
close(ch)
return &stubBackoffTimer{ch: ch}
}
func (t *stubBackoffTimer) C() <-chan time.Time {
return t.ch
}
func (t *stubBackoffTimer) Stop() bool {
return true
}
type stubTypingTicker struct {
ch chan time.Time
}
func newStubTypingTicker() *stubTypingTicker {
return &stubTypingTicker{ch: make(chan time.Time, 8)}
}
func (t *stubTypingTicker) C() <-chan time.Time {
return t.ch
}
func (t *stubTypingTicker) Stop() {}
type stubTelegramBot struct {
mu sync.Mutex
sendMessageCalls int
sendPhotoCalls int
sendDocumentCalls int
sendVoiceCalls int
sendAudioCalls int
sendChatActionCalls int
editMessageTextCalls int
deleteMessageCalls int
answerCallbackCalls int
setMyCommandsCalls int
getFileCalls int
setReactionCalls int
sendErr error
getFileErr error
file *models.File
}
func newStubTelegramBot() *stubTelegramBot {
return &stubTelegramBot{
file: &models.File{FilePath: "files/test.dat"},
}
}
func (b *stubTelegramBot) SendMessage(_ context.Context, _ *tgbot.SendMessageParams) (*models.Message, error) {
b.mu.Lock()
b.sendMessageCalls++
b.mu.Unlock()
if b.sendErr != nil {
return nil, b.sendErr
}
return &models.Message{ID: 99}, nil
}
func (b *stubTelegramBot) SendPhoto(_ context.Context, _ *tgbot.SendPhotoParams) (*models.Message, error) {
b.mu.Lock()
b.sendPhotoCalls++
b.mu.Unlock()
if b.sendErr != nil {
return nil, b.sendErr
}
return &models.Message{ID: 99}, nil
}
func (b *stubTelegramBot) SendDocument(_ context.Context, _ *tgbot.SendDocumentParams) (*models.Message, error) {
b.mu.Lock()
b.sendDocumentCalls++
b.mu.Unlock()
if b.sendErr != nil {
return nil, b.sendErr
}
return &models.Message{ID: 99}, nil
}
func (b *stubTelegramBot) SendVoice(_ context.Context, _ *tgbot.SendVoiceParams) (*models.Message, error) {
b.mu.Lock()
b.sendVoiceCalls++
b.mu.Unlock()
if b.sendErr != nil {
return nil, b.sendErr
}
return &models.Message{ID: 99}, nil
}
func (b *stubTelegramBot) SendAudio(_ context.Context, _ *tgbot.SendAudioParams) (*models.Message, error) {
b.mu.Lock()
b.sendAudioCalls++
b.mu.Unlock()
if b.sendErr != nil {
return nil, b.sendErr
}
return &models.Message{ID: 99}, nil
}
func (b *stubTelegramBot) SendChatAction(_ context.Context, _ *tgbot.SendChatActionParams) (bool, error) {
b.mu.Lock()
b.sendChatActionCalls++
b.mu.Unlock()
if b.sendErr != nil {
return false, b.sendErr
}
return true, nil
}
func (b *stubTelegramBot) EditMessageText(_ context.Context, _ *tgbot.EditMessageTextParams) (*models.Message, error) {
b.mu.Lock()
b.editMessageTextCalls++
b.mu.Unlock()
if b.sendErr != nil {
return nil, b.sendErr
}
return &models.Message{ID: 99}, nil
}
func (b *stubTelegramBot) DeleteMessage(_ context.Context, _ *tgbot.DeleteMessageParams) (bool, error) {
b.mu.Lock()
b.deleteMessageCalls++
b.mu.Unlock()
if b.sendErr != nil {
return false, b.sendErr
}
return true, nil
}
func (b *stubTelegramBot) AnswerCallbackQuery(_ context.Context, _ *tgbot.AnswerCallbackQueryParams) (bool, error) {
b.mu.Lock()
b.answerCallbackCalls++
b.mu.Unlock()
return true, nil
}
func (b *stubTelegramBot) SetMyCommands(_ context.Context, _ *tgbot.SetMyCommandsParams) (bool, error) {
b.mu.Lock()
b.setMyCommandsCalls++
b.mu.Unlock()
if b.sendErr != nil {
return false, b.sendErr
}
return true, nil
}
func (b *stubTelegramBot) GetFile(_ context.Context, _ *tgbot.GetFileParams) (*models.File, error) {
b.mu.Lock()
b.getFileCalls++
b.mu.Unlock()
if b.getFileErr != nil {
return nil, b.getFileErr
}
return b.file, nil
}
func (b *stubTelegramBot) FileDownloadLink(f *models.File) string {
return "https://test.example.com/file/" + f.FilePath
}
func (b *stubTelegramBot) SetMessageReaction(_ context.Context, _ *tgbot.SetMessageReactionParams) (bool, error) {
b.mu.Lock()
b.setReactionCalls++
b.mu.Unlock()
return true, nil
}
func (b *stubTelegramBot) SendMessageCallCount() int {
b.mu.Lock()
defer b.mu.Unlock()
return b.sendMessageCalls
}
func (b *stubTelegramBot) SendChatActionCallCount() int {
b.mu.Lock()
defer b.mu.Unlock()
return b.sendChatActionCalls
}
func (b *stubTelegramBot) GetFileCallCount() int {
b.mu.Lock()
defer b.mu.Unlock()
return b.getFileCalls
}
func TestPlatformStart_RetriesInBackgroundUntilConnected(t *testing.T) {
var attempts atomic.Int32
readyCh := make(chan struct{}, 1)
stubBot := newStubTelegramBot()
me := &models.User{ID: 42, Username: "mybot"}
p := &Platform{
token: "token",
httpClient: &http.Client{},
newBot: func(_ string, _ func(context.Context, *models.Update), _ *http.Client) (telegramBot, *models.User, func(context.Context), error) {
if attempts.Add(1) == 1 {
return nil, nil, nil, errors.New("dial failed")
}
return stubBot, me, func(ctx context.Context) { <-ctx.Done() }, nil
},
newBackoffTimer: immediateTimer,
}
p.SetLifecycleHandler(testLifecycleHandler{
onReady: func(core.Platform) {
readyCh <- struct{}{}
},
})
if err := p.Start(func(core.Platform, *core.Message) {}); err != nil {
t.Fatalf("Start: %v", err)
}
defer func() {
if err := p.Stop(); err != nil {
t.Fatalf("Stop: %v", err)
}
}()
select {
case <-readyCh:
case <-time.After(time.Second):
t.Fatal("ready callback not observed")
}
if got := attempts.Load(); got < 2 {
t.Fatalf("attempts = %d, want >= 2", got)
}
}
func TestPlatformStart_InitialConnectFailureEmitsUnavailableOnceBeforeReady(t *testing.T) {
var attempts atomic.Int32
var unavailableCount atomic.Int32
readyCh := make(chan struct{}, 1)
stubBot := newStubTelegramBot()
me := &models.User{ID: 42, Username: "mybot"}
p := &Platform{
token: "token",
httpClient: &http.Client{},
newBot: func(_ string, _ func(context.Context, *models.Update), _ *http.Client) (telegramBot, *models.User, func(context.Context), error) {
if attempts.Add(1) <= 2 {
return nil, nil, nil, errors.New("dial failed")
}
return stubBot, me, func(ctx context.Context) { <-ctx.Done() }, nil
},
newBackoffTimer: immediateTimer,
}
p.SetLifecycleHandler(testLifecycleHandler{
onReady: func(core.Platform) {
readyCh <- struct{}{}
},
onUnavailable: func(core.Platform, error) {
unavailableCount.Add(1)
},
})
if err := p.Start(func(core.Platform, *core.Message) {}); err != nil {
t.Fatalf("Start: %v", err)
}
defer func() {
if err := p.Stop(); err != nil {
t.Fatalf("Stop: %v", err)
}
}()
select {
case <-readyCh:
case <-time.After(time.Second):
t.Fatal("ready callback not observed")
}
if got := unavailableCount.Load(); got != 1 {
t.Fatalf("unavailable callbacks = %d, want 1", got)
}
}
func TestPlatformDisconnectedSendPathsReturnNotConnected(t *testing.T) {
p := &Platform{token: "token", httpClient: &http.Client{}}
ctx := context.Background()
rctx := replyContext{chatID: 1, threadID: 0, messageID: 2}
tests := []struct {
name string
run func() error
}{
{name: "Reply", run: func() error { return p.Reply(ctx, rctx, "hello") }},
{name: "Send", run: func() error { return p.Send(ctx, rctx, "hello") }},
{name: "SendImage", run: func() error { return p.SendImage(ctx, rctx, core.ImageAttachment{Data: []byte("img")}) }},
{name: "SendFile", run: func() error { return p.SendFile(ctx, rctx, core.FileAttachment{Data: []byte("file")}) }},
{name: "SendWithButtons", run: func() error {
return p.SendWithButtons(ctx, rctx, "hello", [][]core.ButtonOption{{{Text: "A", Data: "a"}}})
}},
{name: "SendPreviewStart", run: func() error {
_, err := p.SendPreviewStart(ctx, rctx, "preview")
return err
}},
{name: "UpdateMessage", run: func() error {
return p.UpdateMessage(ctx, &telegramPreviewHandle{chatID: 1, messageID: 2}, "preview")
}},
{name: "DeletePreviewMessage", run: func() error {
return p.DeletePreviewMessage(ctx, &telegramPreviewHandle{chatID: 1, messageID: 2})
}},
{name: "RegisterCommands", run: func() error {
return p.RegisterCommands([]core.BotCommandInfo{{Command: "help", Description: "help"}})
}},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := tt.run()
if err == nil {
t.Fatal("expected error, got nil")
}
if !strings.Contains(err.Error(), "not connected") {
t.Fatalf("error = %q, want to contain %q", err.Error(), "not connected")
}
})
}
stop := p.StartTyping(ctx, rctx)
stop()
}
func TestPlatformLateReadyIgnoredAfterStop(t *testing.T) {
connectStarted := make(chan struct{})
releaseConnect := make(chan struct{})
connectDone := make(chan struct{})
readyCh := make(chan struct{}, 1)
unavailableCh := make(chan error, 1)
stubBot := newStubTelegramBot()
me := &models.User{ID: 42, Username: "latebot"}
p := &Platform{
token: "token",
httpClient: &http.Client{},
newBot: func(_ string, _ func(context.Context, *models.Update), _ *http.Client) (telegramBot, *models.User, func(context.Context), error) {
close(connectStarted)
defer close(connectDone)
<-releaseConnect
return stubBot, me, func(ctx context.Context) { <-ctx.Done() }, nil
},
newBackoffTimer: immediateTimer,
}
p.SetLifecycleHandler(testLifecycleHandler{
onReady: func(core.Platform) {
readyCh <- struct{}{}
},
onUnavailable: func(_ core.Platform, err error) {
unavailableCh <- err
},
})
if err := p.Start(func(core.Platform, *core.Message) {}); err != nil {
t.Fatalf("Start: %v", err)
}
<-connectStarted
if err := p.Stop(); err != nil {
t.Fatalf("Stop: %v", err)
}
close(releaseConnect)
<-connectDone
select {
case <-readyCh:
t.Fatal("unexpected ready callback after Stop")
case err := <-unavailableCh:
t.Fatalf("unexpected unavailable callback after Stop: %v", err)
case <-time.After(100 * time.Millisecond):
}
}
func TestPlatformStartTypingSwitchesToCurrentBotAfterReconnect(t *testing.T) {
oldBot := newStubTelegramBot()
newBot := newStubTelegramBot()
ticker := newStubTypingTicker()
p := &Platform{
token: "token",
httpClient: &http.Client{},
newTypingTicker: func(time.Duration) typingTicker {
return ticker
},
}
me := &models.User{ID: 42, Username: "old"}
p.publishBot(oldBot, me)
ctx, cancel := context.WithCancel(context.Background())
stop := p.StartTyping(ctx, replyContext{chatID: 1, threadID: 0, messageID: 2})
defer func() {
stop()
cancel()
}()
if got := oldBot.SendChatActionCallCount(); got != 1 {
t.Fatalf("old bot action calls after initial typing = %d, want 1", got)
}
me2 := &models.User{ID: 42, Username: "new"}
p.publishBot(newBot, me2)
ticker.ch <- time.Now()
time.Sleep(20 * time.Millisecond)
if got := oldBot.SendChatActionCallCount(); got != 1 {
t.Fatalf("old bot action calls after reconnect tick = %d, want 1", got)
}
if got := newBot.SendChatActionCallCount(); got != 1 {
t.Fatalf("new bot action calls after reconnect tick = %d, want 1", got)
}
}
func TestRetryLogMessage_DistinguishesFailureModes(t *testing.T) {
tests := []struct {
name string
cause retryCause
want string
}{
{name: "initial connect failure", cause: retryCauseInitialConnectFailure, want: "telegram: initial connection failed, retrying"},
{name: "reconnect failure", cause: retryCauseReconnectFailure, want: "telegram: reconnect failed, retrying"},
{name: "connection lost", cause: retryCauseConnectionLost, want: "telegram: connection lost, retrying"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := retryLogMessage(tt.cause); got != tt.want {
t.Fatalf("retryLogMessage(%v) = %q, want %q", tt.cause, got, tt.want)
}
})
}
}
func TestExtractEntityText(t *testing.T) {
tests := []struct {
name string
text string
offset int
length int
want string
}{
{
name: "ASCII only",
text: "hello @bot world",
offset: 6,
length: 4,
want: "@bot",
},
{
name: "Chinese before mention",
text: "你好 @mybot 你好",
offset: 3,
length: 6,
want: "@mybot",
},
{
// 👍 is U+1F44D = surrogate pair (2 UTF-16 code units)
// "Hi " = 3, "👍" = 2, " " = 1 → @mybot starts at UTF-16 offset 6
name: "emoji before mention (surrogate pair)",
text: "Hi 👍 @mybot test",
offset: 6,
length: 6,
want: "@mybot",
},
{
name: "multiple emoji before mention",
text: "🎉🎊 @testbot",
offset: 5,
length: 8,
want: "@testbot",
},
{
name: "out of range returns empty",
text: "short",
offset: 10,
length: 5,
want: "",
},
{
name: "negative offset returns empty",
text: "hello",
offset: -1,
length: 3,
want: "",
},
{
name: "negative length returns empty",
text: "hello",
offset: 0,
length: -1,
want: "",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := extractEntityText(tt.text, tt.offset, tt.length)
if got != tt.want {
t.Errorf("extractEntityText(%q, %d, %d) = %q, want %q",
tt.text, tt.offset, tt.length, got, tt.want)
}
})
}
}
func TestSendAudioRejectsInvalidReplyContext(t *testing.T) {
p := &Platform{}
err := p.SendAudio(context.Background(), "bad-context", []byte("data"), "mp3")
if err == nil {
t.Fatal("expected error for invalid reply context")
}
if !strings.Contains(err.Error(), "telegram: SendAudio: invalid reply context type") {
t.Fatalf("unexpected error: %v", err)
}
}
func TestSendAudioReturnsConversionErrorForWAV(t *testing.T) {
orig := telegramConvertAudioToOpus
t.Cleanup(func() { telegramConvertAudioToOpus = orig })
telegramConvertAudioToOpus = func(_ context.Context, _ []byte, _ string) ([]byte, error) {
return nil, errors.New("mock conversion failure")
}
stubBot := newStubTelegramBot()
p := &Platform{}
p.bot = stubBot
p.selfUser = &models.User{ID: 1, Username: "testbot"}
err := p.SendAudio(context.Background(), replyContext{chatID: 123}, []byte("wav-data"), "wav")
if err == nil {
t.Fatal("expected conversion error")
}
if !strings.Contains(err.Error(), "telegram: SendAudio: convert wav to opus") {
t.Fatalf("unexpected error prefix: %v", err)
}
if !strings.Contains(err.Error(), "mock conversion failure") {
t.Fatalf("expected wrapped conversion error, got: %v", err)
}
}
func TestTruncateTelegramBotDescription_UTF8Safe(t *testing.T) {
t.Parallel()
cjk := strings.Repeat("你", 200)
out := truncateTelegramBotDescription(cjk)
if !utf8.ValidString(out) {
t.Fatal("invalid UTF-8 from CJK description")
}
if got, max := utf8.RuneCountInString(out), telegramBotCommandDescriptionLimit; got > max {
t.Fatalf("rune count %d > %d", got, max)
}
long := strings.Repeat("b", 60)
out2 := truncateTelegramBotDescription(long)
if want := telegramBotCommandDescriptionLimit; utf8.RuneCountInString(out2) != want {
t.Fatalf("ascii truncation: got %d runes want %d", utf8.RuneCountInString(out2), want)
}
if !utf8.ValidString(out2) {
t.Fatal("invalid UTF-8 after ascii truncation")
}
}
func TestTruncateForLog_UTF8Safe(t *testing.T) {
t.Parallel()
s := strings.Repeat("世", 50) // 50 runes
out := truncateForLog(s, 10)
if !utf8.ValidString(out) {
t.Fatal("invalid UTF-8")
}
if utf8.RuneCountInString(out) != 13 { // 10 + "..."
t.Fatalf("got %d runes", utf8.RuneCountInString(out))
}
}
func TestSendAudioMP3PrefersVoice(t *testing.T) {
var paths []string
p := newTelegramTestPlatform(t, func(w http.ResponseWriter, r *http.Request) {
paths = append(paths, r.URL.Path)
fmt.Fprint(w, `{"ok":true,"result":{"message_id":1}}`)
})
if err := p.SendAudio(context.Background(), replyContext{chatID: 123}, []byte("mp3-data"), "mp3"); err != nil {
t.Fatalf("SendAudio returned error: %v", err)
}
if len(paths) != 1 {
t.Fatalf("request count = %d, want 1", len(paths))
}
if !strings.HasSuffix(paths[0], "/sendVoice") {
t.Fatalf("path = %q, want sendVoice", paths[0])
}
}
func TestSendAudioWAVConvertsToVoice(t *testing.T) {
orig := telegramConvertAudioToOpus
t.Cleanup(func() { telegramConvertAudioToOpus = orig })
var (
paths []string
converted bool
gotFormat string
gotPayload []byte
)
telegramConvertAudioToOpus = func(_ context.Context, audio []byte, format string) ([]byte, error) {
converted = true
gotFormat = format
gotPayload = append([]byte(nil), audio...)
return []byte("converted-opus"), nil
}
p := newTelegramTestPlatform(t, func(w http.ResponseWriter, r *http.Request) {
paths = append(paths, r.URL.Path)
fmt.Fprint(w, `{"ok":true,"result":{"message_id":1}}`)
})
if err := p.SendAudio(context.Background(), replyContext{chatID: 123}, []byte("wav-data"), "wav"); err != nil {
t.Fatalf("SendAudio returned error: %v", err)
}
if !converted {
t.Fatal("expected wav input to be converted before sendVoice")
}
if gotFormat != "wav" {
t.Fatalf("converter format = %q, want wav", gotFormat)
}
if string(gotPayload) != "wav-data" {
t.Fatalf("converter payload = %q, want wav-data", gotPayload)
}
if len(paths) != 1 || !strings.HasSuffix(paths[0], "/sendVoice") {
t.Fatalf("paths = %v, want only sendVoice", paths)
}
}
func TestSendAudioFallsBackToSendAudioForMP3(t *testing.T) {
var paths []string
p := newTelegramTestPlatform(t, func(w http.ResponseWriter, r *http.Request) {
paths = append(paths, r.URL.Path)
if strings.HasSuffix(r.URL.Path, "/sendVoice") {
fmt.Fprint(w, `{"ok":false,"error_code":400,"description":"voice rejected"}`)
return
}
fmt.Fprint(w, `{"ok":true,"result":{"message_id":1}}`)
})
if err := p.SendAudio(context.Background(), replyContext{chatID: 123}, []byte("mp3-data"), "mp3"); err != nil {
t.Fatalf("SendAudio returned error: %v", err)
}
if len(paths) != 2 {
t.Fatalf("request count = %d, want 2", len(paths))
}
if !strings.HasSuffix(paths[0], "/sendVoice") || !strings.HasSuffix(paths[1], "/sendAudio") {
t.Fatalf("paths = %v, want sendVoice then sendAudio", paths)
}
}
func TestBuildSessionKey(t *testing.T) {
tests := []struct {
name string
shared bool
chatID int64
thread int
userID int64
want string
}{
{name: "private no topic", shared: false, chatID: 100, thread: 0, userID: 7, want: "telegram:100:7"},
{name: "private with topic", shared: false, chatID: 100, thread: 42, userID: 7, want: "telegram:100:42:7"},
{name: "shared no topic", shared: true, chatID: 100, thread: 0, userID: 7, want: "telegram:100"},
{name: "shared with topic", shared: true, chatID: 100, thread: 42, userID: 7, want: "telegram:100:42"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
p := &Platform{shareSessionInChannel: tt.shared}
got := p.buildSessionKey(tt.chatID, tt.thread, tt.userID)
if got != tt.want {
t.Fatalf("buildSessionKey(%d, %d, %d) = %q, want %q", tt.chatID, tt.thread, tt.userID, got, tt.want)
}
})
}
}
func TestReconstructReplyCtx(t *testing.T) {
tests := []struct {
name string
shared bool
key string
wantChat int64
wantThr int
wantErr bool
}{
{name: "shared no topic", shared: true, key: "telegram:100", wantChat: 100, wantThr: 0},
{name: "shared with topic", shared: true, key: "telegram:100:42", wantChat: 100, wantThr: 42},
{name: "per-user no topic", shared: false, key: "telegram:100:7", wantChat: 100, wantThr: 0},
{name: "per-user with topic", shared: false, key: "telegram:100:42:7", wantChat: 100, wantThr: 42},
{name: "invalid prefix", shared: false, key: "slack:100:7", wantErr: true},
{name: "too short", shared: false, key: "telegram", wantErr: true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
p := &Platform{shareSessionInChannel: tt.shared}
rctx, err := p.ReconstructReplyCtx(tt.key)
if tt.wantErr {
if err == nil {
t.Fatal("expected error, got nil")
}
return
}
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
rc := rctx.(replyContext)
if rc.chatID != tt.wantChat {
t.Fatalf("chatID = %d, want %d", rc.chatID, tt.wantChat)
}
if rc.threadID != tt.wantThr {
t.Fatalf("threadID = %d, want %d", rc.threadID, tt.wantThr)
}
})
}
}
func TestIsDirectedAtBot(t *testing.T) {
p := &Platform{token: "token", httpClient: &http.Client{}}
p.selfUser = &models.User{ID: 42, Username: "mybot"}
tests := []struct {
name string
msg *models.Message
want bool
}{
{
name: "command without @suffix",
msg: &models.Message{
Text: "/help",
Chat: models.Chat{ID: 1, Type: models.ChatTypeGroup},
Entities: []models.MessageEntity{
{Type: models.MessageEntityTypeBotCommand, Offset: 0, Length: 5},
},
},
want: true,
},
{
name: "command @mybot",
msg: &models.Message{
Text: "/help@mybot",
Chat: models.Chat{ID: 1, Type: models.ChatTypeGroup},
Entities: []models.MessageEntity{
{Type: models.MessageEntityTypeBotCommand, Offset: 0, Length: 11},
},
},
want: true,
},
{
name: "command @otherbot",
msg: &models.Message{
Text: "/help@otherbot",
Chat: models.Chat{ID: 1, Type: models.ChatTypeGroup},
Entities: []models.MessageEntity{
{Type: models.MessageEntityTypeBotCommand, Offset: 0, Length: 14},
},
},
want: false,
},
{
name: "@mention in text",
msg: &models.Message{
Text: "hey @mybot do something",
Chat: models.Chat{ID: 1, Type: models.ChatTypeGroup},
Entities: []models.MessageEntity{
{Type: models.MessageEntityTypeMention, Offset: 4, Length: 6},
},
},
want: true,
},
{
name: "reply to bot message",
msg: &models.Message{
Text: "yes do it",
Chat: models.Chat{ID: 1, Type: models.ChatTypeGroup},
ReplyToMessage: &models.Message{
From: &models.User{ID: 42},
},
},
want: true,
},
{
name: "plain text not directed",
msg: &models.Message{
Text: "hello everyone",
Chat: models.Chat{ID: 1, Type: models.ChatTypeGroup},
},
want: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := p.isDirectedAtBot(tt.msg)
if got != tt.want {
t.Fatalf("isDirectedAtBot() = %v, want %v", got, tt.want)
}
})
}
}
func TestHandleMessageWithForumTopic(t *testing.T) {
handled := make(chan *core.Message, 1)
p := &Platform{
token: "token",
httpClient: &http.Client{},
groupReplyAll: true,
}
p.handler = func(_ core.Platform, msg *core.Message) {
handled <- msg
}
stubBot := newStubTelegramBot()
p.bot = stubBot
p.selfUser = &models.User{ID: 42, Username: "mybot"}
msg := &models.Message{
ID: 10,
MessageThreadID: 55,
Text: "hello from topic",
Date: int(time.Now().Unix()),
From: &models.User{ID: 7, Username: "alice"},
Chat: models.Chat{
ID: 100,
Type: models.ChatTypeSupergroup,
Title: "Test Group",
IsForum: true,
},
}
p.handleMessage(context.Background(), msg)
select {
case got := <-handled:
if got.SessionKey != "telegram:100:55:7" {
t.Fatalf("SessionKey = %q, want %q", got.SessionKey, "telegram:100:55:7")
}
rc := got.ReplyCtx.(replyContext)
if rc.threadID != 55 {
t.Fatalf("threadID = %d, want 55", rc.threadID)
}
case <-time.After(time.Second):
t.Fatal("message not handled")
}
}
func TestHandleMessagePrivateTopicUsesThreadID(t *testing.T) {
handled := make(chan *core.Message, 1)
p := &Platform{
token: "token",
httpClient: &http.Client{},
groupReplyAll: true,
}
p.handler = func(_ core.Platform, msg *core.Message) {
handled <- msg
}
stubBot := newStubTelegramBot()
p.bot = stubBot
p.selfUser = &models.User{ID: 42, Username: "mybot"}
msg := &models.Message{
ID: 10,
MessageThreadID: 55,
Text: "hello",
Date: int(time.Now().Unix()),
From: &models.User{ID: 7, Username: "alice"},
Chat: models.Chat{
ID: 100,
Type: models.ChatTypePrivate,
IsForum: false,
},
}
p.handleMessage(context.Background(), msg)
select {
case got := <-handled:
if got.SessionKey != "telegram:100:55:7" {
t.Fatalf("SessionKey = %q, want %q", got.SessionKey, "telegram:100:55:7")
}
rc := got.ReplyCtx.(replyContext)
if rc.threadID != 55 {
t.Fatalf("threadID = %d, want 55", rc.threadID)
}
case <-time.After(time.Second):
t.Fatal("message not handled")
}
}
func newTelegramTestPlatform(t *testing.T, handler func(http.ResponseWriter, *http.Request)) *Platform {
t.Helper()
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if strings.HasSuffix(r.URL.Path, "/getMe") {
fmt.Fprint(w, `{"ok":true,"result":{"id":1,"is_bot":true,"first_name":"Test","username":"testbot"}}`)
return
}
handler(w, r)
}))
t.Cleanup(server.Close)
b, err := tgbot.New("TEST_TOKEN",
tgbot.WithServerURL(server.URL),
tgbot.WithHTTPClient(5*time.Second, server.Client()),
)
if err != nil {
t.Fatalf("tgbot.New returned error: %v", err)
}
return &Platform{
bot: b,
selfUser: &models.User{ID: 1, Username: "testbot"},
httpClient: server.Client(),
}
}