package pipeline import ( "fmt" "strconv" "strings" "time" ) // RenderedContentPiece maps to LLM API content parts. type RenderedContentPiece struct { Type string `json:"type"` // "text" or "image" Text string `json:"text,omitempty"` URL string `json:"url,omitempty"` } // ImageAttachmentRef holds the content hash and MIME type of an image // attachment that can be inlined as a vision input via the media store. type ImageAttachmentRef struct { ContentHash string `json:"content_hash"` Mime string `json:"mime,omitempty"` } // RenderedSegment is a single segment of rendered context, one per IC node. type RenderedSegment struct { ReceivedAtMs int64 `json:"received_at_ms"` Content []RenderedContentPiece `json:"content"` IsMyself bool `json:"is_myself,omitempty"` IsSelfSent bool `json:"is_self_sent,omitempty"` MentionsMe bool `json:"mentions_me,omitempty"` RepliesToMe bool `json:"replies_to_me,omitempty"` ImageRefs []ImageAttachmentRef `json:"image_refs,omitempty"` } // RenderedContext is the output of the Rendering layer — a slice of segments. type RenderedContext []RenderedSegment // RenderParams controls rendering behavior. type RenderParams struct { BotUserID string ContactNames map[string]string } // Render converts an IntermediateContext into a RenderedContext. func Render(ic IntermediateContext, params RenderParams) RenderedContext { segments := make([]RenderedSegment, 0, len(ic.Nodes)) for _, node := range ic.Nodes { if node.Message != nil { seg := renderMessage(node.Message, params) segments = append(segments, seg) } else if node.SystemEvent != nil { seg := renderSystemEvent(node.SystemEvent, params) segments = append(segments, seg) } } return segments } // RCToXML converts a RenderedContext to a single XML string for debugging. func RCToXML(rc RenderedContext) string { var sb strings.Builder for _, seg := range rc { for _, p := range seg.Content { if p.Type == "text" { sb.WriteString(p.Text) } else { sb.WriteString("[thumbnail]") } } sb.WriteByte('\n') } return sb.String() } func renderMessage(msg *ICMessage, params RenderParams) RenderedSegment { isMyself := params.BotUserID != "" && msg.Sender != nil && msg.Sender.ID == params.BotUserID mentionsMe := params.BotUserID != "" && hasMention(msg.Content, params.BotUserID) repliesToMe := params.BotUserID != "" && msg.ReplyToSender != nil && msg.ReplyToSender.ID == params.BotUserID attrs := []string{} attrs = append(attrs, fmt.Sprintf("id=%q", escapeXMLAttrValue(msg.MessageID))) if msg.Sender != nil { attrs = append(attrs, fmt.Sprintf("sender=%q", escapeXMLAttrValue(formatSender(msg.Sender, params.ContactNames)))) } if isMyself { attrs = append(attrs, `myself="true"`) } attrs = append(attrs, fmt.Sprintf("t=%q", formatTimestamp(msg.TimestampSec, msg.UTCOffsetMin))) if msg.EditedAtSec > 0 { attrs = append(attrs, fmt.Sprintf("edited=%q", formatTimestamp(msg.EditedAtSec, msg.EditUTCOffsetMin))) } attrs = append(attrs, fmt.Sprintf("channel=%q", escapeXMLAttrValue(msg.Conversation.Channel))) if msg.Conversation.ConversationName != "" { attrs = append(attrs, fmt.Sprintf("conversation=%q", escapeXMLAttrValue(msg.Conversation.ConversationName))) } if msg.Conversation.ConversationType != "" { attrs = append(attrs, fmt.Sprintf("type=%q", escapeXMLAttrValue(msg.Conversation.ConversationType))) } if msg.Conversation.Target != "" { attrs = append(attrs, fmt.Sprintf("target=%q", escapeXMLAttrValue(msg.Conversation.Target))) } if msg.ForwardInfo != nil { from := resolveForwardFrom(msg.ForwardInfo, params.ContactNames) attrs = append(attrs, fmt.Sprintf("forwarded_from=%q", escapeXMLAttrValue(from))) } if msg.Deleted { text := fmt.Sprintf("", strings.Join(attrs, " ")) return RenderedSegment{ ReceivedAtMs: msg.ReceivedAtMs, Content: []RenderedContentPiece{{Type: "text", Text: text}}, IsMyself: isMyself, IsSelfSent: msg.IsSelfSent, MentionsMe: mentionsMe, RepliesToMe: repliesToMe, } } var parts []string if msg.ReplyToMessageID != "" { replyAttrs := []string{fmt.Sprintf("id=%q", escapeXMLAttrValue(msg.ReplyToMessageID))} if msg.ReplyToSender != nil { replyAttrs = append(replyAttrs, fmt.Sprintf("sender=%q", escapeXMLAttrValue(formatSender(msg.ReplyToSender, params.ContactNames)))) } preview := "" if msg.ReplyToPreview != "" { preview = escapeXMLText(msg.ReplyToPreview) } parts = append(parts, fmt.Sprintf("%s", strings.Join(replyAttrs, " "), preview)) } body := renderContentNodes(msg.Content) if body != "" { parts = append(parts, body) } for _, att := range msg.Attachments { parts = append(parts, renderAttachment(att)) } text := fmt.Sprintf("\n%s\n", strings.Join(attrs, " "), strings.Join(parts, "\n")) pieces := []RenderedContentPiece{{Type: "text", Text: text}} var imageRefs []ImageAttachmentRef for _, att := range msg.Attachments { if strings.EqualFold(att.Type, "image") && att.ContentHash != "" { imageRefs = append(imageRefs, ImageAttachmentRef{ ContentHash: att.ContentHash, Mime: att.MimeType, }) } } return RenderedSegment{ ReceivedAtMs: msg.ReceivedAtMs, Content: pieces, IsMyself: isMyself, IsSelfSent: msg.IsSelfSent, MentionsMe: mentionsMe, RepliesToMe: repliesToMe, ImageRefs: imageRefs, } } func renderSystemEvent(event *ICSystemEvent, params RenderParams) RenderedSegment { text := renderSystemEventXML(event, params.ContactNames) return RenderedSegment{ ReceivedAtMs: event.ReceivedAtMs, Content: []RenderedContentPiece{{Type: "text", Text: text}}, } } func renderSystemEventXML(event *ICSystemEvent, contactNames map[string]string) string { t := formatTimestamp(event.TimestampSec, event.UTCOffsetMin) actorAttr := "" if event.Actor != nil { actorAttr = fmt.Sprintf(` actor=%q`, escapeXMLAttrValue(formatSender(event.Actor, contactNames))) } switch event.Kind { case "user_renamed": return fmt.Sprintf(``, t, escapeXMLAttrValue(formatSender(event.OldUser, contactNames)), escapeXMLAttrValue(formatSender(event.NewUser, contactNames))) case "members_joined": names := make([]string, 0, len(event.Members)) for _, m := range event.Members { names = append(names, formatSender(&m, contactNames)) } return fmt.Sprintf(``, t, actorAttr, escapeXMLAttrValue(strings.Join(names, ", "))) case "member_left": return fmt.Sprintf(``, t, actorAttr, escapeXMLAttrValue(formatSender(event.Member, contactNames))) case "chat_renamed": fromAttr := "" if event.OldTitle != "" { fromAttr = fmt.Sprintf(` from=%q`, escapeXMLAttrValue(event.OldTitle)) } return fmt.Sprintf(``, t, actorAttr, fromAttr, escapeXMLAttrValue(event.NewTitle)) case "chat_photo_changed": return fmt.Sprintf(``, t, actorAttr) case "chat_photo_deleted": return fmt.Sprintf(``, t, actorAttr) case "message_pinned": if event.PinnedPreview != "" { return fmt.Sprintf(`%s`, t, actorAttr, escapeXMLAttrValue(event.PinnedMessageID), escapeXMLText(event.PinnedPreview)) } return fmt.Sprintf(``, t, actorAttr, escapeXMLAttrValue(event.PinnedMessageID)) default: return "" } } // --- Helpers --- func escapeXMLAttrValue(s string) string { r := strings.NewReplacer( "&", "&", "<", "<", ">", ">", "\"", """, ) return r.Replace(s) } func escapeXMLText(s string) string { r := strings.NewReplacer( "&", "&", "<", "<", ">", ">", ) return r.Replace(s) } func formatSender(user *CanonicalUser, contactNames map[string]string) string { if user == nil { return "" } if contactNames != nil { if name, ok := contactNames[user.ID]; ok { if user.Username != "" && user.Username != name { return name + " (@" + user.Username + ")" } return name } } displayName := user.DisplayName if displayName == "" { if user.Username != "" { return user.Username } return user.ID } if user.Username != "" && user.Username != displayName { return displayName + " (@" + user.Username + ")" } return displayName } func resolveForwardFrom(info *ForwardInfo, contactNames map[string]string) string { if info.Sender != nil { return formatSender(info.Sender, contactNames) } if info.SenderName != "" { return info.SenderName } if info.FromUserID != "" { return "user:" + info.FromUserID } if info.FromChatID != "" { return "chat:" + info.FromChatID } return "unknown" } func pad2(n int) string { if n < 10 && n >= 0 { return "0" + strconv.Itoa(n) } return strconv.Itoa(n) } func formatTimestamp(epochSec int64, utcOffsetMin int) string { t := time.Unix(epochSec, 0).UTC().Add(time.Duration(utcOffsetMin) * time.Minute) date := fmt.Sprintf("%d-%s-%s", t.Year(), pad2(int(t.Month())), pad2(t.Day())) timeStr := fmt.Sprintf("%s:%s:%s", pad2(t.Hour()), pad2(t.Minute()), pad2(t.Second())) sign := "+" abs := utcOffsetMin if utcOffsetMin < 0 { sign = "-" abs = -utcOffsetMin } offset := fmt.Sprintf("%s%s:%s", sign, pad2(abs/60), pad2(abs%60)) return date + "T" + timeStr + offset } func hasMention(nodes []ContentNode, userID string) bool { for _, n := range nodes { if n.Type == "mention" && n.UserID == userID { return true } if hasMention(n.Children, userID) { return true } } return false } func renderContentNodes(nodes []ContentNode) string { var sb strings.Builder for _, n := range nodes { renderContentNode(&sb, n) } return sb.String() } func renderContentNode(sb *strings.Builder, node ContentNode) { switch node.Type { case "text": sb.WriteString(escapeXMLText(node.Text)) case "code": sb.WriteString("") sb.WriteString(escapeXMLText(node.Text)) sb.WriteString("") case "pre": if node.Language != "" { fmt.Fprintf(sb, `
`, escapeXMLAttrValue(node.Language))
		} else {
			sb.WriteString("
")
		}
		sb.WriteString(escapeXMLText(node.Text))
		sb.WriteString("
") case "bold": sb.WriteString("") renderChildren(sb, node.Children) sb.WriteString("") case "italic": sb.WriteString("") renderChildren(sb, node.Children) sb.WriteString("") case "underline": sb.WriteString("") renderChildren(sb, node.Children) sb.WriteString("") case "strikethrough": sb.WriteString("") renderChildren(sb, node.Children) sb.WriteString("") case "spoiler": sb.WriteString("") renderChildren(sb, node.Children) sb.WriteString("") case "blockquote": sb.WriteString("
") renderChildren(sb, node.Children) sb.WriteString("
") case "link": fmt.Fprintf(sb, ``, escapeXMLAttrValue(node.URL)) renderChildren(sb, node.Children) sb.WriteString("") case "mention": if node.UserID != "" { fmt.Fprintf(sb, ``, escapeXMLAttrValue(node.UserID)) } else { sb.WriteString("") } renderChildren(sb, node.Children) sb.WriteString("") case "custom_emoji": renderChildren(sb, node.Children) } } func renderChildren(sb *strings.Builder, children []ContentNode) { for _, child := range children { renderContentNode(sb, child) } } func renderAttachment(att Attachment) string { attrs := []string{fmt.Sprintf("type=%q", att.Type)} if att.MimeType != "" { attrs = append(attrs, fmt.Sprintf("mime=%q", escapeXMLAttrValue(att.MimeType))) } if att.FileName != "" { attrs = append(attrs, fmt.Sprintf("name=%q", escapeXMLAttrValue(att.FileName))) } if att.Width > 0 && att.Height > 0 { attrs = append(attrs, fmt.Sprintf("size=%q", fmt.Sprintf("%dx%d", att.Width, att.Height))) } if att.Duration > 0 { attrs = append(attrs, fmt.Sprintf("duration=%q", strconv.Itoa(att.Duration))) } if att.FilePath != "" { attrs = append(attrs, fmt.Sprintf("path=%q", escapeXMLAttrValue(att.FilePath))) } if att.AltText != "" { return fmt.Sprintf("%s", strings.Join(attrs, " "), escapeXMLText(att.AltText)) } return fmt.Sprintf("", strings.Join(attrs, " ")) }