mirror of
https://github.com/abhinavxd/libredesk.git
synced 2026-10-27 06:47:01 +00:00
1025 lines
44 KiB
Go
1025 lines
44 KiB
Go
package main
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"image"
|
|
"image/png"
|
|
"io"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strconv"
|
|
"strings"
|
|
"sync"
|
|
"testing"
|
|
|
|
amodels "github.com/abhinavxd/libredesk/internal/auth/models"
|
|
"github.com/abhinavxd/libredesk/internal/authz"
|
|
"github.com/abhinavxd/libredesk/internal/automation"
|
|
"github.com/abhinavxd/libredesk/internal/conversation"
|
|
cmodels "github.com/abhinavxd/libredesk/internal/conversation/models"
|
|
"github.com/abhinavxd/libredesk/internal/csat"
|
|
telegramChannel "github.com/abhinavxd/libredesk/internal/inbox/channel/telegram"
|
|
imodels "github.com/abhinavxd/libredesk/internal/inbox/models"
|
|
"github.com/abhinavxd/libredesk/internal/media"
|
|
localfs "github.com/abhinavxd/libredesk/internal/media/stores/localfs"
|
|
"github.com/abhinavxd/libredesk/internal/setting"
|
|
"github.com/abhinavxd/libredesk/internal/sla"
|
|
"github.com/abhinavxd/libredesk/internal/telegram"
|
|
tmpl "github.com/abhinavxd/libredesk/internal/template"
|
|
"github.com/abhinavxd/libredesk/internal/user"
|
|
"github.com/abhinavxd/libredesk/internal/webhook"
|
|
"github.com/abhinavxd/libredesk/internal/ws"
|
|
"github.com/jmoiron/sqlx"
|
|
"github.com/valyala/fasthttp"
|
|
"github.com/zerodha/fastglue"
|
|
)
|
|
|
|
func TestTelegramWebhookValidation(t *testing.T) {
|
|
app, db := newInboxApp(t)
|
|
app.ctx = t.Context()
|
|
inbox, err := app.inbox.Create(imodels.Inbox{Channel: "telegram", Name: "test", Enabled: true, Config: json.RawMessage(`{"bot_token":"123:secret","secret_token":"webhook-secret"}`)})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
for _, tc := range []struct {
|
|
name, path, secret, body string
|
|
status int
|
|
}{
|
|
{"invalid id", "nope", "", "{}", 400},
|
|
{"missing inbox", "99999", "", "{}", 400},
|
|
{"missing secret", strconv.Itoa(inbox.ID), "", "{}", 403},
|
|
{"wrong secret", strconv.Itoa(inbox.ID), "incorrect", "{}", 403},
|
|
{"malformed update", strconv.Itoa(inbox.ID), "webhook-secret", "{", 400},
|
|
{"other update", strconv.Itoa(inbox.ID), "webhook-secret", `{"update_id":1}`, 200},
|
|
{"group message", strconv.Itoa(inbox.ID), "webhook-secret", `{"message":{"message_id":1,"from":{"id":42},"chat":{"id":-123,"type":"group"},"text":"ignore"}}`, 200},
|
|
{"sender mismatch", strconv.Itoa(inbox.ID), "webhook-secret", `{"message":{"message_id":1,"from":{"id":42},"chat":{"id":43,"type":"private"},"text":"ignore"}}`, 200},
|
|
} {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
r := telegramTestRequest(app, tc.path, tc.secret, tc.body)
|
|
if err := handleTelegramWebhook(r); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if got := r.RequestCtx.Response.StatusCode(); got != tc.status {
|
|
t.Fatalf("status=%d body=%s", got, r.RequestCtx.Response.Body())
|
|
}
|
|
if !json.Valid(r.RequestCtx.Response.Body()) {
|
|
t.Fatal("handler omitted JSON envelope")
|
|
}
|
|
})
|
|
}
|
|
db.MustExec(`UPDATE inboxes SET enabled=false WHERE id=$1`, inbox.ID)
|
|
r := telegramTestRequest(app, strconv.Itoa(inbox.ID), "webhook-secret", "{")
|
|
if err := handleTelegramWebhook(r); err != nil || r.RequestCtx.Response.StatusCode() != 200 {
|
|
t.Fatalf("disabled inbox: %v", err)
|
|
}
|
|
db.MustExec(`UPDATE inboxes SET channel='email',config='{}' WHERE id=$1`, inbox.ID)
|
|
r = telegramTestRequest(app, strconv.Itoa(inbox.ID), "webhook-secret", "{}")
|
|
handleTelegramWebhook(r)
|
|
if r.RequestCtx.Response.StatusCode() != 404 {
|
|
t.Fatal("accepted other channel")
|
|
}
|
|
db.MustExec(`UPDATE inboxes SET channel='telegram',config='{"bot_id":"invalid"}' WHERE id=$1`, inbox.ID)
|
|
r = telegramTestRequest(app, strconv.Itoa(inbox.ID), "webhook-secret", "{}")
|
|
handleTelegramWebhook(r)
|
|
if r.RequestCtx.Response.StatusCode() != 500 {
|
|
t.Fatal("invalid config hidden")
|
|
}
|
|
if err := ingestTelegramMessage(t.Context(), app, inbox, telegramChannel.Config{}, telegram.Message{}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
|
|
func TestFetchTelegramAttachment(t *testing.T) {
|
|
app := testI18nApp(t)
|
|
app.consts.Store(&constants{MaxFileUploadSizeMB: 100})
|
|
app.telegramClient = telegram.New()
|
|
cfg := telegramChannel.Config{BotToken: "123:secret"}
|
|
for _, tc := range []struct {
|
|
name, payload, response, body, wantName, wantType string
|
|
status int
|
|
notice, wantErr bool
|
|
}{
|
|
{name: "text", payload: `{"text":"hi"}`},
|
|
{name: "unsupported", payload: `{}`, notice: true},
|
|
{name: "oversize metadata", payload: `{"document":{"file_id":"file","file_size":20971521}}`, notice: true},
|
|
{name: "not found", payload: `{"document":{"file_id":"file"}}`, response: `{"ok":false,"error_code":404}`, notice: true},
|
|
{name: "bad file", payload: `{"document":{"file_id":"file"}}`, response: `{"ok":false,"error_code":400}`, notice: true},
|
|
{name: "expired token", payload: `{"document":{"file_id":"file"}}`, response: `{"ok":false,"error_code":401}`, wantErr: true},
|
|
{name: "server failure", payload: `{"document":{"file_id":"file"}}`, response: `{"ok":false,"error_code":500}`, wantErr: true},
|
|
{name: "oversize getFile", payload: `{"document":{"file_id":"file"}}`, response: `{"ok":true,"result":{"file_size":20971521}}`, notice: true},
|
|
{name: "empty file", payload: `{"document":{"file_id":"file"}}`, response: `{"ok":true,"result":{"file_path":"documents/file.txt"}}`, notice: true},
|
|
{name: "original filename", payload: `{"document":{"file_id":"file","file_name":"invoice.txt","mime_type":"text/plain"}}`, body: "test", wantName: "invoice.txt", wantType: "text/plain"},
|
|
{name: "extension MIME", payload: `{"document":{"file_id":"file"}}`, body: "test", wantName: "file.txt", wantType: "text/plain; charset=utf-8"},
|
|
{name: "sniffed MIME", payload: `{"document":{"file_id":"file","file_name":"no-extension"}}`, body: "test", wantName: "no-extension", wantType: "text/plain; charset=utf-8"},
|
|
{name: "name fallback", payload: `{"document":{"file_id":"file","file_name":"."}}`, body: "test", wantName: "document.plain", wantType: "text/plain; charset=utf-8"},
|
|
} {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
var msg telegram.Message
|
|
if err := json.Unmarshal([]byte(tc.payload), &msg); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
if r.URL.Path == "/bot123:secret/getFile" {
|
|
response := tc.response
|
|
if response == "" {
|
|
response = `{"ok":true,"result":{"file_path":"documents/file.txt"}}`
|
|
}
|
|
io.WriteString(w, response)
|
|
} else {
|
|
io.WriteString(w, tc.body)
|
|
}
|
|
}))
|
|
defer server.Close()
|
|
app.telegramClient.SetBaseURL(server.URL)
|
|
files, notice, err := fetchTelegramAttachment(t.Context(), app, cfg, msg)
|
|
if (err != nil) != tc.wantErr || (notice != "") != tc.notice {
|
|
t.Fatalf("notice=%q err=%v", notice, err)
|
|
}
|
|
if tc.wantName != "" && (len(files) != 1 || files[0].Name != tc.wantName || files[0].ContentType != tc.wantType || string(files[0].Content) != tc.body) {
|
|
t.Fatalf("files=%+v", files)
|
|
}
|
|
})
|
|
}
|
|
app.consts.Store(&constants{MaxFileUploadSizeMB: 1})
|
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
if r.URL.Path == "/bot123:secret/getFile" {
|
|
io.WriteString(w, `{"ok":true,"result":{"file_path":"documents/file.txt"}}`)
|
|
return
|
|
}
|
|
w.Write(make([]byte, 1024*1024+1))
|
|
}))
|
|
defer server.Close()
|
|
app.telegramClient.SetBaseURL(server.URL)
|
|
for _, file := range []*telegram.File{{ID: "file", Size: 1024*1024 + 1}, {ID: "file"}} {
|
|
files, notice, err := fetchTelegramAttachment(t.Context(), app, cfg, telegram.Message{Document: file})
|
|
if err != nil || notice == "" || len(files) != 0 {
|
|
t.Fatalf("upload limit ignored: files=%d notice=%q err=%v", len(files), notice, err)
|
|
}
|
|
}
|
|
ctx, cancel := context.WithCancel(t.Context())
|
|
cancel()
|
|
_, _, err := fetchTelegramAttachment(ctx, app, cfg, telegram.Message{Document: &telegram.File{ID: "file"}})
|
|
if err == nil {
|
|
t.Fatal("canceled download succeeded")
|
|
}
|
|
}
|
|
|
|
func TestTelegramCallbackURL(t *testing.T) {
|
|
for _, tc := range []struct{ root, want string }{{"", ""}, {"https://desk.example.com", "https://desk.example.com/webhooks/telegram/7"}, {"https://desk.example.com///", "https://desk.example.com/webhooks/telegram/7"}} {
|
|
if got := telegramCallbackURL(tc.root, 7); got != tc.want {
|
|
t.Fatalf("got %q want %q", got, tc.want)
|
|
}
|
|
}
|
|
}
|
|
|
|
func telegramTestRequest(app *App, id, secret, body string) *fastglue.Request {
|
|
r := &fastglue.Request{Context: app, RequestCtx: &fasthttp.RequestCtx{}}
|
|
r.RequestCtx.SetUserValue("inbox_id", id)
|
|
r.RequestCtx.Request.Header.Set("X-Telegram-Bot-Api-Secret-Token", secret)
|
|
r.RequestCtx.Request.SetBodyString(body)
|
|
return r
|
|
}
|
|
|
|
func TestTelegramIngestionAndReopening(t *testing.T) {
|
|
app, db, rec := newTelegramIntegrationApp(t)
|
|
cfg := telegramChannel.Config{BotToken: "123:secret", SecretToken: "webhook-secret"}
|
|
var msg telegram.Message
|
|
json.Unmarshal([]byte(`{"message_id":1,"from":{"id":4500000000000,"first_name":"First","last_name":"Last"},"chat":{"id":4500000000000,"type":"private"},"text":"First message"}`), &msg)
|
|
deliver := func() {
|
|
t.Helper()
|
|
if err := ingestTelegramMessage(t.Context(), app, rec, cfg, msg); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
count := func(table string) int {
|
|
t.Helper()
|
|
var n int
|
|
if err := db.Get(&n, "SELECT COUNT(*) FROM "+table); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return n
|
|
}
|
|
deliver()
|
|
var original int
|
|
db.Get(&original, `SELECT id FROM conversations WHERE inbox_id=$1`, rec.ID)
|
|
var wg sync.WaitGroup
|
|
for range 10 {
|
|
wg.Go(func() {
|
|
if err := ingestTelegramMessage(t.Context(), app, rec, cfg, msg); err != nil {
|
|
t.Error(err)
|
|
}
|
|
})
|
|
}
|
|
wg.Wait()
|
|
if count("conversation_messages") != 1 || count("contact_channel_identities") != 1 || count("conversations") != 1 {
|
|
t.Fatal("duplicate delivery created extra data")
|
|
}
|
|
msg.ID++
|
|
msg.Text = "Second message"
|
|
deliver()
|
|
if count("conversations") != 1 {
|
|
t.Fatal("open conversation not reused")
|
|
}
|
|
resolve := func() {
|
|
db.MustExec(`UPDATE conversations SET status_id=(SELECT id FROM conversation_statuses WHERE category='resolved' LIMIT 1),last_resolved_at=NOW() WHERE inbox_id=$1`, rec.ID)
|
|
}
|
|
resolve()
|
|
msg.ID++
|
|
deliver()
|
|
var status string
|
|
db.Get(&status, `SELECT cs.category FROM conversations c JOIN conversation_statuses cs ON cs.id=c.status_id WHERE c.id=$1`, original)
|
|
if status != "open" || count("conversations") != 1 {
|
|
t.Fatalf("recent conversation was not reopened: status=%s", status)
|
|
}
|
|
resolve()
|
|
db.MustExec(`UPDATE conversations SET last_resolved_at=NOW()-INTERVAL '49 hours' WHERE id=$1`, original)
|
|
msg.ID++
|
|
deliver()
|
|
if count("conversations") != 2 {
|
|
t.Fatal("expired reopen window reused conversation")
|
|
}
|
|
resolve()
|
|
rec.ReopenWindowHours = 0
|
|
msg.ID++
|
|
deliver()
|
|
if count("conversations") != 3 {
|
|
t.Fatal("zero reopen window reused conversation")
|
|
}
|
|
other, err := app.inbox.Create(imodels.Inbox{Name: "second bot", Channel: "telegram", Config: rec.Config})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := ingestTelegramMessage(t.Context(), app, other, cfg, msg); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if count("contact_channel_identities") != 1 || count("conversations") != 4 {
|
|
t.Fatal("cross-bot contact identity or routing incorrect")
|
|
}
|
|
ctx, cancel := context.WithCancel(t.Context())
|
|
cancel()
|
|
msg.ID++
|
|
if err := ingestTelegramMessage(ctx, app, rec, cfg, msg); err != context.Canceled {
|
|
t.Fatalf("cancellation=%v", err)
|
|
}
|
|
|
|
source := telegram.SourceID(rec.ID, msg.Chat.ID, 1)
|
|
chatID := strconv.FormatInt(msg.Chat.ID, 10)
|
|
if err := app.conversation.UpdateTelegramMessageContent(rec.ID, chatID, source, "Corrected", "Corrected", cmodels.ContentTypeText, 100); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := app.conversation.UpdateTelegramMessageContent(rec.ID, chatID, source, "Older edit", "Older edit", cmodels.ContentTypeText, 99); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
var content string
|
|
db.Get(&content, `SELECT content FROM conversation_messages WHERE source_id=$1`, source)
|
|
if content != "Corrected" {
|
|
t.Fatal("older edit overwrote correction")
|
|
}
|
|
if err := app.conversation.UpdateTelegramMessageContent(rec.ID, "1", source, "Other chat", "Other chat", cmodels.ContentTypeText, 200); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := app.conversation.UpdateTelegramMessageContent(other.ID, chatID, source, "Other inbox", "Other inbox", cmodels.ContentTypeText, 200); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
db.Get(&content, `SELECT content FROM conversation_messages WHERE source_id=$1`, source)
|
|
if content != "Corrected" {
|
|
t.Fatal("edit from another chat or inbox changed the message")
|
|
}
|
|
if err := app.conversation.UpdateTelegramMessageContent(rec.ID, chatID, "telegram:999:999:999", "missing", "missing", cmodels.ContentTypeText, 100); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
request := telegramTestRequest(app, strconv.Itoa(rec.ID), "webhook-secret", `{"edited_message":{"message_id":1,"edit_date":101,"from":{"id":4500000000000},"chat":{"id":4500000000000,"type":"private"},"text":"Edited through webhook"}}`)
|
|
handleTelegramWebhook(request)
|
|
if request.RequestCtx.Response.StatusCode() != 200 {
|
|
t.Fatalf("edit failed: %s", request.RequestCtx.Response.Body())
|
|
}
|
|
db.Get(&content, `SELECT content FROM conversation_messages WHERE source_id=$1`, source)
|
|
if content != "Edited through webhook" {
|
|
t.Fatal("webhook edit not saved")
|
|
}
|
|
request = telegramTestRequest(app, strconv.Itoa(rec.ID), "webhook-secret", `{"message":{"message_id":99,"from":{"id":4500000000000},"chat":{"id":4500000000000,"type":"private"},"text":"Through webhook"}}`)
|
|
handleTelegramWebhook(request)
|
|
if request.RequestCtx.Response.StatusCode() != 200 {
|
|
t.Fatalf("webhook failed: %s", request.RequestCtx.Response.Body())
|
|
}
|
|
}
|
|
|
|
func TestTelegramIngestionFailuresAndMedia(t *testing.T) {
|
|
app, db, rec := newTelegramIntegrationApp(t)
|
|
cfg := telegramChannel.Config{BotToken: "123:secret"}
|
|
var msg telegram.Message
|
|
json.Unmarshal([]byte(`{"message_id":1,"from":{"id":42,"first_name":"Test"},"chat":{"id":42,"type":"private"},"text":"Caption"}`), &msg)
|
|
getFileFailure := true
|
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
if r.URL.Path == "/bot123:secret/getFile" {
|
|
if getFileFailure {
|
|
io.WriteString(w, `{"ok":false,"error_code":500}`)
|
|
} else {
|
|
io.WriteString(w, `{"ok":true,"result":{"file_path":"documents/file.txt"}}`)
|
|
}
|
|
} else {
|
|
io.WriteString(w, "attachment content")
|
|
}
|
|
}))
|
|
defer server.Close()
|
|
app.telegramClient.SetBaseURL(server.URL)
|
|
msg.Document = &telegram.File{ID: "file", Name: "original.txt", MIME: "text/plain"}
|
|
if err := ingestTelegramMessage(t.Context(), app, rec, cfg, msg); err == nil {
|
|
t.Fatal("transient media failure acknowledged")
|
|
}
|
|
getFileFailure = false
|
|
db.MustExec(`ALTER TABLE media ADD CONSTRAINT telegram_test_reject_media CHECK (false) NOT VALID`)
|
|
if err := ingestTelegramMessage(t.Context(), app, rec, cfg, msg); err == nil {
|
|
t.Fatal("media storage failure ignored")
|
|
}
|
|
db.MustExec(`ALTER TABLE media DROP CONSTRAINT telegram_test_reject_media`)
|
|
if err := ingestTelegramMessage(t.Context(), app, rec, cfg, msg); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
var name string
|
|
if err := db.Get(&name, `SELECT filename FROM media LIMIT 1`); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if name != "original.txt" {
|
|
t.Fatalf("filename=%s", name)
|
|
}
|
|
msg.ID++
|
|
msg.Text = ""
|
|
if err := ingestTelegramMessage(t.Context(), app, rec, cfg, msg); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
msg.ID++
|
|
msg.Document.Size = telegram.MaxDownloadBytes + 1
|
|
msg.Text = "Unavailable attachment caption"
|
|
if err := ingestTelegramMessage(t.Context(), app, rec, cfg, msg); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
msg.ID++
|
|
msg.Text = ""
|
|
if err := ingestTelegramMessage(t.Context(), app, rec, cfg, msg); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
msg.ID++
|
|
msg.Document = nil
|
|
msg.Text = "retry me"
|
|
db.MustExec(`CREATE OR REPLACE FUNCTION reject_telegram_test() RETURNS trigger LANGUAGE plpgsql AS $$ BEGIN RAISE EXCEPTION 'test failure'; END $$`)
|
|
for _, table := range []string{"users", "conversations", "conversation_messages"} {
|
|
db.MustExec(`CREATE TRIGGER reject_telegram_test BEFORE INSERT ON ` + table + ` FOR EACH ROW EXECUTE FUNCTION reject_telegram_test()`)
|
|
msg.Chat.ID++
|
|
msg.From.ID = msg.Chat.ID
|
|
msg.ID++
|
|
if err := ingestTelegramMessage(t.Context(), app, rec, cfg, msg); err == nil {
|
|
t.Fatalf("%s failure ignored", table)
|
|
}
|
|
if table == "conversation_messages" {
|
|
body, _ := json.Marshal(telegram.Update{Message: &msg})
|
|
r := telegramTestRequest(app, strconv.Itoa(rec.ID), "webhook-secret", string(body))
|
|
handleTelegramWebhook(r)
|
|
if r.RequestCtx.Response.StatusCode() != 503 {
|
|
t.Fatalf("storage failure acknowledged: %s", r.RequestCtx.Response.Body())
|
|
}
|
|
}
|
|
db.MustExec(`DROP TRIGGER reject_telegram_test ON ` + table)
|
|
}
|
|
if err := ingestTelegramMessage(t.Context(), app, rec, cfg, msg); err != nil {
|
|
t.Fatal("retry after recovery failed:", err)
|
|
}
|
|
msg.ID++
|
|
db.MustExec(`ALTER TABLE conversations RENAME COLUMN contact_id TO missing_contact_id`)
|
|
if err := ingestTelegramMessage(t.Context(), app, rec, cfg, msg); err == nil {
|
|
t.Fatal("conversation lookup error ignored")
|
|
}
|
|
db.MustExec(`ALTER TABLE conversations RENAME COLUMN missing_contact_id TO contact_id`)
|
|
db.Close()
|
|
if err := ingestTelegramMessage(t.Context(), app, rec, cfg, msg); err == nil {
|
|
t.Fatal("duplicate lookup database failure ignored")
|
|
}
|
|
if err := app.conversation.UpdateTelegramMessageContent(rec.ID, "42", "source", "text", "text", cmodels.ContentTypeText, 100); err == nil {
|
|
t.Fatal("edit database failure ignored")
|
|
}
|
|
}
|
|
|
|
func TestTelegramBusinessAndTopics(t *testing.T) {
|
|
app, db, rec := newTelegramIntegrationApp(t)
|
|
var count int
|
|
for _, body := range []string{
|
|
`{"business_message":{"business_connection_id":"business-a","message_id":1,"from":{"id":7777,"first_name":"Customer"},"chat":{"id":7777,"type":"private"},"text":"Customer to business A"}}`,
|
|
`{"business_message":{"business_connection_id":"business-b","message_id":1,"from":{"id":7777},"chat":{"id":7777,"type":"private"},"text":"Customer to business B"}}`,
|
|
`{"message":{"message_id":1,"from":{"id":7777},"chat":{"id":7777,"type":"private"},"text":"Customer to bot"}}`,
|
|
`{"message":{"message_id":2,"message_thread_id":44,"from":{"id":7777},"chat":{"id":7777,"type":"private"},"text":"Customer in topic"}}`,
|
|
`{"business_message":{"business_connection_id":"business-a","message_id":2,"from":{"id":8888,"first_name":"Owner"},"chat":{"id":7777,"type":"private","first_name":"Customer"},"text":"Reply from Telegram app"}}`,
|
|
`{"business_message":{"business_connection_id":"business-c","message_id":1,"from":{"id":8888,"first_name":"Owner"},"chat":{"id":7777,"type":"private","first_name":"Customer"},"text":"Owner initiates"}}`,
|
|
} {
|
|
r := telegramTestRequest(app, strconv.Itoa(rec.ID), "webhook-secret", body)
|
|
if err := handleTelegramWebhook(r); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if r.RequestCtx.Response.StatusCode() != 200 {
|
|
t.Fatalf("status=%d body=%s", r.RequestCtx.Response.StatusCode(), r.RequestCtx.Response.Body())
|
|
}
|
|
}
|
|
db.Get(&count, `SELECT COUNT(*) FROM conversations WHERE inbox_id=$1`, rec.ID)
|
|
if count != 5 {
|
|
t.Fatalf("separate business accounts, bot chats and topics mixed: %d", count)
|
|
}
|
|
db.Get(&count, `SELECT COUNT(*) FROM conversation_messages WHERE type='outgoing'`)
|
|
if count != 2 {
|
|
t.Fatalf("business owner replies not mirrored: %d", count)
|
|
}
|
|
db.Get(&count, `SELECT COUNT(*) FROM contact_channel_identities WHERE channel='telegram' AND identifier='7777'`)
|
|
if count != 1 {
|
|
t.Fatal("same customer identity duplicated")
|
|
}
|
|
r := telegramTestRequest(app, strconv.Itoa(rec.ID), "webhook-secret", `{"edited_business_message":{"business_connection_id":"business-a","message_id":2,"edit_date":100,"from":{"id":8888},"chat":{"id":7777,"type":"private"},"text":"Corrected owner reply"}}`)
|
|
handleTelegramWebhook(r)
|
|
var content string
|
|
db.Get(&content, `SELECT content FROM conversation_messages WHERE source_id=$1`, telegram.BusinessSourceID(rec.ID, "business-a", 7777, 2))
|
|
if content != "Corrected owner reply" {
|
|
t.Fatal("business edit not applied")
|
|
}
|
|
db.MustExec(`UPDATE inboxes SET config = config || '{"bot_id":123}'::jsonb WHERE id=$1`, rec.ID)
|
|
r = telegramTestRequest(app, strconv.Itoa(rec.ID), "webhook-secret", `{"business_message":{"business_connection_id":"business-a","message_id":99,"sender_business_bot":{"id":123},"from":{"id":8888},"chat":{"id":7777,"type":"private"},"text":"Own echo"}}`)
|
|
handleTelegramWebhook(r)
|
|
db.Get(&count, `SELECT COUNT(*) FROM conversation_messages WHERE source_id=$1`, telegram.BusinessSourceID(rec.ID, "business-a", 7777, 99))
|
|
if count != 0 {
|
|
t.Fatal("own bot echo duplicated")
|
|
}
|
|
r = telegramTestRequest(app, strconv.Itoa(rec.ID), "webhook-secret", `{"edited_business_message":{"business_connection_id":"business-a","message_id":2,"edit_date":101,"sender_business_bot":{"id":123},"from":{"id":8888},"chat":{"id":7777,"type":"private"},"text":"Edited bot reply"}}`)
|
|
handleTelegramWebhook(r)
|
|
db.Get(&content, `SELECT content FROM conversation_messages WHERE source_id=$1`, telegram.BusinessSourceID(rec.ID, "business-a", 7777, 2))
|
|
if content != "Edited bot reply" {
|
|
t.Fatal("own bot message edit ignored")
|
|
}
|
|
db.Get(&content, `SELECT last_message FROM conversations WHERE meta->'telegram'->>'business_connection_id'='business-a'`)
|
|
if content != "Edited bot reply" {
|
|
t.Fatalf("conversation preview stale: %s", content)
|
|
}
|
|
db.MustExec(`DELETE FROM users WHERE email='System'`)
|
|
r = telegramTestRequest(app, strconv.Itoa(rec.ID), "webhook-secret", `{"business_message":{"business_connection_id":"business-a","message_id":100,"from":{"id":8888},"chat":{"id":7777,"type":"private"},"text":"Cannot attribute owner"}}`)
|
|
handleTelegramWebhook(r)
|
|
if r.RequestCtx.Response.StatusCode() != 503 {
|
|
t.Fatal("owner attribution error acknowledged")
|
|
}
|
|
}
|
|
|
|
func TestTelegramContactAvatar(t *testing.T) {
|
|
app, db, rec := newTelegramIntegrationApp(t)
|
|
var contactID int
|
|
if err := db.Get(&contactID, `INSERT INTO users (type,first_name,last_name) VALUES ('contact','Avatar','Test') RETURNING id`); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
var picture bytes.Buffer
|
|
if err := png.Encode(&picture, image.NewRGBA(image.Rect(0, 0, 2, 2))); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
mode := "profile error"
|
|
calls := 0
|
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
calls++
|
|
switch r.URL.Path {
|
|
case "/bot123:secret/getUserProfilePhotos":
|
|
if mode == "profile error" {
|
|
io.WriteString(w, `{"ok":false,"error_code":401}`)
|
|
} else if mode == "no photo" {
|
|
io.WriteString(w, `{"ok":true,"result":{"photos":[]}}`)
|
|
} else {
|
|
io.WriteString(w, `{"ok":true,"result":{"photos":[[{"file_id":"photo"}]]}}`)
|
|
}
|
|
case "/bot123:secret/getFile":
|
|
if mode == "download error" {
|
|
io.WriteString(w, `{"ok":false,"error_code":400}`)
|
|
} else {
|
|
io.WriteString(w, `{"ok":true,"result":{"file_path":"photos/avatar.png"}}`)
|
|
}
|
|
default:
|
|
if mode == "invalid image" {
|
|
io.WriteString(w, "not an image")
|
|
} else {
|
|
w.Write(picture.Bytes())
|
|
}
|
|
}
|
|
}))
|
|
defer server.Close()
|
|
app.telegramClient.SetBaseURL(server.URL)
|
|
cfg := telegramChannel.Config{BotToken: "123:secret"}
|
|
for _, value := range []string{"profile error", "download error", "invalid image"} {
|
|
mode = value
|
|
if err := fetchTelegramAvatar(t.Context(), app, cfg, contactID, 42); err == nil {
|
|
t.Fatalf("%s ignored", mode)
|
|
}
|
|
}
|
|
mode = "no photo"
|
|
if err := fetchTelegramAvatar(t.Context(), app, cfg, contactID, 42); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
mode = "valid"
|
|
db.MustExec(`ALTER TABLE media ADD CONSTRAINT telegram_test_avatar CHECK (false) NOT VALID`)
|
|
if err := fetchTelegramAvatar(t.Context(), app, cfg, contactID, 42); err == nil {
|
|
t.Fatal("media error ignored")
|
|
}
|
|
db.MustExec(`ALTER TABLE media DROP CONSTRAINT telegram_test_avatar`)
|
|
db.MustExec(`CREATE OR REPLACE FUNCTION reject_telegram_test() RETURNS trigger LANGUAGE plpgsql AS $$ BEGIN RAISE EXCEPTION 'test failure'; END $$`)
|
|
db.MustExec(`CREATE TRIGGER reject_telegram_avatar BEFORE UPDATE ON users FOR EACH ROW EXECUTE FUNCTION reject_telegram_test()`)
|
|
if err := fetchTelegramAvatar(t.Context(), app, cfg, contactID, 42); err == nil {
|
|
t.Fatal("avatar update error ignored")
|
|
}
|
|
db.MustExec(`DROP TRIGGER reject_telegram_avatar ON users`)
|
|
if err := fetchTelegramAvatar(t.Context(), app, cfg, contactID, 42); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
var avatar string
|
|
if err := db.Get(&avatar, `SELECT avatar_url FROM users WHERE id=$1`, contactID); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if !strings.HasPrefix(avatar, "/uploads/") {
|
|
t.Fatalf("avatar must store a relative path: %s", avatar)
|
|
}
|
|
before := calls
|
|
if err := fetchTelegramAvatar(t.Context(), app, cfg, contactID, 42); err != nil || calls != before {
|
|
t.Fatal("existing avatar overwritten")
|
|
}
|
|
if err := fetchTelegramAvatar(t.Context(), app, cfg, 999999, 42); err == nil {
|
|
t.Fatal("missing contact accepted")
|
|
}
|
|
mode = "no photo"
|
|
r := telegramTestRequest(app, strconv.Itoa(rec.ID), "webhook-secret", `{"message":{"message_id":1,"from":{"id":88888},"chat":{"id":88888,"type":"private"},"reply_to_message":{"message_id":99,"text":"Original"},"text":"Quoted reply"}}`)
|
|
handleTelegramWebhook(r)
|
|
if r.RequestCtx.Response.StatusCode() != 200 {
|
|
t.Fatalf("quoted ingress failed: %s", r.RequestCtx.Response.Body())
|
|
}
|
|
var quote string
|
|
db.Get("e, `SELECT meta->'reply_to'->>'content' FROM conversation_messages WHERE source_id=$1`, telegram.SourceID(rec.ID, 88888, 1))
|
|
if quote != "Original" {
|
|
t.Fatal("incoming quote context lost")
|
|
}
|
|
before = calls
|
|
r = telegramTestRequest(app, strconv.Itoa(rec.ID), "webhook-secret", `{"message":{"message_id":2,"from":{"id":88888},"chat":{"id":88888,"type":"private"},"text":"Follow up"}}`)
|
|
handleTelegramWebhook(r)
|
|
if r.RequestCtx.Response.StatusCode() != 200 || calls != before {
|
|
t.Fatalf("avatar fetched for an existing conversation: status=%d calls=%d", r.RequestCtx.Response.StatusCode(), calls-before)
|
|
}
|
|
}
|
|
|
|
func TestTelegramCallbackDelivery(t *testing.T) {
|
|
app, db, rec := newTelegramIntegrationApp(t)
|
|
var callbacks int
|
|
fail := false
|
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
if strings.HasSuffix(r.URL.Path, "/answerCallbackQuery") {
|
|
callbacks++
|
|
var body map[string]string
|
|
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
|
t.Error(err)
|
|
}
|
|
if body["callback_query_id"] != "query-1" {
|
|
t.Errorf("callback=%v", body)
|
|
}
|
|
if fail {
|
|
w.WriteHeader(400)
|
|
io.WriteString(w, `{"ok":false,"error_code":400,"description":"query expired"}`)
|
|
return
|
|
}
|
|
io.WriteString(w, `{"ok":true,"result":true}`)
|
|
return
|
|
}
|
|
io.WriteString(w, `{"ok":true,"result":{"photos":[]}}`)
|
|
}))
|
|
defer server.Close()
|
|
app.telegramClient.SetBaseURL(server.URL)
|
|
for _, body := range []string{
|
|
`{"callback_query":{"id":"query-1","data":"Billing","from":{"id":42},"message":{"message_id":1,"from":{"id":123,"is_bot":true},"chat":{"id":42,"type":"private"}}}}`,
|
|
`{"callback_query":{"id":"query-1","data":"Billing","from":{"id":42},"message":{"message_id":1,"chat":{"id":42,"type":"private"}}}}`,
|
|
`{"callback_query":{"id":"empty","data":""}}`,
|
|
} {
|
|
r := telegramTestRequest(app, strconv.Itoa(rec.ID), "webhook-secret", body)
|
|
if err := handleTelegramWebhook(r); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if r.RequestCtx.Response.StatusCode() != 200 {
|
|
t.Fatalf("status=%d", r.RequestCtx.Response.StatusCode())
|
|
}
|
|
fail = true
|
|
}
|
|
var count int
|
|
if err := db.Get(&count, `SELECT COUNT(*) FROM conversation_messages WHERE source_id=$1 AND content='Billing' AND meta->>'telegram_callback'='true'`, fmt.Sprintf("telegram:%d::42:callback:query-1", rec.ID)); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if count != 1 || callbacks != 2 {
|
|
t.Fatalf("messages=%d acknowledgements=%d", count, callbacks)
|
|
}
|
|
}
|
|
|
|
func TestTelegramAlbumsAndFormattedEdits(t *testing.T) {
|
|
app, db, rec := newTelegramIntegrationApp(t)
|
|
cfg := telegramChannel.Config{BotToken: "123:secret"}
|
|
var msg telegram.Message
|
|
json.Unmarshal([]byte(`{"message_id":1,"media_group_id":"album","from":{"id":42},"chat":{"id":42,"type":"private"},"text":"Hello","entities":[{"type":"bold","offset":0,"length":5}]}`), &msg)
|
|
if err := ingestTelegramMessage(t.Context(), app, rec, cfg, msg); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
var row struct{ UUID, Content, Text, Kind, Album string }
|
|
if err := db.Get(&row, `SELECT uuid,content,text_content AS text,content_type AS kind,meta->>'telegram_media_group_id' AS album FROM conversation_messages WHERE source_id=$1`, telegram.SourceID(rec.ID, 42, 1)); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if row.Content != "<b>Hello</b>" || row.Kind != "html" || row.Album != "album" || !strings.Contains(row.Text, "Hello") {
|
|
t.Fatalf("row=%+v", row)
|
|
}
|
|
sourceIDs := []string{telegram.SourceID(rec.ID, 42, 10), telegram.SourceID(rec.ID, 42, 11)}
|
|
if err := app.conversation.RecordTelegramSend(row.UUID, sourceIDs); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
var stored struct {
|
|
Source string
|
|
Meta json.RawMessage
|
|
}
|
|
if err := db.Get(&stored, `SELECT source_id AS source,meta FROM conversation_messages WHERE uuid=$1`, row.UUID); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
var meta struct {
|
|
Sources []string `json:"telegram_source_ids"`
|
|
Album string `json:"telegram_media_group_id"`
|
|
}
|
|
json.Unmarshal(stored.Meta, &meta)
|
|
if stored.Source != sourceIDs[0] || len(meta.Sources) != 2 || meta.Sources[1] != sourceIDs[1] || meta.Album != "album" {
|
|
t.Fatalf("stored=%+v meta=%+v", stored, meta)
|
|
}
|
|
if err := app.conversation.UpdateTelegramMessageContent(rec.ID, "42", sourceIDs[1], "<i>Corrected</i>", "Corrected", "html", 100); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := db.Get(&row, `SELECT uuid,content,text_content AS text,content_type AS kind,meta->>'telegram_media_group_id' AS album FROM conversation_messages WHERE uuid=$1`, row.UUID); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if row.Content != "<i>Corrected</i>" || row.Text != "Corrected" || row.Kind != "html" {
|
|
t.Fatalf("edit=%+v", row)
|
|
}
|
|
for _, input := range []struct {
|
|
uuid string
|
|
ids []string
|
|
}{{row.UUID, nil}, {"", sourceIDs}} {
|
|
if err := app.conversation.RecordTelegramSend(input.uuid, input.ids); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
db.Close()
|
|
if err := app.conversation.RecordTelegramSend(row.UUID, sourceIDs); err == nil {
|
|
t.Fatal("database failure ignored")
|
|
}
|
|
}
|
|
|
|
func TestTelegramNativeRatings(t *testing.T) {
|
|
app, db, rec := newTelegramIntegrationApp(t)
|
|
var msg telegram.Message
|
|
json.Unmarshal([]byte(`{"message_id":1,"from":{"id":42},"chat":{"id":42,"type":"private"},"text":"Hello"}`), &msg)
|
|
if err := ingestTelegramMessage(t.Context(), app, rec, telegramChannel.Config{}, msg); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
source := telegram.SourceID(rec.ID, 42, 1)
|
|
db.MustExec(`INSERT INTO csat_responses (conversation_id) SELECT conversation_id FROM conversation_messages WHERE source_id=$1`, source)
|
|
db.MustExec(`UPDATE conversation_messages m SET type='outgoing',meta=jsonb_build_object('is_csat',true,'csat_uuid',r.uuid::text) FROM csat_responses r WHERE r.conversation_id=m.conversation_id AND m.source_id=$1`, source)
|
|
deliver := func(chat, owner int64, business, data string) {
|
|
t.Helper()
|
|
body := fmt.Sprintf(`{"callback_query":{"id":"rating","data":%q,"from":{"id":%d},"message":{"message_id":1,"business_connection_id":%q,"chat":{"id":%d,"type":"private"}}}}`, data, owner, business, chat)
|
|
r := telegramTestRequest(app, strconv.Itoa(rec.ID), "webhook-secret", body)
|
|
if err := handleTelegramWebhook(r); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if r.RequestCtx.Response.StatusCode() != 200 {
|
|
t.Fatalf("status=%d body=%s", r.RequestCtx.Response.StatusCode(), r.RequestCtx.Response.Body())
|
|
}
|
|
}
|
|
for _, data := range []string{telegram.RatingCallbackPrefix + "0", telegram.RatingCallbackPrefix + "6", telegram.RatingCallbackPrefix + "bad"} {
|
|
deliver(42, 42, "", data)
|
|
}
|
|
deliver(43, 43, "", telegram.RatingCallbackPrefix+"5")
|
|
deliver(42, 77, "business", telegram.RatingCallbackPrefix+"5")
|
|
var count int
|
|
db.Get(&count, `SELECT COUNT(*) FROM csat_responses WHERE response_timestamp IS NOT NULL`)
|
|
if count != 0 {
|
|
t.Fatal("invalid or another chat's rating accepted")
|
|
}
|
|
deliver(42, 42, "", telegram.RatingCallbackPrefix+"4")
|
|
deliver(42, 42, "", telegram.RatingCallbackPrefix+"1")
|
|
var score int
|
|
if err := db.Get(&score, `SELECT rating FROM csat_responses`); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if score != 4 {
|
|
t.Fatalf("rating was overwritten: %d", score)
|
|
}
|
|
db.Get(&count, `SELECT COUNT(*) FROM conversation_messages`)
|
|
if count != 1 {
|
|
t.Fatal("rating created a conversation reply")
|
|
}
|
|
db.Close()
|
|
if err := app.conversation.SubmitTelegramRating(source, 5); err == nil {
|
|
t.Fatal("database failure ignored")
|
|
}
|
|
}
|
|
|
|
func TestTelegramContactAttributes(t *testing.T) {
|
|
app, db, rec := newTelegramIntegrationApp(t)
|
|
var msg telegram.Message
|
|
json.Unmarshal([]byte(`{"message_id":1,"from":{"id":42,"username":"customer","language_code":"hi"},"chat":{"id":42,"type":"private"},"text":"Hello"}`), &msg)
|
|
if err := ingestTelegramMessage(t.Context(), app, rec, telegramChannel.Config{}, msg); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
var attrs json.RawMessage
|
|
if err := db.Get(&attrs, `SELECT custom_attributes FROM users WHERE id=(SELECT contact_id FROM contact_channel_identities WHERE channel='telegram' AND identifier='42')`); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
var parsed map[string]any
|
|
json.Unmarshal(attrs, &parsed)
|
|
if parsed["telegram_username"] != "customer" || parsed["telegram_language"] != "hi" {
|
|
t.Fatalf("attributes=%s", attrs)
|
|
}
|
|
db.MustExec(`CREATE OR REPLACE FUNCTION reject_telegram_attribute_test() RETURNS trigger LANGUAGE plpgsql AS $$ BEGIN RAISE EXCEPTION 'test failure'; END $$`)
|
|
db.MustExec(`CREATE TRIGGER reject_telegram_attribute_test BEFORE UPDATE ON users FOR EACH ROW EXECUTE FUNCTION reject_telegram_attribute_test()`)
|
|
t.Cleanup(func() { db.Exec(`DROP TRIGGER IF EXISTS reject_telegram_attribute_test ON users`) })
|
|
msg.ID++
|
|
if err := ingestTelegramMessage(t.Context(), app, rec, telegramChannel.Config{}, msg); err != nil {
|
|
t.Fatal("unchanged attributes were written:", err)
|
|
}
|
|
msg.ID++
|
|
msg.From.Username = "renamed"
|
|
if err := ingestTelegramMessage(t.Context(), app, rec, telegramChannel.Config{}, msg); err == nil {
|
|
t.Fatal("attribute storage failure ignored")
|
|
}
|
|
}
|
|
|
|
func TestTelegramCSATRequest(t *testing.T) {
|
|
app, db, rec := newTelegramIntegrationApp(t)
|
|
var msg telegram.Message
|
|
json.Unmarshal([]byte(`{"message_id":1,"from":{"id":42},"chat":{"id":42,"type":"private"},"text":"Hello"}`), &msg)
|
|
if err := ingestTelegramMessage(t.Context(), app, rec, telegramChannel.Config{}, msg); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
var conversationID, actorID int
|
|
db.Get(&conversationID, `SELECT id FROM conversations WHERE inbox_id=$1`, rec.ID)
|
|
db.Get(&actorID, `SELECT id FROM users WHERE email='System'`)
|
|
db.MustExec(`UPDATE settings SET value='"https://desk.example.com"' WHERE key='app.root_url'`)
|
|
conv, err := app.conversation.GetConversation(conversationID, "", "")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := app.conversation.SendCSATReply(actorID, conv); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
var raw json.RawMessage
|
|
if err := db.Get(&raw, `SELECT meta FROM conversation_messages WHERE conversation_id=$1 AND type='outgoing'`, conversationID); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
var meta struct {
|
|
IsCSAT bool `json:"is_csat"`
|
|
UUID string `json:"csat_uuid"`
|
|
Telegram struct {
|
|
Buttons []telegram.Button `json:"buttons"`
|
|
} `json:"telegram"`
|
|
}
|
|
if err := json.Unmarshal(raw, &meta); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if !meta.IsCSAT || meta.UUID == "" || len(meta.Telegram.Buttons) != 5 {
|
|
t.Fatalf("meta=%s", raw)
|
|
}
|
|
if err := app.conversation.SendCSATReply(actorID, conv); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
var count int
|
|
db.Get(&count, `SELECT COUNT(*) FROM conversation_messages WHERE conversation_id=$1 AND type='outgoing'`, conversationID)
|
|
if count != 1 {
|
|
t.Fatal("duplicate survey sent")
|
|
}
|
|
}
|
|
|
|
func newTelegramIntegrationApp(t *testing.T) (*App, *sqlx.DB, imodels.Inbox) {
|
|
t.Helper()
|
|
app, db := newInboxApp(t)
|
|
app.ctx = t.Context()
|
|
app.consts.Store(&constants{MaxFileUploadSizeMB: 100})
|
|
var err error
|
|
app.telegramClient = telegram.New()
|
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { io.WriteString(w, `{"ok":true,"result":{"photos":[]}}`) }))
|
|
t.Cleanup(server.Close)
|
|
app.telegramClient.SetBaseURL(server.URL)
|
|
app.user, err = user.New(app.i18n, user.Opts{DB: db, Lo: app.lo})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
db.MustExec(`INSERT INTO users (type,email,first_name,last_name) VALUES ('agent','System','System','') ON CONFLICT DO NOTHING`)
|
|
store, _ := localfs.New(localfs.Opts{UploadPath: t.TempDir(), UploadURI: "/uploads", RootURL: func() string { return "http://localhost" }})
|
|
app.media, err = media.New(media.Opts{DB: db, Lo: app.lo, I18n: app.i18n, Store: store, RootURL: func() string { return "http://localhost" }})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
app.automation, err = automation.New(automation.Opts{DB: db, Lo: app.lo, I18n: app.i18n})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
app.webhook, err = webhook.New(webhook.Opts{DB: db, Lo: app.lo, I18n: app.i18n})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
app.sla, err = sla.New(sla.Opts{DB: db, Lo: app.lo, I18n: app.i18n}, nil, nil, nil, nil, nil, nil)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
app.csat, err = csat.New(csat.Opts{DB: db, Lo: app.lo, I18n: app.i18n})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
app.setting, err = setting.New(setting.Opts{DB: db, Lo: app.lo})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
app.tmpl, err = tmpl.New(app.lo, db, nil, nil, nil, app.i18n)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
app.wsHub = ws.NewHub(app.lo, app.user)
|
|
app.conversation, err = conversation.New(app.wsHub, app.i18n, app.sla, nil, nil, app.inbox, app.user, nil, app.media, app.setting, app.csat, app.automation, app.tmpl, app.webhook, nil, conversation.Opts{DB: db, Lo: app.lo})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
rec, err := app.inbox.Create(imodels.Inbox{Name: "Telegram integration", Channel: "telegram", Enabled: true, ReopenWindowHours: 48, Config: json.RawMessage(`{"bot_token":"123:secret","secret_token":"webhook-secret"}`)})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return app, db, rec
|
|
}
|
|
|
|
func TestTelegramCardsAndReplyReferences(t *testing.T) {
|
|
app, db, rec := newTelegramIntegrationApp(t)
|
|
var message telegram.Message
|
|
if err := json.Unmarshal([]byte(`{"message_id":1,"from":{"id":42},"chat":{"id":42,"type":"private"},"contact":{"first_name":"Shared","last_name":"Contact","phone_number":"+91 90000 00000"}}`), &message); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := ingestTelegramMessage(t.Context(), app, rec, telegramChannel.Config{}, message); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
var contactJSON, firstUUID, convUUID string
|
|
if err := db.QueryRow(`SELECT meta->'telegram_contact',uuid, (SELECT uuid FROM conversations WHERE id=conversation_id) FROM conversation_messages WHERE source_id=$1`, message.SourceID(rec.ID)).Scan(&contactJSON, &firstUUID, &convUUID); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if !strings.Contains(contactJSON, "90000") {
|
|
t.Fatal(contactJSON)
|
|
}
|
|
var wg sync.WaitGroup
|
|
ids := make(chan int, 10)
|
|
for range 10 {
|
|
wg.Go(func() {
|
|
id, err := app.user.SaveSharedContact("Shared", "Contact", "+91 90000 00000")
|
|
if err != nil {
|
|
t.Error(err)
|
|
return
|
|
}
|
|
ids <- id
|
|
})
|
|
}
|
|
wg.Wait()
|
|
close(ids)
|
|
var first int
|
|
for id := range ids {
|
|
if first == 0 {
|
|
first = id
|
|
}
|
|
if first != id {
|
|
t.Fatal("duplicate contacts")
|
|
}
|
|
}
|
|
id, err := app.user.SaveSharedContact("Different", "Name", "919000000000")
|
|
if err != nil || id != first {
|
|
t.Fatalf("reused contact=%d %v", id, err)
|
|
}
|
|
var name string
|
|
db.Get(&name, `SELECT first_name FROM users WHERE id=$1`, id)
|
|
if name != "Shared" {
|
|
t.Fatal("existing name overwritten")
|
|
}
|
|
if _, err := app.user.SaveSharedContact("", "", "no number"); err == nil {
|
|
t.Fatal("empty phone accepted")
|
|
}
|
|
message.ID = 2
|
|
message.Contact = nil
|
|
message.Location = &telegram.Location{Latitude: 12.97, Longitude: 77.59}
|
|
message.ReplyTo = &telegram.Message{ID: 1, Text: "Shared contact"}
|
|
if err := ingestTelegramMessage(t.Context(), app, rec, telegramChannel.Config{}, message); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
var target, location string
|
|
db.QueryRow(`SELECT meta->'reply_to'->>'uuid',meta->'telegram_location' FROM conversation_messages WHERE source_id=$1`, message.SourceID(rec.ID)).Scan(&target, &location)
|
|
if target != firstUUID || !strings.Contains(location, "12.97") {
|
|
t.Fatalf("target=%s location=%s", target, location)
|
|
}
|
|
foreignID := telegram.SourceID(rec.ID, 42, 99)
|
|
if err := app.conversation.RecordTelegramSend(firstUUID, []string{telegram.SourceID(rec.ID, 42, 1), foreignID}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
target, err = app.conversation.GetTelegramReplyUUID(foreignID, convUUID)
|
|
if err != nil || target != firstUUID {
|
|
t.Fatalf("album reference=%s %v", target, err)
|
|
}
|
|
target, err = app.conversation.GetTelegramReplyUUID(foreignID, "00000000-0000-0000-0000-000000000000")
|
|
if err != nil || target != "" {
|
|
t.Fatal("cross-conversation reference")
|
|
}
|
|
db.Close()
|
|
if _, err := app.conversation.GetTelegramReplyUUID(foreignID, convUUID); err == nil {
|
|
t.Fatal("database failure ignored")
|
|
}
|
|
if _, err := app.user.SaveSharedContact("", "", "12345"); err == nil {
|
|
t.Fatal("database failure ignored")
|
|
}
|
|
}
|
|
|
|
func TestTelegramCSATFeedbackAfterRating(t *testing.T) {
|
|
app, db, rec := newTelegramIntegrationApp(t)
|
|
var message telegram.Message
|
|
json.Unmarshal([]byte(`{"message_id":1,"from":{"id":42},"chat":{"id":42,"type":"private"},"text":"Hello"}`), &message)
|
|
if err := ingestTelegramMessage(t.Context(), app, rec, telegramChannel.Config{}, message); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
source := message.SourceID(rec.ID)
|
|
db.MustExec(`INSERT INTO csat_responses (conversation_id) SELECT conversation_id FROM conversation_messages WHERE source_id=$1`, source)
|
|
db.MustExec(`UPDATE conversation_messages m SET type='outgoing',meta=jsonb_build_object('is_csat',true,'csat_uuid',r.uuid::text) FROM csat_responses r WHERE r.conversation_id=m.conversation_id AND m.source_id=$1`, source)
|
|
var uuid string
|
|
db.Get(&uuid, `SELECT uuid FROM csat_responses`)
|
|
if err := app.conversation.SubmitTelegramRating(source, 4); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
survey, err := app.csat.Get(uuid)
|
|
if err != nil || !survey.FeedbackPending() {
|
|
t.Fatal("feedback unavailable after rating")
|
|
}
|
|
if err := app.csat.UpdateResponse(uuid, 1, "", nil); err == nil {
|
|
t.Fatal("rating overwrite allowed")
|
|
}
|
|
if err := app.csat.UpdateResponse(uuid, 1, "Helpful reply", nil); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
survey, err = app.csat.Get(uuid)
|
|
if err != nil || survey.Rating != 4 || survey.Feedback.String != "Helpful reply" || survey.FeedbackPending() {
|
|
t.Fatalf("survey=%+v %v", survey, err)
|
|
}
|
|
if err := app.csat.UpdateResponse(uuid, 5, "Overwrite", nil); err == nil {
|
|
t.Fatal("feedback overwritten")
|
|
}
|
|
if err := app.conversation.SubmitTelegramRating(source, 5); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
survey, _ = app.csat.Get(uuid)
|
|
if survey.Rating != 4 {
|
|
t.Fatal("callback overwrote completed survey")
|
|
}
|
|
db.MustExec(`UPDATE settings SET value='"https://new.example.com"' WHERE key='app.root_url'`)
|
|
buttons, err := app.conversation.TelegramCSATButtons(uuid)
|
|
if err != nil || len(buttons) != 6 || buttons[5].URL != "https://new.example.com/csat/"+uuid {
|
|
t.Fatalf("buttons=%+v %v", buttons, err)
|
|
}
|
|
}
|
|
|
|
func TestTelegramReplyHTTPContract(t *testing.T) {
|
|
app, db, rec := newTelegramIntegrationApp(t)
|
|
app.authz, _ = authz.NewEnforcer(app.lo, app.i18n)
|
|
var message telegram.Message
|
|
json.Unmarshal([]byte(`{"message_id":1,"from":{"id":42},"chat":{"id":42,"type":"private"},"text":"Question"}`), &message)
|
|
if err := ingestTelegramMessage(t.Context(), app, rec, telegramChannel.Config{}, message); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
var actorID int
|
|
db.Get(&actorID, `SELECT id FROM users WHERE email='System'`)
|
|
db.MustExec(`INSERT INTO roles (name,permissions) VALUES ('Telegram writer',ARRAY['conversations:read','conversations:read_all','messages:write'])`)
|
|
db.MustExec(`INSERT INTO user_roles (user_id,role_id) SELECT $1,id FROM roles WHERE name='Telegram writer'`, actorID)
|
|
var uuid, cuuid string
|
|
db.QueryRow(`SELECT m.uuid,c.uuid FROM conversation_messages m JOIN conversations c ON c.id=m.conversation_id WHERE m.source_id=$1`, message.SourceID(rec.ID)).Scan(&uuid, &cuuid)
|
|
for _, tc := range []struct {
|
|
name, target string
|
|
buttons []telegram.Button
|
|
status int
|
|
}{
|
|
{name: "quoted buttons", target: uuid, buttons: []telegram.Button{{Text: "Yes", Data: "yes"}}, status: 200},
|
|
{name: "missing quote", target: "00000000-0000-0000-0000-000000000000", status: 400},
|
|
{name: "invalid link", buttons: []telegram.Button{{Text: "Unsafe", URL: "javascript:alert(1)"}}, status: 400},
|
|
} {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
ctx := &fasthttp.RequestCtx{}
|
|
ctx.SetUserValue("user", amodels.User{ID: actorID})
|
|
ctx.SetUserValue("cuuid", cuuid)
|
|
ctx.Request.Header.SetContentType("application/json")
|
|
body, _ := json.Marshal(messageReq{SenderType: "agent", Message: "Reply", ReplyToMessageUUID: tc.target, TelegramButtons: tc.buttons})
|
|
ctx.Request.SetBody(body)
|
|
if err := handleSendMessage(&fastglue.Request{RequestCtx: ctx, Context: app}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if ctx.Response.StatusCode() != tc.status {
|
|
t.Fatalf("status=%d body=%s", ctx.Response.StatusCode(), ctx.Response.Body())
|
|
}
|
|
if tc.status == 200 && !strings.Contains(string(ctx.Response.Body()), "reply_to") {
|
|
t.Fatal("quote metadata missing")
|
|
}
|
|
})
|
|
}
|
|
}
|