Files
LocalAI/addons/telegram/transport/botapi_test.go
T
Ettore Di Giacinto 1ae80d3a15 feat(connectors): add /new to start a fresh chat from the app
A connection kept one chat per person for good, with no way to ask for
a new one. A person can now send /new (or !new, for Slack, which keeps
messages that begin with a slash for itself) as the whole message.

The host reads the command after the pairing check and before any
model turn, so the agent never sees it and the logs record only the
ids. It drops the link from the person, or from the shared thread or
group, to its chat. The old chat stays in Studio. The link is in the
shared store, so it works with several frontends and for every
connector add-on.

In the chat mode and in a thread of a group, everyone shares the chat,
so only a paired sender may reset it. The thread mode answers that
every message already starts a new chat. A chat with a running turn is
left alone and the sender is asked to repeat the command later.

Telegram did not treat /new@BotName as addressed to the bot in a
group; it does now, and keeps the text. The connection page gets one
help line under Conversations.

Assisted-by: Claude:claude-sonnet-5-5
2026-11-04 13:35:18 +00:00

195 lines
11 KiB
Go

// SPDX-License-Identifier: MIT
package transport_test
import (
"context"
"errors"
"net/http"
"strings"
"github.com/mudler/LocalAI/addons/telegram/transport"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
var _ = Describe("the Bot API client", func() {
ctx := context.Background()
It("reads text and marks the rest", func() {
f := newFakeBot()
f.reply["getUpdates"] = func(map[string]any) (int, string) {
return 200, `{"ok":true,"result":[
{"update_id":10,"message":{"chat":{"id":55,"type":"private"},"from":{"id":55,"first_name":"Ada","last_name":"L","username":"ada"},"text":"hello"}},
{"update_id":11,"message":{"chat":{"id":55,"type":"private"},"from":{"id":55,"first_name":"Ada"},"photo":[{"file_id":"x"}]}},
{"update_id":12,"edited_message":{"chat":{"id":55,"type":"private"},"from":{"id":55},"text":"edit"}},
{"update_id":13,"channel_post":{"chat":{"id":-100,"type":"channel"},"text":"post"}},
{"update_id":14,"message":{"chat":{"id":-200,"type":"group"},"from":{"id":7,"first_name":"Bo"},"text":"in a group"}}]}`
}
ups, err := f.client().Updates(ctx, secretToken, 10, 25)
Expect(err).NotTo(HaveOccurred())
Expect(ups).To(HaveLen(5))
u := ups[0]
Expect(u.Cursor).To(Equal(int64(10)))
Expect([]string{u.Chat, u.SenderID, u.SenderName, u.SenderUsername, u.Text}).To(Equal([]string{"55", "55", "Ada L", "ada", "hello"}))
Expect(u.Direct).To(BeTrue())
Expect(u.Unsupported || u.Skip).To(BeFalse())
Expect(ups[1].Unsupported).To(BeFalse(), "a photo is handed over")
Expect(ups[1].Media).To(Equal([]string{"photo:x"}))
Expect(ups[2].Skip).To(BeTrue())
Expect(ups[3].Skip).To(BeTrue())
Expect(ups[4].Direct).To(BeFalse(), "a group is not a direct chat")
c := f.called("getUpdates")[0]
Expect(c.Body).To(And(HaveKeyWithValue("offset", float64(10)), HaveKeyWithValue("timeout", float64(25))))
Expect(c.Path).To(ContainSubstring("/bot" + secretToken + "/"))
})
DescribeTable("types an error and keeps the token out of it",
func(code int, body string, check func(error) bool) {
f := newFakeBot()
f.reply["sendMessage"] = func(map[string]any) (int, string) { return code, body }
_, err := f.client().Send(ctx, secretToken, 1, 0, "hi")
Expect(err).To(HaveOccurred())
Expect(check(err)).To(BeTrue(), err.Error())
Expect(err.Error()).NotTo(ContainSubstring("SECRET"))
},
Entry("unauthorized", 401, `{"ok":false,"error_code":401,"description":"Unauthorized"}`, func(e error) bool { return errors.Is(e, transport.ErrUnauthorized) }),
Entry("conflict", 409, `{"ok":false,"error_code":409,"description":"Conflict"}`, func(e error) bool { return errors.Is(e, transport.ErrConflict) }),
Entry("retry", 429, `{"ok":false,"error_code":429,"description":"Too Many Requests","parameters":{"retry_after":7}}`, func(e error) bool {
var r *transport.RetryAfter
return errors.As(e, &r) && r.Seconds == 7
}),
Entry("other", 400, `{"ok":false,"error_code":400,"description":"Bad Request: chat not found"}`, func(e error) bool { return e != nil }),
)
It("keeps the token out of the error of a dead connection", func() {
dead := &transport.API{BaseURL: "http://127.0.0.1:1", Client: &http.Client{}}
_, err := dead.Me(ctx, secretToken)
Expect(err).To(HaveOccurred())
Expect(err.Error()).NotTo(ContainSubstring(secretToken))
})
It("speaks getMe, sendMessage, editMessageText and sendChatAction", func() {
f := newFakeBot()
f.reply["getMe"] = func(map[string]any) (int, string) {
return 200, `{"ok":true,"result":{"id":99,"is_bot":true,"username":"helper_bot"}}`
}
f.reply["sendMessage"] = func(map[string]any) (int, string) { return 200, `{"ok":true,"result":{"message_id":321}}` }
c := f.client()
bot, err := c.Me(ctx, secretToken)
Expect(err).NotTo(HaveOccurred())
Expect(bot).To(Equal(transport.Bot{ID: 99, Username: "helper_bot"}))
id, err := c.Send(ctx, secretToken, -42, 0, "plain text")
Expect(err).NotTo(HaveOccurred())
Expect(id).To(Equal(int64(321)))
s := f.called("sendMessage")[0].Body
Expect(s).To(And(HaveKeyWithValue("chat_id", float64(-42)), HaveKeyWithValue("text", "plain text")))
Expect(s).NotTo(HaveKey("parse_mode"), "the text of a model goes as plain text")
Expect(c.Edit(ctx, secretToken, -42, 321, "edited")).To(Succeed())
Expect(f.called("editMessageText")[0].Body).To(And(HaveKeyWithValue("message_id", float64(321)), HaveKeyWithValue("text", "edited")))
Expect(c.Typing(ctx, secretToken, 5, 0)).To(Succeed())
Expect(f.called("sendChatAction")[0].Body).To(And(HaveKeyWithValue("action", "typing"), HaveKeyWithValue("chat_id", float64(5))))
})
It("keeps a token with odd characters in one path segment", func() {
f := newFakeBot()
f.reply["getMe"] = func(map[string]any) (int, string) { return 200, `{"ok":true,"result":{"id":1,"username":"b"}}` }
_, err := f.client().Me(ctx, "1/../x?y")
Expect(err).NotTo(HaveOccurred())
p := f.called("getMe")[0].Path
Expect(p).NotTo(ContainSubstring("/../"))
Expect(strings.Count(p, "/")).To(Equal(2))
})
It("reads a command that names the bot as addressed to it, and keeps the text", func() {
f := newFakeBot()
f.reply["getMe"] = func(map[string]any) (int, string) {
return 200, `{"ok":true,"result":{"id":123456,"username":"helper_bot"}}`
}
f.reply["getUpdates"] = func(map[string]any) (int, string) {
return 200, `{"ok":true,"result":[
{"update_id":30,"message":{"message_id":601,"chat":{"id":-300,"type":"supergroup"},"from":{"id":7,"first_name":"Bo"},"text":"/new@Helper_Bot","entities":[{"type":"bot_command","offset":0,"length":15}]}},
{"update_id":31,"message":{"message_id":602,"chat":{"id":-300,"type":"supergroup"},"from":{"id":7,"first_name":"Bo"},"text":"/new@other_bot","entities":[{"type":"bot_command","offset":0,"length":14}]}},
{"update_id":32,"message":{"message_id":603,"chat":{"id":-300,"type":"supergroup"},"from":{"id":7,"first_name":"Bo"},"text":"/new","entities":[{"type":"bot_command","offset":0,"length":4}]}}]}`
}
ups, err := f.client().Updates(ctx, secretToken, 30, 0)
Expect(err).NotTo(HaveOccurred())
Expect(ups).To(HaveLen(3))
Expect(ups[0].Mention).To(BeTrue())
Expect(ups[0].Text).To(Equal("/new@Helper_Bot"))
Expect(ups[1].Mention).To(BeFalse(), "another bot is named")
Expect(ups[2].Mention).To(BeFalse(), "a bare command in a group is not addressed")
})
It("reads the ID of a message, its reply, its topic and a mention of the bot in a group", func() {
f := newFakeBot()
f.reply["getMe"] = func(map[string]any) (int, string) {
return 200, `{"ok":true,"result":{"id":123456,"username":"helper_bot"}}`
}
f.reply["getUpdates"] = func(map[string]any) (int, string) {
return 200, `{"ok":true,"result":[
{"update_id":20,"message":{"message_id":501,"chat":{"id":-300,"type":"supergroup"},"from":{"id":7,"first_name":"Bo"},"text":"@Helper_Bot what time is it?","entities":[{"type":"mention","offset":0,"length":11}]}},
{"update_id":21,"message":{"message_id":502,"chat":{"id":-300,"type":"supergroup"},"from":{"id":7,"first_name":"Bo"},"text":"and tomorrow?","reply_to_message":{"message_id":499,"from":{"id":123456,"is_bot":true}}}},
{"update_id":22,"message":{"message_id":503,"chat":{"id":-300,"type":"supergroup"},"from":{"id":7,"first_name":"Bo"},"text":"@other_bot hi","entities":[{"type":"mention","offset":0,"length":10}]}},
{"update_id":23,"message":{"message_id":504,"message_thread_id":77,"is_topic_message":true,"chat":{"id":-300,"type":"supergroup"},"from":{"id":7,"first_name":"Bo"},"text":"in a topic"}},
{"update_id":24,"message":{"message_id":505,"message_thread_id":504,"chat":{"id":-300,"type":"supergroup"},"from":{"id":7,"first_name":"Bo"},"text":"a reply, not a topic","reply_to_message":{"message_id":504,"from":{"id":7}}}}]}`
}
ups, err := f.client().Updates(ctx, secretToken, 20, 0)
Expect(err).NotTo(HaveOccurred())
Expect(ups).To(HaveLen(5))
Expect(ups[0].MessageID).To(Equal("501"))
Expect(ups[0].Mention).To(BeTrue(), "the bot is named, in any case")
Expect(ups[0].Text).To(Equal("what time is it?"), "the mention of the bot is taken out")
Expect(ups[1].Mention).To(BeTrue(), "a reply to the bot addresses it")
Expect(ups[1].ReplyTo).To(Equal("499"))
Expect(ups[2].Mention).To(BeFalse(), "another bot is named")
Expect(ups[3].Thread).To(Equal("77"))
Expect(ups[3].Mention).To(BeFalse())
Expect(ups[4].Thread).To(BeEmpty(), "a reply chain is not a topic")
Expect(ups[4].ReplyTo).To(Equal("504"))
Expect(f.called("getMe")).To(HaveLen(1), "the name of the bot is read once")
_, err = f.client().Updates(ctx, secretToken, 20, 0)
Expect(err).NotTo(HaveOccurred())
})
It("sends into a topic, and types there", func() {
f := newFakeBot()
c := f.client()
_, err := c.Send(ctx, secretToken, -300, 77, "in the topic")
Expect(err).NotTo(HaveOccurred())
Expect(f.called("sendMessage")[0].Body).To(HaveKeyWithValue("message_thread_id", float64(77)))
Expect(c.Typing(ctx, secretToken, -300, 77)).To(Succeed())
Expect(f.called("sendChatAction")[0].Body).To(HaveKeyWithValue("message_thread_id", float64(77)))
_, err = c.Send(ctx, secretToken, 5, 0, "no topic")
Expect(err).NotTo(HaveOccurred())
Expect(f.called("sendMessage")[1].Body).NotTo(HaveKey("message_thread_id"))
})
It("sends a picture as a photo and anything else as a document", func() {
f := newFakeBot()
f.reply["sendPhoto"] = func(map[string]any) (int, string) { return 200, `{"ok":true,"result":{"message_id":61}}` }
f.reply["sendDocument"] = func(map[string]any) (int, string) { return 200, `{"ok":true,"result":{"message_id":62}}` }
c := f.client()
id, err := c.SendFileStream(ctx, secretToken, 5, 77, "chart.png", "image/png", 9, strings.NewReader("png-bytes"))
Expect(err).NotTo(HaveOccurred())
Expect(id).To(Equal(int64(61)))
p := f.called("sendPhoto")[0].Body
Expect(p).To(And(HaveKeyWithValue("chat_id", "5"), HaveKeyWithValue("message_thread_id", "77"), HaveKeyWithValue("_file_field", "photo"), HaveKeyWithValue("_file_name", "chart.png"), HaveKeyWithValue("_file_size", int64(9))))
id, err = c.SendFileStream(ctx, secretToken, 5, 0, "report.pdf", "application/pdf", 4, strings.NewReader("%PDF"))
Expect(err).NotTo(HaveOccurred())
Expect(id).To(Equal(int64(62)))
d := f.called("sendDocument")[0].Body
Expect(d).To(And(HaveKeyWithValue("_file_field", "document"), HaveKeyWithValue("_file_name", "report.pdf")))
Expect(d).NotTo(HaveKey("message_thread_id"))
})
It("keeps the token out of the error of a refused file", func() {
f := newFakeBot()
f.reply["sendDocument"] = func(map[string]any) (int, string) {
return 400, `{"ok":false,"error_code":400,"description":"Bad Request: file must be non-empty"}`
}
_, err := f.client().SendFileStream(ctx, secretToken, 5, 0, "a.txt", "text/plain", 1, strings.NewReader("x"))
Expect(err).To(HaveOccurred())
Expect(err.Error()).NotTo(ContainSubstring("SECRET"))
})
})