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
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
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue