clone

a self-hosted multitenant nostr relay with sites, file storage, git hosting, and inbox and outbox delivery

main
tinyrelay / tinyclient / chat-activity.go
chat-activity.go 11.5K
1package tinyclient23import (4 "bytes"5 "context"6 "encoding/json"7 "net/http"8 "net/url"9 "sort"10 "strings"11 "time"12)1314type ChatActivityReader interface {15 ReadChatActivity(context.Context, string, string, string) (any, error)16}1718type chatActivityItem struct {19 Type, ID, Author, Kind, State, Status, Title, Content, Room, Decision string20 CreatedAt, Expires int6421 Answer string22 CanDecide, Question bool23 Native, Multiple bool24 Freeform bool25 Interaction string26 Options []chatActivityOption27 Progress string28 Result, Error *chatActivityResult29 Assignees []string30 CanCancel bool31}32type chatActivityOption struct{ ID, Label string }33type chatActivityResult struct {34 ID, Content string35 Artifacts []chatActivityLink36}37type chatActivityLink struct{ Href, Label string }38type chatActivityPage struct {39 Items []chatActivityItem40 Endpoint, Room, Root, Actor, Error string41 // Ask renders the request form: the backend serves long tasks and the42 // viewer is signed in. Agents lists the room's agent members for it.43 Ask bool44 Agents []chatActivityAgent45}46type chatActivityAgent struct{ PubKey, Operator string }4748func chatActivityView(jobs, approvals any, actor, endpoint, room string) []chatActivityItem {49 items := []chatActivityItem{}50 for _, raw := range collaborationSlice(jobs, "items") {51 item := valueMap(raw)52 id := plainString(item["id"])53 if !eventIDPattern.MatchString(id) {54 continue55 }56 title := strings.TrimSpace(plainString(item["subject"]))57 if title == "" {58 title = strings.TrimSpace(plainString(item["content"]))59 }60 if title == "" {61 title = "Agent task"62 }63 row := chatActivityItem{Type: "job", ID: id, Author: plainString(item["requester"]), Kind: "43001", State: plainString(item["state"]), Status: plainString(item["status"]), Title: truncateActivity(title, 100), Content: plainString(item["content"]), Room: room, CreatedAt: unixSeconds(item["created_at"]), Expires: unixSeconds(item["expires"])}64 for _, assignee := range stringValues(item["assignees"]) {65 if eventIDPattern.MatchString(assignee) {66 row.Assignees = append(row.Assignees, assignee)67 }68 }69 if progress := valueMap(item["progress"]); len(progress) > 0 {70 row.Progress = strings.TrimSpace(plainString(progress["content"]))71 }72 if result := valueMap(item["result"]); eventIDPattern.MatchString(plainString(result["id"])) {73 row.Result = &chatActivityResult{ID: plainString(result["id"]), Content: plainString(result["content"])}74 for _, raw := range collaborationSlice(result, "artifacts") {75 if link, ok := jobArtifactLink(valueMap(raw)); ok {76 row.Result.Artifacts = append(row.Result.Artifacts, link)77 }78 }79 }80 if failure := valueMap(item["error"]); eventIDPattern.MatchString(plainString(failure["id"])) {81 row.Error = &chatActivityResult{ID: plainString(failure["id"]), Content: plainString(failure["content"])}82 }83 if row.State == "" {84 row.State = "open"85 }86 if row.Status == "" {87 row.Status = "queued"88 }89 row.CanCancel = actor != "" && row.Author == actor && row.State == "open"90 items = append(items, row)91 }92 for _, raw := range collaborationSlice(approvals, "items") {93 item := valueMap(raw)94 id := plainString(item["id"])95 if !eventIDPattern.MatchString(id) || actor == "" {96 continue97 }98 asker := plainString(item["asker"])99 can := approvalActorMayDecide(item, actor)100 if !can && asker != actor {101 continue102 }103 title := strings.TrimSpace(plainString(item["subject"]))104 if title == "" {105 title = plainString(item["content"])106 }107 state := plainString(item["state"])108 expires := unixSeconds(item["expires"])109 if state == "open" && expires > 0 && expires <= time.Now().Unix() {110 state = "expired"111 }112 answer := valueMap(item["answer"])113 decision := plainString(answer["decision"])114 row := chatActivityItem{Type: "approval", ID: id, Author: asker, Kind: plainString(item["kind"]), State: state, Title: truncateActivity(title, 150), Content: plainString(item["content"]), Answer: plainString(answer["content"]), Room: room, CreatedAt: unixSeconds(item["created_at"]), Expires: expires, CanDecide: can && state == "open", Question: plainString(item["type"]) == "question", Decision: decision}115 rawOptions := collaborationSlice(item, "options")116 if plainString(item["selection"]) != "" && (len(rawOptions) > 0 || eventHasTag(item, "tinyagent")) {117 row.Native = true118 row.Interaction = plainString(item["interaction"])119 row.Question = row.Interaction == "question"120 row.Multiple = plainString(item["selection"]) == "multiple"121 row.Freeform = plainString(item["freeform"]) == "true" || plainString(item["selection"]) == "text"122 for _, rawOption := range rawOptions {123 option := valueMap(rawOption)124 row.Options = append(row.Options, chatActivityOption{ID: plainString(option["id"]), Label: plainString(option["label"])})125 }126 row.Answer = nativeActivityAnswer(row.Answer, row.Options)127 }128 items = append(items, row)129 }130 sort.SliceStable(items, func(i, j int) bool {131 if items[i].CreatedAt == items[j].CreatedAt {132 return items[i].ID < items[j].ID133 }134 return items[i].CreatedAt < items[j].CreatedAt135 })136 return items137}138139func nativeActivityAnswer(content string, options []chatActivityOption) string {140 if content == "" || len(options) == 0 {141 return content142 }143 var answer struct {144 Choices []string `json:"choices"`145 Text string `json:"text"`146 }147 if err := json.Unmarshal([]byte(content), &answer); err != nil || answer.Text == "" {148 answer.Text = ""149 if err := json.Unmarshal([]byte(content), &answer.Choices); err != nil {150 answer.Choices = []string{content}151 }152 }153 labels := make(map[string]string, len(options))154 for _, option := range options {155 labels[option.ID] = option.Label156 }157 selected := make([]string, 0, len(answer.Choices))158 for _, choice := range answer.Choices {159 label, ok := labels[choice]160 if !ok {161 return content162 }163 selected = append(selected, label)164 }165 result := strings.Join(selected, "\n")166 if answer.Text != "" {167 if result != "" {168 result += "\n\n"169 }170 result += answer.Text171 }172 if result == "" {173 return content174 }175 return result176}177178// jobArtifactLink turns one artifact reference of a result into a link:179// an event id opens the event, a coordinate opens the addressable event180// and an http or https URL opens as given.181func jobArtifactLink(artifact map[string]any) (chatActivityLink, bool) {182 value := strings.TrimSpace(plainString(artifact["value"]))183 switch plainString(artifact["type"]) {184 case "e":185 if eventIDPattern.MatchString(value) {186 return chatActivityLink{Href: "/e/" + value, Label: "Event " + value[:8]}, true187 }188 case "a":189 parts := strings.SplitN(value, ":", 3)190 if len(parts) == 3 && eventIDPattern.MatchString(parts[1]) && parts[2] != "" {191 return chatActivityLink{Href: "/a/" + parts[0] + ":" + parts[1] + ":" + url.PathEscape(parts[2]), Label: parts[2]}, true192 }193 case "r":194 if strings.HasPrefix(value, "https://") || strings.HasPrefix(value, "http://") {195 return chatActivityLink{Href: value, Label: value}, true196 }197 }198 return chatActivityLink{}, false199}200201func eventHasTag(item map[string]any, name string) bool {202 for _, tag := range tagValues(item["event"], name) {203 if tag == "1" {204 return true205 }206 }207 return false208}209func truncateActivity(value string, max int) string {210 runes := []rune(value)211 if len(runes) > max {212 return string(runes[:max]) + "…"213 }214 return value215}216func approvalActorMayDecide(item map[string]any, actor string) bool {217 if actor == "" || plainString(item["asker"]) == actor {218 return false219 }220 for _, key := range stringValues(item["asked"]) {221 if key == actor {222 return true223 }224 }225 return false226}227func stringValues(value any) []string {228 raw, err := json.Marshal(value)229 if err != nil {230 return nil231 }232 var values []string233 if json.Unmarshal(raw, &values) != nil {234 return nil235 }236 return values237}238239// chatActivity renders the room page's panel: the cards plus the request240// form with the room's agent members.241func (a *App) chatActivity(ctx context.Context, actor, room, root string) chatActivityPage {242 page := a.chatActivityCards(ctx, actor, room, root)243 if _, ok := a.backend.(ChatActivityReader); ok && actor != "" && page.Error == "" {244 page.Ask = true245 page.Agents = a.roomAgents(ctx, actor, room)246 }247 return page248}249250func (a *App) chatActivityCards(ctx context.Context, actor, room, root string) chatActivityPage {251 endpoint := "/chat/activity?room=" + url.QueryEscape(room)252 if root != "" {253 endpoint += "&root=" + url.QueryEscape(root)254 }255 page := chatActivityPage{Endpoint: endpoint, Room: room, Root: root, Actor: actor}256 reader, ok := a.backend.(ChatActivityReader)257 if !ok {258 return page259 }260 result, err := reader.ReadChatActivity(ctx, actor, room, root)261 if err != nil {262 page.Error = "Agent activity is unavailable."263 return page264 }265 data := valueMap(result)266 page.Items = chatActivityView(map[string]any{"items": data["jobs"]}, map[string]any{"items": data["approvals"]}, actor, endpoint, room)267 return page268}269270// roomAgents lists the agent members the viewer may see in a room, from the271// same room read the room page uses, sorted by key so the form is stable.272func (a *App) roomAgents(ctx context.Context, actor, room string) []chatActivityAgent {273 var members []any274 if reader := a.roomsReader(); reader != nil {275 page, err := reader.ReadRoom(ctx, actor, room, "", 1)276 if err != nil {277 return nil278 }279 members = roomMembers(roomPageValueFromContract(page))280 } else {281 raw, _ := json.Marshal(map[string]any{"id": room, "limit": 1})282 result, err := a.backend.Query(ctx, "browseroom", []json.RawMessage{raw}, actor)283 if err != nil {284 return nil285 }286 members = roomMembers(result)287 }288 var agents []chatActivityAgent289 for _, row := range members {290 member := valueMap(row)291 pubkey := plainString(member["pubkey"])292 if isAgent, _ := member["agent"].(bool); !isAgent || !eventIDPattern.MatchString(pubkey) {293 continue294 }295 agents = append(agents, chatActivityAgent{PubKey: pubkey, Operator: plainString(member["operator"])})296 }297 sort.Slice(agents, func(i, j int) bool { return agents[i].PubKey < agents[j].PubKey })298 return agents299}300func (a *App) chatActivityHTTP(w http.ResponseWriter, r *http.Request) {301 w.Header().Set("Cache-Control", "private, no-store")302 actor, err := a.resolveActor(r)303 if err != nil {304 http.Error(w, "Sign in to see chat activity.", http.StatusUnauthorized)305 return306 }307 room, root := r.URL.Query().Get("room"), r.URL.Query().Get("root")308 if !roomIDPattern.MatchString(room) || root != "" && !eventIDPattern.MatchString(root) {309 http.Error(w, "Invalid room or thread.", http.StatusBadRequest)310 return311 }312 page := a.chatActivityCards(r.Context(), actor, room, root)313 if page.Error != "" {314 http.Error(w, page.Error, http.StatusForbidden)315 return316 }317 w.Header().Set("Content-Type", "text/html; charset=utf-8")318 var body bytes.Buffer319 if err := a.tmpl.ExecuteTemplate(&body, "chatActivityResponse", page); err != nil {320 http.Error(w, "Activity rendering failed: "+err.Error(), 500)321 return322 }323 _, _ = w.Write([]byte(injectBase(body.String(), requestPrefix(r))))324}