249 lines
7.6 KiB
Go
249 lines
7.6 KiB
Go
package main
|
|
|
|
import (
|
|
"fmt"
|
|
"net/http"
|
|
"slices"
|
|
"strings"
|
|
"time"
|
|
"unicode/utf8"
|
|
)
|
|
|
|
var crowPostable = map[string]bool{
|
|
"say": true,
|
|
"request": true,
|
|
"claim": true,
|
|
"deliver": true,
|
|
"refer": true,
|
|
"decline": true,
|
|
}
|
|
|
|
// nestReplySystem tells an addressed crow the response contract: its reply
|
|
// must open with a KIND line the harness reads (must-respond).
|
|
const markerNestReply = "This is a chatroom request."
|
|
const nestReplySystem = crowSystem + `
|
|
` + markerNestReply + ` Start your reply on its own first line with exactly:
|
|
KIND: claim (you will do it)
|
|
KIND: deliver (here is the result)
|
|
KIND: decline (with a reason)
|
|
Then, on the next line, your message text.`
|
|
|
|
// nestMembers lists who may be addressed in a nest: its crows plus the owner.
|
|
func nestMembers(nest Nest) []string {
|
|
return append(append([]string{}, nest.Crows...), "owner")
|
|
}
|
|
|
|
func (s *Server) getNestMessages(w http.ResponseWriter, r *http.Request) {
|
|
nestID := r.PathValue("nest_id")
|
|
if _, found := s.nests.Get(nestID); !found {
|
|
httpError(w, http.StatusNotFound, fmt.Sprintf("There is no nest called %q.", nestID))
|
|
return
|
|
}
|
|
all := s.msgs.All(nestID)
|
|
out := make([]map[string]any, 0, len(all))
|
|
for _, m := range all {
|
|
out = append(out, map[string]any{
|
|
"msg_id": m.MsgID(),
|
|
"kind": m.Kind,
|
|
"author": m.Author,
|
|
"text": m.Text,
|
|
"to": m.To,
|
|
"refers_to": m.RefersTo,
|
|
"tagged_by": m.TaggedBy,
|
|
"ts": m.Timestamp,
|
|
})
|
|
}
|
|
writeJSON(w, http.StatusOK, out)
|
|
}
|
|
|
|
func (s *Server) postNestMessage(w http.ResponseWriter, r *http.Request) {
|
|
nestID := r.PathValue("nest_id")
|
|
nest, found := s.nests.Get(nestID)
|
|
if !found {
|
|
httpError(w, http.StatusNotFound, fmt.Sprintf("There is no nest called %q.", nestID))
|
|
return
|
|
}
|
|
|
|
var body struct {
|
|
Author string `json:"author"`
|
|
Text string `json:"text"`
|
|
Kind string `json:"kind"`
|
|
To string `json:"to"`
|
|
RefersTo string `json:"refers_to"`
|
|
}
|
|
if !decode(w, r, &body) {
|
|
return
|
|
}
|
|
|
|
body.Text = strings.TrimSpace(body.Text)
|
|
body.To = strings.TrimSpace(body.To)
|
|
if body.To == "" {
|
|
body.To = parseAddressing(body.Text, nestMembers(nest))
|
|
}
|
|
|
|
// Guardrails (EVENTS.md: Nest messages → Guardrails)
|
|
if n := utf8.RuneCountInString(body.Text); n < 1 || n > s.p.G.MaxItemChars {
|
|
httpError(w, http.StatusBadRequest, fmt.Sprintf("A message is 1 to %d characters.", s.p.G.MaxItemChars))
|
|
return
|
|
}
|
|
if body.Author == "owner" {
|
|
// owner can always post
|
|
} else if !slices.Contains(nest.Crows, body.Author) {
|
|
httpError(w, http.StatusBadRequest, fmt.Sprintf("%q is not a member of %s.", body.Author, nestID))
|
|
return
|
|
}
|
|
if body.Author != "owner" {
|
|
if ok, msg, wait := s.p.Ledger.Admit(body.Author, time.Now(), s.p.G); !ok {
|
|
cooldown := int(wait.Seconds())
|
|
writeJSON(w, http.StatusTooManyRequests, map[string]any{"message": msg, "cooldown_s": cooldown})
|
|
return
|
|
}
|
|
}
|
|
if body.Kind != "" && body.Author != "owner" && !crowPostable[body.Kind] {
|
|
httpError(w, http.StatusBadRequest, fmt.Sprintf("%q is not a kind crows may post.", body.Kind))
|
|
return
|
|
}
|
|
|
|
// Tagging (A1, EVENTS.md): Jev first, header fallback, say as last resort.
|
|
kind, taggedBy := s.tagKind(body.Kind, body.Text)
|
|
|
|
// claim/deliver/decline answer a request: with no explicit address they go
|
|
// back to whoever posted the message they refer to (EVENTS.md deliver rule).
|
|
if body.To == "" && (kind == "claim" || kind == "deliver" || kind == "decline") && body.RefersTo != "" {
|
|
if origin, found := s.msgs.ByMsgID(body.RefersTo); found {
|
|
body.To = origin.Author
|
|
}
|
|
}
|
|
|
|
msg := Message{
|
|
NestID: nestID,
|
|
Kind: kind,
|
|
Author: body.Author,
|
|
Text: body.Text,
|
|
To: body.To,
|
|
RefersTo: body.RefersTo,
|
|
TaggedBy: taggedBy,
|
|
Timestamp: time.Now().UTC().Format(time.RFC3339),
|
|
}
|
|
record, err := s.msgs.Append(msg)
|
|
if err != nil {
|
|
httpError(w, http.StatusInternalServerError, "could not save the message: "+err.Error())
|
|
return
|
|
}
|
|
|
|
s.emitNestMessage(record)
|
|
|
|
// Event-driven trigger: a request addressed to a member crow gets one
|
|
// response (must-respond) or a Sentry flag.
|
|
if kind == "request" && slices.Contains(nest.Crows, record.To) {
|
|
go s.respondToRequest(nest, record)
|
|
}
|
|
|
|
writeJSON(w, http.StatusCreated, map[string]any{
|
|
"msg_id": record.MsgID(),
|
|
"kind": record.Kind,
|
|
"author": record.Author,
|
|
"text": record.Text,
|
|
"to": record.To,
|
|
"refers_to": record.RefersTo,
|
|
"tagged_by": record.TaggedBy,
|
|
"ts": record.Timestamp,
|
|
})
|
|
}
|
|
|
|
// tagKind runs the A1 rule: Jev first; Jev unsure -> the poster's header;
|
|
// neither -> say. tagged_by records which source won, and a Jev override of a
|
|
// header logs as a disagreement (fuel for calibration).
|
|
func (s *Server) tagKind(header, text string) (string, string) {
|
|
kind, _, ok := s.p.Flock.ClassifyKind(s.ctx, text)
|
|
if ok {
|
|
if crowPostable[header] && header != kind {
|
|
return kind, "disagreement"
|
|
}
|
|
return kind, "jev"
|
|
}
|
|
if crowPostable[header] {
|
|
return header, "header"
|
|
}
|
|
return "say", "default"
|
|
}
|
|
|
|
func (s *Server) emitNestMessage(m Message) {
|
|
s.hub.PublishNest(m.NestID, "nest.message", map[string]any{
|
|
"nest_id": m.NestID,
|
|
"msg_id": m.MsgID(),
|
|
"kind": m.Kind,
|
|
"author": m.Author,
|
|
"text": m.Text,
|
|
"to": m.To,
|
|
"refers_to": m.RefersTo,
|
|
"tagged_by": m.TaggedBy,
|
|
})
|
|
}
|
|
|
|
// respondToRequest is the must-respond rule: the addressed crow answers with
|
|
// claim, deliver or decline. A reply that isn't one of those is retried once
|
|
// with a stricter ask; still nothing responsive and the crow gets a flag from
|
|
// the Sentry (no crow reply is posted).
|
|
func (s *Server) respondToRequest(nest Nest, req Message) {
|
|
crow := req.To
|
|
budget := &Budget{name: "nest-message", limit: s.p.G.TokenBudgetPerItem}
|
|
system := systemFor(crow, nestReplySystem, false)
|
|
prompt := "NEST: " + nest.Name + "\nREQUEST FROM: " + req.Author + "\nTASK: " + req.Text + "\nRespond to " + req.Author + "."
|
|
|
|
kind, body, ok := s.nestReply(system, prompt, crow, budget)
|
|
if !ok {
|
|
nudge := prompt + "\nYour last reply did not claim, deliver or decline this request. Answer with a KIND line now."
|
|
kind, body, ok = s.nestReply(system, nudge, crow, budget)
|
|
}
|
|
if !ok {
|
|
s.postFlag(nest, crow, req)
|
|
return
|
|
}
|
|
reply := Message{
|
|
NestID: nest.ID,
|
|
Kind: kind,
|
|
Author: crow,
|
|
Text: body,
|
|
To: req.Author, // responses go back to whoever requested
|
|
RefersTo: req.MsgID(),
|
|
TaggedBy: "header",
|
|
Timestamp: time.Now().UTC().Format(time.RFC3339),
|
|
}
|
|
if record, err := s.msgs.Append(reply); err == nil {
|
|
s.emitNestMessage(record)
|
|
}
|
|
}
|
|
|
|
// nestReply asks one crow once and reads its KIND header. ok means the reply
|
|
// was responsive (claim, deliver or decline).
|
|
func (s *Server) nestReply(system, prompt, crow string, budget *Budget) (kind, body string, ok bool) {
|
|
answer, err := s.p.Flock.ask(s.ctx, nil, crow, system, prompt, budget)
|
|
if err != nil {
|
|
return "", "", false
|
|
}
|
|
kind, body = splitKind(answer)
|
|
if !responsiveKind[kind] || strings.TrimSpace(body) == "" {
|
|
return "", "", false
|
|
}
|
|
return kind, strings.TrimSpace(body), true
|
|
}
|
|
|
|
// postFlag records the Sentry's breach note: an addressed crow that would not
|
|
// answer. The quarantine/ask_owner loop lands with the watchdog epic.
|
|
func (s *Server) postFlag(nest Nest, crow string, req Message) {
|
|
flag := Message{
|
|
NestID: nest.ID,
|
|
Kind: "flag",
|
|
Author: "Sentry",
|
|
Text: fmt.Sprintf("%s ignored a request from %s and did not answer after one retry. Flagged for contract breach.", crow, req.Author),
|
|
To: crow,
|
|
RefersTo: req.MsgID(),
|
|
TaggedBy: "harness",
|
|
Timestamp: time.Now().UTC().Format(time.RFC3339),
|
|
}
|
|
if record, err := s.msgs.Append(flag); err == nil {
|
|
s.emitNestMessage(record)
|
|
}
|
|
}
|