Repository: crazy-max/diun

Issue:
Pushover notification failed error="invalid character '<'
## Behaviour

Pushover notification failed for some images that are found. 
Example:

Pushover notification failed error="invalid character '<' looking for beginning of value" image=ghcr.io/linuxserver/sabnzbd:latest


* Diun version : 4.12.0
* Platform (windows/linux) : linux


Hints from issue comments:
@crazy-max: @tongliang79 Can you paste your logs here please? And also a screenshot of a working notification with pushover. Thanks.@networkshokunin: Thanks for looking into this! Here you go:

[debug.log](https://github.com/crazy-max/diun/files/5976858/debug.log)

Screenshot of a working pushover notification:
<img width="367" alt="Screenshot" src="https://user-images.githubusercontent.com/17445886/107866168-a531d680-6ea8-11eb-85f0-e13374a4cebf.png">
@crazy-max: @zerocool79 I have made some changes to enhance message sanitization in #273. Can you try with `crazymax/diun:edge`?@networkshokunin: @crazy-max , seems like the issue is still there.

[debug_20200215.log](https://github.com/crazy-max/diun/files/5980230/debug_20200215.log)
@crazy-max: @zerocool79 Ok thanks for your feedback. Can you test with `crazymax/diun:pushover-debug` and give me your logs? Thanks.@networkshokunin: I have used crazymax/diun:pushover-debug and it doesn't look like there is a change in version number.  But here it is regardless.
[debug_20200216.log](https://github.com/crazy-max/diun/files/5988157/debug_20200216.log)
@crazy-max: @zerocool79 Ok thanks for the logs, First I thought the issue was about message formatting but it looks like Pushover recipient details API is the culprit here, that's why I cannot reproduce. Must be specific to your account profile on Pushover website. I have updated `crazymax/diun:pushover-debug` to print more logs and also skip the recipient details part. Can you make another test and give me your logs please? Thanks!@crazy-max: @zerocool79 `crazymax/diun:edge` should fix your issue. Can you confirm? Thanks!@networkshokunin: Hi there,

Deepest apologies for the lack of response over the past few days but
family and work has kept me busy.
I do see some errors but this might not have been due to pushover.

Here is the log as attached.



On Thu, Feb 18, 2021 at 4:23 AM CrazyMax <notifications@github.com> wrote:

> @zerocool79 <https://github.com/zerocool79> crazymax/diun:edge should fix
> your issue. Can you confirm? Thanks!
>
> —
> You are receiving this because you were mentioned.
> Reply to this email directly, view it on GitHub
> <https://github.com/crazy-max/diun/issues/272#issuecomment-780829480>, or
> unsubscribe
> <https://github.com/notifications/unsubscribe-auth/AEFDH7XRNOE6VT6IFPOISTLS7QQSLANCNFSM4XSCPWIA>
> .
>
@crazy-max: @zerocool79 

> Here is the log as attached.

I don't see the log file.@Phontana: @crazy-max I just tested the `edge` version and the issue is still there.@RickDB: @crazy-max issue is still there in recent builds and any update?

Recent logs and seems to affect only certain images:
```

Mon, 05 May 2025 10:43:24 CEST INF New image found image=docker.io/portainer/portainer-ce:2.29.2 provider=docker

Mon, 05 May 2025 10:43:24 CEST ERR Pushover notification failed error="invalid character '<' looking for beginning of value" image=docker.io/journeyover/plex-auto-languages:main

Mon, 05 May 2025 10:43:24 CEST ERR Pushover notification failed error="invalid character '<' looking for beginning of value" image=docker.io/jlesage/jdownloader-2:latest

Mon, 05 May 2025 10:43:24 CEST ERR Pushover notification failed error="invalid character '<' looking for beginning of value" image=docker.io/jellyfin/jellyfin:10.10.7

Mon, 05 May 2025 10:43:25 CEST INF New image found image=docker.io/library/postgres:15.2 provider=docker

Mon, 05 May 2025 10:43:25 CEST INF New image found image=docker.io/library/postgres:17.4 provider=docker
```@DrEsteban: I am seeing the same here for the Prometheus image: 

```
ERR Pushover notification failed error="invalid character '<' looking for beginning of value" image=docker.io/prom/prometheus:latest
```

Solution patch:
diff --git a/docs/notif/telegram.md b/docs/notif/telegram.md
index b638458b..b4197cc5 100644
--- a/docs/notif/telegram.md
+++ b/docs/notif/telegram.md
@@ -15,8 +15,10 @@ Multiple chat IDs can be provided in order to deliver notifications to multiple
       telegram:
         token: aabbccdd:11223344
         chatIDs:
-          - 123456789
-          - 987654321
+          - "123456789"
+          - "987654321"
+          - "567891234:25"
+          - "891256734:25;12"
         templateBody: |
           Docker tag {{ .Entry.Image }} which you subscribed to through {{ .Entry.Provider }} provider has been released.
     ```
@@ -25,7 +27,7 @@ Multiple chat IDs can be provided in order to deliver notifications to multiple
 |--------------------|------------------------------------|---------------------------------------------------------------------------|
 | `token`            |                                    | Telegram bot token                                                        |
 | `tokenFile`        |                                    | Use content of secret file as Telegram bot token if `token` not defined   |
-| `chatIDs`          |                                    | List of chat IDs to send notifications to                                 |
+| `chatIDs`          |                                    | List of [chat IDs](#chatids-format) to send notifications to              |
 | `chatIDsFile`      |                                    | Use content of secret file as chat IDs if `chatIDs` not defined           |
 | `templateBody`[^1] | See [below](#default-templatebody) | [Notification template](../faq.md#notification-template) for message body |
 
@@ -37,7 +39,19 @@ Multiple chat IDs can be provided in order to deliver notifications to multiple
     * `DIUN_NOTIF_TELEGRAM_TEMPLATEBODY`
 
 !!! example "chat IDs secret file"
-    Chat IDs secret file must be a valid JSON array like: `[123456789,987654321]`
+    Chat IDs secret file must be a valid JSON array like: `[123456789,987654321,"567891234:25","891256734:25;12"]`
+
+### `chatIDs` format
+
+Chat IDs can be provided in the following formats:
+
+* `123456789`: Send to chat ID `123456789`
+* `567891234:25`: Send to chat ID `567891234` with target message topic `25`
+* `891256734:25;12`: Send to chat ID `891256734` with target message topics `25` and `12`
+
+Each chat ID can be a simple integer or a string with additional topics. This
+allows you to specify not only the chat ID but also the specific topics within
+the chat to which the message should be sent.
 
 ### Default `templateBody`
 
diff --git a/internal/config/fixtures/config.test.yml b/internal/config/fixtures/config.test.yml
index 58d3c686..6d8b94ce 100644
--- a/internal/config/fixtures/config.test.yml
+++ b/internal/config/fixtures/config.test.yml
@@ -99,8 +99,10 @@ notif:
   telegram:
     token: abcdef123456
     chatIDs:
-      - 8547439
-      - 1234567
+      - "8547439"
+      - "1234567"
+      - "567891234:25"
+      - "891256734:25;12"
   webhook:
     endpoint: http://webhook.foo.com/sd54qad89azd5a
     method: GET
diff --git a/internal/config/fixtures/config.validate.yml b/internal/config/fixtures/config.validate.yml
index f36c59d9..0c5576dc 100644
--- a/internal/config/fixtures/config.validate.yml
+++ b/internal/config/fixtures/config.validate.yml
@@ -91,8 +91,10 @@ notif:
   telegram:
     token: abcdef123456
     chatIDs:
-      - 8547439
-      - 1234567
+      - "8547439"
+      - "1234567"
+      - "567891234:25"
+      - "891256734:25;12"
   webhook:
     endpoint: http://webhook.foo.com/sd54qad89azd5a
     method: GET
diff --git a/internal/model/notif_telegram.go b/internal/model/notif_telegram.go
index cd83bf53..33fe58a1 100644
--- a/internal/model/notif_telegram.go
+++ b/internal/model/notif_telegram.go
@@ -5,11 +5,11 @@ const NotifTelegramDefaultTemplateBody = `Docker tag {{ if .Entry.Image.HubLink
 
 // NotifTelegram holds Telegram notification configuration details
 type NotifTelegram struct {
-	Token        string  `yaml:"token,omitempty" json:"token,omitempty" validate:"omitempty"`
-	TokenFile    string  `yaml:"tokenFile,omitempty" json:"tokenFile,omitempty" validate:"omitempty,file"`
-	ChatIDs      []int64 `yaml:"chatIDs,omitempty" json:"chatIDs,omitempty" validate:"omitempty"`
-	ChatIDsFile  string  `yaml:"chatIDsFile,omitempty" json:"chatIDsFile,omitempty" validate:"omitempty,file"`
-	TemplateBody string  `yaml:"templateBody,omitempty" json:"templateBody,omitempty" validate:"required"`
+	Token        string   `yaml:"token,omitempty" json:"token,omitempty" validate:"omitempty"`
+	TokenFile    string   `yaml:"tokenFile,omitempty" json:"tokenFile,omitempty" validate:"omitempty,file"`
+	ChatIDs      []string `yaml:"chatIDs,omitempty" json:"chatIDs,omitempty" validate:"omitempty"`
+	ChatIDsFile  string   `yaml:"chatIDsFile,omitempty" json:"chatIDsFile,omitempty" validate:"omitempty,file"`
+	TemplateBody string   `yaml:"templateBody,omitempty" json:"templateBody,omitempty" validate:"required"`
 }
 
 // GetDefaults gets the default values
diff --git a/internal/notif/gotify/client.go b/internal/notif/gotify/client.go
index 094ebe7f..de51bd07 100644
--- a/internal/notif/gotify/client.go
+++ b/internal/notif/gotify/client.go
@@ -114,9 +114,8 @@ func (c *Client) Send(entry model.NotifEntry) error {
 			ErrorCode        int    `json:"errorCode"`
 			ErrorDescription string `json:"errorDescription"`
 		}
-		err := json.NewDecoder(resp.Body).Decode(&errBody)
-		if err != nil {
-			return err
+		if err := json.NewDecoder(resp.Body).Decode(&errBody); err != nil {
+			return errors.Wrapf(err, "cannot decode JSON error response for HTTP %d %s status", resp.StatusCode, http.StatusText(resp.StatusCode))
 		}
 		return errors.Errorf("%d %s: %s", errBody.ErrorCode, errBody.Error, errBody.ErrorDescription)
 	}
diff --git a/internal/notif/ntfy/client.go b/internal/notif/ntfy/client.go
index c664f0de..dc26f64a 100644
--- a/internal/notif/ntfy/client.go
+++ b/internal/notif/ntfy/client.go
@@ -108,9 +108,8 @@ func (c *Client) Send(entry model.NotifEntry) error {
 			ErrorCode        int    `json:"errorCode"`
 			ErrorDescription string `json:"errorDescription"`
 		}
-		err := json.NewDecoder(resp.Body).Decode(&errBody)
-		if err != nil {
-			return err
+		if err := json.NewDecoder(resp.Body).Decode(&errBody); err != nil {
+			return errors.Wrapf(err, "cannot decode JSON error response for HTTP %d %s status", resp.StatusCode, http.StatusText(resp.StatusCode))
 		}
 		return errors.Errorf("%d %s: %s", errBody.ErrorCode, errBody.Error, errBody.ErrorDescription)
 	}
diff --git a/internal/notif/rocketchat/client.go b/internal/notif/rocketchat/client.go
index 40d65fc2..cb20e94e 100644
--- a/internal/notif/rocketchat/client.go
+++ b/internal/notif/rocketchat/client.go
@@ -147,14 +147,11 @@ func (c *Client) Send(entry model.NotifEntry) error {
 		Error     string `json:"error,omitempty"`
 		ErrorType string `json:"errorType,omitempty"`
 	}
-	err = json.NewDecoder(resp.Body).Decode(&respBody)
-	if err == nil {
-		return err
+	if err = json.NewDecoder(resp.Body).Decode(&respBody); err != nil {
+		return errors.Wrapf(err, "cannot decode JSON body response for HTTP %d %s status", resp.StatusCode, http.StatusText(resp.StatusCode))
 	}
-
 	if resp.StatusCode != http.StatusOK {
 		return errors.Errorf("unexpected HTTP error %d: %s", resp.StatusCode, respBody.ErrorType)
 	}
-
 	return nil
 }
diff --git a/internal/notif/telegram/client.go b/internal/notif/telegram/client.go
index b46dfa70..3b884e2e 100644
--- a/internal/notif/telegram/client.go
+++ b/internal/notif/telegram/client.go
@@ -3,6 +3,7 @@ package telegram
 import (
 	"encoding/json"
 	"net/http"
+	"strconv"
 	"strings"
 	"text/template"
 
@@ -21,6 +22,11 @@ type Client struct {
 	meta model.Meta
 }
 
+type chatID struct {
+	id     int64
+	topics []int64
+}
+
 // New creates a new Telegram notification instance
 func New(config *model.NotifTelegram, meta model.Meta) notifier.Notifier {
 	return notifier.Notifier{
@@ -43,16 +49,27 @@ func (c *Client) Send(entry model.NotifEntry) error {
 		return errors.Wrap(err, "cannot retrieve token secret for Telegram notifier")
 	}
 
-	chatIDs := c.cfg.ChatIDs
-	chatIDsRaw, err := utl.GetSecret("", c.cfg.ChatIDsFile)
+	var cids []interface{}
+	for _, cid := range c.cfg.ChatIDs {
+		cids = append(cids, cid)
+	}
+	cidsRaw, err := utl.GetSecret("", c.cfg.ChatIDsFile)
 	if err != nil {
 		return errors.Wrap(err, "cannot retrieve chat IDs secret for Telegram notifier")
 	}
-	if len(chatIDsRaw) > 0 {
-		if err = json.Unmarshal([]byte(chatIDsRaw), &chatIDs); err != nil {
+	if len(cidsRaw) > 0 {
+		if err = json.Unmarshal([]byte(cidsRaw), &cids); err != nil {
 			return errors.Wrap(err, "cannot unmarshal chat IDs secret for Telegram notifier")
 		}
 	}
+	if len(cids) == 0 {
+		return errors.New("no chat IDs provided for Telegram notifier")
+	}
+
+	parsedChatIDs, err := parseChatIDs(cids)
+	if err != nil {
+		return errors.Wrap(err, "cannot parse chat IDs for Telegram notifier")
+	}
 
 	bot, err := gotgbot.NewBot(token, &gotgbot.BotOpts{
 		BotClient: &gotgbot.BaseBotClient{
@@ -90,15 +107,67 @@ func (c *Client) Send(entry model.NotifEntry) error {
 		return err
 	}
 
-	for _, chatID := range chatIDs {
-		_, err := bot.SendMessage(chatID, string(body), &gotgbot.SendMessageOpts{
-			ParseMode:          gotgbot.ParseModeMarkdown,
-			LinkPreviewOptions: &gotgbot.LinkPreviewOptions{IsDisabled: true},
-		})
-		if err != nil {
-			return err
+	for _, cid := range parsedChatIDs {
+		if len(cid.topics) > 0 {
+			for _, topic := range cid.topics {
+				if err = sendTelegramMessage(bot, cid.id, topic, string(body)); err != nil {
+					return err
+				}
+			}
+		} else {
+			if err = sendTelegramMessage(bot, cid.id, 0, string(body)); err != nil {
+				return err
+			}
 		}
 	}
 
 	return nil
 }
+
+func parseChatIDs(entries []interface{}) ([]chatID, error) {
+	var chatIDs []chatID
+	for _, entry := range entries {
+		switch v := entry.(type) {
+		case int:
+			chatIDs = append(chatIDs, chatID{id: int64(v)})
+		case int64:
+			chatIDs = append(chatIDs, chatID{id: v})
+		case string:
+			parts := strings.Split(v, ":")
+			if len(parts) < 1 || len(parts) > 2 {
+				return nil, errors.Errorf("invalid chat ID %q", v)
+			}
+			id, err := strconv.ParseInt(parts[0], 10, 64)
+			if err != nil {
+				return nil, errors.Wrap(err, "invalid chat ID")
+			}
+			var topics []int64
+			if len(parts) == 2 {
+				topicParts := strings.Split(parts[1], ";")
+				for _, topicPart := range topicParts {
+					topic, err := strconv.ParseInt(topicPart, 10, 64)
+					if err != nil {
+						return nil, errors.Wrapf(err, "invalid topic %q for chat ID %d", topicPart, id)
+					}
+					topics = append(topics, topic)
+				}
+			}
+			chatIDs = append(chatIDs, chatID{
+				id:     id,
+				topics: topics,
+			})
+		default:
+			return nil, errors.Errorf("invalid chat ID %v (type=%T)", entry, entry)
+		}
+	}
+	return chatIDs, nil
+}
+
+func sendTelegramMessage(bot *gotgbot.Bot, chatID int64, threadID int64, message string) error {
+	_, err := bot.SendMessage(chatID, message, &gotgbot.SendMessageOpts{
+		MessageThreadId:    threadID,
+		ParseMode:          gotgbot.ParseModeMarkdown,
+		LinkPreviewOptions: &gotgbot.LinkPreviewOptions{IsDisabled: true},
+	})
+	return err
+}



Test patch:
diff --git a/internal/config/config_test.go b/internal/config/config_test.go
index 0c0b7add..1feedcfe 100644
--- a/internal/config/config_test.go
+++ b/internal/config/config_test.go
@@ -174,8 +174,13 @@ for <code>{{ .Entry.Manifest.Platform }}</code> platform.
 						TemplateBody: model.NotifTeamsDefaultTemplateBody,
 					},
 					Telegram: &model.NotifTelegram{
-						Token:        "abcdef123456",
-						ChatIDs:      []int64{8547439, 1234567},
+						Token: "abcdef123456",
+						ChatIDs: []string{
+							"8547439",
+							"1234567",
+							"567891234:25",
+							"891256734:25;12",
+						},
 						TemplateBody: model.NotifTelegramDefaultTemplateBody,
 					},
 					Webhook: &model.NotifWebhook{
@@ -333,8 +338,11 @@ func TestLoadEnv(t *testing.T) {
 				Defaults: (&model.Defaults{}).GetDefaults(),
 				Notif: &model.Notif{
 					Telegram: &model.NotifTelegram{
-						Token:        "abcdef123456",
-						ChatIDs:      []int64{8547439, 1234567},
+						Token: "abcdef123456",
+						ChatIDs: []string{
+							"8547439",
+							"1234567",
+						},
 						TemplateBody: model.NotifTelegramDefaultTemplateBody,
 					},
 				},
diff --git a/internal/notif/telegram/client_test.go b/internal/notif/telegram/client_test.go
new file mode 100644
index 00000000..29fbdca5
--- /dev/null
+++ b/internal/notif/telegram/client_test.go
@@ -0,0 +1,84 @@
+package telegram
+
+import (
+	"testing"
+
+	"github.com/pkg/errors"
+	"github.com/stretchr/testify/require"
+)
+
+func TestParseChatIDs(t *testing.T) {
+	tests := []struct {
+		name     string
+		entries  []interface{}
+		expected []chatID
+		err      error
+	}{
+		{
+			name:    "valid integers",
+			entries: []interface{}{8547439, 1234567},
+			expected: []chatID{
+				{id: 8547439},
+				{id: 1234567},
+			},
+			err: nil,
+		},
+		{
+			name:    "valid strings with topics",
+			entries: []interface{}{"567891234:25", "891256734:25;12"},
+			expected: []chatID{
+				{id: 567891234, topics: []int64{25}},
+				{id: 891256734, topics: []int64{25, 12}},
+			},
+			err: nil,
+		},
+		{
+			name:     "invalid format",
+			entries:  []interface{}{"invalid_format"},
+			expected: nil,
+			err:      errors.New(`invalid chat ID: strconv.ParseInt: parsing "invalid_format": invalid syntax`),
+		},
+		{
+			name:     "invalid type",
+			entries:  []interface{}{true},
+			expected: nil,
+			err:      errors.New("invalid chat ID true (type=bool)"),
+		},
+		{
+			name:     "empty string",
+			entries:  []interface{}{""},
+			expected: nil,
+			err:      errors.New(`invalid chat ID: strconv.ParseInt: parsing "": invalid syntax`),
+		},
+		{
+			name:     "string with invalid topic",
+			entries:  []interface{}{"567891234:invalid"},
+			expected: nil,
+			err:      errors.New(`invalid topic "invalid" for chat ID 567891234: strconv.ParseInt: parsing "invalid": invalid syntax`),
+		},
+		{
+			name:     "mixed valid and invalid entries",
+			entries:  []interface{}{8547439, "567891234:25", "invalid_format", true},
+			expected: nil,
+			err:      errors.New(`invalid chat ID: strconv.ParseInt: parsing "invalid_format": invalid syntax`),
+		},
+		{
+			name:     "invalid format with too many parts",
+			entries:  []interface{}{"567891234:25:extra"},
+			expected: nil,
+			err:      errors.New(`invalid chat ID "567891234:25:extra"`),
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			res, err := parseChatIDs(tt.entries)
+			if tt.err != nil {
+				require.EqualError(t, err, tt.err.Error())
+			} else {
+				require.NoError(t, err)
+			}
+			require.Equal(t, tt.expected, res)
+		})
+	}
+}