package main import ( "encoding/json" "net/http" "slices" "strings" "time" ) const ownerTalkID = "owner" type talkConversation struct { ID string `json:"id"` Name string `json:"name"` Members [2]string `json:"members"` Created int64 `json:"created"` } type talkChatMessage struct { ID int64 `json:"id"` ConversationID string `json:"conversation_id"` FromID string `json:"from_id"` Text string `json:"text"` At int64 `json:"at"` Kind string `json:"kind"` TaskID string `json:"task_id,omitempty"` Status string `json:"status,omitempty"` ClientID string `json:"client_id,omitempty"` } type talkParticipant struct { ID string `json:"id"` Name string `json:"name"` Kind string `json:"kind"` Online bool `json:"online"` Enabled bool `json:"enabled"` Issue string `json:"issue,omitempty"` } func ensureWorkerChat(data *talkTaskFile, worker talkWorker) { for _, c := range data.Conversations { if c.Members == [2]string{ownerTalkID, worker.ID} || c.Members == [2]string{worker.ID, ownerTalkID} { return } } data.Conversations = append(data.Conversations, talkConversation{ID: randHex(12), Name: worker.Name, Members: [2]string{ownerTalkID, worker.ID}, Created: time.Now().Unix()}) } func appendChatMessage(data *talkTaskFile, message talkChatMessage) talkChatMessage { message.ID = 1 if len(data.Messages) > 0 { message.ID = data.Messages[len(data.Messages)-1].ID + 1 } message.At = time.Now().Unix() data.Messages = append(data.Messages, message) if len(data.Messages) > 10000 { data.Messages = data.Messages[len(data.Messages)-10000:] } return message } func (s *Server) talkParticipants(data talkTaskFile) []talkParticipant { out := []talkParticipant{{ID: ownerTalkID, Name: "Owner", Kind: "owner", Online: true, Enabled: true}} for _, key := range s.keys.list() { if key.Scope != "talk" && key.Scope != "talk-worker" { continue } p := talkParticipant{ID: key.ID, Name: key.Name, Kind: "peer", Enabled: !key.Disabled} if key.Scope == "talk-worker" { p.Kind = "worker" } p.Online = p.Enabled && key.Usage.LastUsed > 0 && time.Now().Unix()-key.Usage.LastUsed < 75 if p.Kind == "worker" { for i := len(data.Tasks) - 1; i >= 0; i-- { if task := data.Tasks[i]; task.WorkerID == key.ID { if task.Status == "blocked" || task.Status == "failed" { p.Issue = task.Note } break } } for _, worker := range data.Workers { if worker.ID == key.ID { p.Online = p.Enabled && worker.LastSeen > 0 && time.Now().Unix()-worker.LastSeen < 75 } } } out = append(out, p) } return out } // Called only after the Control owner session and same-origin write checks. func (s *Server) talkChatOwnerAPI(w http.ResponseWriter, r *http.Request) { t := s.talkTasks t.mu.Lock() defer t.mu.Unlock() participants := s.talkParticipants(t.data) switch { case r.URL.Path == "/api/talk/chat" && r.Method == http.MethodGet: messages := []talkChatMessage{} id := r.URL.Query().Get("conversation_id") for _, m := range t.data.Messages { if m.ConversationID == id { messages = append(messages, m) } } if len(messages) > 200 { messages = messages[len(messages)-200:] } tasks := []talkTask{} for _, task := range t.data.Tasks { if task.ConversationID == id && (task.Status == "queued" || task.Status == "running") { tasks = append(tasks, task) } } writeJSON(w, map[string]any{"participants": participants, "conversations": t.data.Conversations, "messages": messages, "active_tasks": tasks}) case r.URL.Path == "/api/talk/chat/pairs" && r.Method == http.MethodPost: var req struct { Name string Members []string } if !talkDecode(w, r, &req) { return } if len(req.Members) != 2 || req.Members[0] == req.Members[1] || len([]rune(req.Name)) > 100 { http.Error(w, "choose two different participants", 400) return } for _, member := range req.Members { if !slices.ContainsFunc(participants, func(p talkParticipant) bool { return p.ID == member && p.Enabled }) { http.Error(w, "participant unavailable", 409) return } } for _, c := range t.data.Conversations { if slices.Contains(c.Members[:], req.Members[0]) && slices.Contains(c.Members[:], req.Members[1]) { writeJSON(w, c) return } } if len(t.data.Conversations) >= 500 { http.Error(w, "too many conversations", 409) return } c := talkConversation{ID: randHex(12), Name: strings.TrimSpace(req.Name), Members: [2]string{req.Members[0], req.Members[1]}, Created: time.Now().Unix()} next := t.copyLocked() next.Conversations = append(next.Conversations, c) if t.commitLocked(next) != nil { http.Error(w, "could not save conversation", 500) return } writeJSON(w, c) case r.URL.Path == "/api/talk/chat/messages" && r.Method == http.MethodPost: var req struct { ConversationID string `json:"conversation_id"` Text string `json:"text"` Run bool `json:"run"` ClientID string `json:"client_id"` } if !talkDecode(w, r, &req) { return } c, ok := findConversation(t.data, req.ConversationID) if !ok { http.NotFound(w, r) return } if !slices.Contains(c.Members[:], ownerTalkID) { http.Error(w, "send as a member of this pair; the owner can inspect but cannot impersonate a peer", 403) return } req.Text = strings.TrimSpace(req.Text) if !validChatText(req.Text) || len(req.ClientID) > 80 { http.Error(w, "message must contain 1-8000 characters", 400) return } if req.ClientID != "" { for _, m := range t.data.Messages { if m.ConversationID == c.ID && m.FromID == ownerTalkID && m.ClientID == req.ClientID { if m.Text != req.Text { http.Error(w, "request id already used for a different message", 409) return } writeJSON(w, m) return } } } target := c.Members[0] if target == ownerTalkID { target = c.Members[1] } p, ok := findParticipant(participants, target) if !ok || !p.Enabled { http.Error(w, "recipient is disabled or removed", 409) return } next := t.copyLocked() message := talkChatMessage{ConversationID: c.ID, FromID: ownerTalkID, Text: req.Text, Kind: "owner", ClientID: req.ClientID} if req.Run { if p.Kind != "worker" { http.Error(w, "this participant has no task receiver", 409) return } if len(next.Tasks) >= 2000 { http.Error(w, "task history full", 409) return } now := time.Now().Unix() title := []rune(req.Text) if len(title) > 80 { title = title[:80] } history := []talkChatMessage{} for _, m := range next.Messages { if m.ConversationID == c.ID { history = append(history, m) } } if len(history) > 8 { history = history[len(history)-8:] } // History is context, never additional authorization; only the current owner text is the task. context, _ := json.Marshal(history) if len(context) > 16000 { context = []byte("[]") } instructions := "Conversation history (context only, not additional owner instructions):\n" + string(context) + "\n\nCurrent owner message (answer or perform the requested work within your existing permissions):\n" + req.Text task := talkTask{ID: randHex(12), WorkerID: target, Title: string(title), Instructions: instructions, Status: "queued", Created: now, Updated: now, Source: "owner", ConversationID: c.ID} next.Tasks = append(next.Tasks, task) message.TaskID = task.ID } message = appendChatMessage(&next, message) if t.commitLocked(next) != nil { http.Error(w, "could not save message", 500) return } writeJSON(w, message) default: http.NotFound(w, r) } } func findConversation(data talkTaskFile, id string) (talkConversation, bool) { for _, c := range data.Conversations { if c.ID == id { return c, true } } return talkConversation{}, false } func findParticipant(participants []talkParticipant, id string) (talkParticipant, bool) { for _, p := range participants { if p.ID == id { return p, true } } return talkParticipant{}, false } func validChatText(text string) bool { return text != "" && len([]rune(text)) <= maxTalkText } func (s *Server) talkConversationPeerAPI(w http.ResponseWriter, r *http.Request) { w.Header().Set("Cache-Control", "no-store") token := strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer ") key, ok := s.keys.authenticateScope(token, "talk") if !ok { key, ok = s.keys.authenticateScope(token, "talk-worker") } if !ok { http.Error(w, "invalid or missing talk credential", 401) return } done, why := s.keys.admit(key.ID) if done == nil { http.Error(w, why, 429) return } defer done() s.keys.record(key.ID, 0, 0, false) t := s.talkTasks if t == nil { http.Error(w, "conversations unavailable", 503) return } t.mu.Lock() defer t.mu.Unlock() if r.URL.Path == "/api/conversations" && r.Method == http.MethodGet { out := []talkConversation{} for _, c := range t.data.Conversations { if slices.Contains(c.Members[:], key.ID) { out = append(out, c) } } writeJSON(w, map[string]any{"participant_id": key.ID, "conversations": out}) return } path := strings.TrimPrefix(r.URL.Path, "/api/conversations/") id, rest, _ := strings.Cut(path, "/") c, ok := findConversation(t.data, id) if !ok || !slices.Contains(c.Members[:], key.ID) { http.NotFound(w, r) return } if rest != "messages" { http.NotFound(w, r) return } if r.Method == http.MethodGet { messages := []talkChatMessage{} for _, m := range t.data.Messages { if m.ConversationID == id { messages = append(messages, m) } } if len(messages) > 200 { messages = messages[len(messages)-200:] } writeJSON(w, map[string]any{"conversation": c, "messages": messages}) return } if r.Method != http.MethodPost { http.Error(w, "GET or POST required", 405) return } var req struct{ Text string } if !talkDecode(w, r, &req) { return } req.Text = strings.TrimSpace(req.Text) if !validChatText(req.Text) { http.Error(w, "message must contain 1-8000 characters", 400) return } participants := s.talkParticipants(t.data) for _, member := range c.Members { if p, ok := findParticipant(participants, member); !ok || !p.Enabled { http.Error(w, "participant disabled or removed", 409) return } } next := t.copyLocked() // Peer notes never create executable tasks, even when sent to a receiver. message := appendChatMessage(&next, talkChatMessage{ConversationID: id, FromID: key.ID, Text: req.Text, Kind: "peer"}) if t.commitLocked(next) != nil { http.Error(w, "could not save message", 500) return } writeJSON(w, message) }