1
0
Fork 0

Adding upstream version 0.8.9.

Signed-off-by: Daniel Baumann <daniel@debian.org>
This commit is contained in:
Daniel Baumann 2025-05-22 10:16:14 +02:00
parent 3b2c48b5e4
commit c0c4addb85
Signed by: daniel
GPG key ID: FBB4F0E80A80222F
285 changed files with 25880 additions and 0 deletions

View file

@ -0,0 +1,89 @@
package telegram
import (
"errors"
"fmt"
"net/url"
"github.com/nicholas-fedor/shoutrrr/pkg/format"
"github.com/nicholas-fedor/shoutrrr/pkg/services/standard"
"github.com/nicholas-fedor/shoutrrr/pkg/types"
)
// apiFormat defines the Telegram API endpoint template.
const (
apiFormat = "https://api.telegram.org/bot%s/%s"
maxlength = 4096
)
// ErrMessageTooLong indicates that the message exceeds the maximum allowed length.
var (
ErrMessageTooLong = errors.New("Message exceeds the max length")
)
// Service sends notifications to configured Telegram chats.
type Service struct {
standard.Standard
Config *Config
pkr format.PropKeyResolver
}
// Send delivers a notification message to Telegram.
func (service *Service) Send(message string, params *types.Params) error {
if len(message) > maxlength {
return ErrMessageTooLong
}
config := *service.Config
if err := service.pkr.UpdateConfigFromParams(&config, params); err != nil {
return fmt.Errorf("updating config from params: %w", err)
}
return service.sendMessageForChatIDs(message, &config)
}
// Initialize configures the service with a URL and logger.
func (service *Service) Initialize(configURL *url.URL, logger types.StdLogger) error {
service.SetLogger(logger)
service.Config = &Config{
Preview: true,
Notification: true,
}
service.pkr = format.NewPropKeyResolver(service.Config)
if err := service.Config.setURL(&service.pkr, configURL); err != nil {
return err
}
return nil
}
// GetID returns the identifier for this service.
func (service *Service) GetID() string {
return Scheme
}
// sendMessageForChatIDs sends the message to all configured chat IDs.
func (service *Service) sendMessageForChatIDs(message string, config *Config) error {
for _, chat := range service.Config.Chats {
if err := sendMessageToAPI(message, chat, config); err != nil {
return err
}
}
return nil
}
// GetConfig returns the current configuration for the service.
func (service *Service) GetConfig() *Config {
return service.Config
}
// sendMessageToAPI sends a message to the Telegram API for a specific chat.
func sendMessageToAPI(message string, chat string, config *Config) error {
client := &Client{token: config.Token}
payload := createSendMessagePayload(message, chat, config)
_, err := client.SendMessage(&payload)
return err
}

View file

@ -0,0 +1,74 @@
package telegram
import (
"encoding/json"
"fmt"
"github.com/nicholas-fedor/shoutrrr/pkg/util/jsonclient"
)
// Client for Telegram API.
type Client struct {
token string
}
func (c *Client) apiURL(endpoint string) string {
return fmt.Sprintf(apiFormat, c.token, endpoint)
}
// GetBotInfo returns the bot User info.
func (c *Client) GetBotInfo() (*User, error) {
response := &userResponse{}
err := jsonclient.Get(c.apiURL("getMe"), response)
if !response.OK {
return nil, GetErrorResponse(jsonclient.ErrorBody(err))
}
return &response.Result, nil
}
// GetUpdates retrieves the latest updates.
func (c *Client) GetUpdates(
offset int,
limit int,
timeout int,
allowedUpdates []string,
) ([]Update, error) {
request := &updatesRequest{
Offset: offset,
Limit: limit,
Timeout: timeout,
AllowedUpdates: allowedUpdates,
}
response := &updatesResponse{}
err := jsonclient.Post(c.apiURL("getUpdates"), request, response)
if !response.OK {
return nil, GetErrorResponse(jsonclient.ErrorBody(err))
}
return response.Result, nil
}
// SendMessage sends the specified Message.
func (c *Client) SendMessage(message *SendMessagePayload) (*Message, error) {
response := &messageResponse{}
err := jsonclient.Post(c.apiURL("sendMessage"), message, response)
if !response.OK {
return nil, GetErrorResponse(jsonclient.ErrorBody(err))
}
return response.Result, nil
}
// GetErrorResponse retrieves the error message from a failed request.
func GetErrorResponse(body string) error {
response := &responseError{}
if err := json.Unmarshal([]byte(body), response); err == nil {
return response
}
return nil
}

View file

@ -0,0 +1,94 @@
package telegram
import (
"errors"
"fmt"
"net/url"
"strings"
"github.com/nicholas-fedor/shoutrrr/pkg/format"
"github.com/nicholas-fedor/shoutrrr/pkg/types"
)
// Scheme identifies this service in configuration URLs.
const (
Scheme = "telegram"
)
// ErrInvalidToken indicates an invalid Telegram token format or content.
var (
ErrInvalidToken = errors.New("invalid telegram token")
ErrNoChannelsDefined = errors.New("no channels defined in config URL")
)
// Config holds settings for the Telegram notification service.
type Config struct {
Token string `url:"user"`
Preview bool ` default:"Yes" desc:"If disabled, no web page preview will be displayed for URLs" key:"preview"`
Notification bool ` default:"Yes" desc:"If disabled, sends Message silently" key:"notification"`
ParseMode parseMode ` default:"None" desc:"How the text Message should be parsed" key:"parsemode"`
Chats []string ` desc:"Chat IDs or Channel names (using @channel-name)" key:"chats,channels"`
Title string ` default:"" desc:"Notification title, optionally set by the sender" key:"title"`
}
// Enums returns the fields that use an EnumFormatter for their values.
func (config *Config) Enums() map[string]types.EnumFormatter {
return map[string]types.EnumFormatter{
"ParseMode": ParseModes.Enum,
}
}
// GetURL generates a URL from the current configuration values.
func (config *Config) GetURL() *url.URL {
resolver := format.NewPropKeyResolver(config)
return config.getURL(&resolver)
}
// SetURL updates the configuration from a URL representation.
func (config *Config) SetURL(url *url.URL) error {
resolver := format.NewPropKeyResolver(config)
return config.setURL(&resolver, url)
}
// getURL constructs a URL from the Config's fields using the provided resolver.
func (config *Config) getURL(resolver types.ConfigQueryResolver) *url.URL {
tokenParts := strings.Split(config.Token, ":")
return &url.URL{
User: url.UserPassword(tokenParts[0], tokenParts[1]),
Host: Scheme,
Scheme: Scheme,
ForceQuery: true,
RawQuery: format.BuildQuery(resolver),
}
}
// setURL updates the Config from a URL using the provided resolver.
func (config *Config) setURL(resolver types.ConfigQueryResolver, url *url.URL) error {
password, _ := url.User.Password()
token := url.User.Username() + ":" + password
if url.String() != "telegram://dummy@dummy.com" {
if !IsTokenValid(token) {
return fmt.Errorf("%w: %s", ErrInvalidToken, token)
}
}
for key, vals := range url.Query() {
if err := resolver.Set(key, vals[0]); err != nil {
return fmt.Errorf("setting config property %q from URL query: %w", key, err)
}
}
if url.String() != "telegram://dummy@dummy.com" {
if len(config.Chats) < 1 {
return ErrNoChannelsDefined
}
}
config.Token = token
return nil
}

View file

@ -0,0 +1,215 @@
package telegram
import (
"errors"
"fmt"
"io"
"os"
"os/signal"
"slices"
"strconv"
"syscall"
"github.com/nicholas-fedor/shoutrrr/pkg/format"
"github.com/nicholas-fedor/shoutrrr/pkg/types"
"github.com/nicholas-fedor/shoutrrr/pkg/util/generator"
)
// UpdatesLimit defines the number of updates to retrieve per API call.
const (
UpdatesLimit = 10 // Number of updates to retrieve per call
UpdatesTimeout = 120 // Timeout in seconds for long polling
)
// ErrNoChatsSelected indicates that no chats were selected during generation.
var (
ErrNoChatsSelected = errors.New("no chats were selected")
)
// Generator facilitates Telegram-specific URL generation via user interaction.
type Generator struct {
userDialog *generator.UserDialog
client *Client
chats []string
chatNames []string
chatTypes []string
done bool
botName string
Reader io.Reader
Writer io.Writer
}
// Generate creates a Telegram Shoutrrr configuration from user dialog input.
func (g *Generator) Generate(
_ types.Service,
props map[string]string,
_ []string,
) (types.ServiceConfig, error) {
var config Config
if g.Reader == nil {
g.Reader = os.Stdin
}
if g.Writer == nil {
g.Writer = os.Stdout
}
g.userDialog = generator.NewUserDialog(g.Reader, g.Writer, props)
userDialog := g.userDialog
userDialog.Writelnf(
"To start we need your bot token. If you haven't created a bot yet, you can use this link:",
)
userDialog.Writelnf(" %v", format.ColorizeLink("https://t.me/botfather?start"))
userDialog.Writelnf("")
token := userDialog.QueryString(
"Enter your bot token:",
generator.ValidateFormat(IsTokenValid),
"token",
)
userDialog.Writelnf("Fetching bot info...")
g.client = &Client{token: token}
botInfo, err := g.client.GetBotInfo()
if err != nil {
return &Config{}, err
}
g.botName = botInfo.Username
userDialog.Writelnf("")
userDialog.Writelnf(
"Okay! %v will listen for any messages in PMs and group chats it is invited to.",
format.ColorizeString("@", g.botName),
)
g.done = false
lastUpdate := 0
signals := make(chan os.Signal, 1)
// Subscribe to system signals
signal.Notify(signals, syscall.SIGINT, syscall.SIGTERM)
for !g.done {
userDialog.Writelnf("Waiting for messages to arrive...")
updates, err := g.client.GetUpdates(lastUpdate, UpdatesLimit, UpdatesTimeout, nil)
if err != nil {
panic(err)
}
// If no updates were retrieved, prompt user to continue
promptDone := len(updates) == 0
for _, update := range updates {
lastUpdate = update.UpdateID + 1
switch {
case update.Message != nil || update.ChannelPost != nil:
message := update.Message
if update.ChannelPost != nil {
message = update.ChannelPost
}
chat := message.Chat
source := message.Chat.Name()
if message.From != nil {
source = "@" + message.From.Username
}
userDialog.Writelnf("Got Message '%v' from %v in %v chat %v",
format.ColorizeString(message.Text),
format.ColorizeProp(source),
format.ColorizeEnum(chat.Type),
format.ColorizeNumber(chat.ID))
userDialog.Writelnf(g.addChat(chat))
// Another chat was added, prompt user to continue
promptDone = true
case update.ChatMemberUpdate != nil:
cmu := update.ChatMemberUpdate
oldStatus := cmu.OldChatMember.Status
newStatus := cmu.NewChatMember.Status
userDialog.Writelnf(
"Got a bot chat member update for %v, status was changed from %v to %v",
format.ColorizeProp(cmu.Chat.Name()),
format.ColorizeEnum(oldStatus),
format.ColorizeEnum(newStatus),
)
default:
userDialog.Writelnf("Got unknown Update. Ignored!")
}
}
if promptDone {
userDialog.Writelnf("")
g.done = !userDialog.QueryBool(
fmt.Sprintf("Got %v chat ID(s) so far. Want to add some more?",
format.ColorizeNumber(len(g.chats))),
"",
)
}
}
userDialog.Writelnf("")
userDialog.Writelnf("Cleaning up the bot session...")
// Notify API that we got the updates
if _, err = g.client.GetUpdates(lastUpdate, 0, 0, nil); err != nil {
g.userDialog.Writelnf(
"Failed to mark last updates as received: %v",
format.ColorizeError(err),
)
}
if len(g.chats) < 1 {
return nil, ErrNoChatsSelected
}
userDialog.Writelnf("Selected chats:")
for i, id := range g.chats {
name := g.chatNames[i]
chatType := g.chatTypes[i]
userDialog.Writelnf(
" %v (%v) %v",
format.ColorizeNumber(id),
format.ColorizeEnum(chatType),
format.ColorizeString(name),
)
}
userDialog.Writelnf("")
config = Config{
Notification: true,
Token: token,
Chats: g.chats,
}
return &config, nil
}
// addChat adds a chat to the generator's list if its not already present.
func (g *Generator) addChat(chat *Chat) string {
chatID := strconv.FormatInt(chat.ID, 10)
name := chat.Name()
if slices.Contains(g.chats, chatID) {
return fmt.Sprintf("chat %v is already selected!", format.ColorizeString(name))
}
g.chats = append(g.chats, chatID)
g.chatNames = append(g.chatNames, name)
g.chatTypes = append(g.chatTypes, chat.Type)
return fmt.Sprintf("Added new chat %v!", format.ColorizeString(name))
}

View file

@ -0,0 +1,131 @@
package telegram_test
import (
"fmt"
"io"
"strings"
"github.com/jarcoal/httpmock"
"github.com/mattn/go-colorable"
"github.com/onsi/ginkgo/v2"
"github.com/onsi/gomega"
"github.com/onsi/gomega/gbytes"
"github.com/nicholas-fedor/shoutrrr/pkg/services/telegram"
)
const (
mockToken = `0:MockToken`
mockAPIBase = "https://api.telegram.org/bot" + mockToken + "/"
)
var (
userOut *gbytes.Buffer
userIn *gbytes.Buffer
userInMono io.Writer
)
func mockTyped(a ...any) {
fmt.Fprint(userOut, a...)
fmt.Fprint(userOut, "\n")
}
func dumpBuffers() {
for _, line := range strings.Split(string(userIn.Contents()), "\n") {
fmt.Fprint(ginkgo.GinkgoWriter, "> ", line, "\n")
}
for _, line := range strings.Split(string(userOut.Contents()), "\n") {
fmt.Fprint(ginkgo.GinkgoWriter, "< ", line, "\n")
}
}
func mockAPI(endpoint string) string {
return mockAPIBase + endpoint
}
var _ = ginkgo.Describe("TelegramGenerator", func() {
ginkgo.BeforeEach(func() {
userOut = gbytes.NewBuffer()
userIn = gbytes.NewBuffer()
userInMono = colorable.NewNonColorable(userIn)
httpmock.Activate()
})
ginkgo.AfterEach(func() {
httpmock.DeactivateAndReset()
})
ginkgo.It("should return the ", func() {
gen := telegram.Generator{
Reader: userOut,
Writer: userInMono,
}
resultChannel := make(chan string, 1)
httpmock.RegisterResponder(
"GET",
mockAPI(`getMe`),
httpmock.NewJsonResponderOrPanic(200, &struct {
OK bool
Result *telegram.User
}{
true, &telegram.User{
ID: 1,
IsBot: true,
Username: "mockbot",
},
}),
)
httpmock.RegisterResponder(
"POST",
mockAPI(`getUpdates`),
httpmock.NewJsonResponderOrPanic(200, &struct {
OK bool
Result []telegram.Update
}{
true,
[]telegram.Update{
{
ChatMemberUpdate: &telegram.ChatMemberUpdate{
Chat: &telegram.Chat{Type: `channel`, Title: `mockChannel`},
OldChatMember: &telegram.ChatMember{Status: `kicked`},
NewChatMember: &telegram.ChatMember{Status: `administrator`},
},
},
{
Message: &telegram.Message{
Text: "hi!",
From: &telegram.User{Username: `mockUser`},
Chat: &telegram.Chat{Type: `private`, ID: 667, Username: `mockUser`},
},
},
},
}),
)
go func() {
defer ginkgo.GinkgoRecover()
conf, err := gen.Generate(nil, nil, nil)
gomega.Expect(conf).ToNot(gomega.BeNil())
gomega.Expect(err).NotTo(gomega.HaveOccurred())
resultChannel <- conf.GetURL().String()
}()
defer dumpBuffers()
mockTyped(mockToken)
mockTyped(`no`)
gomega.Eventually(userIn).
Should(gbytes.Say(`Got a bot chat member update for mockChannel, status was changed from kicked to administrator`))
gomega.Eventually(userIn).
Should(gbytes.Say(`Got 1 chat ID\(s\) so far\. Want to add some more\?`))
gomega.Eventually(userIn).Should(gbytes.Say(`Selected chats:`))
gomega.Eventually(userIn).Should(gbytes.Say(`667 \(private\) @mockUser`))
gomega.Eventually(resultChannel).
Should(gomega.Receive(gomega.Equal(`telegram://0:MockToken@telegram?chats=667&preview=No`)))
})
})

View file

