package main import "strings" // serverOf returns the homeserver part of an mxid (`@ai:vojo.chat` → `vojo.chat`). func serverOf(mxid string) string { if i := strings.IndexByte(mxid, ':'); i >= 0 { return mxid[i+1:] } return "" } // localpartOf returns the localpart of an mxid (`@ai:vojo.chat` → `ai`). func localpartOf(mxid string) string { s := strings.TrimPrefix(mxid, "@") if i := strings.IndexByte(s, ':'); i >= 0 { return s[:i] } return s } // mentionsBot decides whether a message intentionally addresses the bot. // // Canonical path (MSC3952): the sender's client lists mentioned mxids in // content["m.mentions"].user_ids. cinny ALWAYS writes this (RoomInput.tsx:491-492), // and the presence of m.mentions suppresses legacy body-keyword push rules — so a // plain-text "@ai" with no pill is intentionally NOT a trigger (F30). // // Fallbacks for non-cinny senders (Element/FluffyChat/bridges) that still pill: // - a matrix.to / matrix: pill href targeting the bot mxid in formatted_body; // - a reply whose parent we sent (resolved by the caller via replyParentIsBot). // // We deliberately do NOT scan body for the bot's localpart — that would re-create // the unintentional-mention problem MSC3952 removed. func mentionsBot(mc *MessageContent, botMXID string, replyParentIsBot bool) bool { if mc.Mentions != nil { for _, uid := range mc.Mentions.UserIDs { // UserIDs may be nil — range is safe (F29) if uid == botMXID { return true } } } if replyParentIsBot { return true } return pillTargetsBot(mc.FormattedBody, botMXID) } // stripBotMention removes the bot's own mention text from a trigger body before it is // used as a web-search query, a prompt turn, a buffer entry, or telemetry. cinny writes // the plain-text fallback of a mention pill as the bot's FULL mxid ("@ai:vojo.chat …"), // and that literal mxid, sent verbatim to the grounding provider as the search query, made // it treat "vojo.chat" as the SUBJECT entity — it searched "was the Vojo.chat messenger // removed?", found nothing, and confabulated "no, it's available", the exact first-ask // hallucination + same-question/different-answer the "Max" thread showed (the second ask // happened to anchor on "макс" instead, hence two opposite grounded answers). Mention // DETECTION already ran upstream via m.mentions (MSC3952), so dropping the body text never // changes routing. We strip only the UNAMBIGUOUS mxid forms — the full mxid and a // standalone "@localpart"; the human display name is deliberately left intact so a real // question that names the product ("что умеет Vojo AI") is never mangled. func stripBotMention(body, botMXID string) string { body = strings.ReplaceAll(body, botMXID, " ") at := "@" + localpartOf(botMXID) fields := strings.Fields(body) kept := fields[:0] for _, f := range fields { // Drop a standalone "@ai" pill fallback (with trailing address punctuation), but // keep "@aibot" or any word that merely contains it. if strings.EqualFold(strings.Trim(f, ",.:;!?–—-"), at) { continue } kept = append(kept, f) } out := strings.Join(kept, " ") return strings.TrimLeft(out, " ,:–—-") // leftover leading address punctuation ("@ai, …") } // pillTargetsBot looks for an mention pill addressing the bot in the // HTML body. Matrix pills use either matrix.to/#/ or a matrix: URI. func pillTargetsBot(formattedBody, botMXID string) bool { if formattedBody == "" { return false } // matrix.to URLs URL-encode the leading '@' as %40; cover both forms. needles := []string{ "matrix.to/#/" + botMXID, "matrix.to/#/%40" + strings.TrimPrefix(botMXID, "@"), "matrix:u/" + strings.TrimPrefix(botMXID, "@"), } for _, n := range needles { if strings.Contains(formattedBody, n) { return true } } return false }