fluid-telegram/cmd/compose/main.go
tegwick 4861dd994b Attach images to posts and return the Telegram message id
The adapter response now carries telegram_message_id and a t.me/c link.
compose visual resolves a post's image: own visual_url, else the entry's
rendered portrait, else hold an entry with only a visual prompt, else the
HelixForge main image. send-post.sh resolves before touching credentials,
requires an image on public posts and runs the adapter with ATTACH_VISUAL.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

Assistant: claude-code
Assistant-Model: opus
Assistant-Process: 5457@bnt-lap001
Assistant-Session: 792bf84a-3783-4f5d-a811-cded61dd2012
2026-10-08 10:20:09 +02:00

178 lines
5.2 KiB
Go

// Command compose is the temporary quoted-remembrance draft tool.
//
// It writes a review queue the caller names and, once a person has approved
// a draft with an explicit consent basis, POSTs that draft to an adapter's
// /v1/channel-posts. It has no path into the adapter package or Telegram.
package main
import (
"context"
"encoding/json"
"errors"
"flag"
"fmt"
"os"
"path/filepath"
"github.com/tegwick/fluid-telegram/internal/compose"
)
const usage = `compose — temporary quoted-remembrance drafts
compose draft --entry <file> --queue <dir> (--source-url <url> | --base-url <url>)
compose approve --queue <dir> --post <id> --reviewer <name> --consent explicit|standing|not-required
compose publish --queue <dir> --post <id> --adapter <base>
compose visual [--entry <file>] --hall-dir <dir> --hall-raw-base <url> --fallback <url>
visual prints the image a post should carry as JSON: the entry's rendered
portrait, else "needs-render" with its prompt, else (no prompt) the fallback.
Consent is never defaulted. A draft is not published until approve has
recorded a reviewer and a consent basis the caller supplied.
`
func main() {
if err := run(os.Args[1:]); err != nil {
fmt.Fprintln(os.Stderr, "compose:", err)
os.Exit(1)
}
}
func run(args []string) error {
if len(args) < 1 {
return errors.New(usage)
}
switch args[0] {
case "draft":
return cmdDraft(args[1:])
case "approve":
return cmdApprove(args[1:])
case "publish":
return cmdPublish(args[1:])
case "visual":
return cmdVisual(args[1:])
case "-h", "--help", "help":
fmt.Fprint(os.Stderr, usage)
return nil
default:
return fmt.Errorf("unknown command %q\n%s", args[0], usage)
}
}
func cmdDraft(args []string) error {
fs := flag.NewFlagSet("draft", flag.ContinueOnError)
fs.SetOutput(os.Stderr)
entry := fs.String("entry", "", "hall entry markdown")
queue := fs.String("queue", "", "review queue directory")
sourceURL := fs.String("source-url", "", "explicit source entry URL")
baseURL := fs.String("base-url", "", "base URL; /hall-of-helix/<id> is appended")
if err := fs.Parse(args); err != nil {
return err
}
if *entry == "" || *queue == "" {
return errors.New("draft requires --entry and --queue")
}
raw, err := os.ReadFile(*entry)
if err != nil {
return err
}
d, err := compose.Compose(string(raw), compose.Source{URL: *sourceURL, Base: *baseURL})
if err != nil {
return err
}
q, err := compose.Open(*queue)
if err != nil {
return err
}
if err := q.Put(d); err != nil {
return err
}
fmt.Println(d.PostID)
return nil
}
func cmdApprove(args []string) error {
fs := flag.NewFlagSet("approve", flag.ContinueOnError)
fs.SetOutput(os.Stderr)
queue := fs.String("queue", "", "review queue directory")
post := fs.String("post", "", "draft post_id")
reviewer := fs.String("reviewer", "", "person approving the draft")
consent := fs.String("consent", "", "explicit, standing, or not-required")
if err := fs.Parse(args); err != nil {
return err
}
if *queue == "" || *post == "" || *reviewer == "" {
return errors.New("approve requires --queue, --post, and --reviewer")
}
if *consent == "" {
return errors.New("--consent is required (explicit, standing, or not-required); it is not defaulted")
}
q, err := compose.Open(*queue)
if err != nil {
return err
}
if _, err := q.Approve(*post, *reviewer, *consent); err != nil {
return err
}
fmt.Println(*post)
return nil
}
func cmdPublish(args []string) error {
fs := flag.NewFlagSet("publish", flag.ContinueOnError)
fs.SetOutput(os.Stderr)
queue := fs.String("queue", "", "review queue directory")
post := fs.String("post", "", "draft post_id")
adapter := fs.String("adapter", "", "adapter base URL")
if err := fs.Parse(args); err != nil {
return err
}
if *queue == "" || *post == "" || *adapter == "" {
return errors.New("publish requires --queue, --post, and --adapter")
}
q, err := compose.Open(*queue)
if err != nil {
return err
}
d, err := q.Publish(context.Background(), *adapter, *post)
if err != nil {
return err
}
fmt.Printf("published %s %d\n", d.PostID, d.Publication.StatusCode)
return nil
}
func cmdVisual(args []string) error {
fs := flag.NewFlagSet("visual", flag.ContinueOnError)
fs.SetOutput(os.Stderr)
entry := fs.String("entry", "", "hall entry markdown; omit for a post with no source entry")
hallDir := fs.String("hall-dir", "", "hall-of-helix checkout, to confirm a portrait file exists")
rawBase := fs.String("hall-raw-base", "", "public raw URL of the hall repository root")
fallback := fs.String("fallback", "", "public URL of the campaign's main image")
if err := fs.Parse(args); err != nil {
return err
}
var markdown string
if *entry != "" {
raw, err := os.ReadFile(*entry)
if err != nil {
return err
}
markdown = string(raw)
}
v, err := compose.ResolveVisual(markdown, *rawBase, *fallback)
if err != nil {
return err
}
if v.Kind == compose.VisualPortrait {
if *hallDir == "" {
return errors.New("visual: --hall-dir is required to confirm the portrait exists")
}
if _, err := os.Stat(filepath.Join(*hallDir, v.Path)); err != nil {
return fmt.Errorf("visual: entry links %s, which is not in the hall checkout", v.Path)
}
}
enc := json.NewEncoder(os.Stdout)
enc.SetIndent("", " ")
return enc.Encode(v)
}