Diffstat (limited to 'internal')
| -rw-r--r-- | internal/draft/parser.go | 179 | ||||
| -rw-r--r-- | internal/draft/parser_test.go | 83 | ||||
| -rw-r--r-- | internal/storage/b2.go | 142 | ||||
| -rw-r--r-- | internal/storage/index.go | 92 | ||||
| -rw-r--r-- | internal/storage/s3.go | 377 | ||||
| -rw-r--r-- | internal/storage/storage_interface.go | 11 | ||||
| -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 |
11 files changed, 228 insertions, 1589 deletions
diff --git a/internal/draft/parser.go b/internal/draft/parser.go deleted file mode 100644 index 2761c87..0000000 --- a/internal/draft/parser.go +++ /dev/null @@ -1,179 +0,0 @@ -package draft - -import ( - "regexp" - "strings" - - "github.com/SayaAndy/saya-today-article-metadata-add/internal/frontmatter" -) - -// layoutTokenRe matches gallery layout hints like "2x", "3x" that are -// meaningful only to the sayauz renderer and must be dropped elsewhere. -var layoutTokenRe = regexp.MustCompile(`^\d+x$`) - -type BlockKind int - -const ( - TextBlock BlockKind = iota - GalleryBlock -) - -// Block is one ordered piece of a section: either a run of markdown text or a -// gallery. Exactly one of Text / Gallery is meaningful depending on Kind. -type Block struct { - Kind BlockKind - Text string - Gallery *Gallery -} - -type Gallery struct { - Timezone string - KeyPrefix string - Photos []Photo -} - -type Photo struct { - Key string // KeyPrefix + filename, e.g. "Sevsk/20241008-093833.jpg" - Caption string -} - -// Section is the content between two "..." separator lines, split into ordered -// text and gallery blocks. -type Section struct { - Index int - Blocks []Block -} - -type Document struct { - SourcePath string - Codename string - Metadata *frontmatter.Metadata - RawContent []byte - Body string - Sections []Section -} - -// ParseDraft parses a draft file: frontmatter metadata, the markdown body, and -// the body split into sections (on "..." lines) of ordered text/gallery blocks. -func ParseDraft(sourcePath, codename string, content []byte) (*Document, error) { - metadata, body, err := frontmatter.ParseFrontmatter(content) - if err != nil { - return nil, err - } - - doc := &Document{ - SourcePath: sourcePath, - Codename: codename, - Metadata: metadata, - RawContent: content, - Body: string(body), - } - doc.Sections = splitSections(doc.Body) - - return doc, nil -} - -func splitSections(body string) []Section { - lines := strings.Split(body, "\n") - - var sections []Section - var chunk []string - - flush := func() { - blocks := parseBlocks(chunk) - chunk = nil - if len(blocks) == 0 { - return - } - sections = append(sections, Section{Index: len(sections), Blocks: blocks}) - } - - for _, line := range lines { - if strings.TrimSpace(line) == "..." { - flush() - continue - } - chunk = append(chunk, line) - } - flush() - - return sections -} - -func parseBlocks(lines []string) []Block { - var blocks []Block - var text []string - - flushText := func() { - joined := strings.TrimSpace(strings.Join(text, "\n")) - text = nil - if joined != "" { - blocks = append(blocks, Block{Kind: TextBlock, Text: joined}) - } - } - - for i := 0; i < len(lines); i++ { - trimmed := strings.TrimSpace(lines[i]) - if strings.HasPrefix(trimmed, "{Gallery:") { - flushText() - gallery, next := parseGallery(lines, i) - if gallery != nil { - blocks = append(blocks, Block{Kind: GalleryBlock, Gallery: gallery}) - } - i = next - continue - } - text = append(text, lines[i]) - } - flushText() - - return blocks -} - -// parseGallery reads a {Gallery:...} block starting at lines[start] and returns -// the parsed gallery plus the index of the closing {/Gallery} line (or the last -// consumed line if unterminated). -func parseGallery(lines []string, start int) (*Gallery, int) { - header := strings.TrimSpace(lines[start]) - header = strings.TrimPrefix(header, "{Gallery:") - header = strings.TrimSuffix(header, "}") - tz, keyPrefix, _ := strings.Cut(header, ":") - - gallery := &Gallery{Timezone: tz, KeyPrefix: keyPrefix} - - i := start + 1 - for ; i < len(lines); i++ { - trimmed := strings.TrimSpace(lines[i]) - if trimmed == "{/Gallery}" { - break - } - if trimmed == "" { - continue - } - gallery.Photos = append(gallery.Photos, parsePhoto(keyPrefix, trimmed)) - } - - return gallery, i -} - -func parsePhoto(keyPrefix, line string) Photo { - parts := strings.Split(line, "|") - for i := range parts { - parts[i] = strings.TrimSpace(parts[i]) - } - - filename := parts[0] - - var captionParts []string - for _, p := range parts[1:] { - if p == "" || layoutTokenRe.MatchString(p) { - continue - } - captionParts = append(captionParts, p) - } - - return Photo{ - Key: keyPrefix + filename, - Caption: strings.Join(captionParts, " "), - } -} diff --git a/internal/draft/parser_test.go b/internal/draft/parser_test.go deleted file mode 100644 index c6d454d..0000000 --- a/internal/draft/parser_test.go +++ /dev/null @@ -1,83 +0,0 @@ -package draft - -import ( - "os" - "strings" - "testing" -) - -func TestParseDraftSevsk(t *testing.T) { - raw, err := os.ReadFile("../../.draft/sevsk.md.draft") - if err != nil { - t.Fatal(err) - } - - doc, err := ParseDraft("../../.draft/sevsk.md.draft", "sevsk", raw) - if err != nil { - t.Fatal(err) - } - if doc.Metadata == nil || doc.Metadata.Title == "" { - t.Fatal("expected frontmatter metadata") - } - - var galleries int - for _, section := range doc.Sections { - for _, block := range section.Blocks { - if block.Kind != GalleryBlock { - continue - } - galleries++ - for _, p := range block.Gallery.Photos { - if !strings.HasPrefix(p.Key, "Sevsk/20241008-") { - t.Errorf("unexpected photo key %q", p.Key) - } - if strings.Contains(p.Caption, "2x") { - t.Errorf("layout token leaked into caption %q", p.Caption) - } - } - } - } - if galleries == 0 { - t.Fatal("expected at least one gallery") - } - - // First gallery: 4 photos, captions on photos 1 and 3, none on 2; photo 4 had "2x |" => empty caption. - first := firstGallery(doc) - if first == nil || len(first.Photos) != 4 { - t.Fatalf("first gallery: want 4 photos, got %v", first) - } - if first.Timezone != "Europe/Moscow" { - t.Errorf("first gallery tz = %q", first.Timezone) - } - if first.Photos[0].Caption != "Никольская церковь. Действующая" { - t.Errorf("photo[0] caption = %q", first.Photos[0].Caption) - } - if first.Photos[1].Caption != "" { - t.Errorf("photo[1] caption = %q, want empty", first.Photos[1].Caption) - } - if first.Photos[3].Caption != "" { - t.Errorf("photo[3] caption = %q, want empty (only 2x token)", first.Photos[3].Caption) - } - if first.Photos[3].Key != "Sevsk/20241008-094036.jpg" { - t.Errorf("photo[3] key = %q", first.Photos[3].Key) - } -} - -func firstGallery(doc *Document) *Gallery { - for _, section := range doc.Sections { - for _, block := range section.Blocks { - if block.Kind == GalleryBlock { - return block.Gallery - } - } - } - return nil -} - -func TestSplitSectionsSeparator(t *testing.T) { - body := "intro\n...\nmiddle\n...\nend" - sections := splitSections(body) - if len(sections) != 3 { - t.Fatalf("want 3 sections, got %d", len(sections)) - } -} diff --git a/internal/storage/b2.go b/internal/storage/b2.go index 6697023..b02261f 100644 --- a/internal/storage/b2.go +++ b/internal/storage/b2.go @@ -3,6 +3,7 @@ package storage import ( "context" "fmt" + "io" "strconv" "strings" "time" @@ -15,12 +16,13 @@ import ( var _ StorageClient = &B2StorageClient{} type B2StorageClient struct { - prefix string - bucket *b2.Bucket - b2cl *b2.Client + prefix string + bucket *b2.Bucket + b2cl *b2.Client + draftModeCfg *config.DraftModeConfig } -func NewB2StorageClient(cfg *config.StorageConfig) (StorageClient, error) { +func NewB2StorageClient(cfg *config.StorageConfig, draftModeCfg *config.DraftModeConfig) (StorageClient, error) { if cfg.Type != "b2" { return nil, fmt.Errorf("invalid storage type for B2InputClient") } @@ -36,25 +38,73 @@ func NewB2StorageClient(cfg *config.StorageConfig) (StorageClient, error) { return nil, err } - return &B2StorageClient{b2cl: b2cl, bucket: bucket, prefix: b2cfg.Prefix}, nil + draftModeCfgCopy := *draftModeCfg + + return &B2StorageClient{b2cl: b2cl, bucket: bucket, prefix: b2cfg.Prefix, draftModeCfg: &draftModeCfgCopy}, nil } -func (sc *B2StorageClient) GetMetadata(key string) (map[string]string, error) { - obj := sc.bucket.Object(sc.prefix + key) +func (sc *B2StorageClient) Scan() ([]string, error) { + filePaths := []string{} + + iter := sc.bucket.List(context.Background(), b2.ListPrefix(sc.prefix)) + + for iter.Next() { + obj := iter.Object() + if obj == nil { + return nil, fmt.Errorf("failed to reference object in B2 bucket") + } + + attrs, err := obj.Attrs(context.Background()) + if err != nil { + return nil, fmt.Errorf("get attributes for object: %w", err) + } + + if attrs.Status != b2.Uploaded { + continue + } + + name := obj.Name() + if !strings.HasSuffix(name, ".md") { + continue + } + + if sc.draftModeCfg.Enabled && !strings.HasSuffix(name, sc.draftModeCfg.DraftSuffix) { + continue + } + + filePaths = append(filePaths, strings.TrimPrefix(name, sc.prefix)) + } + + if err := iter.Err(); err != nil { + return nil, fmt.Errorf("iterate over B2 objects: %w", err) + } + + return filePaths, nil +} + +func (sc *B2StorageClient) GetReader(path string) (io.ReadCloser, int64, error) { + obj := sc.bucket.Object(sc.prefix + path) if obj == nil { - return nil, fmt.Errorf("failed to reference object in B2 bucket") + return nil, 0, fmt.Errorf("failed to reference object in B2 bucket") } attrs, err := obj.Attrs(context.Background()) if err != nil { - if b2.IsNotExist(err) { - return map[string]string{}, nil - } - return nil, fmt.Errorf("get attributes of B2 object %q: %w", sc.prefix+key, err) + return nil, 0, fmt.Errorf("error getting attributes of an object: %w", err) } - return attrs.Info, nil + + return obj.NewReader(context.Background()), attrs.Size, nil } -func (sc *B2StorageClient) Put(key string, content []byte, metadata *frontmatter.Metadata) error { +func (sc *B2StorageClient) WriteMetadata(path string, metadata *frontmatter.Metadata) error { + draft := sc.bucket.Object(sc.prefix + path) + if draft == nil { + return fmt.Errorf("failed to reference draft object in B2 bucket") + } + draftAttrs, err := draft.Attrs(context.Background()) + if err != nil { + return fmt.Errorf("error getting attributes of a draft object: %w", err) + } + geolocationParts := strings.Split(metadata.Geolocation, " ") if (len(geolocationParts) == 1 && geolocationParts[0] != "") || len(geolocationParts) >= 4 { return fmt.Errorf("invalid geolocation format, expecting '{x} {y} [areaError]' or an empty string") @@ -81,19 +131,32 @@ func (sc *B2StorageClient) Put(key string, content []byte, metadata *frontmatter attrs := &b2.Attrs{ ContentType: "text/markdown; charset=utf-8", Info: map[string]string{ - "title": metadata.Title, - "short-description": metadata.ShortDescription, - "action-date": metadata.ActionDate, - "published-time": metadata.PublishedTime.Format(time.RFC3339), - "thumbnail": metadata.Thumbnail, - "tags": strings.Join(metadata.Tags, ","), - "geolocation": metadata.Geolocation, - "medley": medley, + "title": metadata.Title, + "short-description": metadata.ShortDescription, + "action-date": metadata.ActionDate, + "published-time": metadata.PublishedTime.Format(time.RFC3339), + "thumbnail": metadata.Thumbnail, + "tags": strings.Join(metadata.Tags, ","), + "geolocation": metadata.Geolocation, + "medley": medley, + "metadata-last-update-sha1": draftAttrs.SHA1, }} - prod := sc.bucket.Object(sc.prefix + key) - if prod == nil { - return fmt.Errorf("failed to reference prod object in B2 bucket") + reader := draft.NewReader(context.Background()) + content := make([]byte, draftAttrs.Size) + if _, err = reader.Read(content); err != nil { + return fmt.Errorf("failed to read a draft object back for writing (required for attribute setting): %w", err) + } + + var prod *b2.Object + if sc.draftModeCfg.Enabled { + prodPath := strings.TrimSuffix(path, sc.draftModeCfg.DraftSuffix) + sc.draftModeCfg.ProdSuffix + prod = sc.bucket.Object(sc.prefix + prodPath) + if prod == nil { + return fmt.Errorf("failed to reference prod object in B2 bucket") + } + } else { + prod = draft } writer := prod.NewWriter(context.Background(), b2.WithAttrsOption(attrs)) @@ -105,6 +168,31 @@ func (sc *B2StorageClient) Put(key string, content []byte, metadata *frontmatter return nil } -func (sc *B2StorageClient) BuildIndex() error { - return fmt.Errorf("BuildIndex not implemented for B2 storage") +func (sc *B2StorageClient) CompareDraftAndProd(path string) (changed bool) { + draft := sc.bucket.Object(sc.prefix + path) + if draft == nil { + return false + } + + prodPath := strings.TrimSuffix(path, sc.draftModeCfg.DraftSuffix) + sc.draftModeCfg.ProdSuffix + prod := sc.bucket.Object(sc.prefix + prodPath) + if prod == nil { + return true + } + + draftAttrs, err := draft.Attrs(context.Background()) + if err != nil { + return false + } + prodAttrs, err := prod.Attrs(context.Background()) + if err != nil { + return true + } + + lastUpdateSha1, ok := prodAttrs.Info["metadata-last-update-sha1"] + if !ok { + return true + } + + return draftAttrs.SHA1 != lastUpdateSha1 } diff --git a/internal/storage/index.go b/internal/storage/index.go deleted file mode 100644 index bfeafbe..0000000 --- a/internal/storage/index.go +++ /dev/null @@ -1,92 +0,0 @@ -package storage - -import ( - "encoding/json" - "fmt" - "time" -) - -const IndexFileName = "index.json" -const MedleysIndexFileName = "medleys.json" - -const IndexSchemaVersion = 2 - -type IndexEntry struct { - Link string `json:"link"` - ModifiedTime time.Time `json:"modifiedTime"` - Title string `json:"title"` - ShortDescription string `json:"shortDescription"` - ActionDate string `json:"actionDate"` - PublishedTime time.Time `json:"publishedTime"` - Thumbnail string `json:"thumbnail"` - Tags []string `json:"tags"` - Geolocation string `json:"geolocation"` - Medley string `json:"medley,omitempty"` - MedleyPart int `json:"medleyPart,omitempty"` -} - -type IndexV2Category struct { - GeneratedAt time.Time `json:"generatedAt"` - Pages map[string]IndexEntry `json:"pages"` -} - -type IndexV1Category struct { - GeneratedAt time.Time `json:"generatedAt"` - Pages []IndexEntry `json:"pages"` -} - -type Index struct { - SchemaVersion int `json:"schemaVersion"` - GeneratedAt time.Time `json:"generatedAt"` - Categories any `json:"categories"` -} - -func (idx *Index) UnmarshalJSON(data []byte) error { - var tmp struct { - SchemaVersion int `json:"schemaVersion"` - GeneratedAt time.Time `json:"generatedAt"` - Categories json.RawMessage `json:"categories"` - } - - if err := json.Unmarshal(data, &tmp); err != nil { - return err - } - - idx.SchemaVersion = tmp.SchemaVersion - idx.GeneratedAt = tmp.GeneratedAt - - switch tmp.SchemaVersion { - case 1: - var categories map[string]*IndexV1Category - if err := json.Unmarshal(tmp.Categories, &categories); err != nil { - return fmt.Errorf("unmarshal map[string]*IndexV1Category: %w", err) - } - idx.Categories = &categories - case 2: - var categories map[string]*IndexV2Category - if err := json.Unmarshal(tmp.Categories, &categories); err != nil { - return fmt.Errorf("unmarshal map[string]*IndexV2Category: %w", err) - } - idx.Categories = &categories - default: - return fmt.Errorf("unsupported index version: %d", tmp.SchemaVersion) - } - - return nil -} - -type IndexV1 struct { - SchemaVersion int `json:"schemaVersion"` - GeneratedAt time.Time `json:"generatedAt"` - Categories map[string]*IndexV1Category `json:"categories"` -} - -type MedleyEntry struct { - Codename string `json:"codename"` - Content []string `json:"content"` -} - -type MedleyPageEntry struct { - Codename string `json:"codename"` - Position int `json:"position"` -} diff --git a/internal/storage/s3.go b/internal/storage/s3.go index 5f3acc7..5e9bcd2 100644 --- a/internal/storage/s3.go +++ b/internal/storage/s3.go @@ -3,40 +3,31 @@ package storage import ( "bytes" "context" - "encoding/json" - "errors" "fmt" "io" - "log/slog" - "maps" - "net/url" - "slices" "strconv" "strings" - "sync" "time" "github.com/aws/aws-sdk-go-v2/aws" awsconfig "github.com/aws/aws-sdk-go-v2/config" "github.com/aws/aws-sdk-go-v2/credentials" "github.com/aws/aws-sdk-go-v2/service/s3" - s3types "github.com/aws/aws-sdk-go-v2/service/s3/types" "github.com/SayaAndy/saya-today-article-metadata-add/config" "github.com/SayaAndy/saya-today-article-metadata-add/internal/frontmatter" ) -const s3IndexConcurrency = 32 - var _ StorageClient = &S3StorageClient{} type S3StorageClient struct { - prefix string - bucket string - client *s3.Client + prefix string + bucket string + client *s3.Client + draftModeCfg *config.DraftModeConfig } -func NewS3StorageClient(cfg *config.StorageConfig) (StorageClient, error) { +func NewS3StorageClient(cfg *config.StorageConfig, draftModeCfg *config.DraftModeConfig) (StorageClient, error) { if cfg.Type != "s3" { return nil, fmt.Errorf("invalid storage type for S3StorageClient") } @@ -68,322 +59,166 @@ func NewS3StorageClient(cfg *config.StorageConfig) (StorageClient, error) { client := s3.NewFromConfig(awsCfg, s3Opts...) + draftModeCfgCopy := *draftModeCfg + return &S3StorageClient{ - client: client, - bucket: s3cfg.BucketName, - prefix: s3cfg.Prefix, + client: client, + bucket: s3cfg.BucketName, + prefix: s3cfg.Prefix, + draftModeCfg: &draftModeCfgCopy, }, nil } -func (sc *S3StorageClient) GetMetadata(key string) (map[string]string, error) { - head, err := sc.client.HeadObject(context.Background(), &s3.HeadObjectInput{ - Bucket: aws.String(sc.bucket), - Key: aws.String(sc.prefix + key), - }) - if err != nil { - var nsk *s3types.NoSuchKey - var nf *s3types.NotFound - if errors.As(err, &nsk) || errors.As(err, &nf) { - return map[string]string{}, nil - } - return nil, fmt.Errorf("head S3 object %q: %w", sc.prefix+key, err) - } - return head.Metadata, nil -} - -func (sc *S3StorageClient) Put(key string, content []byte, metadata *frontmatter.Metadata) error { - geolocationParts := strings.Split(metadata.Geolocation, " ") - if (len(geolocationParts) == 1 && geolocationParts[0] != "") || len(geolocationParts) >= 4 { - return fmt.Errorf("invalid geolocation format, expecting '{x} {y} [areaError]' or an empty string") - } - if len(geolocationParts) >= 2 { - if _, err := strconv.ParseFloat(geolocationParts[0], 64); err != nil { - return fmt.Errorf("invalid geolocation parameter, expected float for X: %w", err) - } - if _, err := strconv.ParseFloat(geolocationParts[1], 64); err != nil { - return fmt.Errorf("invalid geolocation parameter, expected float for Y: %w", err) - } - } - if len(geolocationParts) == 3 { - if _, err := strconv.ParseFloat(geolocationParts[2], 64); err != nil { - return fmt.Errorf("invalid geolocation parameter, expected float for area error: %w", err) - } - } - - s3Metadata := map[string]string{ - "title": url.QueryEscape(metadata.Title), - "short-description": url.QueryEscape(metadata.ShortDescription), - "action-date": metadata.ActionDate, - "published-time": metadata.PublishedTime.Format(time.RFC3339), - "thumbnail": url.QueryEscape(metadata.Thumbnail), - "tags": strings.Join(metadata.Tags, ","), - "geolocation": metadata.Geolocation, - } - - targetKey := sc.prefix + key - _, err := sc.client.PutObject(context.Background(), &s3.PutObjectInput{ - Bucket: aws.String(sc.bucket), - Key: aws.String(targetKey), - Body: bytes.NewReader(content), - ContentType: aws.String("text/markdown; charset=utf-8"), - Metadata: s3Metadata, - }) - if err != nil { - return fmt.Errorf("put S3 object %q: %w", targetKey, err) - } - - return nil -} - -func (sc *S3StorageClient) BuildIndex() error { - type candidate struct { - key string - codename string - lastModified time.Time - } - var candidates []candidate +func (sc *S3StorageClient) Scan() ([]string, error) { + var filePaths []string paginator := s3.NewListObjectsV2Paginator(sc.client, &s3.ListObjectsV2Input{ Bucket: aws.String(sc.bucket), Prefix: aws.String(sc.prefix), }) + for paginator.HasMorePages() { page, err := paginator.NextPage(context.Background()) if err != nil { - return fmt.Errorf("list S3 objects: %w", err) + return nil, fmt.Errorf("list S3 objects: %w", err) } + for _, obj := range page.Contents { - key := aws.ToString(obj.Key) - if key == IndexFileName { + name := aws.ToString(obj.Key) + if !strings.HasSuffix(name, ".md") { continue } - if !strings.HasSuffix(key, ".md") { + + if sc.draftModeCfg.Enabled && !strings.HasSuffix(name, sc.draftModeCfg.DraftSuffix) { continue } - codename := key[strings.LastIndex(key, "/")+1 : strings.LastIndex(key, ".")] - candidates = append(candidates, candidate{key, codename, aws.ToTime(obj.LastModified)}) - } - } - medleys, err := sc.scanMedleys() - if err != nil { - slog.Warn("skipped reading medleys due to an error", slog.String("error", err.Error())) - } - pageToMedleyMap := make(map[string]MedleyPageEntry) - for _, medley := range medleys { - for i, page := range medley.Content { - pageToMedleyMap[page] = MedleyPageEntry{medley.Codename, i} + filePaths = append(filePaths, strings.TrimPrefix(name, sc.prefix)) } } - type result struct { - catKey string - codename string - entry IndexEntry - } - results := make([]*result, len(candidates)) - sem := make(chan struct{}, s3IndexConcurrency) - var wg sync.WaitGroup - var firstErr error - var errMu sync.Mutex - - for i, cand := range candidates { - sem <- struct{}{} - wg.Add(1) - go func() { - defer wg.Done() - defer func() { <-sem }() - - head, err := sc.client.HeadObject(context.Background(), &s3.HeadObjectInput{ - Bucket: aws.String(sc.bucket), - Key: aws.String(cand.key), - }) - if err != nil { - errMu.Lock() - if firstErr == nil { - firstErr = fmt.Errorf("head S3 object %s: %w", cand.key, err) - } - errMu.Unlock() - return - } - - if head.ContentType == nil || !strings.Contains(*head.ContentType, "text/markdown") { - return - } - meta := head.Metadata - if meta["title"] == "" { - return - } - - publishedTime, err := time.Parse(time.RFC3339, meta["published-time"]) - if err != nil { - slog.Warn("skip entry with bad published-time", slog.String("key", cand.key), slog.String("error", err.Error())) - return - } + return filePaths, nil +} - catKey := cand.key[:strings.Index(cand.key, "/")] +func (sc *S3StorageClient) GetReader(path string) (io.ReadCloser, int64, error) { + key := sc.prefix + path - tags := strings.Split(meta["tags"], ",") - slices.Sort(tags) + out, err := sc.client.GetObject(context.Background(), &s3.GetObjectInput{ + Bucket: aws.String(sc.bucket), + Key: aws.String(key), + }) + if err != nil { + return nil, 0, fmt.Errorf("get S3 object %q: %w", key, err) + } - title, _ := url.QueryUnescape(meta["title"]) - shortDescription, _ := url.QueryUnescape(meta["short-description"]) - thumbnail, _ := url.QueryUnescape(meta["thumbnail"]) + return out.Body, aws.ToInt64(out.ContentLength), nil +} - medleyName, medleyPart := "", 0 - if medley, ok := pageToMedleyMap[cand.codename]; ok { - medleyName, medleyPart = medley.Codename, medley.Position - } +func (sc *S3StorageClient) WriteMetadata(path string, metadata *frontmatter.Metadata) error { + draftKey := sc.prefix + path - results[i] = &result{ - catKey: catKey, - codename: cand.codename, - entry: IndexEntry{ - Link: cand.key, - ModifiedTime: cand.lastModified, - Title: title, - ShortDescription: shortDescription, - ActionDate: meta["action-date"], - PublishedTime: publishedTime, - Thumbnail: thumbnail, - Tags: tags, - Geolocation: meta["geolocation"], - Medley: medleyName, - MedleyPart: medleyPart, - }, - } - }() + getOut, err := sc.client.GetObject(context.Background(), &s3.GetObjectInput{ + Bucket: aws.String(sc.bucket), + Key: aws.String(draftKey), + }) + if err != nil { + return fmt.Errorf("get draft object %q: %w", draftKey, err) + } + content, err := io.ReadAll(getOut.Body) + getOut.Body.Close() + if err != nil { + return fmt.Errorf("read draft object %q: %w", draftKey, err) } - wg.Wait() - if firstErr != nil { - return firstErr + headOut, err := sc.client.HeadObject(context.Background(), &s3.HeadObjectInput{ + Bucket: aws.String(sc.bucket), + Key: aws.String(draftKey), + }) + if err != nil { + return fmt.Errorf("head draft object %q: %w", draftKey, err) } + draftETag := aws.ToString(headOut.ETag) - now := time.Now().UTC() - fresh := make(map[string]*IndexV2Category) - for _, r := range results { - if r == nil { - continue + geolocationParts := strings.Split(metadata.Geolocation, " ") + if (len(geolocationParts) == 1 && geolocationParts[0] != "") || len(geolocationParts) >= 4 { + return fmt.Errorf("invalid geolocation format, expecting '{x} {y} [areaError]' or an empty string") + } + if len(geolocationParts) >= 2 { + if _, err := strconv.ParseFloat(geolocationParts[0], 64); err != nil { + return fmt.Errorf("invalid geolocation parameter, expected float for X: %w", err) } - if _, ok := fresh[r.catKey]; !ok { - fresh[r.catKey] = &IndexV2Category{ - Pages: make(map[string]IndexEntry), - } + if _, err := strconv.ParseFloat(geolocationParts[1], 64); err != nil { + return fmt.Errorf("invalid geolocation parameter, expected float for Y: %w", err) + } + } + if len(geolocationParts) == 3 { + if _, err := strconv.ParseFloat(geolocationParts[2], 64); err != nil { + return fmt.Errorf("invalid geolocation parameter, expected float for area error: %w", err) } - fresh[r.catKey].Pages[r.codename] = r.entry - fresh[r.catKey].GeneratedAt = now } - merged, err := sc.loadAndMergeIndex(fresh) - if err != nil { - return err + medley := "" + if metadata.Medley != "" { + medley = fmt.Sprintf("%s %d", metadata.Medley, metadata.MedleyPart) } - idx := Index{ - SchemaVersion: IndexSchemaVersion, - GeneratedAt: now, - Categories: merged, + s3Metadata := map[string]string{ + "title": metadata.Title, + "short-description": metadata.ShortDescription, + "action-date": metadata.ActionDate, + "published-time": metadata.PublishedTime.Format(time.RFC3339), + "thumbnail": metadata.Thumbnail, + "tags": strings.Join(metadata.Tags, ","), + "geolocation": metadata.Geolocation, + "medley": medley, + "metadata-last-update-etag": draftETag, } - body, err := json.Marshal(idx) - if err != nil { - return fmt.Errorf("marshal index: %w", err) + targetKey := draftKey + if sc.draftModeCfg.Enabled { + prodPath := strings.TrimSuffix(path, sc.draftModeCfg.DraftSuffix) + sc.draftModeCfg.ProdSuffix + targetKey = sc.prefix + prodPath } _, err = sc.client.PutObject(context.Background(), &s3.PutObjectInput{ Bucket: aws.String(sc.bucket), - Key: aws.String(IndexFileName), - Body: bytes.NewReader(body), - ContentType: aws.String("application/json; charset=utf-8"), + Key: aws.String(targetKey), + Body: bytes.NewReader(content), + ContentType: aws.String("text/markdown; charset=utf-8"), + Metadata: s3Metadata, }) if err != nil { - return fmt.Errorf("put index %q: %w", IndexFileName, err) + return fmt.Errorf("put S3 object %q: %w", targetKey, err) } - totalEntries := 0 - for _, c := range merged { - totalEntries += len(c.Pages) - } - slog.Info("wrote index", - slog.String("key", IndexFileName), - slog.Int("categories", len(merged)), - slog.Int("entries", totalEntries)) return nil } -func (sc *S3StorageClient) scanMedleys() ([]MedleyEntry, error) { - out, err := sc.client.GetObject(context.Background(), &s3.GetObjectInput{ +func (sc *S3StorageClient) CompareDraftAndProd(path string) bool { + draftKey := sc.prefix + path + prodPath := strings.TrimSuffix(path, sc.draftModeCfg.DraftSuffix) + sc.draftModeCfg.ProdSuffix + prodKey := sc.prefix + prodPath + + draftHead, err := sc.client.HeadObject(context.Background(), &s3.HeadObjectInput{ Bucket: aws.String(sc.bucket), - Key: aws.String(MedleysIndexFileName), + Key: aws.String(draftKey), }) if err != nil { - return nil, fmt.Errorf("get %s: %w", MedleysIndexFileName, err) - } - defer out.Body.Close() - - raw, err := io.ReadAll(out.Body) - if err != nil { - return nil, fmt.Errorf("read %s: %w", MedleysIndexFileName, err) + return false } - var entries []MedleyEntry - if err := json.Unmarshal(raw, &entries); err != nil { - return nil, fmt.Errorf("unmarshal %s: %w", MedleysIndexFileName, err) - } - - return entries, nil -} - -func (sc *S3StorageClient) loadAndMergeIndex(fresh map[string]*IndexV2Category) (map[string]*IndexV2Category, error) { - merged := make(map[string]*IndexV2Category) - - out, err := sc.client.GetObject(context.Background(), &s3.GetObjectInput{ + prodHead, err := sc.client.HeadObject(context.Background(), &s3.HeadObjectInput{ Bucket: aws.String(sc.bucket), - Key: aws.String(IndexFileName), + Key: aws.String(prodKey), }) - if err == nil { - defer out.Body.Close() - raw, readErr := io.ReadAll(out.Body) - if readErr != nil { - return nil, fmt.Errorf("read existing index: %w", readErr) - } - - var legacyIdx Index - if err := json.Unmarshal(raw, &legacyIdx); err != nil { - return nil, fmt.Errorf("unmarshal existing index: %w", err) - } - - switch legacyIdx.SchemaVersion { - case 1: - for k, v := range *legacyIdx.Categories.(*map[string]*IndexV1Category) { - pages := make(map[string]IndexEntry, len(v.Pages)) - for _, page := range v.Pages { - pages[page.Link[strings.LastIndex(page.Link, "/")+1:strings.LastIndex(page.Link, ".")]] = page - } - merged[k] = &IndexV2Category{ - GeneratedAt: v.GeneratedAt, - Pages: pages, - } - } - case 2: - maps.Copy(merged, *legacyIdx.Categories.(*map[string]*IndexV2Category)) - default: - slog.Warn("unknown index schema, discarding", slog.Int("schema_version", legacyIdx.SchemaVersion)) - } - } else { - var nsk *s3types.NoSuchKey - if !errors.As(err, &nsk) { - return nil, fmt.Errorf("get existing index: %w", err) - } + if err != nil { + return true } - for k := range merged { - if strings.HasPrefix(k, sc.prefix) { - delete(merged, k) - } + lastUpdateETag, ok := prodHead.Metadata["metadata-last-update-etag"] + if !ok { + return true } - maps.Copy(merged, fresh) - return merged, nil + return aws.ToString(draftHead.ETag) != lastUpdateETag } diff --git a/internal/storage/storage_interface.go b/internal/storage/storage_interface.go index 92a37e2..7134e30 100644 --- a/internal/storage/storage_interface.go +++ b/internal/storage/storage_interface.go @@ -1,17 +1,20 @@ package storage import ( + "io" + "github.com/SayaAndy/saya-today-article-metadata-add/config" "github.com/SayaAndy/saya-today-article-metadata-add/internal/frontmatter" ) type StorageClient interface { - Put(key string, content []byte, metadata *frontmatter.Metadata) error - GetMetadata(key string) (metadata map[string]string, err error) - BuildIndex() error + Scan() (paths []string, err error) + GetReader(path string) (reader io.ReadCloser, sz int64, err error) + WriteMetadata(path string, metadata *frontmatter.Metadata) error + CompareDraftAndProd(path string) (changed bool) } -var NewStorageClientMap = map[string]func(*config.StorageConfig) (StorageClient, error){ +var NewStorageClientMap = map[string]func(*config.StorageConfig, *config.DraftModeConfig) (StorageClient, error){ "b2": NewB2StorageClient, "s3": NewS3StorageClient, } 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, "&", "&") - 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 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, -} |