// Message triage: the pure decision of whether — and how — to answer one // MESSAGE_CREATE. Ported from the Python bot's _response_for_message / // _mentions_bot / _is_dm / _strip_mentions; it is plain string/JSON logic // with no data dependency, so it lives in Go. Anything needing climate data // (the actual grade) goes back to Python via Grader. package gateway import ( "encoding/json" "fmt" "strings" ) // helpText answers an empty query. Go owns this constant because it needs no // data; the wording matches the Python original exactly. const helpText = "Mention me with a city name — e.g. `@Thermograph Phoenix` — and I'll grade " + "today's weather against ~45 years of local history." // snowflake decodes a Discord id that may arrive as a JSON string (what v10 // sends) or a bare number. The Python compared ids through str() coercion; // this keeps that tolerance instead of betting the payload shape never // wobbles. type snowflake string func (s *snowflake) UnmarshalJSON(b []byte) error { if string(b) == "null" { *s = "" return nil } var str string if err := json.Unmarshal(b, &str); err == nil { *s = snowflake(str) return nil } var n json.Number if err := json.Unmarshal(b, &n); err == nil { *s = snowflake(n.String()) return nil } return fmt.Errorf("snowflake: cannot decode %s", b) } // gwMessage is the slice of a MESSAGE_CREATE payload the triage needs. type gwMessage struct { ID snowflake `json:"id"` ChannelID snowflake `json:"channel_id"` GuildID snowflake `json:"guild_id"` Content string `json:"content"` Author gwAuthor `json:"author"` Mentions []gwAuthor `json:"mentions"` } type gwAuthor struct { ID snowflake `json:"id"` Bot bool `json:"bot"` } // action is triage's verdict for one message. type action int const ( actSilent action = iota // no reply actHelp // reply with helpText actGrade // call back into Python with the query ) // mentionsBot reports whether the message @mentions our own user id. func mentionsBot(m *gwMessage, botID string) bool { for _, u := range m.Mentions { if string(u.ID) == botID { return true } } return false } // isDM: a DM has no guild_id; a guild message always carries one. func isDM(m *gwMessage) bool { return m.GuildID == "" } // stripMentions drops every form of the bot's own mention (<@id> and the // legacy <@!id>) and collapses the surrounding whitespace, leaving just the // user's query text. func stripMentions(content, botID string) string { out := strings.ReplaceAll(content, "<@"+botID+">", " ") out = strings.ReplaceAll(out, "<@!"+botID+">", " ") return strings.Join(strings.Fields(out), " ") } // triage decides the reply for one MESSAGE_CREATE: silence, the help line, or // a grade lookup for the returned query. // // Silent unless the message DMs the bot or @mentions it, and never for a bot // author — which includes ourselves, the guard against a mention-loop (our // reply quoting the mention must not trigger another reply). The text after // the mention is treated as a city query; DMs are the query as-is (trimmed // but not collapsed, matching the Python's .strip()). func triage(m *gwMessage, botID string) (action, string) { if m.Author.Bot || string(m.Author.ID) == botID { return actSilent, "" } dm := isDM(m) if !dm && !mentionsBot(m, botID) { return actSilent, "" } var query string if dm { query = strings.TrimSpace(m.Content) } else { query = stripMentions(m.Content, botID) } if query == "" { return actHelp, "" } return actGrade, query }