Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .dockerignore
Original file line number Diff line number Diff line change
Expand Up @@ -13,3 +13,4 @@ docker-compose-*.yml.save
local/run-local.sh

.env*
/.claude/
2 changes: 1 addition & 1 deletion .github/workflows/test.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ jobs:
- name: Install Go
uses: actions/setup-go@v2
with:
go-version: 1.24.x
go-version: 1.25.x
- name: Checkout code
uses: actions/checkout@v2
- name: Test
Expand Down
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -27,3 +27,4 @@ docker-compose-*.yml.save
*.sqlite
local/run-local.sh
.env*
/.claude/settings.local.json
4 changes: 2 additions & 2 deletions Dockerfile
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
FROM golang:1.24 as build
FROM golang:1.25 as build

ENV GO111MODULE=on
ENV CGO_ENABLED=1
Expand All @@ -10,7 +10,7 @@ RUN \
cd cmd && go build -o /build/music-news


FROM golang:1.24
FROM golang:1.25

WORKDIR /srv

Expand Down
28 changes: 15 additions & 13 deletions go.mod
Original file line number Diff line number Diff line change
@@ -1,22 +1,23 @@
module github.com/bigspawn/music-news

go 1.24.0

toolchain go1.24.5
go 1.25.6

require (
github.com/PuerkitoBio/goquery v1.10.3
github.com/PuerkitoBio/goquery v1.11.0
github.com/bigspawn/go-itunes-api v0.0.3
github.com/bigspawn/go-odesli v0.0.4
github.com/disintegration/imaging v1.6.2
github.com/go-co-op/gocron v1.37.0
github.com/go-pkgz/lgr v0.12.1
github.com/go-pkgz/lgr v0.12.2
github.com/jessevdk/go-flags v1.6.1
github.com/mattn/go-sqlite3 v1.14.32
github.com/mattn/go-sqlite3 v1.14.34
github.com/mmcdole/gofeed v1.3.0
github.com/prometheus/client_golang v1.23.2
github.com/stretchr/testify v1.11.1
github.com/zmb3/spotify/v2 v2.4.3
go.uber.org/automaxprocs v1.6.0
golang.org/x/net v0.44.0
golang.org/x/net v0.52.0
golang.org/x/oauth2 v0.36.0
gopkg.in/telebot.v3 v3.3.8
)

Expand All @@ -33,13 +34,14 @@ require (
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/prometheus/client_model v0.6.2 // indirect
github.com/prometheus/common v0.66.1 // indirect
github.com/prometheus/procfs v0.17.0 // indirect
github.com/prometheus/common v0.67.5 // indirect
github.com/prometheus/procfs v0.20.1 // indirect
github.com/robfig/cron/v3 v3.0.1 // indirect
go.uber.org/atomic v1.11.0 // indirect
go.yaml.in/yaml/v2 v2.4.3 // indirect
golang.org/x/sys v0.36.0 // indirect
golang.org/x/text v0.29.0 // indirect
google.golang.org/protobuf v1.36.9 // indirect
go.yaml.in/yaml/v2 v2.4.4 // indirect
golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8 // indirect
golang.org/x/sys v0.42.0 // indirect
golang.org/x/text v0.35.0 // indirect
google.golang.org/protobuf v1.36.11 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)
55 changes: 34 additions & 21 deletions go.sum

Large diffs are not rendered by default.

29 changes: 22 additions & 7 deletions internal/app.go
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,14 @@ func NewApp(ctx context.Context, opt *Options, lgr lgr.L) (*App, error) {
return nil, fmt.Errorf("failed to create odesli api client: %w", err)
}

var spotifyApi *SpotifyApi
if opt.SpotifyClientID != "" {
spotifyApi, err = NewSpotifyApi(lgr, opt.SpotifyClientID, opt.SpotifyClientSecret)
if err != nil {
return nil, fmt.Errorf("failed to create spotify api: %w", err)
}
}