@ -0,0 +1,153 @@
package telegram
import (
"encoding/json"
"errors"
"log"
"net/url"
"github.com/onsi/ginkgo/v2"
"github.com/onsi/gomega"
"github.com/onsi/gomega/gstruct"
)
var _ = ginkgo.Describe("the telegram service", func() {
var logger *log.Logger
ginkgo.BeforeEach(func() {
logger = log.New(ginkgo.GinkgoWriter, "Test", log.LstdFlags)
})
ginkgo.Describe("creating configurations", func() {
ginkgo.When("given an url", func() {
ginkgo.When("a parse mode is not supplied", func() {
ginkgo.It("no parse_mode should be present in payload", func() {
payload, err := getPayloadStringFromURL(
"telegram://12345:mock-token@telegram/?channels=channel-1",
"Message",
logger,
)
gomega.Expect(err).NotTo(gomega.HaveOccurred())
gomega.Expect(payload).NotTo(gomega.ContainSubstring("parse_mode"))
})
})
ginkgo.When("a parse mode is supplied", func() {
ginkgo.When("it's set to a valid mode and not None", func() {
ginkgo.It("parse_mode should be present in payload", func() {
payload, err := getPayloadStringFromURL(
"telegram://12345:mock-token@telegram/?channels=channel-1&parsemode=Markdown",
"Message",
logger,
)
gomega.Expect(err).NotTo(gomega.HaveOccurred())
gomega.Expect(payload).To(gomega.ContainSubstring("parse_mode"))
})
})
ginkgo.When("it's set to None", func() {
ginkgo.When("no title has been provided", func() {
ginkgo.It("no parse_mode should be present in payload", func() {
payload, err := getPayloadStringFromURL(
"telegram://12345:mock-token@telegram/?channels=channel-1&parsemode=None",
"Message",
logger,
)
gomega.Expect(err).NotTo(gomega.HaveOccurred())
gomega.Expect(payload).NotTo(gomega.ContainSubstring("parse_mode"))
})
})
ginkgo.When("a title has been provided", func() {
payload, err := getPayloadFromURL(
"telegram://12345:mock-token@telegram/?channels=channel-1&title=MessageTitle",
`Oh wow! <3 Cool & stuff ->`,
logger,
)
gomega.Expect(err).NotTo(gomega.HaveOccurred())
ginkgo.It("should have parse_mode set to HTML", func() {
gomega.Expect(payload.ParseMode).To(gomega.Equal("HTML"))
})
ginkgo.It("should contain the title prepended in the message", func() {
gomega.Expect(payload.Text).To(gomega.ContainSubstring("MessageTitle"))
})
ginkgo.It("should escape the message HTML tags", func() {
gomega.Expect(payload.Text).To(gomega.ContainSubstring("&lt;3"))
gomega.Expect(payload.Text).
To(gomega.ContainSubstring("Cool &amp; stuff"))
gomega.Expect(payload.Text).To(gomega.ContainSubstring("-&gt;"))
})
})
})
})
ginkgo.When("parsing URL that might have a message thread id", func() {
ginkgo.When("no thread id is provided", func() {
payload, err := getPayloadFromURL(
"telegram://12345:mock-token@telegram/?channels=channel-1&title=MessageTitle",
`Oh wow! <3 Cool & stuff ->`,
logger,
)
gomega.Expect(err).NotTo(gomega.HaveOccurred())
ginkgo.It("should have message_thread_id set to nil", func() {
gomega.Expect(payload.MessageThreadID).To(gomega.BeNil())
})
})
ginkgo.When("a numeric thread id is provided", func() {
payload, err := getPayloadFromURL(
"telegram://12345:mock-token@telegram/?channels=channel-1:10&title=MessageTitle",
`Oh wow! <3 Cool & stuff ->`,
logger,
)
gomega.Expect(err).NotTo(gomega.HaveOccurred())
ginkgo.It("should have message_thread_id set to 10", func() {
gomega.Expect(payload.MessageThreadID).To(gstruct.PointTo(gomega.Equal(10)))
})
})
ginkgo.When("non-numeric thread id is provided", func() {
payload, err := getPayloadFromURL(
"telegram://12345:mock-token@telegram/?channels=channel-1:invalid&title=MessageTitle",
`Oh wow! <3 Cool & stuff ->`,
logger,
)
gomega.Expect(err).NotTo(gomega.HaveOccurred())
ginkgo.It("should have message_thread_id set to nil", func() {
gomega.Expect(payload.MessageThreadID).To(gomega.BeNil())
})
})
})
})
})
})
func getPayloadFromURL(
testURL string,
message string,
logger *log.Logger,
) (SendMessagePayload, error) {
telegram := &Service{}
serviceURL, err := url.Parse(testURL)
if err != nil {
return SendMessagePayload{}, err
}
if err = telegram.Initialize(serviceURL, logger); err != nil {
return SendMessagePayload{}, err
}
if len(telegram.Config.Chats) < 1 {
return SendMessagePayload{}, errors.New("no channels were supplied")
}
return createSendMessagePayload(message, telegram.Config.Chats[0], telegram.Config), nil
}
func getPayloadStringFromURL(testURL string, message string, logger *log.Logger) ([]byte, error) {
payload, err := getPayloadFromURL(testURL, message, logger)
if err != nil {
return nil, err
}
raw, err := json.Marshal(payload)
return raw, err
}

View file

@ -0,0 +1,234 @@
package telegram
import (
"fmt"
"html"
"strconv"
"strings"
)
// SendMessagePayload is the notification payload for the telegram notification service.
type SendMessagePayload struct {
Text string `json:"text"`
ID string `json:"chat_id"`
MessageThreadID *int `json:"message_thread_id,omitempty"`
ParseMode string `json:"parse_mode,omitempty"`
DisablePreview bool `json:"disable_web_page_preview"`
DisableNotification bool `json:"disable_notification"`
ReplyMarkup *replyMarkup `json:"reply_markup,omitempty"`
Entities []entity `json:"entities,omitempty"`
ReplyTo int64 `json:"reply_to_message_id"`
MessageID int64 `json:"message_id,omitempty"`
}
// Message represents one chat message.
type Message struct {
MessageID int64 `json:"message_id"`
Text string `json:"text"`
From *User `json:"from"`
Chat *Chat `json:"chat"`
}
type messageResponse struct {
OK bool `json:"ok"`
Result *Message `json:"result"`
}
type responseError struct {
OK bool `json:"ok"`
ErrorCode int `json:"error_code"`
Description string `json:"description"`
}
type userResponse struct {
OK bool `json:"ok"`
Result User `json:"result"`
}
// User contains information about a telegram user or bot.
type User struct {
// Unique identifier for this User or bot
ID int64 `json:"id"`
// True, if this User is a bot
IsBot bool `json:"is_bot"`
// User's or bot's first name
FirstName string `json:"first_name"`
// Optional. User's or bot's last name
LastName string `json:"last_name"`
// Optional. User's or bot's username
Username string `json:"username"`
// Optional. IETF language tag of the User's language
LanguageCode string `json:"language_code"`
// Optional. True, if the bot can be invited to groups. Returned only in getMe.
CanJoinGroups bool `json:"can_join_groups"`
// Optional. True, if privacy mode is disabled for the bot. Returned only in getMe.
CanReadAllGroupMessages bool `json:"can_read_all_group_messages"`
// Optional. True, if the bot supports inline queries. Returned only in getMe.
SupportsInlineQueries bool `json:"supports_inline_queries"`
}
type updatesRequest struct {
Offset int `json:"offset"`
Limit int `json:"limit"`
Timeout int `json:"timeout"`
AllowedUpdates []string `json:"allowed_updates"`
}
type updatesResponse struct {
OK bool `json:"ok"`
Result []Update `json:"result"`
}
type inlineQuery struct {
// Unique identifier for this query
ID string `json:"id"`
// Sender
From User `json:"from"`
// Text of the query (up to 256 characters)
Query string `json:"query"`
// Offset of the results to be returned, can be controlled by the bot
Offset string `json:"offset"`
}
type chosenInlineResult struct{}
// Update contains state changes since the previous Update.
type Update struct {
// The Update's unique identifier. Update identifiers start from a certain positive number and increase sequentially. This ID becomes especially handy if you're using Webhooks, since it allows you to ignore repeated updates or to restore the correct Update sequence, should they get out of order. If there are no new updates for at least a week, then identifier of the next Update will be chosen randomly instead of sequentially.
UpdateID int `json:"update_id"`
// Optional. New incoming Message of any kind — text, photo, sticker, etc.
Message *Message `json:"Message"`
// Optional. New version of a Message that is known to the bot and was edited
EditedMessage *Message `json:"edited_message"`
// Optional. New incoming channel post of any kind — text, photo, sticker, etc.
ChannelPost *Message `json:"channel_post"`
// Optional. New version of a channel post that is known to the bot and was edited
EditedChannelPost *Message `json:"edited_channel_post"`
// Optional. New incoming inline query
InlineQuery *inlineQuery `json:"inline_query"`
//// Optional. The result of an inline query that was chosen by a User and sent to their chat partner. Please see our documentation on the feedback collecting for details on how to enable these updates for your bot.
ChosenInlineResult *chosenInlineResult `json:"chosen_inline_result"`
//// Optional. New incoming callback query
CallbackQuery *callbackQuery `json:"callback_query"`
// API fields that are not used by the client has been commented out
//// Optional. New incoming shipping query. Only for invoices with flexible price
// ShippingQuery `json:"shipping_query"`
//// Optional. New incoming pre-checkout query. Contains full information about checkout
// PreCheckoutQuery `json:"pre_checkout_query"`
//// Optional. New poll state. Bots receive only updates about stopped polls and polls, which are sent by the bot
// Poll `json:"poll"`
//// Optional. A User changed their answer in a non-anonymous poll. Bots receive new votes only in polls that were sent by the bot itself.
// Poll_answer PollAnswer `json:"poll_answer"`
ChatMemberUpdate *ChatMemberUpdate `json:"my_chat_member"`
}
// Chat represents a telegram conversation.
type Chat struct {
ID int64 `json:"id"`
Type string `json:"type"`
Title string `json:"title"`
Username string `json:"username"`
}
type inlineKey struct {
Text string `json:"text"`
URL string `json:"url"`
LoginURL string `json:"login_url"`
CallbackData string `json:"callback_data"`
SwitchInlineQuery string `json:"switch_inline_query"`
SwitchInlineQueryCurrent string `json:"switch_inline_query_current_chat"`
}
type replyMarkup struct {
InlineKeyboard [][]inlineKey `json:"inline_keyboard,omitempty"`
}
type entity struct {
Type string `json:"type"`
Offset int `json:"offset"`
Length int `json:"length"`
}
type callbackQuery struct {
ID string `json:"id"`
From *User `json:"from"`
Message *Message `json:"Message"`
Data string `json:"data"`
}
// ChatMemberUpdate represents a member update in a telegram chat.
type ChatMemberUpdate struct {
// Chat the user belongs to
Chat *Chat `json:"chat"`
// Performer of the action, which resulted in the change
From *User `json:"from"`
// Date the change was done in Unix time
Date int `json:"date"`
// Previous information about the chat member
OldChatMember *ChatMember `json:"old_chat_member"`
// New information about the chat member
NewChatMember *ChatMember `json:"new_chat_member"`
// Optional. Chat invite link, which was used by the user to join the chat; for joining by invite link events only.
// invite_link ChatInviteLink
}
// ChatMember represents the membership state for a user in a telegram chat.
type ChatMember struct {
// The member's status in the chat
Status string `json:"status"`
// Information about the user
User *User `json:"user"`
}
func (e *responseError) Error() string {
return e.Description
}
// Name returns the name of the channel based on its type.
func (c *Chat) Name() string {
if c.Type == "private" || c.Type == "channel" && c.Username != "" {
return "@" + c.Username
}
return c.Title
}
func createSendMessagePayload(message string, channel string, config *Config) SendMessagePayload {
var threadID *int
chatID, thread, ok := strings.Cut(channel, ":")
if ok {
if parsed, err := strconv.Atoi(thread); err == nil {
threadID = &parsed
}
}
payload := SendMessagePayload{
Text: message,
ID: chatID,
MessageThreadID: threadID,
DisableNotification: !config.Notification,
DisablePreview: !config.Preview,
}
parseMode := config.ParseMode
if config.ParseMode == ParseModes.None && config.Title != "" {
parseMode = ParseModes.HTML
// no parse mode has been provided, treat message as unescaped HTML
message = html.EscapeString(message)
}
if parseMode != ParseModes.None {
payload.ParseMode = parseMode.String()
}
// only HTML parse mode is supported for titles
if parseMode == ParseModes.HTML {
payload.Text = fmt.Sprintf("<b>%v</b>\n%v", html.EscapeString(config.Title), message)
}
return payload
}

View file

@ -0,0 +1,42 @@
package telegram
import (
"github.com/nicholas-fedor/shoutrrr/pkg/format"
"github.com/nicholas-fedor/shoutrrr/pkg/types"
)
const (
ParseModeNone parseMode = iota // 0
ParseModeMarkdown // 1
ParseModeHTML // 2
ParseModeMarkdownV2 // 3
)
// ParseModes is an enum helper for parseMode.
var ParseModes = &parseModeVals{
None: ParseModeNone,
Markdown: ParseModeMarkdown,
HTML: ParseModeHTML,
MarkdownV2: ParseModeMarkdownV2,
Enum: format.CreateEnumFormatter(
[]string{
"None",
"Markdown",
"HTML",
"MarkdownV2",
}),
}
type parseMode int
type parseModeVals struct {
None parseMode
Markdown parseMode
HTML parseMode
MarkdownV2 parseMode
Enum types.EnumFormatter
}
func (pm parseMode) String() string {
return ParseModes.Enum.Print(int(pm))
}

View file

@ -0,0 +1,187 @@
package telegram
import (
"fmt"
"log"
"net/url"
"os"
"strings"
"testing"
"github.com/jarcoal/httpmock"
"github.com/onsi/ginkgo/v2"
"github.com/onsi/gomega"
"github.com/nicholas-fedor/shoutrrr/internal/testutils"
)
func TestTelegram(t *testing.T) {
gomega.RegisterFailHandler(ginkgo.Fail)
ginkgo.RunSpecs(t, "Shoutrrr Telegram Suite")
}
var (
envTelegramURL string
logger *log.Logger
_ = ginkgo.BeforeSuite(func() {
envTelegramURL = os.Getenv("SHOUTRRR_TELEGRAM_URL")
logger = log.New(ginkgo.GinkgoWriter, "Test", log.LstdFlags)
})
)
var _ = ginkgo.Describe("the telegram service", func() {
var telegram *Service // No telegram. prefix needed
ginkgo.BeforeEach(func() {
telegram = &Service{}
})
ginkgo.When("running integration tests", func() {
ginkgo.It("should not error out", func() {
if envTelegramURL == "" {
return
}
serviceURL, _ := url.Parse(envTelegramURL)
err := telegram.Initialize(serviceURL, logger)
gomega.Expect(err).NotTo(gomega.HaveOccurred())
err = telegram.Send("This is an integration test Message", nil)
gomega.Expect(err).NotTo(gomega.HaveOccurred())
})
ginkgo.When("given a Message that exceeds the max length", func() {
ginkgo.It("should generate an error", func() {
if envTelegramURL == "" {
return
}
hundredChars := "this string is exactly (to the letter) a hundred characters long which will make the send func error"
serviceURL, _ := url.Parse("telegram://12345:mock-token@telegram/?chats=channel-1")
builder := strings.Builder{}
for range 42 {
builder.WriteString(hundredChars)
}
err := telegram.Initialize(serviceURL, logger)
gomega.Expect(err).NotTo(gomega.HaveOccurred())
err = telegram.Send(builder.String(), nil)
gomega.Expect(err).To(gomega.HaveOccurred())
})
})
ginkgo.When("given a valid request with a faked token", func() {
if envTelegramURL == "" {
return
}
ginkgo.It("should generate a 401", func() {
serviceURL, _ := url.Parse(
"telegram://000000000:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA@telegram/?chats=channel-id",
)
message := "this is a perfectly valid Message"
err := telegram.Initialize(serviceURL, logger)
gomega.Expect(err).NotTo(gomega.HaveOccurred())
err = telegram.Send(message, nil)
gomega.Expect(err).To(gomega.HaveOccurred())
gomega.Expect(strings.Contains(err.Error(), "401 Unauthorized")).To(gomega.BeTrue())
})
})
})
ginkgo.Describe("creating configurations", func() {
ginkgo.When("given an url", func() {
ginkgo.It("should return an error if no arguments where supplied", func() {
expectErrorAndEmptyObject(telegram, "telegram://", logger)
})
ginkgo.It("should return an error if the token has an invalid format", func() {
expectErrorAndEmptyObject(telegram, "telegram://invalid-token", logger)
})
ginkgo.It("should return an error if only the api token where supplied", func() {
expectErrorAndEmptyObject(telegram, "telegram://12345:mock-token@telegram", logger)
})
ginkgo.When("the url is valid", func() {
var config *Config // No telegram. prefix
var err error
ginkgo.BeforeEach(func() {
serviceURL, _ := url.Parse(
"telegram://12345:mock-token@telegram/?chats=channel-1,channel-2,channel-3",
)
err = telegram.Initialize(serviceURL, logger)
config = telegram.GetConfig()
})
ginkgo.It("should create a config object", func() {
gomega.Expect(err).NotTo(gomega.HaveOccurred())
gomega.Expect(config).ToNot(gomega.BeNil())
})
ginkgo.It("should create a config object containing the API Token", func() {
gomega.Expect(err).NotTo(gomega.HaveOccurred())
gomega.Expect(config.Token).To(gomega.Equal("12345:mock-token"))
})
ginkgo.It("should add every chats query field as a chat ID", func() {
gomega.Expect(err).NotTo(gomega.HaveOccurred())
gomega.Expect(config.Chats).To(gomega.Equal([]string{
"channel-1",
"channel-2",
"channel-3",
}))
})
})
})
})
ginkgo.Describe("sending the payload", func() {
var err error
ginkgo.BeforeEach(func() {
httpmock.Activate()
})
ginkgo.AfterEach(func() {
httpmock.DeactivateAndReset()
})
ginkgo.It("should not report an error if the server accepts the payload", func() {
serviceURL, _ := url.Parse(
"telegram://12345:mock-token@telegram/?chats=channel-1,channel-2,channel-3",
)
err = telegram.Initialize(serviceURL, logger)
gomega.Expect(err).NotTo(gomega.HaveOccurred())
setupResponder("sendMessage", telegram.GetConfig().Token, 200, "")
err = telegram.Send("Message", nil)
gomega.Expect(err).NotTo(gomega.HaveOccurred())
})
})
ginkgo.It("should implement basic service API methods correctly", func() {
serviceURL, _ := url.Parse("telegram://12345:mock-token@telegram/?chats=channel-1")
err := telegram.Initialize(serviceURL, logger)
gomega.Expect(err).NotTo(gomega.HaveOccurred())
config := telegram.GetConfig()
testutils.TestConfigGetInvalidQueryValue(config)
testutils.TestConfigSetInvalidQueryValue(
config,
"telegram://12345:mock-token@telegram/?chats=channel-1&foo=bar",
)
testutils.TestConfigGetEnumsCount(config, 1)
testutils.TestConfigGetFieldsCount(config, 6)
})
ginkgo.It("should return the correct service ID", func() {
service := &Service{}
gomega.Expect(service.GetID()).To(gomega.Equal("telegram"))
})
})
func expectErrorAndEmptyObject(telegram *Service, rawURL string, logger *log.Logger) {
serviceURL, _ := url.Parse(rawURL)
err := telegram.Initialize(serviceURL, logger)
gomega.Expect(err).To(gomega.HaveOccurred())
config := telegram.GetConfig()
gomega.Expect(config.Token).To(gomega.BeEmpty())
gomega.Expect(config.Chats).To(gomega.BeEmpty())
}
func setupResponder(endpoint string, token string, code int, body string) {
targetURL := fmt.Sprintf("https://api.telegram.org/bot%s/%s", token, endpoint)
httpmock.RegisterResponder("POST", targetURL, httpmock.NewStringResponder(code, body))
}

View file

@ -0,0 +1,10 @@
package telegram
import "regexp"
// IsTokenValid for use with telegram.
func IsTokenValid(token string) bool {
matched, err := regexp.MatchString("^[0-9]+:[a-zA-Z0-9_-]+$", token)
return matched && err == nil
}