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
This commit is contained in:
parent
b61261d042
commit
4861dd994b
7 changed files with 342 additions and 12 deletions
|
|
@ -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 <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.
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
|||
<a href="%s">Source</a>`, 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<id>). 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) {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
130
internal/compose/visual.go
Normal file
130
internal/compose/visual.go
Normal file
|
|
@ -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/<file> 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
|
||||
}
|
||||
74
internal/compose/visual_test.go
Normal file
74
internal/compose/visual_test.go
Normal file
|
|
@ -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\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\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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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<id>`). 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
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue