summaryrefslogtreecommitdiff
path: root/internal/transcoder
diff refs
from: back
to: back
| flip
diff options
context:
space:
mode:
Diffstat (limited to 'internal/transcoder')
-rw-r--r--internal/transcoder/format_test.go28
-rw-r--r--internal/transcoder/markdown.go164
-rw-r--r--internal/transcoder/sayauz.go138
-rw-r--r--internal/transcoder/telegram.go584
-rw-r--r--internal/transcoder/transcoder.go19
5 files changed, 0 insertions, 933 deletions
diff --git a/internal/transcoder/format_test.go b/internal/transcoder/format_test.go
deleted file mode 100644
index 9b25dc6..0000000
--- a/internal/transcoder/format_test.go
+++ /dev/null
@@ -1,28 +0,0 @@
-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
deleted file mode 100644
index 975c3bc..0000000
--- a/internal/transcoder/markdown.go
+++ /dev/null
@@ -1,164 +0,0 @@
-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, "&", "&amp;")
- s = strings.ReplaceAll(s, "<", "&lt;")
- s = strings.ReplaceAll(s, ">", "&gt;")
- s = strings.ReplaceAll(s, `"`, "&quot;")
- return s
-}
diff --git a/internal/transcoder/sayauz.go b/internal/transcoder/sayauz.go
deleted file mode 100644
index ee888e5..0000000
--- a/internal/transcoder/sayauz.go
+++ /dev/null
@@ -1,138 +0,0 @@
-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
deleted file mode 100644
index b521fb0..0000000
--- a/internal/transcoder/telegram.go
+++ /dev/null
@@ -1,584 +0,0 @@
-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
deleted file mode 100644
index 3dbdf7e..0000000
--- a/internal/transcoder/transcoder.go
+++ /dev/null
@@ -1,19 +0,0 @@
-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,
-}