diff options
Diffstat (limited to 'internal/transcoder')
| -rw-r--r-- | internal/transcoder/format_test.go | 28 | ||||
| -rw-r--r-- | internal/transcoder/markdown.go | 164 | ||||
| -rw-r--r-- | internal/transcoder/sayauz.go | 138 | ||||
| -rw-r--r-- | internal/transcoder/telegram.go | 584 | ||||
| -rw-r--r-- | internal/transcoder/transcoder.go | 19 |
5 files changed, 933 insertions, 0 deletions
diff --git a/internal/transcoder/format_test.go b/internal/transcoder/format_test.go new file mode 100644 index 0000000..9b25dc6 --- /dev/null +++ b/internal/transcoder/format_test.go @@ -0,0 +1,28 @@ +package transcoder + +import ( + "os" + "testing" + "time" +) + +func TestFormatContentMatchesCommitted(t *testing.T) { + draftRaw, err := os.ReadFile("../../.draft/sevsk.md.draft") + if err != nil { + t.Fatal(err) + } + expected, err := os.ReadFile("../../.draft/sevsk.md") + if err != nil { + t.Fatal(err) + } + + published, err := time.Parse(time.RFC3339, "2025-08-26T11:21:53+07:00") + if err != nil { + t.Fatal(err) + } + + got := formatContent(draftRaw, published, true) + if string(got) != string(expected) { + t.Errorf("formatted output mismatch\n--- got ---\n%q\n--- want ---\n%q", got, expected) + } +} diff --git a/internal/transcoder/markdown.go b/internal/transcoder/markdown.go new file mode 100644 index 0000000..975c3bc --- /dev/null +++ b/internal/transcoder/markdown.go @@ -0,0 +1,164 @@ +package transcoder + +import ( + "fmt" + "strings" + + "github.com/yuin/goldmark" + "github.com/yuin/goldmark/ast" + "github.com/yuin/goldmark/extension" + xast "github.com/yuin/goldmark/extension/ast" + "github.com/yuin/goldmark/text" +) + +// telegramMD parses CommonMark once and is reused across calls. +var telegramMD = goldmark.New(goldmark.WithExtensions(extension.Strikethrough, extension.Linkify)) + +// markdownToTelegramHTML converts CommonMark source into the limited HTML +// subset Telegram accepts (parse_mode=HTML). Telegram supports only +// b/i/u/s/a/code/pre/blockquote — block constructs with no Telegram tag +// (paragraphs, headings, lists) are flattened to text + newlines. Anything +// outside the subset is dropped rather than emitted, so a message is never +// rejected for an unsupported tag. +func markdownToTelegramHTML(src string) string { + source := []byte(src) + doc := telegramMD.Parser().Parse(text.NewReader(source)) + + var b strings.Builder + renderNodes(&b, doc, source) + + // Collapse the runs of blank lines block rendering can leave behind. + out := strings.TrimSpace(b.String()) + for strings.Contains(out, "\n\n\n") { + out = strings.ReplaceAll(out, "\n\n\n", "\n\n") + } + return out +} + +func renderNodes(b *strings.Builder, parent ast.Node, source []byte) { + for n := parent.FirstChild(); n != nil; n = n.NextSibling() { + renderNode(b, n, source) + } +} + +func renderNode(b *strings.Builder, n ast.Node, source []byte) { + switch node := n.(type) { + case *ast.Document: + renderNodes(b, node, source) + + case *ast.Paragraph, *ast.TextBlock: + renderNodes(b, node, source) + b.WriteString("\n\n") + + case *ast.Heading: + // Telegram has no headings; render the line in bold. + b.WriteString("<b>") + renderNodes(b, node, source) + b.WriteString("</b>\n\n") + + case *ast.Blockquote: + b.WriteString("<blockquote>") + renderNodes(b, node, source) + trimTrailingNewlines(b) + b.WriteString("</blockquote>\n\n") + + case *ast.List: + renderList(b, node, source) + b.WriteString("\n") + + case *ast.FencedCodeBlock, *ast.CodeBlock: + b.WriteString("<pre>") + writeRawLines(b, n, source) + b.WriteString("</pre>\n\n") + + case *ast.ThematicBreak: + // horizontal rule — nothing meaningful in a Telegram message + + // --- inline --- + case *ast.Text: + b.WriteString(escapeHTML(string(node.Segment.Value(source)))) + if node.HardLineBreak() || node.SoftLineBreak() { + b.WriteByte('\n') + } + case *ast.String: + b.WriteString(escapeHTML(string(node.Value))) + + case *ast.Emphasis: + tag := "i" + if node.Level == 2 { + tag = "b" + } + fmt.Fprintf(b, "<%s>", tag) + renderNodes(b, node, source) + fmt.Fprintf(b, "</%s>", tag) + + case *xast.Strikethrough: + b.WriteString("<s>") + renderNodes(b, node, source) + b.WriteString("</s>") + + case *ast.CodeSpan: + b.WriteString("<code>") + renderNodes(b, node, source) + b.WriteString("</code>") + + case *ast.Link: + fmt.Fprintf(b, `<a href="%s">`, escapeHTML(string(node.Destination))) + renderNodes(b, node, source) + b.WriteString("</a>") + + case *ast.AutoLink: + url := string(node.URL(source)) + fmt.Fprintf(b, `<a href="%s">%s</a>`, escapeHTML(url), escapeHTML(url)) + + case *ast.Image: + // Images can't render inline in text; keep the alt text only. + renderNodes(b, node, source) + + case *ast.RawHTML, *ast.HTMLBlock: + // Drop raw HTML — it is almost certainly not in Telegram's tag subset. + + default: + // Unknown node: recurse so inline text inside it is not lost. + renderNodes(b, n, source) + } +} + +func renderList(b *strings.Builder, list *ast.List, source []byte) { + i := list.Start + for item := list.FirstChild(); item != nil; item = item.NextSibling() { + if list.IsOrdered() { + fmt.Fprintf(b, "%d. ", i) + i++ + } else { + b.WriteString("• ") + } + renderNodes(b, item, source) + trimTrailingNewlines(b) + b.WriteByte('\n') + } +} + +func writeRawLines(b *strings.Builder, n ast.Node, source []byte) { + lines := n.Lines() + for i := 0; i < lines.Len(); i++ { + seg := lines.At(i) + b.WriteString(escapeHTML(string(seg.Value(source)))) + } +} + +func trimTrailingNewlines(b *strings.Builder) { + s := strings.TrimRight(b.String(), "\n") + b.Reset() + b.WriteString(s) +} + +// escapeHTML escapes the three characters Telegram's HTML parser treats as +// markup. Quotes are escaped too so the value is safe inside an href="...". +func escapeHTML(s string) string { + s = strings.ReplaceAll(s, "&", "&") + s = strings.ReplaceAll(s, "<", "<") + s = strings.ReplaceAll(s, ">", ">") + s = strings.ReplaceAll(s, `"`, """) + return s +} diff --git a/internal/transcoder/sayauz.go b/internal/transcoder/sayauz.go new file mode 100644 index 0000000..ee888e5 --- /dev/null +++ b/internal/transcoder/sayauz.go @@ -0,0 +1,138 @@ +package transcoder + +import ( + "fmt" + "log/slog" + "os" + "path/filepath" + "strings" + "time" + + "github.com/SayaAndy/saya-today-article-metadata-add/config" + "github.com/SayaAndy/saya-today-article-metadata-add/internal/draft" + "github.com/SayaAndy/saya-today-article-metadata-add/internal/storage" +) + +type SayauzTranscoder struct { + category string + storage storage.StorageClient +} + +func NewSayauzTranscoder(cfg any) (Transcoder, error) { + sayauzCfg, ok := cfg.(*config.SayauzConfig) + if !ok { + return nil, fmt.Errorf("invalid config for sayauz transcoder") + } + + newClient, ok := storage.NewStorageClientMap[sayauzCfg.Storage.Type] + if !ok { + return nil, fmt.Errorf("unsupported storage type %q for sayauz transcoder", sayauzCfg.Storage.Type) + } + storageClient, err := newClient(&sayauzCfg.Storage) + if err != nil { + return nil, fmt.Errorf("init storage client: %w", err) + } + + return &SayauzTranscoder{ + category: sayauzCfg.Category, + storage: storageClient, + }, nil +} + +func (t *SayauzTranscoder) Name() string { return "sayauz" } + +func (t *SayauzTranscoder) Transcode(doc *draft.Document) error { + if doc.Metadata == nil { + return fmt.Errorf("draft %q has no frontmatter metadata", doc.SourcePath) + } + + prodKey := t.category + "/" + doc.Codename + ".md" + + // publishedTime is the first-publish time: reuse an existing value (from the + // draft frontmatter, else from the already-published object), otherwise stamp now. + inject := doc.Metadata.PublishedTime.IsZero() + if inject { + published := time.Time{} + if meta, err := t.storage.GetMetadata(prodKey); err == nil { + if v := meta["published-time"]; v != "" { + if parsed, err := time.Parse(time.RFC3339, v); err == nil { + published = parsed + } + } + } else { + slog.Warn("could not read existing metadata for publishedTime, stamping now", + slog.String("key", prodKey), slog.String("error", err.Error())) + } + if published.IsZero() { + published = time.Now() + } + doc.Metadata.PublishedTime = published + } + + formatted := formatContent(doc.RawContent, doc.Metadata.PublishedTime, inject) + + localPath := filepath.Join(filepath.Dir(doc.SourcePath), doc.Codename+".md") + if err := os.WriteFile(localPath, formatted, 0o644); err != nil { + return fmt.Errorf("write formatted file %q: %w", localPath, err) + } + + if err := t.storage.Put(prodKey, formatted, doc.Metadata); err != nil { + return fmt.Errorf("upload %q: %w", prodKey, err) + } + + slog.Info("sayauz published page", + slog.String("key", prodKey), + slog.String("local", localPath)) + return nil +} + +func (t *SayauzTranscoder) Finalize() error { + return t.storage.BuildIndex() +} + +// formatContent renders a draft into its published form: standalone "..." +// separator lines become blank lines (trailing ones are dropped), and when +// inject is true a "publishedTime:" line is added after "actionDate:" in the +// frontmatter. Frontmatter, galleries and "---" rules are otherwise untouched. +func formatContent(raw []byte, published time.Time, inject bool) []byte { + lines := strings.Split(string(raw), "\n") + out := make([]string, 0, len(lines)+1) + + inFrontmatter := false + frontmatterDone := false + + for i, line := range lines { + trimmed := strings.TrimSpace(line) + + if !frontmatterDone { + if i == 0 && trimmed == "---" { + inFrontmatter = true + out = append(out, line) + continue + } + if inFrontmatter { + out = append(out, line) + switch { + case trimmed == "---": + inFrontmatter = false + frontmatterDone = true + case inject && strings.HasPrefix(trimmed, "actionDate:"): + out = append(out, "publishedTime: "+published.Format(time.RFC3339)) + } + continue + } + } + + if trimmed == "..." { + out = append(out, "") + continue + } + out = append(out, line) + } + + for len(out) > 0 && out[len(out)-1] == "" { + out = out[:len(out)-1] + } + + return []byte(strings.Join(out, "\n")) +} diff --git a/internal/transcoder/telegram.go b/internal/transcoder/telegram.go new file mode 100644 index 0000000..b521fb0 --- /dev/null +++ b/internal/transcoder/telegram.go @@ -0,0 +1,584 @@ +package transcoder + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "log/slog" + "mime/multipart" + "net/http" + "os" + "path/filepath" + "slices" + "strconv" + "strings" + "time" + + "github.com/SayaAndy/saya-today-article-metadata-add/config" + "github.com/SayaAndy/saya-today-article-metadata-add/internal/draft" +) + +const ( + telegramMediaGroupLimit = 10 + // telegramCaptionLimit is the max length (runes) of a media caption. + telegramCaptionLimit = 1024 + // telegramMaxAttempts caps retries when the API returns 429. + telegramMaxAttempts = 5 +) + +type TelegramTranscoder struct { + botToken string + channelID int + groupID int + lastUpdateID int + imageBaseURL string + previewBaseURL string + previewExtension string + stateDir string + http *http.Client +} + +func NewTelegramTranscoder(cfg any) (Transcoder, error) { + tgCfg, ok := cfg.(*config.TelegramConfig) + if !ok { + return nil, fmt.Errorf("invalid config for telegram transcoder") + } + + transcoder := &TelegramTranscoder{ + botToken: tgCfg.BotToken, + channelID: tgCfg.ChannelID, + groupID: tgCfg.GroupID, + lastUpdateID: -1, + imageBaseURL: strings.TrimRight(tgCfg.ImageBaseURL, "/"), + previewBaseURL: strings.TrimRight(tgCfg.PreviewBaseURL, "/"), + previewExtension: tgCfg.PreviewExtension, + stateDir: tgCfg.StateDir, + http: &http.Client{Timeout: time.Minute}, + } + + if _, err := transcoder.getUpdates(); err != nil { + return nil, fmt.Errorf("failed to get last update id for checkpoint: %w", err) + } + + return transcoder, nil +} + +func (t *TelegramTranscoder) Name() string { return "telegram" } + +func (t *TelegramTranscoder) Finalize() error { return nil } + +// telegramState records, per draft, which section indices have already been +// posted and the message ids they produced. Posting is append-only: a section +// already present here is never re-sent. +type telegramState struct { + ChannelID string `json:"channelID"` + Posted map[string][]int `json:"posted"` +} + +func (t *TelegramTranscoder) Transcode(doc *draft.Document) error { + statePath := filepath.Join(t.stateDir, doc.Codename+".telegram-state.json") + state, err := loadTelegramState(statePath) + if err != nil { + return fmt.Errorf("load telegram state %q: %w", statePath, err) + } + + if state.ChannelID != "" && state.ChannelID != strconv.Itoa(t.channelID) { + slog.Warn("telegram channel changed, re-posting all sections to the new channel", + slog.String("codename", doc.Codename), + slog.String("old", state.ChannelID), + slog.Int("new", t.channelID)) + state.Posted = map[string][]int{} + } + state.ChannelID = strconv.Itoa(t.channelID) + + for _, section := range doc.Sections { + idx := strconv.Itoa(section.Index) + if _, done := state.Posted[idx]; done { + continue + } + + var msgIDs []int + pendingText := "" + + // flushText sends any buffered text as its own message. + flushText := func() error { + if pendingText == "" { + return nil + } + id, err := t.sendMessage(pendingText) + if err != nil { + return err + } + msgIDs = append(msgIDs, id) + pendingText = "" + return nil + } + + for _, block := range section.Blocks { + switch block.Kind { + case draft.TextBlock: + text := stripRules(block.Text) + if text == "" { + continue + } + // flush any earlier text before buffering this one + if err := flushText(); err != nil { + return fmt.Errorf("send section %d text: %w", section.Index, err) + } + pendingText = text + case draft.GalleryBlock: + // Attach the immediately preceding text as the album caption when + // it fits; otherwise send it as its own message first. + caption := "" + if pendingText != "" { + if len([]rune(pendingText)) <= telegramCaptionLimit { + caption = pendingText + pendingText = "" + } else if err := flushText(); err != nil { + return fmt.Errorf("send section %d text: %w", section.Index, err) + } + } + for i, group := range splitPhotos(block.Gallery.Photos, telegramMediaGroupLimit) { + groupCaption := "" + if i == 0 { + groupCaption = caption + } + ids, err := t.sendMediaGroup(group, groupCaption, "photo", t.channelID, 0) + if err != nil { + return fmt.Errorf("send section %d gallery: %w", section.Index, err) + } + msgIDs = append(msgIDs, ids...) + if err := flushText(); err != nil { + return fmt.Errorf("send section %d text: %w", section.Index, err) + } + if len(msgIDs) > 0 { + time.Sleep(5 * time.Second) + forwardedID, err := t.getForwardedID(msgIDs) + if err != nil { + return fmt.Errorf("send full documents for section %d gallery: %w", section.Index, err) + } + if forwardedID == 0 { + return fmt.Errorf("fail to get forwarded id of section %d gallery", section.Index) + } + _, err = t.sendMediaGroup(group, "", "document", t.groupID, forwardedID) + if err != nil { + return fmt.Errorf("send section %d documents: %w", section.Index, err) + } + } + } + } + } + + state.Posted[idx] = msgIDs + if err := saveTelegramState(statePath, state); err != nil { + return fmt.Errorf("save telegram state %q: %w", statePath, err) + } + slog.Info("telegram posted section", + slog.String("codename", doc.Codename), + slog.Int("section", section.Index), + slog.Int("messages", len(msgIDs))) + } + + return nil +} + +func loadTelegramState(path string) (*telegramState, error) { + raw, err := os.ReadFile(path) + if err != nil { + if os.IsNotExist(err) { + return &telegramState{Posted: map[string][]int{}}, nil + } + return nil, err + } + var state telegramState + if err := json.Unmarshal(raw, &state); err != nil { + return nil, err + } + if state.Posted == nil { + state.Posted = map[string][]int{} + } + return &state, nil +} + +func saveTelegramState(path string, state *telegramState) error { + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + return err + } + raw, err := json.MarshalIndent(state, "", " ") + if err != nil { + return err + } + return os.WriteFile(path, raw, 0o644) +} + +// stripRules drops standalone "---" horizontal-rule lines (meaningless in a +// Telegram message) and trims the remaining text. +func stripRules(text string) string { + lines := strings.Split(text, "\n") + kept := lines[:0] + for _, line := range lines { + if strings.TrimSpace(line) == "---" { + continue + } + kept = append(kept, line) + } + return strings.TrimSpace(strings.Join(kept, "\n")) +} + +// splitPhotos divides photos into near-equal groups each no larger than limit. +func splitPhotos(photos []draft.Photo, limit int) [][]draft.Photo { + n := len(photos) + if n == 0 { + return nil + } + groupCount := (n + limit - 1) / limit + base := n / groupCount + rem := n % groupCount + + var groups [][]draft.Photo + start := 0 + for g := range groupCount { + size := base + if g < rem { + size++ + } + groups = append(groups, photos[start:start+size]) + start += size + } + return groups +} + +type inputMediaPhoto struct { + Type string `json:"type"` + Media string `json:"media"` + Caption string `json:"caption,omitempty"` + ParseMode string `json:"parse_mode,omitempty"` +} + +func (t *TelegramTranscoder) photoURL(key string) string { + return t.imageBaseURL + "/" + key +} + +func (t *TelegramTranscoder) previewURL(key string) string { + base := t.previewBaseURL + if base == "" { + base = t.imageBaseURL + } + if t.previewExtension != "" { + key = strings.TrimSuffix(key, filepath.Ext(key)) + t.previewExtension + } + return base + "/" + key +} + +func (t *TelegramTranscoder) sendMessage(text string) (int, error) { + payload := map[string]any{ + "chat_id": strconv.Itoa(t.channelID), + "text": markdownToTelegramHTML(text), + "parse_mode": "HTML", + "disable_web_page_preview": true, + } + var result struct { + MessageID int `json:"message_id"` + } + if err := t.call("sendMessage", payload, &result); err != nil { + return 0, err + } + return result.MessageID, nil +} + +func (t *TelegramTranscoder) sendMediaGroup(photos []draft.Photo, caption string, sendAsType string, id int, replyTo int) ([]int, error) { + media := make([]inputMediaPhoto, 0, len(photos)) + files := map[string]uploadFile{} + for i, p := range photos { + item := inputMediaPhoto{Type: sendAsType} + if sendAsType == "document" { + // Upload the original file directly rather than passing Telegram a + // URL to fetch: full-resolution originals routinely fail Telegram's + // server-side downloader with WEBPAGE_CURL_FAILED. + data, err := t.downloadPhoto(p.Key) + if err != nil { + return nil, fmt.Errorf("download %q for upload: %w", p.Key, err) + } + field := fmt.Sprintf("file%d", i) + files[field] = uploadFile{name: filepath.Base(p.Key), data: data} + item.Media = "attach://" + field + } else { + item.Media = t.previewURL(p.Key) + } + // Telegram shows an album's caption in the feed only when exactly one + // item is captioned. So the section text goes on the first photo and + // every other photo is left uncaptioned (per-photo captions dropped). + if i == 0 && caption != "" { + item.Caption = markdownToTelegramHTML(caption) + item.ParseMode = "HTML" + } + media = append(media, item) + } + + var result []struct { + MessageID int `json:"message_id"` + } + + if len(files) > 0 { + mediaJSON, err := json.Marshal(media) + if err != nil { + return nil, err + } + fields := map[string]string{ + "chat_id": strconv.Itoa(id), + "media": string(mediaJSON), + } + if replyTo != 0 { + replyJSON, err := json.Marshal(map[string]any{"message_id": replyTo}) + if err != nil { + return nil, err + } + fields["reply_parameters"] = string(replyJSON) + } + if err := t.callMultipart("sendMediaGroup", fields, files, &result); err != nil { + return nil, err + } + } else { + payload := map[string]any{ + "chat_id": strconv.Itoa(id), + "media": media, + } + if replyTo != 0 { + payload["reply_parameters"] = map[string]any{ + "message_id": replyTo, + } + } + if err := t.call("sendMediaGroup", payload, &result); err != nil { + return nil, err + } + } + + ids := make([]int, 0, len(result)) + for _, r := range result { + ids = append(ids, r.MessageID) + } + return ids, nil +} + +type uploadFile struct { + name string + data []byte +} + +// downloadPhoto fetches the original bytes for a photo key so they can be +// uploaded directly to Telegram instead of fetched server-side by Telegram. +func (t *TelegramTranscoder) downloadPhoto(key string) ([]byte, error) { + url := t.photoURL(key) + resp, err := t.http.Get(url) + if err != nil { + return nil, err + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("unexpected status %d fetching %q", resp.StatusCode, url) + } + return io.ReadAll(resp.Body) +} + +func (t *TelegramTranscoder) getForwardedID(channelMessageIDs []int) (int, error) { + var payloads []getUpdatesPayload + var err error + slog.Debug("search for forwarded message id", slog.Any("original_id", channelMessageIDs), slog.Int("group_id", t.groupID), slog.Int("channel_id", t.channelID)) + + for range 3 { + for payloads, err = t.getUpdates(); len(payloads) > 0 && err == nil; payloads, err = t.getUpdates() { + for _, payload := range payloads { + if payload.Message.MessageOrigin.Type == "channel" && + payload.Message.MessageOrigin.Chat.ID == t.channelID && + slices.Contains(channelMessageIDs, payload.Message.MessageOrigin.MessageID) && + payload.Message.Chat.ID == t.groupID { + return payload.Message.MessageID, nil + } + } + } + time.Sleep(3 * time.Second) + } + + return 0, err +} + +type getUpdatesPayload struct { + UpdateID int `json:"update_id"` + Message struct { + MessageID int `json:"message_id"` + Chat struct { + ID int `json:"id"` + Title string `json:"title"` + Type string `json:"type"` + } `json:"chat"` + MessageOrigin struct { + Type string `json:"type"` + Date int `json:"date"` + Chat struct { + ID int `json:"id"` + Title string `json:"title"` + Type string `json:"type"` + } `json:"chat"` + MessageID int `json:"message_id"` + } `json:"forward_origin"` + Date int `json:"date"` + Text string `json:"text"` + } `json:"message"` +} + +func (t *TelegramTranscoder) getUpdates() ([]getUpdatesPayload, error) { + var out []getUpdatesPayload + + payload := struct { + Offset int `json:"offset"` + }{ + Offset: t.lastUpdateID, + } + + if err := t.call("getUpdates", payload, &out); err != nil { + return nil, fmt.Errorf("failed to transcode received message: %w", err) + } + + for _, p := range out { + pjson, _ := json.Marshal(p) + slog.Debug("parsed new update", slog.String("payload", string(pjson))) + if p.UpdateID > t.lastUpdateID { + t.lastUpdateID = p.UpdateID + } + } + t.lastUpdateID++ + + return out, nil +} + +// handleResponse decodes a Telegram API response envelope. When the API asks us +// to back off (HTTP 429) it returns a positive wait duration and a nil error so +// the caller can retry; otherwise it unmarshals the result into out (if any) or +// returns the API error verbatim. +func (t *TelegramTranscoder) handleResponse(method string, resp *http.Response, attempt int, out any) (time.Duration, error) { + var envelope struct { + OK bool `json:"ok"` + ErrorCode int `json:"error_code"` + Description string `json:"description"` + Parameters struct { + RetryAfter int `json:"retry_after"` + } `json:"parameters"` + Result json.RawMessage `json:"result"` + } + decodeErr := json.NewDecoder(resp.Body).Decode(&envelope) + resp.Body.Close() + if decodeErr != nil { + return 0, fmt.Errorf("decode telegram response (%s): %w", method, decodeErr) + } + + envelopeBytes, _ := json.Marshal(envelope) + slog.Debug("telegram response", slog.String("method", method), slog.String("response", string(envelopeBytes))) + + if envelope.OK { + if out == nil { + return 0, nil + } + return 0, json.Unmarshal(envelope.Result, out) + } + + // 429 Too Many Requests: Telegram tells us how long to wait in + // parameters.retry_after. Back off and retry instead of failing. + if envelope.ErrorCode == 429 && attempt < telegramMaxAttempts { + wait := time.Duration(envelope.Parameters.RetryAfter) * time.Second + if wait <= 0 { + wait = time.Second + } + slog.Warn("telegram rate limited, backing off", + slog.String("method", method), + slog.Int("attempt", attempt), + slog.Duration("retry_after", wait)) + return wait, nil + } + + return 0, fmt.Errorf("telegram %s failed: %s", method, envelope.Description) +} + +// call invokes a Telegram Bot API method with a JSON body and decodes the +// "result" field into out. The API's error description is surfaced verbatim. +func (t *TelegramTranscoder) call(method string, payload any, out any) error { + body, err := json.Marshal(payload) + if err != nil { + return err + } + + url := fmt.Sprintf("https://api.telegram.org/bot%s/%s", t.botToken, method) + + for attempt := 1; ; attempt++ { + req, err := http.NewRequest(http.MethodPost, url, bytes.NewReader(body)) + if err != nil { + return err + } + req.Header.Set("Content-Type", "application/json") + + slog.Debug("send message", slog.String("method", method), slog.String("request", string(body))) + + resp, err := t.http.Do(req) + if err != nil { + return err + } + + wait, err := t.handleResponse(method, resp, attempt, out) + if wait > 0 { + time.Sleep(wait) + continue + } + return err + } +} + +// callMultipart invokes a Telegram Bot API method using multipart/form-data, +// uploading the given files directly instead of handing Telegram URLs to fetch +// server-side. fields carries the non-file form values (chat_id, media JSON, +// reply_parameters, ...); files maps a form field name to its bytes and upload +// filename, referenced from the media JSON via attach://<field>. +func (t *TelegramTranscoder) callMultipart(method string, fields map[string]string, files map[string]uploadFile, out any) error { + url := fmt.Sprintf("https://api.telegram.org/bot%s/%s", t.botToken, method) + + for attempt := 1; ; attempt++ { + var body bytes.Buffer + w := multipart.NewWriter(&body) + for k, v := range fields { + if err := w.WriteField(k, v); err != nil { + return err + } + } + for field, f := range files { + part, err := w.CreateFormFile(field, f.name) + if err != nil { + return err + } + if _, err := part.Write(f.data); err != nil { + return err + } + } + if err := w.Close(); err != nil { + return err + } + + req, err := http.NewRequest(http.MethodPost, url, &body) + if err != nil { + return err + } + req.Header.Set("Content-Type", w.FormDataContentType()) + + slog.Debug("send media group upload", slog.String("method", method), slog.Int("files", len(files))) + + resp, err := t.http.Do(req) + if err != nil { + return err + } + + wait, err := t.handleResponse(method, resp, attempt, out) + if wait > 0 { + time.Sleep(wait) + continue + } + return err + } +} diff --git a/internal/transcoder/transcoder.go b/internal/transcoder/transcoder.go new file mode 100644 index 0000000..3dbdf7e --- /dev/null +++ b/internal/transcoder/transcoder.go @@ -0,0 +1,19 @@ +package transcoder + +import ( + "github.com/SayaAndy/saya-today-article-metadata-add/internal/draft" +) + +// Transcoder turns a parsed draft into a published artifact (an S3 page, a +// Telegram thread, ...). Transcode runs once per draft; Finalize runs once +// after all drafts have been processed. +type Transcoder interface { + Name() string + Transcode(doc *draft.Document) error + Finalize() error +} + +var NewTranscoderMap = map[string]func(cfg any) (Transcoder, error){ + "sayauz": NewSayauzTranscoder, + "telegram": NewTelegramTranscoder, +} |