links, err := NewLinksApi(LinksApiParams{
Lgr: lgr,
ITunesClient: itunesAPI,
Expand All @@ -51,6 +59,7 @@ func NewApp(ctx context.Context, opt *Options, lgr lgr.L) (*App, error) {
if err != nil {
return nil, fmt.Errorf("failed to create links api: %w", err)
}
links.Spotify = spotifyApi

store, err := NewStore(StoreParams{
Lgr: lgr,
Expand All @@ -70,15 +79,15 @@ func NewApp(ctx context.Context, opt *Options, lgr lgr.L) (*App, error) {
}

scheduler := gocron.NewScheduler(time.UTC)
httpClient := NewHttpClient(NewDialer())

_, err = createNotifier(ctx, lgr, opt, bot, store, links, scheduler)
_, err = createNotifier(ctx, lgr, opt, bot, store, links, httpClient, scheduler)
if err != nil {
return nil, fmt.Errorf("failed to create notifier: %w", err)
}

var (
ch = make(chan []News)
httpClient = NewHttpClient(NewDialer())
reNotifiedJob *ReNotifiedJob
)

Expand All @@ -99,7 +108,7 @@ func NewApp(ctx context.Context, opt *Options, lgr lgr.L) (*App, error) {
// return nil, fmt.Errorf("failed to run get rock music scraper: %w", err)
// }

_, err = createPublisher(ctx, lgr, store, bot, opt, ch)
_, err = createPublisher(ctx, lgr, store, bot, opt, httpClient, ch)
if err != nil {
return nil, fmt.Errorf("failed to create publisher: %w", err)
}
Expand Down Expand Up @@ -145,11 +154,14 @@ func createNotifier(
bot *tb.Bot,
store *Store,
links *LinksApi,
httpClient *http.Client,
scheduler *gocron.Scheduler,
) (*Notifier, error) {
notifyBot, err := NewBotAPI(BotAPIParams{
Bot: bot,
ChantID: tb.ChatID(opt.NotifierChatID),
Lgr: lgr,
Bot: bot,
ChantID: tb.ChatID(opt.NotifierChatID),
HTTPClient: httpClient,
})
if err != nil {
return nil, fmt.Errorf("failed to create bot api: %w", err)
Expand Down Expand Up @@ -368,11 +380,14 @@ func createPublisher(
store *Store,
bot *tb.Bot,
opt *Options,
httpClient *http.Client,
ch chan []News,
) (*Publisher, error) {
notifyBot, err := NewBotAPI(BotAPIParams{
Bot: bot,
ChantID: tb.ChatID(opt.NewsChatID),
Lgr: lgr,
Bot: bot,
ChantID: tb.ChatID(opt.NewsChatID),
HTTPClient: httpClient,
})
if err != nil {
return nil, fmt.Errorf("failed to create bot api: %w", err)
Expand Down
137 changes: 127 additions & 10 deletions internal/bot.go
Original file line number Diff line number Diff line change
@@ -1,20 +1,32 @@
package internal

import (
"bytes"
"context"
"errors"
"fmt"
"image/jpeg"
"io"
"net/http"
"sort"
"strings"
"time"

goOdesli "github.com/bigspawn/go-odesli"
"github.com/disintegration/imaging"
"github.com/go-pkgz/lgr"
tb "gopkg.in/telebot.v3"
)

type botSender interface {
SendNews(ctx context.Context, n News) (int, error)
SendReleaseNews(ctx context.Context, n ReleaseNews) (int, error)
Delete(ctx context.Context, id int) error
}

type RetryableBotApiParams struct {
Lgr lgr.L
Bot *BotAPI
Bot botSender
}

func (p *RetryableBotApiParams) Validate() error {
Expand Down Expand Up @@ -76,15 +88,15 @@ func (api *RetryableBotApi) SendReleaseNews(ctx context.Context, n ReleaseNews)
return nil
}

if id > 0 {
_ = api.Delete(ctx, id)
}

retryErr := api.retry(ctx, n.Title, err, func() error { return api.SendReleaseNews(ctx, n) })
if retryErr == nil {
return nil
}

if id > 0 {
_ = api.Delete(ctx, id)
}

return retryErr
}

Expand All @@ -108,8 +120,10 @@ func (api RetryableBotApi) retry(ctx context.Context, info interface{}, err erro
}

type BotAPIParams struct {
Bot *tb.Bot
ChantID tb.ChatID
Lgr lgr.L
Bot *tb.Bot
ChantID tb.ChatID
HTTPClient *http.Client
}

func (p *BotAPIParams) Validate() error {
Expand All @@ -130,6 +144,9 @@ func NewBotAPI(params BotAPIParams) (*BotAPI, error) {
if err := params.Validate(); err != nil {
return nil, err
}
if params.HTTPClient == nil {
params.HTTPClient = http.DefaultClient
}
return &BotAPI{
BotAPIParams: params,
}, nil
Expand Down Expand Up @@ -169,7 +186,7 @@ func (api *BotAPI) SendNews(ctx context.Context, n News) (int, error) {
keyboard[0] = append(keyboard[0], SafeInlineButton(fmt.Sprintf("Download_%d", i), l))
}

text := fmt.Sprintf("%s\n%s", n.Title, n.Text)
text := buildNewsText(n.Title, n.Text)

msg, err := api.Bot.Send(api.ChantID, text, &tb.ReplyMarkup{InlineKeyboard: keyboard})
if err != nil {
Expand All @@ -179,20 +196,120 @@ func (api *BotAPI) SendNews(ctx context.Context, n News) (int, error) {
return msg.ID, nil
}

const (
maxImageSize = 10 * 1024 * 1024 // 10MB Telegram limit for photos
maxTelegramMessageLength = 4096
)

func truncateText(text string, maxLen int) string {
if len(text) <= maxLen {
return text
}
// try to cut at last newline within limit
truncated := text[:maxLen]
if idx := strings.LastIndex(truncated, "\n"); idx > 0 {
return truncated[:idx]
}
return truncated
}

func buildNewsText(title, body string) string {
text := fmt.Sprintf("%s\n%s", title, body)
return truncateText(text, maxTelegramMessageLength)
}

func buildReleaseNewsText(title, body, releaseLink string) string {
suffix := fmt.Sprintf("\n<a href=\"%s\">Release album link</a>", releaseLink)
maxBody := maxTelegramMessageLength - len(title) - 1 - len(suffix) // 1 for \n between title and body
if len(body) > maxBody {
body = truncateText(body, maxBody)
}
return fmt.Sprintf("%s\n%s%s", title, body, suffix)
}

func (api *BotAPI) SendImageByLink(ctx context.Context, imageLink string) (int, error) {
link, err := EncodeQuery(imageLink)
if err != nil {
return 0, fmt.Errorf("failed to encode image link: %w", err)
}

msg, err := api.Bot.Send(api.ChantID, &tb.Photo{File: tb.FromURL(link)})
if err != nil {
if err == nil {
return msg.ID, nil
}

if !isTelegramImageError(err) {
return 0, fmt.Errorf("failed to send image: %w", err)
}

// Fallback: download image ourselves and upload as file
api.Lgr.Logf("[INFO] image FromURL failed for %s, falling back to upload: %v", imageLink, err)
return api.sendImageByUpload(ctx, imageLink)
}

func isTelegramImageError(err error) bool {
s := err.Error()
return strings.Contains(s, "wrong type of the web page content") ||
strings.Contains(s, "failed to get HTTP URL content") ||
strings.Contains(s, "wrong file identifier/HTTP URL specified")
}

const maxImageWidth = 1200

func (api *BotAPI) sendImageByUpload(ctx context.Context, imageLink string) (int, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, imageLink, nil)
if err != nil {
return 0, fmt.Errorf("failed to create image request: %w", err)
}

req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:84.0) Gecko/20100101 Firefox/84.0")

resp, err := api.HTTPClient.Do(req)
if err != nil {
return 0, fmt.Errorf("failed to download image: %w", err)
}
defer func() { _ = resp.Body.Close() }()

if resp.StatusCode != http.StatusOK {
return 0, fmt.Errorf("failed to download image: status=%s", resp.Status)
}

data, err := io.ReadAll(io.LimitReader(resp.Body, maxImageSize+1))
if err != nil {
return 0, fmt.Errorf("failed to read image body: %w", err)
}

resized, err := resizeImage(data)
if err != nil {
return 0, fmt.Errorf("failed to resize image: %w", err)
}

msg, err := api.Bot.Send(api.ChantID, &tb.Photo{File: tb.FromReader(bytes.NewReader(resized))})
if err != nil {
return 0, fmt.Errorf("failed to upload image: %w", err)
}

return msg.ID, nil
}

func resizeImage(data []byte) ([]byte, error) {
img, err := imaging.Decode(bytes.NewReader(data))
if err != nil {
return nil, fmt.Errorf("decode image: %w", err)
}

if img.Bounds().Dx() > maxImageWidth {
img = imaging.Resize(img, maxImageWidth, 0, imaging.Lanczos)
}

var buf bytes.Buffer
if err := jpeg.Encode(&buf, img, &jpeg.Options{Quality: 85}); err != nil {
return nil, fmt.Errorf("encode jpeg: %w", err)
}

return buf.Bytes(), nil
}

func (api *BotAPI) Delete(ctx context.Context, id int) error {
return api.Bot.Delete(&tb.Message{ID: id, Chat: &tb.Chat{ID: int64(api.ChantID)}})
}
Expand All @@ -210,7 +327,7 @@ func (api *BotAPI) SendReleaseNews(ctx context.Context, n ReleaseNews) (int, err
return 0, fmt.Errorf("failed to send image: %w", err)
}

text := fmt.Sprintf("%s\n%s\n<a href=\"%s\">Release album link</a>", n.Title, n.Text, n.ReleaseLink)
text := buildReleaseNewsText(n.Title, n.Text, n.ReleaseLink)

rows := make([]tb.InlineButton, 0, len(n.PlatformLinks))
for platform, link := range n.PlatformLinks {
Expand Down
Loading
Loading