From 4861dd994bc3684bd92d938eb8f698084c25bf8b Mon Sep 17 00:00:00 2001 From: tegwick Date: Thu, 8 Oct 2026 10:20:09 +0200 Subject: [PATCH] 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 Assistant: claude-code Assistant-Model: opus Assistant-Process: 5457@bnt-lap001 Assistant-Session: 792bf84a-3783-4f5d-a811-cded61dd2012 --- cmd/compose/main.go | 43 ++++++ internal/adapter/adapter.go | 32 ++++- internal/adapter/adapter_test.go | 25 ++++ internal/compose/visual.go | 130 ++++++++++++++++++ internal/compose/visual_test.go | 74 ++++++++++ scripts/send-post.sh | 39 +++++- ...0001-telegram-identity-and-hall-channel.md | 11 +- 7 files changed, 342 insertions(+), 12 deletions(-) create mode 100644 internal/compose/visual.go create mode 100644 internal/compose/visual_test.go diff --git a/cmd/compose/main.go b/cmd/compose/main.go index 17813db..62babce 100644 --- a/cmd/compose/main.go +++ b/cmd/compose/main.go @@ -7,10 +7,12 @@ package main import ( "context" + "encoding/json" "errors" "flag" "fmt" "os" + "path/filepath" "github.com/tegwick/fluid-telegram/internal/compose" ) @@ -20,6 +22,10 @@ const usage = `compose — temporary quoted-remembrance drafts compose draft --entry --queue (--source-url | --base-url ) compose approve --queue --post --reviewer --consent explicit|standing|not-required compose publish --queue --post --adapter + compose visual [--entry ] --hall-dir --hall-raw-base --fallback + +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. @@ -43,6 +49,8 @@ func run(args []string) error { 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 @@ -133,3 +141,38 @@ func cmdPublish(args []string) error { 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) +} diff --git a/internal/adapter/adapter.go b/internal/adapter/adapter.go index df5e6f0..8287707 100644 --- a/internal/adapter/adapter.go +++ b/internal/adapter/adapter.go @@ -171,7 +171,7 @@ func (a *Adapter) handlePublish(w http.ResponseWriter, r *http.Request) { existing, found := a.store.Get(req.PostID) if found { if existing.Body == rendered { - writeJSON(w, http.StatusOK, publicationView(existing)) + writeJSON(w, http.StatusOK, a.publicationView(existing)) return } // Same post_id, changed content: edit rather than duplicate. A @@ -189,7 +189,7 @@ func (a *Adapter) handlePublish(w http.ResponseWriter, r *http.Request) { existing.State = StatePublished existing.LastAttemptErr = "" a.store.Put(existing) - writeJSON(w, http.StatusOK, publicationView(existing)) + writeJSON(w, http.StatusOK, a.publicationView(existing)) return } @@ -212,7 +212,7 @@ func (a *Adapter) handlePublish(w http.ResponseWriter, r *http.Request) { pub.TelegramMsgID = msgID a.store.Put(pub) a.tel.Emit(r.Context(), "published", map[string]any{"post_id": req.PostID, "variant": req.Variant}) - writeJSON(w, http.StatusCreated, publicationView(pub)) + writeJSON(w, http.StatusCreated, a.publicationView(pub)) } func (a *Adapter) sendNew(ctx context.Context, rendered string, req postRequest) (int, error) { @@ -241,7 +241,7 @@ func (a *Adapter) handleGet(w http.ResponseWriter, postID string) { writeError(w, http.StatusNotFound, "this post has not been published") return } - writeJSON(w, http.StatusOK, publicationView(pub)) + writeJSON(w, http.StatusOK, a.publicationView(pub)) } func (a *Adapter) handleList(w http.ResponseWriter, r *http.Request) { @@ -260,7 +260,7 @@ func (a *Adapter) handleList(w http.ResponseWriter, r *http.Request) { pubs := a.store.List(filter) views := make([]map[string]any, 0, len(pubs)) for _, p := range pubs { - views = append(views, publicationView(p)) + views = append(views, a.publicationView(p)) } writeJSON(w, http.StatusOK, map[string]any{"posts": views}) } @@ -284,14 +284,32 @@ func sourceLink(url string) string { Source`, escapeAttr(url)) } -func publicationView(p Publication) map[string]any { - return map[string]any{ +func (a *Adapter) publicationView(p Publication) map[string]any { + v := map[string]any{ "post_id": p.PostID, "state": p.State, "source_entry_id": p.SourceEntryID, "variant": p.Variant, "published_at": p.PublishedAt, } + if p.TelegramMsgID > 0 { + v["telegram_message_id"] = p.TelegramMsgID + if link := messageLink(a.cfg.ChatID, p.TelegramMsgID); link != "" { + v["telegram_message_link"] = link + } + } + return v +} + +// messageLink is the t.me/c link to a message in a channel addressed in Bot +// API form (-100). It opens for anyone who can read the channel, private +// or public. Other chat ids have no such link. +func messageLink(chatID int64, msgID int) string { + const channelBase = -1000000000000 + if chatID >= channelBase { + return "" + } + return fmt.Sprintf("https://t.me/c/%d/%d", channelBase-chatID, msgID) } func writeJSON(w http.ResponseWriter, status int, v any) { diff --git a/internal/adapter/adapter_test.go b/internal/adapter/adapter_test.go index 815a5eb..e8c60cb 100644 --- a/internal/adapter/adapter_test.go +++ b/internal/adapter/adapter_test.go @@ -226,3 +226,28 @@ func TestListPosts(t *testing.T) { t.Fatalf("expected 2 posts, got %d", len(resp.Posts)) } } + +func TestPublishReturnsTelegramMessageID(t *testing.T) { + snd := &fakeSender{nextID: 6} + a := New(Config{ChatID: -1004427430639}, snd, NewMemStore(), nil) + w := post(t, a.Handler(), validReq("p1")) + if w.Code != http.StatusCreated { + t.Fatalf("status = %d, body = %s", w.Code, w.Body.String()) + } + var got map[string]any + if err := json.Unmarshal(w.Body.Bytes(), &got); err != nil { + t.Fatal(err) + } + if got["telegram_message_id"] != float64(7) { + t.Fatalf("telegram_message_id = %v", got["telegram_message_id"]) + } + if got["telegram_message_link"] != "https://t.me/c/4427430639/7" { + t.Fatalf("telegram_message_link = %v", got["telegram_message_link"]) + } +} + +func TestMessageLinkOnlyForChannels(t *testing.T) { + if l := messageLink(-1001, 3); l != "" { + t.Fatalf("non-channel chat got a link: %s", l) + } +} diff --git a/internal/compose/visual.go b/internal/compose/visual.go new file mode 100644 index 0000000..6254109 --- /dev/null +++ b/internal/compose/visual.go @@ -0,0 +1,130 @@ +package compose + +import ( + "errors" + "net/url" + "regexp" + "strings" +) + +// VisualKind says where a post's image comes from. Public posts carry an +// image; the order of preference is the post's own visual_url (set by the +// caller), then the entry's rendered portrait, then a render of the entry's +// visual prompt, and only for a post with no prompt at all the campaign's +// main image. +type VisualKind string + +const ( + VisualPortrait VisualKind = "portrait" + VisualNeedsRender VisualKind = "needs-render" + VisualFallback VisualKind = "fallback" +) + +// Visual is the resolved image for one post. For VisualNeedsRender, URL is +// empty, Prompt is the entry's brief and Target the file the hall expects the +// render at (empty if the entry names none). Such a post is not published +// with the fallback: the entry asked for its own portrait. +type Visual struct { + Kind VisualKind `json:"kind"` + URL string `json:"visual_url,omitempty"` + Path string `json:"path,omitempty"` + Prompt string `json:"prompt,omitempty"` + Target string `json:"target,omitempty"` +} + +var ( + htmlComment = regexp.MustCompile(`(?s)`) + markdownImg = regexp.MustCompile(`!\[[^\]]*\]\(([^)\s]+)\)`) + visualTarget = regexp.MustCompile(`visuals/[A-Za-z0-9._-]+\.(?:jpe?g|png)`) + // A bare file name in backticks, as draft seats name their intended render. + namedTarget = regexp.MustCompile("`([A-Za-z0-9._-]+\\.(?:jpe?g|png))`") +) + +// ResolveVisual picks the image for a post written from entryMarkdown, which +// is empty for a post that has no source entry (an outlet post). Portrait +// paths are returned relative to the hall repository root ("visuals/x.png") +// and joined onto hallRawBase; the caller checks that the file exists. +func ResolveVisual(entryMarkdown, hallRawBase, fallbackURL string) (Visual, error) { + if entryMarkdown != "" { + visible := htmlComment.ReplaceAllString(entryMarkdown, "") + for _, m := range markdownImg.FindAllStringSubmatch(visible, -1) { + rel := portraitPath(m[1]) + if rel == "" { + continue + } + u, err := joinURL(hallRawBase, rel) + if err != nil { + return Visual{}, err + } + return Visual{Kind: VisualPortrait, URL: u, Path: rel}, nil + } + if body, ok := section(entryMarkdown, "Visual prompt"); ok { + if prompt := promptText(body); prompt != "" { + return Visual{ + Kind: VisualNeedsRender, + Prompt: prompt, + Target: renderTarget(entryMarkdown), + }, nil + } + } + } + if fallbackURL == "" { + return Visual{}, errors.New("no portrait or visual prompt, and no fallback image configured") + } + return Visual{Kind: VisualFallback, URL: fallbackURL}, nil +} + +// renderTarget is where the hall expects the entry's portrait: a +// visuals/ path anywhere in the entry, else a backticked image file name. +func renderTarget(markdown string) string { + if t := visualTarget.FindString(markdown); t != "" { + return t + } + if m := namedTarget.FindStringSubmatch(markdown); m != nil { + return "visuals/" + m[1] + } + return "" +} + +// portraitPath reduces an entry's image link ("../visuals/x.png") to its +// path in the hall repository, or "" for a link that is not a hall visual. +func portraitPath(link string) string { + if strings.Contains(link, "://") { + return "" + } + i := strings.Index(link, "visuals/") + if i < 0 { + return "" + } + return link[i:] +} + +// promptText is the brief inside a Visual prompt section: its quoted lines +// when it has any, without the quote markers; otherwise the section minus +// image lines and italic status notes. +func promptText(body string) string { + var quoted, plain []string + for _, line := range strings.Split(htmlComment.ReplaceAllString(body, ""), "\n") { + t := strings.TrimSpace(line) + switch { + case strings.HasPrefix(t, ">"): + quoted = append(quoted, strings.TrimSpace(strings.TrimPrefix(t, ">"))) + case t == "", markdownImg.MatchString(t), strings.HasPrefix(t, "_"): + default: + plain = append(plain, t) + } + } + if len(quoted) > 0 { + return strings.TrimSpace(strings.Join(quoted, " ")) + } + return strings.TrimSpace(strings.Join(plain, " ")) +} + +func joinURL(base, rel string) (string, error) { + u, err := url.Parse(base) + if err != nil || u.Host == "" || (u.Scheme != "http" && u.Scheme != "https") { + return "", errors.New("hall raw base must be an absolute http(s) URL") + } + u.Path = strings.TrimRight(u.Path, "/") + "/" + rel + return u.String(), nil +} diff --git a/internal/compose/visual_test.go b/internal/compose/visual_test.go new file mode 100644 index 0000000..b4c0dad --- /dev/null +++ b/internal/compose/visual_test.go @@ -0,0 +1,74 @@ +package compose + +import "testing" + +const hallRaw = "https://forge.example/coulomb/hall-of-helix/raw/branch/main" +const fallback = "https://forge.example/coulomb/pr-hall-of-helix/raw/branch/main/assets/helix-forge.png" + +func TestResolveVisualPrefersRenderedPortrait(t *testing.T) { + entry := "# Seat\n\n## Visual prompt\n\n> Constellation dialect.\n\n![Tegwick at work](../visuals/bernd-how-it-started.png)\n\n## Handoff\n" + v, err := ResolveVisual(entry, hallRaw, fallback) + if err != nil { + t.Fatal(err) + } + if v.Kind != VisualPortrait || v.Path != "visuals/bernd-how-it-started.png" || + v.URL != hallRaw+"/visuals/bernd-how-it-started.png" { + t.Fatalf("got %+v", v) + } +} + +func TestResolveVisualPromptWithoutPortraitNeedsRender(t *testing.T) { + entry := "# Seat\n\n## Visual prompt\n\n> Constellation dialect. Square.\n> Brass scales.\n\n" + + "\n\n" + + "_Draft: portrait not yet rendered, expected as `claude-x-scales.jpg`._\n\n## Handoff\n" + v, err := ResolveVisual(entry, hallRaw, fallback) + if err != nil { + t.Fatal(err) + } + if v.Kind != VisualNeedsRender || v.URL != "" { + t.Fatalf("a commented-out portrait or the fallback was used: %+v", v) + } + if v.Prompt != "Constellation dialect. Square. Brass scales." { + t.Fatalf("prompt = %q", v.Prompt) + } + if v.Target != "visuals/claude-x-scales.jpg" { + t.Fatalf("target = %q", v.Target) + } +} + +func TestResolveVisualFallsBackOnlyWithoutPrompt(t *testing.T) { + for _, entry := range []string{"", "# Seat\n\n## Handoff\n\nNothing visual.\n"} { + v, err := ResolveVisual(entry, hallRaw, fallback) + if err != nil { + t.Fatal(err) + } + if v.Kind != VisualFallback || v.URL != fallback { + t.Fatalf("entry %q: got %+v", entry, v) + } + } + if _, err := ResolveVisual("", hallRaw, ""); err == nil { + t.Fatal("resolved with no fallback configured") + } +} + +func TestResolveVisualIgnoresExternalImages(t *testing.T) { + entry := "# Seat\n\n![badge](https://img.example/visuals/x.png)\n" + v, err := ResolveVisual(entry, hallRaw, fallback) + if err != nil { + t.Fatal(err) + } + if v.Kind != VisualFallback { + t.Fatalf("got %+v", v) + } +} + +func TestResolveVisualTargetFromBareFileName(t *testing.T) { + entry := "# Seat\n\n## Visual prompt\n\n> Scales.\n\n_Draft: expected under `visuals/` as `claude-y-scales.jpg`._\n" + v, err := ResolveVisual(entry, hallRaw, fallback) + if err != nil { + t.Fatal(err) + } + if v.Target != "visuals/claude-y-scales.jpg" { + t.Fatalf("target = %q", v.Target) + } +} diff --git a/scripts/send-post.sh b/scripts/send-post.sh index d6c0a4f..6511f01 100755 --- a/scripts/send-post.sh +++ b/scripts/send-post.sh @@ -42,6 +42,38 @@ TELEGRAM_CHANNEL_ID=$(go run ./cmd/provision adapter-config --spec "$spec" --cha tmp=$(mktemp -d) go build -o "$tmp/adapter" ./cmd/adapter +go build -o "$tmp/compose" ./cmd/compose + +# The image, resolved before any credential is touched. A post's own +# request.visual_url wins; otherwise its source entry's rendered portrait; +# an entry with only a visual prompt is held until the portrait is rendered; +# a post with no prompt at all (an outlet post) carries the campaign image. +# Public posts always carry an image. +hall=${HALL_DIR:-$(realpath ../hall-of-helix)} +forge=${FORGE_RAW:-https://forgejo.coulomb.social/coulomb} +python3 - "$post" "$channel" "$tmp" "$hall" "$forge" <<'PY' || { rm -rf "$tmp"; exit 2; } +import json, os, subprocess, sys, yaml +post, channel, tmp, hall, forge = sys.argv[1:] +d = yaml.safe_load(open(post)) +req = d["request"] +if not req.get("visual_url"): + cmd = [f"{tmp}/compose", "visual", "--hall-dir", hall, + "--hall-raw-base", f"{forge}/hall-of-helix/raw/branch/main", + "--fallback", f"{forge}/pr-hall-of-helix/raw/branch/main/assets/helix-forge.png"] + if d.get("source_entry_path"): + cmd += ["--entry", os.path.join(hall, d["source_entry_path"])] + v = json.loads(subprocess.run(cmd, check=True, capture_output=True, text=True).stdout) + if v["kind"] == "needs-render": + sys.exit(f"held: the source entry has a visual prompt but no rendered portrait " + f"(expected at {v.get('target') or 'an unnamed visuals/ file'}); render it into the hall first") + req["visual_url"] = v["visual_url"] + print(f"image: {v['kind']} {v['visual_url']}") +else: + print(f"image: post {req['visual_url']}") +if channel == "public" and not req.get("visual_url"): + sys.exit("public posts carry an image") +json.dump(req, open(f"{tmp}/request.json", "w")) +PY # Read the KV v2 data path directly: `bao kv get` first queries # sys/internal/ui/mounts, which a workload-scoped token may not be allowed to. @@ -50,7 +82,7 @@ token=$(bao read -format=json \ | python3 -c 'import sys, json; print(json.load(sys.stdin)["data"]["data"]["token"])') \ || { rm -rf "$tmp"; echo "could not read the bot token from OpenBao (logged in? read on platform/data/workloads/infotech/fluid-telegram/hall-of-helix/*?)" >&2; exit 2; } -TELEGRAM_BOT_TOKEN=$token TELEGRAM_CHANNEL_ID=$TELEGRAM_CHANNEL_ID \ +TELEGRAM_BOT_TOKEN=$token TELEGRAM_CHANNEL_ID=$TELEGRAM_CHANNEL_ID ATTACH_VISUAL=true \ ADAPTER_LISTEN_ADDR=127.0.0.1:$port "$tmp/adapter" & pid=$! unset token @@ -61,6 +93,5 @@ for _ in $(seq 50); do sleep 0.1 done -python3 -c 'import sys, yaml, json; print(json.dumps(yaml.safe_load(open(sys.argv[1]))["request"]))' "$post" \ - | curl -sS -w '\nHTTP %{http_code}\n' -X POST -H 'Content-Type: application/json' \ - --data-binary @- "http://127.0.0.1:$port/v1/channel-posts" +curl -sS -w '\nHTTP %{http_code}\n' -X POST -H 'Content-Type: application/json' \ + --data-binary @"$tmp/request.json" "http://127.0.0.1:$port/v1/channel-posts" diff --git a/workplans/FT-WP-0001-telegram-identity-and-hall-channel.md b/workplans/FT-WP-0001-telegram-identity-and-hall-channel.md index c8a825a..ca67883 100644 --- a/workplans/FT-WP-0001-telegram-identity-and-hall-channel.md +++ b/workplans/FT-WP-0001-telegram-identity-and-hall-channel.md @@ -287,7 +287,16 @@ adapter (`scripts/send-post.sh` inside the attended OpenBao login): HTTP 201, published 10:10:19 +02:00. Fixed on the way: channel ids are now emitted in Bot API form (`-100`). This is not T08: T08 still needs a post composed from one entry, which waits on the T07 consent basis. The rendering in the -channel still needs a person's look before T02's public channel. +channel was checked by the founder (rendering fine) before T02's public channel. + +2026-10-08: posts now carry images. The adapter response includes +`telegram_message_id` and a `t.me/c/...` link. `compose visual` resolves a post's +image: the post's `visual_url`, else the source entry's rendered portrait, else +**hold** when the entry has only a visual prompt, else the HelixForge main image. +Across the hall: 154 entries have portraits and 103 drafts need a render. No +renderer is wired in yet; that is a founder decision. `send-post.sh` resolves +the image before touching credentials, refuses public posts without one, and +runs the adapter with `ATTACH_VISUAL=true` (caption limit 1024). ## T09 — Implement both delivery forms as R-2 and R-3