Welcome.
diff --git a/apps/api/internal/httpapi/direct.go b/apps/api/internal/httpapi/direct.go index 0537f59e6..f7d468a91 100644 --- a/apps/api/internal/httpapi/direct.go +++ b/apps/api/internal/httpapi/direct.go @@ -129,18 +129,20 @@ func (s *Server) listDirectMessages(w http.ResponseWriter, r *http.Request) { } func (s *Server) createDirectMessage(w http.ResponseWriter, r *http.Request) { + w.Header().Set(store.QuestionCapabilityHeader, store.QuestionCapabilityHeaderValue) act, err := s.currentActor(r) if err != nil { writeError(w, http.StatusUnauthorized, err) return } var body struct { - Body string `json:"body"` - QuotedMessageID string `json:"quoted_message_id"` - Nonce string `json:"nonce"` - UploadID string `json:"upload_id"` - Kind string `json:"kind"` - TurnID string `json:"turn_id"` + Body string `json:"body"` + QuotedMessageID string `json:"quoted_message_id"` + Nonce string `json:"nonce"` + UploadID string `json:"upload_id"` + Kind string `json:"kind"` + TurnID string `json:"turn_id"` + Question *store.QuestionSpec `json:"question"` } if err := readJSON(w, r, &body); err != nil { writeError(w, http.StatusBadRequest, err) @@ -154,13 +156,16 @@ func (s *Server) createDirectMessage(w http.ResponseWriter, r *http.Request) { if !ok { return } + if !requireQuestionAuthor(w, act, body.Question, kind) { + return + } if !s.requireBotDirectWorkspace(w, r, act, chi.URLParam(r, "conversation_id")) { return } if !s.requireCreateUpload(w, r, act, body.UploadID, body.Nonce, "", chi.URLParam(r, "conversation_id")) { return } - message, event, err := s.store.CreateDirectMessage(r.Context(), store.CreateDirectMessageInput{ConversationID: chi.URLParam(r, "conversation_id"), AuthorID: act.user.ID, Body: body.Body, QuotedMessageID: optionalString(body.QuotedMessageID), Nonce: body.Nonce, UploadID: body.UploadID, Kind: kind, TurnID: turnID}) + message, event, err := s.store.CreateDirectMessage(r.Context(), store.CreateDirectMessageInput{ConversationID: chi.URLParam(r, "conversation_id"), AuthorID: act.user.ID, Body: body.Body, QuotedMessageID: optionalString(body.QuotedMessageID), Nonce: body.Nonce, UploadID: body.UploadID, Kind: kind, TurnID: turnID, Question: body.Question}) if err == nil && event.ID != "" { s.publishEvent(r.Context(), event) if !store.IsActivityMessageKind(message.Kind) { diff --git a/apps/api/internal/httpapi/messages.go b/apps/api/internal/httpapi/messages.go index e4b5e8868..e7ab337f8 100644 --- a/apps/api/internal/httpapi/messages.go +++ b/apps/api/internal/httpapi/messages.go @@ -42,6 +42,7 @@ func (s *Server) listMessages(w http.ResponseWriter, r *http.Request) { } func (s *Server) createMessage(w http.ResponseWriter, r *http.Request) { + w.Header().Set(store.QuestionCapabilityHeader, store.QuestionCapabilityHeaderValue) act, err := s.currentActor(r) if err != nil { writeError(w, http.StatusUnauthorized, err) @@ -52,13 +53,14 @@ func (s *Server) createMessage(w http.ResponseWriter, r *http.Request) { return } var body struct { - Body string `json:"body"` - QuotedMessageID string `json:"quoted_message_id"` - Nonce string `json:"nonce"` - TopicID string `json:"topic_id"` - UploadID string `json:"upload_id"` - Kind string `json:"kind"` - TurnID string `json:"turn_id"` + Body string `json:"body"` + QuotedMessageID string `json:"quoted_message_id"` + Nonce string `json:"nonce"` + TopicID string `json:"topic_id"` + UploadID string `json:"upload_id"` + Kind string `json:"kind"` + TurnID string `json:"turn_id"` + Question *store.QuestionSpec `json:"question"` } if err := readJSON(w, r, &body); err != nil { writeError(w, http.StatusBadRequest, err) @@ -68,13 +70,16 @@ func (s *Server) createMessage(w http.ResponseWriter, r *http.Request) { if !ok { return } + if !requireQuestionAuthor(w, act, body.Question, kind) { + return + } if !s.requireBotChannelWorkspace(w, r, act, chi.URLParam(r, "channel_id")) { return } if !s.requireCreateUpload(w, r, act, body.UploadID, body.Nonce, chi.URLParam(r, "channel_id"), "") { return } - message, event, err := s.store.CreateMessage(r.Context(), store.CreateMessageInput{ChannelID: chi.URLParam(r, "channel_id"), AuthorID: act.user.ID, Body: body.Body, QuotedMessageID: optionalString(body.QuotedMessageID), Nonce: body.Nonce, TopicID: body.TopicID, UploadID: body.UploadID, Kind: kind, TurnID: turnID}) + message, event, err := s.store.CreateMessage(r.Context(), store.CreateMessageInput{ChannelID: chi.URLParam(r, "channel_id"), AuthorID: act.user.ID, Body: body.Body, QuotedMessageID: optionalString(body.QuotedMessageID), Nonce: body.Nonce, TopicID: body.TopicID, UploadID: body.UploadID, Kind: kind, TurnID: turnID, Question: body.Question}) if err == nil && event.ID != "" { s.publishEvent(r.Context(), event) if !store.IsActivityMessageKind(message.Kind) { @@ -245,6 +250,7 @@ func (s *Server) getThread(w http.ResponseWriter, r *http.Request) { } func (s *Server) createThreadReply(w http.ResponseWriter, r *http.Request) { + w.Header().Set(store.QuestionCapabilityHeader, store.QuestionCapabilityHeaderValue) act, err := s.currentActor(r) if err != nil { writeError(w, http.StatusUnauthorized, err) @@ -255,18 +261,22 @@ func (s *Server) createThreadReply(w http.ResponseWriter, r *http.Request) { return } var body struct { - Body string `json:"body"` - QuotedMessageID string `json:"quoted_message_id"` - Nonce string `json:"nonce"` + Body string `json:"body"` + QuotedMessageID string `json:"quoted_message_id"` + Nonce string `json:"nonce"` + Question *store.QuestionSpec `json:"question"` } if err := readJSON(w, r, &body); err != nil { writeError(w, http.StatusBadRequest, err) return } + if !requireQuestionAuthor(w, act, body.Question, "") { + return + } if _, ok := s.requireBotMessageResource(w, r, act, chi.URLParam(r, "message_id"), "dms:write"); !ok { return } - message, state, events, err := s.store.CreateThreadReply(r.Context(), store.CreateThreadReplyInput{RootMessageID: chi.URLParam(r, "message_id"), AuthorID: act.user.ID, Body: body.Body, QuotedMessageID: optionalString(body.QuotedMessageID), Nonce: body.Nonce}) + message, state, events, err := s.store.CreateThreadReply(r.Context(), store.CreateThreadReplyInput{RootMessageID: chi.URLParam(r, "message_id"), AuthorID: act.user.ID, Body: body.Body, QuotedMessageID: optionalString(body.QuotedMessageID), Nonce: body.Nonce, Question: body.Question}) if err == nil && len(events) > 0 { s.publishEvents(r.Context(), events) s.notifyMessageCreated(r.Context(), message, messageEventMentionedUserIDs(events)) diff --git a/apps/api/internal/httpapi/questions.go b/apps/api/internal/httpapi/questions.go new file mode 100644 index 000000000..ca4e278ae --- /dev/null +++ b/apps/api/internal/httpapi/questions.go @@ -0,0 +1,181 @@ +package httpapi + +import ( + "database/sql" + "errors" + "fmt" + "net/http" + "strconv" + "strings" + + "github.com/go-chi/chi/v5" + "github.com/openclaw/clickclack/apps/api/internal/store" +) + +// requireQuestionAuthor admits questions from bot tokens only; people answer them. +// Agent activity rows fold into progress blocks, so they cannot carry one. +func requireQuestionAuthor(w http.ResponseWriter, act actor, question *store.QuestionSpec, kind string) bool { + if question == nil { + return true + } + if act.botTokenID == "" { + writeError(w, http.StatusForbidden, errors.New("questions require a bot token")) + return false + } + if store.IsActivityMessageKind(kind) { + writeError(w, http.StatusBadRequest, errors.New("questions attach only to ordinary messages")) + return false + } + return true +} + +func (s *Server) answerQuestion(w http.ResponseWriter, r *http.Request) { + w.Header().Set(store.QuestionCapabilityHeader, store.QuestionCapabilityHeaderValue) + act, err := s.currentActor(r) + if err != nil { + writeError(w, http.StatusUnauthorized, err) + return + } + if act.botTokenID != "" { + writeError(w, http.StatusForbidden, errors.New("bot tokens cannot answer questions")) + return + } + if err := act.requireScope("messages:write"); err != nil { + writeError(w, http.StatusForbidden, err) + return + } + var body struct { + Answers map[string][]string `json:"answers"` + Skip bool `json:"skip"` + Nonce string `json:"nonce"` + ExpectedVersion *int64 `json:"expected_version"` + } + if err := readJSON(w, r, &body); err != nil { + writeError(w, http.StatusBadRequest, err) + return + } + if body.Skip && len(body.Answers) > 0 { + writeError(w, http.StatusBadRequest, errors.New("send answers or skip, not both")) + return + } + message, events, err := s.store.AnswerQuestion(r.Context(), store.AnswerQuestionInput{ + MessageID: chi.URLParam(r, "message_id"), + UserID: act.user.ID, + Answers: body.Answers, + Skip: body.Skip, + Nonce: body.Nonce, + ExpectedVersion: body.ExpectedVersion, + }) + if err != nil { + writeQuestionError(w, err) + return + } + s.publishEvents(r.Context(), events) + writeJSON(w, http.StatusOK, map[string]any{"message": message, "events": nonNilEvents(events)}) +} + +func (s *Server) resolveQuestion(w http.ResponseWriter, r *http.Request) { + act, err := s.currentActor(r) + if err != nil { + writeError(w, http.StatusUnauthorized, err) + return + } + if act.botTokenID == "" { + writeError(w, http.StatusForbidden, errors.New("only the bot that asked a question can resolve it")) + return + } + if err := act.requireScope("messages:write"); err != nil { + writeError(w, http.StatusForbidden, err) + return + } + var body struct { + Status string `json:"status"` + Note string `json:"note"` + Answers map[string][]string `json:"answers"` + ExpectedVersion *int64 `json:"expected_version"` + } + if err := readJSON(w, r, &body); err != nil { + writeError(w, http.StatusBadRequest, err) + return + } + if _, ok := s.requireBotMessageResource(w, r, act, chi.URLParam(r, "message_id"), "dms:write"); !ok { + return + } + message, event, err := s.store.ResolveQuestion(r.Context(), store.ResolveQuestionInput{ + MessageID: chi.URLParam(r, "message_id"), + BotUserID: act.user.ID, + Status: body.Status, + Note: body.Note, + Answers: body.Answers, + ExpectedVersion: body.ExpectedVersion, + }) + if err != nil { + writeQuestionError(w, err) + return + } + response := map[string]any{"message": message} + if event.ID != "" { + s.publishEvent(r.Context(), event) + response["event"] = event + } + writeJSON(w, http.StatusOK, response) +} + +func (s *Server) listBotQuestions(w http.ResponseWriter, r *http.Request) { + act, err := s.currentActor(r) + if err != nil { + writeError(w, http.StatusUnauthorized, err) + return + } + if act.botTokenID == "" { + writeError(w, http.StatusForbidden, errors.New("bot tokens only")) + return + } + if err := act.requireScope("messages:read"); err != nil { + writeError(w, http.StatusForbidden, err) + return + } + limit := 100 + if raw := strings.TrimSpace(r.URL.Query().Get("limit")); raw != "" { + parsed, err := strconv.Atoi(raw) + if err != nil || parsed < 1 || parsed > store.MaxBotQuestionPageSize { + writeError(w, http.StatusBadRequest, fmt.Errorf("limit must be between 1 and %d", store.MaxBotQuestionPageSize)) + return + } + limit = parsed + } + // Direct-message questions stay behind the same scope as direct messages. + includeDirect := act.requireScope("dms:read") == nil + questions, err := s.store.ListBotUnresolvedQuestions(r.Context(), act.workspaceID, act.user.ID, strings.TrimSpace(r.URL.Query().Get("after")), includeDirect, limit+1) + if err != nil { + writeStoreError(w, err) + return + } + var next *string + if len(questions) > limit { + questions = questions[:limit] + cursor := questions[limit-1].MessageID + next = &cursor + } + writeJSON(w, http.StatusOK, map[string]any{"questions": questions, "next_cursor": next}) +} + +func writeQuestionError(w http.ResponseWriter, err error) { + switch { + case errors.Is(err, sql.ErrNoRows): + writeError(w, http.StatusNotFound, errors.New("question not found")) + case errors.Is(err, store.ErrQuestionClosed), errors.Is(err, store.ErrQuestionConflict): + writeError(w, http.StatusConflict, err) + case errors.Is(err, store.ErrQuestionResponderRequired), errors.Is(err, store.ErrQuestionAuthorRequired): + writeError(w, http.StatusForbidden, err) + default: + writeStoreError(w, err) + } +} + +func nonNilEvents(events []store.Event) []store.Event { + if events == nil { + return []store.Event{} + } + return events +} diff --git a/apps/api/internal/httpapi/questions_test.go b/apps/api/internal/httpapi/questions_test.go new file mode 100644 index 000000000..306519693 --- /dev/null +++ b/apps/api/internal/httpapi/questions_test.go @@ -0,0 +1,270 @@ +package httpapi + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/coder/websocket" + "github.com/openclaw/clickclack/apps/api/internal/realtime" + "github.com/openclaw/clickclack/apps/api/internal/store" +) + +func TestQuestionHTTPLifecycle(t *testing.T) { + t.Parallel() + ctx := context.Background() + st := newEmptyHTTPStore(t) + owner, err := st.EnsureBootstrap(ctx, "Owner", "question-http-owner@example.com") + if err != nil { + t.Fatal(err) + } + workspaces, err := st.ListWorkspaces(ctx, owner.ID) + if err != nil { + t.Fatal(err) + } + workspace := workspaces[0] + channels, err := st.ListChannels(ctx, workspace.ID, owner.ID) + if err != nil { + t.Fatal(err) + } + member, err := st.CreateUser(ctx, store.CreateUserInput{DisplayName: "Member", Email: "question-http-member@example.com"}) + if err != nil { + t.Fatal(err) + } + if err := st.AddWorkspaceMember(ctx, workspace.ID, member.ID, store.WorkspaceRoleMember); err != nil { + t.Fatal(err) + } + _, botToken, err := st.CreateBot(ctx, store.CreateBotInput{WorkspaceID: workspace.ID, DisplayName: "Asker", Scopes: []string{"bot:write"}, CreatedBy: owner.ID}) + if err != nil { + t.Fatal(err) + } + _, otherToken, err := st.CreateBot(ctx, store.CreateBotInput{WorkspaceID: workspace.ID, DisplayName: "Other", Scopes: []string{"bot:write"}, CreatedBy: owner.ID}) + if err != nil { + t.Fatal(err) + } + server := httptest.NewServer(New(st, realtime.NewHub(), Options{UploadDir: filepath.Join(t.TempDir(), "uploads")}).Handler()) + t.Cleanup(server.Close) + + question := map[string]any{ + "external_id": "ask_http", + "expires_at": time.Now().Add(10 * time.Minute).UTC().Format(time.RFC3339), + "responder_user_ids": []string{member.ID}, + "items": []map[string]any{ + {"id": "ship_date", "header": "Fecha", "prompt": "¿Qué día?", "options": []map[string]string{{"label": "Lun 15 sep"}, {"label": "Mar 16 sep"}}}, + }, + } + messagesURL := server.URL + "/api/channels/" + channels[0].ID + "/messages" + expectStatusAsUser(t, owner.ID, http.MethodPost, messagesURL, strings.NewReader(`{"body":"human question","question":{"expires_at":"2099-01-01T00:00:00Z","items":[]}}`), http.StatusForbidden) + invalid, status := requestJSONWithBearerStatus[map[string]any](t, botToken.Token, http.MethodPost, messagesURL, map[string]any{"body": "empty", "question": map[string]any{"expires_at": question["expires_at"], "items": []any{}}}) + if status != http.StatusBadRequest || !strings.Contains(invalid["error"].(string), "invalid question") { + t.Fatalf("invalid question status=%d body=%#v", status, invalid) + } + + created, status, headers := requestJSONWithBearerHeaders[struct { + Message store.Message `json:"message"` + }](t, botToken.Token, http.MethodPost, messagesURL, map[string]any{"body": "Agent needs input: ¿Qué día?", "question": question}) + if status != http.StatusCreated || headers.Get(store.QuestionCapabilityHeader) != store.QuestionCapabilityHeaderValue { + t.Fatalf("create status=%d capability=%q", status, headers.Get(store.QuestionCapabilityHeader)) + } + if created.Message.Question == nil || created.Message.Question.Status != store.QuestionStatusOpen { + t.Fatalf("created message question = %#v", created.Message.Question) + } + answersURL := server.URL + "/api/messages/" + created.Message.ID + "/question/answers" + resolutionURL := server.URL + "/api/messages/" + created.Message.ID + "/question/resolution" + + botConn := dialRealtimeWithBotToken(t, server.URL, workspace.ID, botToken.Token) + t.Cleanup(func() { _ = botConn.Close(websocket.StatusNormalClosure, "done") }) + readEventType(t, botConn, "message.created") + + answer := `{"answers":{"ship_date":["Mar 16 sep"]},"nonce":"http-answer"}` + expectStatusWithBearer(t, botToken.Token, http.MethodPost, answersURL, strings.NewReader(answer), http.StatusForbidden) + expectStatusAsUser(t, owner.ID, http.MethodPost, answersURL, strings.NewReader(answer), http.StatusForbidden) + expectStatusAsUser(t, member.ID, http.MethodPost, answersURL, strings.NewReader(`{"answers":{"ship_date":["Mar 16 sep"]},"skip":true}`), http.StatusBadRequest) + expectStatusAsUser(t, member.ID, http.MethodPost, answersURL, strings.NewReader(`{"answers":{"ship_date":["Mar 16 sep"]},"nonce":"stale-view","expected_version":9}`), http.StatusConflict) + answered := postJSONAsUser[struct { + Message store.Message `json:"message"` + Events []store.Event `json:"events"` + }](t, member.ID, answersURL, map[string]any{"answers": map[string][]string{"ship_date": {"Mar 16 sep"}}, "nonce": "http-answer"}) + if answered.Message.Question.Status != store.QuestionStatusSubmitted || len(answered.Events) != 2 { + t.Fatalf("answer response = %#v", answered) + } + submitted := readEventType(t, botConn, "question.submitted") + if payload := submitted.Payload.(map[string]any); payload["message_id"] != created.Message.ID || payload["responder_id"] != member.ID || payload["answers"] != nil { + t.Fatalf("question.submitted payload = %#v", submitted.Payload) + } + expectStatusAsUser(t, member.ID, http.MethodPost, answersURL, strings.NewReader(`{"answers":{"ship_date":["Lun 15 sep"]},"nonce":"late"}`), http.StatusConflict) + + expectStatusWithBearer(t, otherToken.Token, http.MethodPost, resolutionURL, strings.NewReader(`{"status":"answered"}`), http.StatusForbidden) + expectStatusAsUser(t, owner.ID, http.MethodPost, resolutionURL, strings.NewReader(`{"status":"answered"}`), http.StatusForbidden) + expectStatusWithBearer(t, botToken.Token, http.MethodPost, resolutionURL, strings.NewReader(`{"status":"answered","expected_version":1}`), http.StatusConflict) + resolved, status := requestJSONWithBearerStatus[struct { + Message store.Message `json:"message"` + Event *store.Event `json:"event"` + }](t, botToken.Token, http.MethodPost, resolutionURL, map[string]any{"status": "answered", "expected_version": 2}) + if status != http.StatusOK || resolved.Message.Question.Status != store.QuestionStatusAnswered || resolved.Event == nil { + t.Fatalf("resolution status=%d body=%#v", status, resolved) + } + repeated, status := requestJSONWithBearerStatus[struct { + Event *store.Event `json:"event"` + }](t, botToken.Token, http.MethodPost, resolutionURL, map[string]any{"status": "answered"}) + if status != http.StatusOK || repeated.Event != nil { + t.Fatalf("repeated resolution status=%d body=%#v", status, repeated) + } + + pending, status := requestJSONWithBearerStatus[struct { + Message store.Message `json:"message"` + }](t, botToken.Token, http.MethodPost, messagesURL, map[string]any{"body": "still waiting", "question": question}) + if status != http.StatusCreated { + t.Fatalf("second question status=%d", status) + } + latest, status := requestJSONWithBearerStatus[struct { + Message store.Message `json:"message"` + }](t, botToken.Token, http.MethodPost, messagesURL, map[string]any{"body": "one more", "question": question}) + if status != http.StatusCreated { + t.Fatalf("third question status=%d", status) + } + type questionPage struct { + Questions []store.BotQuestion `json:"questions"` + NextCursor *string `json:"next_cursor"` + } + listURL := server.URL + "/api/bots/self/questions" + firstPage, status := getJSONWithBearerStatus[questionPage](t, botToken.Token, listURL+"?limit=1") + if status != http.StatusOK || len(firstPage.Questions) != 1 || firstPage.Questions[0].MessageID != pending.Message.ID || firstPage.NextCursor == nil || *firstPage.NextCursor != pending.Message.ID { + t.Fatalf("first unresolved page status=%d body=%#v", status, firstPage) + } + secondPage, status := getJSONWithBearerStatus[questionPage](t, botToken.Token, listURL+"?limit=1&after="+*firstPage.NextCursor) + if status != http.StatusOK || len(secondPage.Questions) != 1 || secondPage.Questions[0].MessageID != latest.Message.ID || secondPage.NextCursor != nil { + t.Fatalf("second unresolved page status=%d body=%#v", status, secondPage) + } + expectStatusWithBearer(t, botToken.Token, http.MethodGet, listURL+"?limit=0", nil, http.StatusBadRequest) + expectStatusAsUser(t, owner.ID, http.MethodGet, server.URL+"/api/bots/self/questions", nil, http.StatusForbidden) + expectStatusAsUser(t, member.ID, http.MethodPost, server.URL+"/api/messages/msg_missing/question/answers", strings.NewReader(`{"skip":true}`), http.StatusNotFound) +} + +func TestBotQuestionListingKeepsDirectScopesAndPages(t *testing.T) { + t.Parallel() + ctx := context.Background() + st := newEmptyHTTPStore(t) + owner, err := st.EnsureBootstrap(ctx, "Owner", "question-list-owner@example.com") + if err != nil { + t.Fatal(err) + } + workspaces, err := st.ListWorkspaces(ctx, owner.ID) + if err != nil { + t.Fatal(err) + } + workspace := workspaces[0] + channels, err := st.ListChannels(ctx, workspace.ID, owner.ID) + if err != nil { + t.Fatal(err) + } + member, err := st.CreateUser(ctx, store.CreateUserInput{DisplayName: "Member", Email: "question-list-member@example.com"}) + if err != nil { + t.Fatal(err) + } + if err := st.AddWorkspaceMember(ctx, workspace.ID, member.ID, store.WorkspaceRoleMember); err != nil { + t.Fatal(err) + } + bot, fullToken, err := st.CreateBot(ctx, store.CreateBotInput{WorkspaceID: workspace.ID, DisplayName: "Asker", Scopes: []string{"bot:write"}, CreatedBy: owner.ID}) + if err != nil { + t.Fatal(err) + } + readOnlyToken, err := st.CreateBotToken(ctx, store.CreateBotTokenInput{WorkspaceID: workspace.ID, BotUserID: bot.ID, Name: "read only", Scopes: []string{"messages:read"}, CreatedBy: owner.ID}) + if err != nil { + t.Fatal(err) + } + activityToken, err := st.CreateBotToken(ctx, store.CreateBotTokenInput{WorkspaceID: workspace.ID, BotUserID: bot.ID, Name: "activity", Scopes: []string{"bot:write", store.AgentActivityWriteScope}, CreatedBy: owner.ID}) + if err != nil { + t.Fatal(err) + } + server := httptest.NewServer(New(st, realtime.NewHub(), Options{UploadDir: filepath.Join(t.TempDir(), "uploads")}).Handler()) + t.Cleanup(server.Close) + + spec := func() *store.QuestionSpec { + return &store.QuestionSpec{ + ExpiresAt: time.Now().Add(10 * time.Minute).UTC().Format(time.RFC3339), + Items: []store.QuestionItem{{ID: "ok", Header: "OK", Prompt: "Proceed?", Options: []store.QuestionOption{{Label: "Yes"}, {Label: "No"}}}}, + } + } + conversation, err := st.CreateDirectConversation(ctx, store.CreateDirectConversationInput{WorkspaceID: workspace.ID, UserID: bot.ID, MemberIDs: []string{member.ID}}) + if err != nil { + t.Fatal(err) + } + direct, _, err := st.CreateDirectMessage(ctx, store.CreateDirectMessageInput{ConversationID: conversation.ID, AuthorID: bot.ID, Body: "private question", Question: spec()}) + if err != nil { + t.Fatal(err) + } + for index := range store.MaxBotQuestionPageSize { + if _, _, err := st.CreateMessage(ctx, store.CreateMessageInput{ChannelID: channels[0].ID, AuthorID: bot.ID, Body: fmt.Sprintf("question %d", index), Question: spec()}); err != nil { + t.Fatal(err) + } + } + + type page struct { + Questions []store.BotQuestion `json:"questions"` + NextCursor *string `json:"next_cursor"` + } + listURL := server.URL + "/api/bots/self/questions?limit=" + fmt.Sprint(store.MaxBotQuestionPageSize) + first, status := getJSONWithBearerStatus[page](t, fullToken.Token, listURL) + if status != http.StatusOK || len(first.Questions) != store.MaxBotQuestionPageSize || first.NextCursor == nil { + t.Fatalf("full page status=%d questions=%d next=%v", status, len(first.Questions), first.NextCursor) + } + second, status := getJSONWithBearerStatus[page](t, fullToken.Token, listURL+"&after="+*first.NextCursor) + if status != http.StatusOK || len(second.Questions) != 1 || second.NextCursor != nil { + t.Fatalf("remainder status=%d questions=%d next=%v", status, len(second.Questions), second.NextCursor) + } + seenDirect := false + for _, question := range append(first.Questions, second.Questions...) { + seenDirect = seenDirect || question.MessageID == direct.ID + } + if !seenDirect { + t.Fatal("a token with dms:read must reconcile direct-message questions") + } + restricted, status := getJSONWithBearerStatus[page](t, readOnlyToken.Token, listURL) + if status != http.StatusOK || len(restricted.Questions) != store.MaxBotQuestionPageSize || restricted.NextCursor != nil { + t.Fatalf("read-only status=%d questions=%d next=%v", status, len(restricted.Questions), restricted.NextCursor) + } + for _, question := range restricted.Questions { + if question.DirectConversationID != "" { + t.Fatalf("a token without dms:read received direct question %#v", question) + } + } + if _, err := st.RevokeBotToken(ctx, readOnlyToken.ID, owner.ID); err != nil { + t.Fatal(err) + } + expectStatusWithBearer(t, readOnlyToken.Token, http.MethodGet, listURL, nil, http.StatusUnauthorized) + + activity := fmt.Sprintf(`{"body":"running","kind":"agent_tool","turn_id":"turn_question","question":{"expires_at":%q,"items":[{"id":"ok","header":"OK","prompt":"Proceed?"}]}}`, time.Now().Add(10*time.Minute).UTC().Format(time.RFC3339)) + expectStatusWithBearer(t, activityToken.Token, http.MethodPost, server.URL+"/api/channels/"+channels[0].ID+"/messages", strings.NewReader(activity), http.StatusBadRequest) +} + +func requestJSONWithBearerHeaders[T any](t *testing.T, token, method, endpoint string, body any) (T, int, http.Header) { + t.Helper() + encoded, err := json.Marshal(body) + if err != nil { + t.Fatal(err) + } + req, err := http.NewRequest(method, endpoint, bytes.NewReader(encoded)) + if err != nil { + t.Fatal(err) + } + req.Header.Set("Authorization", "Bearer "+token) + req.Header.Set("Content-Type", "application/json") + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatal(err) + } + defer resp.Body.Close() + var out T + if err := json.NewDecoder(resp.Body).Decode(&out); err != nil { + t.Fatalf("%s %s: decode response: %v", method, endpoint, err) + } + return out, resp.StatusCode, resp.Header +} diff --git a/apps/api/internal/httpapi/server.go b/apps/api/internal/httpapi/server.go index ace8e7dbd..62ce3d6d2 100644 --- a/apps/api/internal/httpapi/server.go +++ b/apps/api/internal/httpapi/server.go @@ -222,6 +222,7 @@ func (s *Server) Handler() http.Handler { r.Post("/workspaces/{workspace_id}/bots/{bot_user_id}/setup-codes", s.createWorkspaceBotSetupCode) r.Post("/bot-setup-codes/claim", s.claimBotSetupCode) r.Put("/bots/self/commands", s.setBotCommands) + r.Get("/bots/self/questions", s.listBotQuestions) r.Delete("/bots/{bot_user_id}", s.deleteBot) r.Get("/bots/{bot_user_id}/tokens", s.listBotTokens) r.Post("/bots/{bot_user_id}/tokens", s.createBotToken) @@ -258,6 +259,8 @@ func (s *Server) Handler() http.Handler { r.Post("/messages/{message_id}/route", s.ensureMessageRoute) r.Get("/messages/{message_id}/thread", s.getThread) r.Post("/messages/{message_id}/thread/replies", s.createThreadReply) + r.Post("/messages/{message_id}/question/answers", s.answerQuestion) + r.Post("/messages/{message_id}/question/resolution", s.resolveQuestion) r.Post("/messages/{message_id}/reactions", s.addReaction) r.Delete("/messages/{message_id}/reactions/{emoji}", s.removeReaction) r.Get("/realtime/events", s.listEvents) diff --git a/apps/api/internal/store/events.go b/apps/api/internal/store/events.go index 4cc338356..f3fe3c331 100644 --- a/apps/api/internal/store/events.go +++ b/apps/api/internal/store/events.go @@ -15,6 +15,7 @@ var DurableEventTypes = []string{ "message.updated", "pin.added", "pin.removed", + "question.submitted", "reaction.added", "reaction.removed", "thread.reply_created", diff --git a/apps/api/internal/store/postgres/dms.go b/apps/api/internal/store/postgres/dms.go index 7fa602e4f..044d5fc14 100644 --- a/apps/api/internal/store/postgres/dms.go +++ b/apps/api/internal/store/postgres/dms.go @@ -234,6 +234,10 @@ func (s *Store) CreateDirectMessage(ctx context.Context, input store.CreateDirec if err := requireDirectActivePeerTx(ctx, tx, input.ConversationID, input.AuthorID); err != nil { return store.Message{}, store.Event{}, err } + question, err := prepareQuestion(input.Question) + if err != nil { + return store.Message{}, store.Event{}, err + } if err := lockMessageSequenceTx(ctx, tx, "direct", input.ConversationID); err != nil { return store.Message{}, store.Event{}, err } @@ -263,11 +267,19 @@ func (s *Store) CreateDirectMessage(ctx context.Context, input store.CreateDirec if existing.DirectConversationID != input.ConversationID || existing.ChannelID != "" || existing.ParentMessageID != nil || existing.Body != body || existing.Kind != kind || existing.TurnID != input.TurnID || !sameQuotedMessageID(existing, quotedID) { return store.Message{}, store.Event{}, store.ErrClientNonceConflict } + if matches, err := questionReplayMatchesTx(ctx, tx, existing.ID, question); err != nil { + return store.Message{}, store.Event{}, err + } else if !matches { + return store.Message{}, store.Event{}, store.ErrClientNonceConflict + } existing, err = hydrateMessageCreateReplay(ctx, tx, existing, input.UploadID) return existing, store.Event{}, err } else if !errors.Is(err, sql.ErrNoRows) { return store.Message{}, store.Event{}, err } + if err := validateNewQuestionTx(ctx, tx, workspaceID, "", input.ConversationID, kind, question); err != nil { + return store.Message{}, store.Event{}, err + } if quotedID != "" { snap, authorID, err := resolveQuoteRefTx(ctx, tx, quotedID, quoteScope{kind: "dm", directConversationID: input.ConversationID}) if err != nil { @@ -314,6 +326,10 @@ func (s *Store) CreateDirectMessage(ctx context.Context, input store.CreateDirec } attachedUpload = &upload } + createdQuestion, err := insertMessageQuestionTx(ctx, tx, id, workspaceID, input.AuthorID, createdAt, question) + if err != nil { + return store.Message{}, store.Event{}, err + } if err := qtx.UnhideDirectConversationForMembers(ctx, input.ConversationID); err != nil { return store.Message{}, store.Event{}, err } @@ -328,7 +344,7 @@ func (s *Store) CreateDirectMessage(ctx context.Context, input store.CreateDirec if input.TurnID != "" { dmEventFields["turn_id"] = input.TurnID } - event, err := insertEventWithRecipients(ctx, tx, workspaceID, "", "message.created", &seq, eventPayload(ctx, dmEventFields, nonce), recipients) + event, err := insertEventWithRecipientsAndMentions(ctx, tx, workspaceID, "", "message.created", &seq, eventPayload(ctx, dmEventFields, nonce), recipients, mergeMentionedUserIDs(nil, question)) if err != nil { return store.Message{}, store.Event{}, err } @@ -339,6 +355,7 @@ func (s *Store) CreateDirectMessage(ctx context.Context, input store.CreateDirec if attachedUpload != nil { msg.Attachments = []store.Upload{*attachedUpload} } + msg.Question = createdQuestion return msg, event, tx.Commit() } diff --git a/apps/api/internal/store/postgres/message_pages.go b/apps/api/internal/store/postgres/message_pages.go index 99d6781a1..b1c1a1675 100644 --- a/apps/api/internal/store/postgres/message_pages.go +++ b/apps/api/internal/store/postgres/message_pages.go @@ -81,6 +81,10 @@ func (s *Store) listMessagePage(ctx context.Context, scope messagePageScope, req if err != nil { return store.MessagePage{}, err } + messages, err = s.hydrateQuestions(ctx, messages) + if err != nil { + return store.MessagePage{}, err + } messages, err = s.hydrateThreadStates(ctx, messages) if err != nil { return store.MessagePage{}, err diff --git a/apps/api/internal/store/postgres/migrations/0036_message_questions.sql b/apps/api/internal/store/postgres/migrations/0036_message_questions.sql new file mode 100644 index 000000000..55378ce91 --- /dev/null +++ b/apps/api/internal/store/postgres/migrations/0036_message_questions.sql @@ -0,0 +1,28 @@ +-- Structured questions that bots attach to their messages. The question itself +-- is immutable; the first valid answer and the bot's recorded outcome live on +-- the same row so each transition is one conditional update. +CREATE TABLE IF NOT EXISTS message_questions ( + message_id TEXT PRIMARY KEY REFERENCES messages(id) ON DELETE CASCADE, + workspace_id TEXT NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE, + bot_user_id TEXT NOT NULL REFERENCES users(id), + external_id TEXT NOT NULL DEFAULT '', + spec_json TEXT NOT NULL, + responder_user_ids TEXT NOT NULL DEFAULT '[]', + allow_skip BIGINT NOT NULL DEFAULT 1 CHECK (allow_skip IN (0, 1)), + expires_at TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'open' + CHECK (status IN ('open', 'submitted', 'answered', 'cancelled', 'expired', 'failed')), + response_json TEXT NOT NULL DEFAULT '', + response_source TEXT NOT NULL DEFAULT '', + responded_by TEXT REFERENCES users(id) ON DELETE SET NULL, + responded_at TEXT, + response_nonce TEXT NOT NULL DEFAULT '', + note TEXT NOT NULL DEFAULT '', + resolved_at TEXT, + version BIGINT NOT NULL DEFAULT 1, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL +); + +CREATE INDEX IF NOT EXISTS idx_message_questions_bot_status + ON message_questions(workspace_id, bot_user_id, status, message_id); diff --git a/apps/api/internal/store/postgres/pins.go b/apps/api/internal/store/postgres/pins.go index 29f14b1ad..bba7ab709 100644 --- a/apps/api/internal/store/postgres/pins.go +++ b/apps/api/internal/store/postgres/pins.go @@ -160,6 +160,10 @@ func (s *Store) ListPinnedMessages(ctx context.Context, channelID, userID string if err != nil { return nil, err } + messages, err = s.hydrateQuestions(ctx, messages) + if err != nil { + return nil, err + } messages, err = s.hydrateReactions(ctx, userID, messages) if err != nil { return nil, err diff --git a/apps/api/internal/store/postgres/postgres.go b/apps/api/internal/store/postgres/postgres.go index c15ebd040..bcac4c022 100644 --- a/apps/api/internal/store/postgres/postgres.go +++ b/apps/api/internal/store/postgres/postgres.go @@ -611,6 +611,10 @@ func (s *Store) GetMessage(ctx context.Context, messageID, userID string) (store if err != nil { return store.Message{}, err } + messages, err = s.hydrateQuestions(ctx, messages) + if err != nil { + return store.Message{}, err + } return messages[0], nil } @@ -663,6 +667,10 @@ func (s *Store) CreateMessage(ctx context.Context, input store.CreateMessageInpu if err := requireTopicTx(ctx, tx, workspaceID, input.ChannelID, input.TopicID); err != nil { return store.Message{}, store.Event{}, err } + question, err := prepareQuestion(input.Question) + if err != nil { + return store.Message{}, store.Event{}, err + } if err := lockMessageSequenceTx(ctx, tx, "channel", input.ChannelID); err != nil { return store.Message{}, store.Event{}, err } @@ -692,6 +700,11 @@ func (s *Store) CreateMessage(ctx context.Context, input store.CreateMessageInpu if existing.ChannelID != input.ChannelID || existing.DirectConversationID != "" || existing.ParentMessageID != nil || existing.Body != body || existing.TopicID != input.TopicID || existing.Kind != kind || existing.TurnID != input.TurnID || !sameQuotedMessageID(existing, quotedID) { return store.Message{}, store.Event{}, store.ErrClientNonceConflict } + if matches, err := questionReplayMatchesTx(ctx, tx, existing.ID, question); err != nil { + return store.Message{}, store.Event{}, err + } else if !matches { + return store.Message{}, store.Event{}, store.ErrClientNonceConflict + } if err := requireMessageAccessTx(ctx, tx, existing, input.AuthorID); err != nil { return store.Message{}, store.Event{}, err } @@ -700,6 +713,9 @@ func (s *Store) CreateMessage(ctx context.Context, input store.CreateMessageInpu } else if !errors.Is(err, sql.ErrNoRows) { return store.Message{}, store.Event{}, err } + if err := validateNewQuestionTx(ctx, tx, workspaceID, input.ChannelID, "", kind, question); err != nil { + return store.Message{}, store.Event{}, err + } if err := requireCanPostTx(ctx, tx, workspaceID, input.ChannelID, input.AuthorID); err != nil { return store.Message{}, store.Event{}, err } @@ -753,6 +769,10 @@ func (s *Store) CreateMessage(ctx context.Context, input store.CreateMessageInpu } attachedUpload = &upload } + createdQuestion, err := insertMessageQuestionTx(ctx, tx, id, workspaceID, input.AuthorID, createdAt, question) + if err != nil { + return store.Message{}, store.Event{}, err + } eventFields := map[string]string{"message_id": id, "author_id": input.AuthorID} if input.TopicID != "" { eventFields["topic_id"] = input.TopicID @@ -767,6 +787,7 @@ func (s *Store) CreateMessage(ctx context.Context, input store.CreateMessageInpu if err != nil { return store.Message{}, store.Event{}, err } + mentionedIDs = mergeMentionedUserIDs(mentionedIDs, question) event, err := insertEventWithRecipientsAndMentions(ctx, tx, workspaceID, input.ChannelID, "message.created", &seq, eventPayload(ctx, eventFields, nonce), nil, mentionedIDs) if err != nil { return store.Message{}, store.Event{}, err @@ -778,6 +799,7 @@ func (s *Store) CreateMessage(ctx context.Context, input store.CreateMessageInpu if attachedUpload != nil { msg.Attachments = []store.Upload{*attachedUpload} } + msg.Question = createdQuestion return msg, event, tx.Commit() } @@ -802,6 +824,10 @@ func (s *Store) CreateThreadReply(ctx context.Context, input store.CreateThreadR if err != nil { return store.Message{}, store.ThreadState{}, nil, err } + question, err := prepareQuestion(input.Question) + if err != nil { + return store.Message{}, store.ThreadState{}, nil, err + } if err := lockMessageSequenceTx(ctx, tx, "thread", root.ID); err != nil { return store.Message{}, store.ThreadState{}, nil, err } @@ -827,6 +853,16 @@ func (s *Store) CreateThreadReply(ctx context.Context, input store.CreateThreadR if existing.ThreadRootID != root.ID || existing.ParentMessageID == nil || *existing.ParentMessageID != root.ID || existing.Body != body || !sameQuotedMessageID(existing, quotedID) { return store.Message{}, store.ThreadState{}, nil, store.ErrClientNonceConflict } + if matches, err := questionReplayMatchesTx(ctx, tx, existing.ID, question); err != nil { + return store.Message{}, store.ThreadState{}, nil, err + } else if !matches { + return store.Message{}, store.ThreadState{}, nil, store.ErrClientNonceConflict + } + if hydrated, err := hydrateQuestions(ctx, tx, []store.Message{existing}); err != nil { + return store.Message{}, store.ThreadState{}, nil, err + } else { + existing = hydrated[0] + } stateRow, err := qtx.GetThreadState(ctx, root.ID) if err != nil { return store.Message{}, store.ThreadState{}, nil, err @@ -835,6 +871,9 @@ func (s *Store) CreateThreadReply(ctx context.Context, input store.CreateThreadR } else if !errors.Is(err, sql.ErrNoRows) { return store.Message{}, store.ThreadState{}, nil, err } + if err := validateNewQuestionTx(ctx, tx, root.WorkspaceID, root.ChannelID, root.DirectConversationID, "", question); err != nil { + return store.Message{}, store.ThreadState{}, nil, err + } if root.DirectConversationID != "" { if err := requireCanSendDirectTx(ctx, tx, root.WorkspaceID, input.AuthorID); err != nil { return store.Message{}, store.ThreadState{}, nil, err @@ -892,6 +931,10 @@ func (s *Store) CreateThreadReply(ctx context.Context, input store.CreateThreadR if err != nil { return store.Message{}, store.ThreadState{}, nil, err } + createdQuestion, err := insertMessageQuestionTx(ctx, tx, id, root.WorkspaceID, input.AuthorID, createdAt, question) + if err != nil { + return store.Message{}, store.ThreadState{}, nil, err + } replyPayload := eventPayload(ctx, map[string]string{ "message_id": id, "root_message_id": root.ID, @@ -910,6 +953,7 @@ func (s *Store) CreateThreadReply(ctx context.Context, input store.CreateThreadR if err != nil { return store.Message{}, store.ThreadState{}, nil, err } + mentionedIDs = mergeMentionedUserIDs(mentionedIDs, question) // recipients is a privacy boundary for direct conversations, not a thread // follower list. Mention metadata must never grant a workspace user access // to a DM they are not already participating in. @@ -925,6 +969,7 @@ func (s *Store) CreateThreadReply(ctx context.Context, input store.CreateThreadR if err != nil { return store.Message{}, store.ThreadState{}, nil, err } + msg.Question = createdQuestion return msg, state, []store.Event{replyEvent, stateEvent}, tx.Commit() } diff --git a/apps/api/internal/store/postgres/questions.go b/apps/api/internal/store/postgres/questions.go new file mode 100644 index 000000000..9ea673a46 --- /dev/null +++ b/apps/api/internal/store/postgres/questions.go @@ -0,0 +1,538 @@ +package postgres + +import ( + "context" + "database/sql" + "encoding/json" + "errors" + "fmt" + "slices" + "strings" + "time" + "unicode/utf8" + + "github.com/openclaw/clickclack/apps/api/internal/store" + "github.com/openclaw/clickclack/apps/api/internal/store/postgres/storedb" +) + +// questionDocument is the immutable part of a question stored in spec_json. +type questionDocument struct { + Title string `json:"title,omitempty"` + Items []store.QuestionItem `json:"items"` +} + +type storedQuestionResponse struct { + Answers map[string][]string `json:"answers,omitempty"` + Skipped bool `json:"skipped,omitempty"` +} + +// prepareQuestion normalizes a bot's question. It reads nothing, so a nonce +// replay can compare it before any rule that depends on the current time or +// membership. +func prepareQuestion(input *store.QuestionSpec) (*store.QuestionSpec, error) { + if input == nil { + return nil, nil + } + spec, err := store.NormalizeQuestionSpec(*input) + if err != nil { + return nil, err + } + return &spec, nil +} + +// validateNewQuestionTx checks what a newly created question must satisfy now: +// an ordinary message, its deadline, and responders who can read the conversation. +func validateNewQuestionTx(ctx context.Context, tx *sql.Tx, workspaceID, channelID, directConversationID, kind string, spec *store.QuestionSpec) error { + if spec == nil { + return nil + } + if store.IsActivityMessageKind(kind) { + return fmt.Errorf("%w: %s", store.ErrInvalidQuestion, "questions attach only to ordinary messages") + } + if err := store.ValidateQuestionLifetime(*spec, time.Now()); err != nil { + return err + } + for _, userID := range spec.ResponderUserIDs { + var one int + query := ` + SELECT 1 + FROM workspace_members wm + JOIN users u ON u.id = wm.user_id + WHERE wm.workspace_id = $1 AND wm.user_id = $2 AND u.kind = 'human'` + args := []any{workspaceID, userID} + if directConversationID != "" { + query += ` AND EXISTS (SELECT 1 FROM direct_conversation_members dcm WHERE dcm.conversation_id = $3 AND dcm.user_id = wm.user_id)` + args = append(args, directConversationID) + } + err := tx.QueryRowContext(ctx, query, args...).Scan(&one) + if err == nil && directConversationID == "" { + err = requireGuestChannelAccessTx(ctx, tx, workspaceID, channelID, userID) + } + if errors.Is(err, sql.ErrNoRows) || errors.Is(err, store.ErrModerationRestricted) { + return fmt.Errorf("%w: %s", store.ErrInvalidQuestion, "responder_user_ids must be people who can read this conversation") + } else if err != nil { + return err + } + } + return nil +} + +func insertMessageQuestionTx(ctx context.Context, tx *sql.Tx, messageID, workspaceID, botUserID, createdAt string, spec *store.QuestionSpec) (*store.MessageQuestion, error) { + if spec == nil { + return nil, nil + } + document, err := json.Marshal(questionDocument{Title: spec.Title, Items: spec.Items}) + if err != nil { + return nil, err + } + responders, err := json.Marshal(nonNilStrings(spec.ResponderUserIDs)) + if err != nil { + return nil, err + } + allowSkip := int64(0) + if spec.AllowSkip == nil || *spec.AllowSkip { + allowSkip = 1 + } + if err := storedb.New(tx).InsertMessageQuestion(ctx, storedb.InsertMessageQuestionParams{ + MessageID: messageID, + WorkspaceID: workspaceID, + BotUserID: botUserID, + ExternalID: spec.ExternalID, + SpecJson: string(document), + ResponderUserIds: string(responders), + AllowSkip: allowSkip, + ExpiresAt: spec.ExpiresAt, + CreatedAt: createdAt, + }); err != nil { + return nil, err + } + return &store.MessageQuestion{ + Status: store.QuestionStatusOpen, + ExternalID: spec.ExternalID, + Title: spec.Title, + ExpiresAt: spec.ExpiresAt, + AllowSkip: allowSkip == 1, + Items: spec.Items, + ResponderUserIDs: spec.ResponderUserIDs, + Version: 1, + }, nil +} + +// questionReplayMatchesTx reports whether a nonce replay repeats the same question. +func questionReplayMatchesTx(ctx context.Context, tx *sql.Tx, messageID string, spec *store.QuestionSpec) (bool, error) { + row, err := storedb.New(tx).GetMessageQuestion(ctx, messageID) + if errors.Is(err, sql.ErrNoRows) { + return spec == nil, nil + } + if err != nil || spec == nil { + return false, err + } + document, err := json.Marshal(questionDocument{Title: spec.Title, Items: spec.Items}) + if err != nil { + return false, err + } + var responders []string + if err := json.Unmarshal([]byte(row.ResponderUserIds), &responders); err != nil { + return false, err + } + allowSkip := spec.AllowSkip == nil || *spec.AllowSkip + return row.SpecJson == string(document) && row.ExternalID == spec.ExternalID && row.ExpiresAt == spec.ExpiresAt && + (row.AllowSkip == 1) == allowSkip && slices.Equal(responders, nonNilStrings(spec.ResponderUserIDs)), nil +} + +func (s *Store) hydrateQuestions(ctx context.Context, messages []store.Message) ([]store.Message, error) { + return hydrateQuestions(ctx, s.db, messages) +} + +func hydrateQuestions(ctx context.Context, db storedb.DBTX, messages []store.Message) ([]store.Message, error) { + indexByID := make(map[string]int, len(messages)) + args := make([]any, 0, len(messages)) + for index, message := range messages { + if message.DeletedAt != nil { + continue + } + indexByID[message.ID] = index + args = append(args, message.ID) + } + if len(args) == 0 { + return messages, nil + } + rows, err := db.QueryContext(ctx, ` + SELECT q.message_id, q.external_id, q.spec_json, q.responder_user_ids, q.allow_skip, q.expires_at, q.status, + q.response_json, q.response_source, q.responded_at, q.note, q.resolved_at, q.version, + u.id, u.kind, u.owner_user_id, u.display_name, u.handle, u.avatar_url, u.created_at + FROM message_questions q + LEFT JOIN users u ON u.id = q.responded_by + WHERE q.message_id IN (`+pgPlaceholders(len(args), 1)+`)`, args...) + if err != nil { + return nil, err + } + defer rows.Close() + now := time.Now() + for rows.Next() { + var row storedb.MessageQuestion + var responderID, responderKind, responderOwner, responderName, responderHandle, responderAvatar, responderCreated sql.NullString + if err := rows.Scan(&row.MessageID, &row.ExternalID, &row.SpecJson, &row.ResponderUserIds, &row.AllowSkip, &row.ExpiresAt, &row.Status, + &row.ResponseJson, &row.ResponseSource, &row.RespondedAt, &row.Note, &row.ResolvedAt, &row.Version, + &responderID, &responderKind, &responderOwner, &responderName, &responderHandle, &responderAvatar, &responderCreated); err != nil { + return nil, err + } + var responder *store.User + if responderID.Valid { + user := storeUserFromDB(responderID.String, responderKind.String, responderOwner, responderName.String, responderHandle.String, responderAvatar.String, responderCreated.String) + responder = &user + } + question, err := messageQuestionFromRow(row, responder, now) + if err != nil { + return nil, err + } + if index, ok := indexByID[row.MessageID]; ok { + messages[index].Question = &question + } + } + return messages, rows.Err() +} + +func messageQuestionFromRow(row storedb.MessageQuestion, responder *store.User, now time.Time) (store.MessageQuestion, error) { + var document questionDocument + if err := json.Unmarshal([]byte(row.SpecJson), &document); err != nil { + return store.MessageQuestion{}, err + } + question := store.MessageQuestion{ + Status: store.EffectiveQuestionStatus(row.Status, row.ExpiresAt, now), + ExternalID: row.ExternalID, + Title: document.Title, + ExpiresAt: row.ExpiresAt, + AllowSkip: row.AllowSkip == 1, + Items: document.Items, + Note: row.Note, + ResolvedAt: ptrFromNull(row.ResolvedAt), + Version: row.Version, + } + if err := json.Unmarshal([]byte(row.ResponderUserIds), &question.ResponderUserIDs); err != nil { + return store.MessageQuestion{}, err + } + if len(question.ResponderUserIDs) == 0 { + question.ResponderUserIDs = nil + } + if row.ResponseJson != "" { + var response storedQuestionResponse + if err := json.Unmarshal([]byte(row.ResponseJson), &response); err != nil { + return store.MessageQuestion{}, err + } + question.Response = &store.QuestionResponse{ + Answers: response.Answers, + Skipped: response.Skipped, + Source: row.ResponseSource, + Responder: responder, + RespondedAt: row.RespondedAt.String, + } + } + return question, nil +} + +func (s *Store) AnswerQuestion(ctx context.Context, input store.AnswerQuestionInput) (store.Message, []store.Event, error) { + nonce, err := store.NormalizeClientNonce(input.Nonce) + if err != nil { + return store.Message{}, nil, err + } + tx, err := s.db.BeginTx(ctx, nil) + if err != nil { + return store.Message{}, nil, err + } + defer tx.Rollback() + qtx := s.q.WithTx(tx) + msg, err := getMessageTx(ctx, tx, input.MessageID) + if err != nil { + return store.Message{}, nil, err + } + if msg.DeletedAt != nil { + return store.Message{}, nil, sql.ErrNoRows + } + row, err := qtx.GetMessageQuestion(ctx, msg.ID) + if err != nil { + return store.Message{}, nil, err + } + if err := requireMessageAccessTx(ctx, tx, msg, input.UserID); err != nil { + return store.Message{}, nil, err + } + if msg.DirectConversationID != "" { + err = requireCanSendDirectTx(ctx, tx, msg.WorkspaceID, input.UserID) + } else { + err = requireNoModerationBlockTx(ctx, tx, msg.WorkspaceID, input.UserID) + } + if err != nil { + return store.Message{}, nil, err + } + var responders []string + if err := json.Unmarshal([]byte(row.ResponderUserIds), &responders); err != nil { + return store.Message{}, nil, err + } + if len(responders) > 0 && !slices.Contains(responders, input.UserID) { + return store.Message{}, nil, store.ErrQuestionResponderRequired + } + if nonce != "" && row.ResponseNonce == nonce && row.RespondedBy.String == input.UserID { + // Release the transaction first: reads below use the store's connection pool. + if err := tx.Rollback(); err != nil { + return store.Message{}, nil, err + } + message, err := s.hydrateQuestionMessage(ctx, msg, input.UserID) + return message, nil, err + } + if input.ExpectedVersion != nil && *input.ExpectedVersion != row.Version { + return store.Message{}, nil, store.ErrQuestionConflict + } + if store.EffectiveQuestionStatus(row.Status, row.ExpiresAt, time.Now()) != store.QuestionStatusOpen { + return store.Message{}, nil, store.ErrQuestionClosed + } + response := storedQuestionResponse{Skipped: input.Skip} + if input.Skip { + if row.AllowSkip != 1 { + return store.Message{}, nil, fmt.Errorf("%w: %s", store.ErrInvalidQuestion, "this question cannot be skipped") + } + } else { + var document questionDocument + if err := json.Unmarshal([]byte(row.SpecJson), &document); err != nil { + return store.Message{}, nil, err + } + if response.Answers, err = store.NormalizeQuestionAnswers(document.Items, input.Answers); err != nil { + return store.Message{}, nil, err + } + } + responseJSON, err := json.Marshal(response) + if err != nil { + return store.Message{}, nil, err + } + respondedAt := now() + affected, err := qtx.SubmitMessageQuestionResponse(ctx, storedb.SubmitMessageQuestionResponseParams{ + ResponseJson: string(responseJSON), + RespondedBy: sqlText(input.UserID), + RespondedAt: sqlText(respondedAt), + ResponseNonce: nonce, + MessageID: msg.ID, + Version: row.Version, + }) + if err != nil { + return store.Message{}, nil, err + } + if affected == 0 { + return store.Message{}, nil, store.ErrQuestionClosed + } + events, err := insertQuestionEventsTx(ctx, tx, msg, map[string]any{ + "external_id": row.ExternalID, + "responder_id": input.UserID, + "skipped": input.Skip, + "version": row.Version + 1, + }) + if err != nil { + return store.Message{}, nil, err + } + if err := tx.Commit(); err != nil { + return store.Message{}, nil, err + } + message, err := s.hydrateQuestionMessage(ctx, msg, input.UserID) + return message, events, err +} + +func (s *Store) ResolveQuestion(ctx context.Context, input store.ResolveQuestionInput) (store.Message, store.Event, error) { + status := strings.TrimSpace(input.Status) + note := strings.TrimSpace(input.Note) + if utf8.RuneCountInString(note) > store.MaxQuestionNoteLength { + return store.Message{}, store.Event{}, fmt.Errorf("%w: %s", store.ErrInvalidQuestion, "note is too long") + } + tx, err := s.db.BeginTx(ctx, nil) + if err != nil { + return store.Message{}, store.Event{}, err + } + defer tx.Rollback() + qtx := s.q.WithTx(tx) + msg, err := getMessageTx(ctx, tx, input.MessageID) + if err != nil { + return store.Message{}, store.Event{}, err + } + if msg.DeletedAt != nil { + return store.Message{}, store.Event{}, sql.ErrNoRows + } + row, err := qtx.GetMessageQuestion(ctx, msg.ID) + if err != nil { + return store.Message{}, store.Event{}, err + } + if row.BotUserID != input.BotUserID { + return store.Message{}, store.Event{}, store.ErrQuestionAuthorRequired + } + if err := requireMessageAccessTx(ctx, tx, msg, input.BotUserID); err != nil { + return store.Message{}, store.Event{}, err + } + if msg.DirectConversationID != "" { + err = requireCanSendDirectTx(ctx, tx, msg.WorkspaceID, input.BotUserID) + } else { + err = requireNoModerationBlockTx(ctx, tx, msg.WorkspaceID, input.BotUserID) + } + if err != nil { + return store.Message{}, store.Event{}, err + } + if input.ExpectedVersion != nil && *input.ExpectedVersion != row.Version { + return store.Message{}, store.Event{}, store.ErrQuestionConflict + } + if row.Status == status && store.IsTerminalQuestionStatus(status) { + if err := tx.Rollback(); err != nil { + return store.Message{}, store.Event{}, err + } + message, err := s.hydrateQuestionMessage(ctx, msg, input.BotUserID) + return message, store.Event{}, err + } + if !store.QuestionResolutionAllowed(row.Status, status) { + return store.Message{}, store.Event{}, store.ErrQuestionConflict + } + params := storedb.ResolveMessageQuestionParams{ + Status: status, + Note: note, + ResponseJson: row.ResponseJson, + ResponseSource: row.ResponseSource, + RespondedBy: row.RespondedBy, + RespondedAt: row.RespondedAt, + ResponseNonce: row.ResponseNonce, + UpdatedAt: now(), + MessageID: msg.ID, + Version: row.Version, + } + switch { + case status == store.QuestionStatusOpen: + if note == "" { + return store.Message{}, store.Event{}, fmt.Errorf("%w: %s", store.ErrInvalidQuestion, "reopening a question requires a note") + } + params.ResponseJson, params.ResponseSource, params.ResponseNonce = "", "", "" + params.RespondedBy, params.RespondedAt = sql.NullString{}, sql.NullString{} + case row.Status == store.QuestionStatusOpen && status == store.QuestionStatusAnswered && len(input.Answers) > 0: + var document questionDocument + if err := json.Unmarshal([]byte(row.SpecJson), &document); err != nil { + return store.Message{}, store.Event{}, err + } + answers, err := store.NormalizeQuestionAnswers(document.Items, input.Answers) + if err != nil { + return store.Message{}, store.Event{}, err + } + responseJSON, err := json.Marshal(storedQuestionResponse{Answers: answers}) + if err != nil { + return store.Message{}, store.Event{}, err + } + params.ResponseJson = string(responseJSON) + params.ResponseSource = store.QuestionResponseSourceExternal + params.RespondedAt = sqlText(params.UpdatedAt) + } + if store.IsTerminalQuestionStatus(status) { + params.ResolvedAt = sqlText(params.UpdatedAt) + } + affected, err := qtx.ResolveMessageQuestion(ctx, params) + if err != nil { + return store.Message{}, store.Event{}, err + } + if affected == 0 { + return store.Message{}, store.Event{}, store.ErrQuestionConflict + } + events, err := insertQuestionEventsTx(ctx, tx, msg, nil) + if err != nil { + return store.Message{}, store.Event{}, err + } + if err := tx.Commit(); err != nil { + return store.Message{}, store.Event{}, err + } + message, err := s.hydrateQuestionMessage(ctx, msg, input.BotUserID) + return message, events[0], err +} + +// insertQuestionEventsTx appends message.updated so every client refreshes the +// card and, for a submitted answer, question.submitted for the authoring bot. +// Neither payload carries answer content. +func insertQuestionEventsTx(ctx context.Context, tx *sql.Tx, msg store.Message, submitted map[string]any) ([]store.Event, error) { + recipients, err := eventRecipientsForMessageTx(ctx, tx, msg) + if err != nil { + return nil, err + } + updated, err := insertEventWithRecipients(ctx, tx, msg.WorkspaceID, msg.ChannelID, "message.updated", msg.ChannelSeq, messagePayload(msg), recipients) + if err != nil || submitted == nil { + return []store.Event{updated}, err + } + submitted["message_id"] = msg.ID + submitted["root_message_id"] = msg.ThreadRootID + if msg.DirectConversationID != "" { + submitted["direct_conversation_id"] = msg.DirectConversationID + } + event, err := insertEventWithRecipients(ctx, tx, msg.WorkspaceID, msg.ChannelID, "question.submitted", msg.ChannelSeq, submitted, recipients) + if err != nil { + return nil, err + } + return []store.Event{updated, event}, nil +} + +func (s *Store) hydrateQuestionMessage(ctx context.Context, msg store.Message, userID string) (store.Message, error) { + fresh, err := getMessage(ctx, s.db, msg.ID) + if err != nil { + return store.Message{}, err + } + messages, err := s.hydrateAttachments(ctx, []store.Message{fresh}) + if err != nil { + return store.Message{}, err + } + if messages, err = s.hydrateReactions(ctx, userID, messages); err != nil { + return store.Message{}, err + } + if messages, err = s.hydrateQuestions(ctx, messages); err != nil { + return store.Message{}, err + } + return messages[0], nil +} + +func (s *Store) ListBotUnresolvedQuestions(ctx context.Context, workspaceID, botUserID, afterMessageID string, includeDirect bool, limit int) ([]store.BotQuestion, error) { + // One row past the largest page lets callers detect a next page. + if limit <= 0 || limit > store.MaxBotQuestionPageSize+1 { + limit = store.MaxBotQuestionPageSize + 1 + } + rows, err := s.q.ListBotUnresolvedQuestions(ctx, storedb.ListBotUnresolvedQuestionsParams{ + WorkspaceID: workspaceID, + BotUserID: botUserID, + AfterMessageID: afterMessageID, + IncludeDirect: includeDirect, + RowLimit: int32(limit), + }) + if err != nil { + return nil, err + } + questions := make([]store.BotQuestion, 0, len(rows)) + now := time.Now() + for _, row := range rows { + questions = append(questions, store.BotQuestion{ + MessageID: row.MessageID, + WorkspaceID: row.WorkspaceID, + ChannelID: row.ChannelID, + DirectConversationID: row.DirectConversationID, + ThreadRootID: row.ThreadRootID, + ExternalID: row.ExternalID, + Status: store.EffectiveQuestionStatus(row.Status, row.ExpiresAt, now), + ExpiresAt: row.ExpiresAt, + Version: row.Version, + }) + } + return questions, nil +} + +func nonNilStrings(values []string) []string { + if values == nil { + return []string{} + } + return values +} + +// mergeMentionedUserIDs adds a question's responders to the resolved mentions so +// mention-only channels still alert the people who must answer. +func mergeMentionedUserIDs(mentioned []string, spec *store.QuestionSpec) []string { + if spec == nil { + return mentioned + } + for _, userID := range spec.ResponderUserIDs { + if !slices.Contains(mentioned, userID) { + mentioned = append(mentioned, userID) + } + } + return mentioned +} diff --git a/apps/api/internal/store/postgres/questions_test.go b/apps/api/internal/store/postgres/questions_test.go new file mode 100644 index 000000000..3abfdf9d2 --- /dev/null +++ b/apps/api/internal/store/postgres/questions_test.go @@ -0,0 +1,51 @@ +package postgres + +import ( + "context" + "testing" + "time" + + "github.com/openclaw/clickclack/apps/api/internal/store/questiontest" +) + +func newMigratedQuestionStore(t *testing.T) *Store { + t.Helper() + st := newIsolatedPostgresTestStore(t) + if err := st.Migrate(context.Background()); err != nil { + t.Fatal(err) + } + return st +} + +func TestQuestionLifecycle(t *testing.T) { + questiontest.QuestionLifecycle(t, newMigratedQuestionStore(t)) +} + +func TestQuestionResolutionRespectsModeration(t *testing.T) { + questiontest.QuestionResolutionRespectsModeration(t, newMigratedQuestionStore(t)) +} + +func TestQuestionSkipReopenAndExternalAnswers(t *testing.T) { + questiontest.QuestionSkipReopenAndExternalAnswers(t, newMigratedQuestionStore(t)) +} + +func TestQuestionExpiryAndAccess(t *testing.T) { + st := newMigratedQuestionStore(t) + questiontest.QuestionExpiryAndAccess(t, st, questiontest.Hooks{ + ExpireQuestion: func(t *testing.T, messageID string) { + t.Helper() + past := time.Now().Add(-time.Minute).UTC().Format(time.RFC3339Nano) + if _, err := st.db.ExecContext(context.Background(), `UPDATE message_questions SET expires_at = $1 WHERE message_id = $2`, past, messageID); err != nil { + t.Fatal(err) + } + }, + }) +} + +func TestQuestionConcurrentAnswers(t *testing.T) { + questiontest.QuestionConcurrentAnswers(t, newMigratedQuestionStore(t)) +} + +func TestQuestionReplayAndVersionGuards(t *testing.T) { + questiontest.QuestionReplayAndVersionGuards(t, newMigratedQuestionStore(t)) +} diff --git a/apps/api/internal/store/postgres/sqlc/queries.sql b/apps/api/internal/store/postgres/sqlc/queries.sql index ee1a39eaf..820612b48 100644 --- a/apps/api/internal/store/postgres/sqlc/queries.sql +++ b/apps/api/internal/store/postgres/sqlc/queries.sql @@ -1944,6 +1944,67 @@ WHERE user_id = sqlc.arg(user_id) AND revoked_at IS NULL FOR UPDATE; +-- name: InsertMessageQuestion :exec +INSERT INTO message_questions ( + message_id, workspace_id, bot_user_id, external_id, spec_json, responder_user_ids, + allow_skip, expires_at, created_at, updated_at +) VALUES ( + sqlc.arg(message_id), sqlc.arg(workspace_id), sqlc.arg(bot_user_id), sqlc.arg(external_id), + sqlc.arg(spec_json), sqlc.arg(responder_user_ids), sqlc.arg(allow_skip), sqlc.arg(expires_at), + sqlc.arg(created_at), sqlc.arg(created_at) +); + +-- name: GetMessageQuestion :one +SELECT message_id, workspace_id, bot_user_id, external_id, spec_json, responder_user_ids, allow_skip, + expires_at, status, response_json, response_source, responded_by, responded_at, response_nonce, + note, resolved_at, version, created_at, updated_at +FROM message_questions +WHERE message_id = sqlc.arg(message_id); + +-- name: SubmitMessageQuestionResponse :execrows +UPDATE message_questions +SET status = 'submitted', + note = '', + response_json = sqlc.arg(response_json), + response_source = 'clickclack', + responded_by = sqlc.arg(responded_by), + responded_at = sqlc.arg(responded_at), + response_nonce = sqlc.arg(response_nonce), + version = version + 1, + updated_at = sqlc.arg(responded_at) +WHERE message_id = sqlc.arg(message_id) + AND status = 'open' + AND version = sqlc.arg(version); + +-- name: ResolveMessageQuestion :execrows +UPDATE message_questions +SET status = sqlc.arg(status), + note = sqlc.arg(note), + response_json = sqlc.arg(response_json), + response_source = sqlc.arg(response_source), + responded_by = sqlc.narg(responded_by), + responded_at = sqlc.narg(responded_at), + response_nonce = sqlc.arg(response_nonce), + resolved_at = sqlc.narg(resolved_at), + version = version + 1, + updated_at = sqlc.arg(updated_at) +WHERE message_id = sqlc.arg(message_id) + AND version = sqlc.arg(version); + +-- name: ListBotUnresolvedQuestions :many +SELECT q.message_id, q.workspace_id, COALESCE(m.channel_id, '') AS channel_id, + COALESCE(m.direct_conversation_id, '') AS direct_conversation_id, m.thread_root_id, + q.external_id, q.status, q.expires_at, q.version +FROM message_questions q +JOIN messages m ON m.id = q.message_id +WHERE q.workspace_id = sqlc.arg(workspace_id) + AND q.bot_user_id = sqlc.arg(bot_user_id) + AND q.status IN ('open', 'submitted') + AND m.deleted_at IS NULL + AND (sqlc.arg(include_direct)::boolean OR m.direct_conversation_id IS NULL) + AND q.message_id > sqlc.arg(after_message_id) +ORDER BY q.message_id +LIMIT sqlc.arg(row_limit); -- name: LockUserPushSubscriptions :one SELECT id FROM users WHERE id = sqlc.arg(user_id) FOR NO KEY UPDATE; diff --git a/apps/api/internal/store/postgres/sqlc/schema.sql b/apps/api/internal/store/postgres/sqlc/schema.sql index 1578a65a3..42eb6b038 100644 --- a/apps/api/internal/store/postgres/sqlc/schema.sql +++ b/apps/api/internal/store/postgres/sqlc/schema.sql @@ -176,6 +176,32 @@ CREATE INDEX idx_messages_direct_search_fts ON messages CREATE INDEX idx_messages_direct_search_scope ON messages(direct_conversation_id) WHERE direct_conversation_id IS NOT NULL AND deleted_at IS NULL AND kind = 'message'; +CREATE TABLE message_questions ( + message_id TEXT PRIMARY KEY REFERENCES messages(id) ON DELETE CASCADE, + workspace_id TEXT NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE, + bot_user_id TEXT NOT NULL REFERENCES users(id), + external_id TEXT NOT NULL DEFAULT '', + spec_json TEXT NOT NULL, + responder_user_ids TEXT NOT NULL DEFAULT '[]', + allow_skip BIGINT NOT NULL DEFAULT 1 CHECK (allow_skip IN (0, 1)), + expires_at TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'open' + CHECK (status IN ('open', 'submitted', 'answered', 'cancelled', 'expired', 'failed')), + response_json TEXT NOT NULL DEFAULT '', + response_source TEXT NOT NULL DEFAULT '', + responded_by TEXT REFERENCES users(id) ON DELETE SET NULL, + responded_at TEXT, + response_nonce TEXT NOT NULL DEFAULT '', + note TEXT NOT NULL DEFAULT '', + resolved_at TEXT, + version BIGINT NOT NULL DEFAULT 1, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL +); + +CREATE INDEX idx_message_questions_bot_status + ON message_questions(workspace_id, bot_user_id, status, message_id); + CREATE TABLE thread_state ( root_message_id TEXT PRIMARY KEY REFERENCES messages(id) ON DELETE CASCADE, reply_count BIGINT NOT NULL DEFAULT 0, diff --git a/apps/api/internal/store/postgres/storedb/models.go b/apps/api/internal/store/postgres/storedb/models.go index 7d76fed5b..f3ea341fa 100644 --- a/apps/api/internal/store/postgres/storedb/models.go +++ b/apps/api/internal/store/postgres/storedb/models.go @@ -277,6 +277,28 @@ type MessageAttachment struct { CreatedAt string `json:"created_at"` } +type MessageQuestion struct { + MessageID string `json:"message_id"` + WorkspaceID string `json:"workspace_id"` + BotUserID string `json:"bot_user_id"` + ExternalID string `json:"external_id"` + SpecJson string `json:"spec_json"` + ResponderUserIds string `json:"responder_user_ids"` + AllowSkip int64 `json:"allow_skip"` + ExpiresAt string `json:"expires_at"` + Status string `json:"status"` + ResponseJson string `json:"response_json"` + ResponseSource string `json:"response_source"` + RespondedBy sql.NullString `json:"responded_by"` + RespondedAt sql.NullString `json:"responded_at"` + ResponseNonce string `json:"response_nonce"` + Note string `json:"note"` + ResolvedAt sql.NullString `json:"resolved_at"` + Version int64 `json:"version"` + CreatedAt string `json:"created_at"` + UpdatedAt string `json:"updated_at"` +} + type OauthTransaction struct { ID string `json:"id"` StateHash string `json:"state_hash"` diff --git a/apps/api/internal/store/postgres/storedb/queries.sql.go b/apps/api/internal/store/postgres/storedb/queries.sql.go index e4aa8b153..396289d9d 100644 --- a/apps/api/internal/store/postgres/storedb/queries.sql.go +++ b/apps/api/internal/store/postgres/storedb/queries.sql.go @@ -1657,6 +1657,41 @@ func (q *Queries) GetMessageIDByAuthorNonce(ctx context.Context, arg GetMessageI return id, err } +const getMessageQuestion = `-- name: GetMessageQuestion :one +SELECT message_id, workspace_id, bot_user_id, external_id, spec_json, responder_user_ids, allow_skip, + expires_at, status, response_json, response_source, responded_by, responded_at, response_nonce, + note, resolved_at, version, created_at, updated_at +FROM message_questions +WHERE message_id = $1 +` + +func (q *Queries) GetMessageQuestion(ctx context.Context, messageID string) (MessageQuestion, error) { + row := q.db.QueryRowContext(ctx, getMessageQuestion, messageID) + var i MessageQuestion + err := row.Scan( + &i.MessageID, + &i.WorkspaceID, + &i.BotUserID, + &i.ExternalID, + &i.SpecJson, + &i.ResponderUserIds, + &i.AllowSkip, + &i.ExpiresAt, + &i.Status, + &i.ResponseJson, + &i.ResponseSource, + &i.RespondedBy, + &i.RespondedAt, + &i.ResponseNonce, + &i.Note, + &i.ResolvedAt, + &i.Version, + &i.CreatedAt, + &i.UpdatedAt, + ) + return i, err +} + const getNotificationSettings = `-- name: GetNotificationSettings :one SELECT pushover_enabled, pushover_user_key FROM user_notification_settings @@ -2826,6 +2861,44 @@ func (q *Queries) InsertMagicLink(ctx context.Context, arg InsertMagicLinkParams return err } +const insertMessageQuestion = `-- name: InsertMessageQuestion :exec +INSERT INTO message_questions ( + message_id, workspace_id, bot_user_id, external_id, spec_json, responder_user_ids, + allow_skip, expires_at, created_at, updated_at +) VALUES ( + $1, $2, $3, $4, + $5, $6, $7, $8, + $9, $9 +) +` + +type InsertMessageQuestionParams struct { + MessageID string `json:"message_id"` + WorkspaceID string `json:"workspace_id"` + BotUserID string `json:"bot_user_id"` + ExternalID string `json:"external_id"` + SpecJson string `json:"spec_json"` + ResponderUserIds string `json:"responder_user_ids"` + AllowSkip int64 `json:"allow_skip"` + ExpiresAt string `json:"expires_at"` + CreatedAt string `json:"created_at"` +} + +func (q *Queries) InsertMessageQuestion(ctx context.Context, arg InsertMessageQuestionParams) error { + _, err := q.db.ExecContext(ctx, insertMessageQuestion, + arg.MessageID, + arg.WorkspaceID, + arg.BotUserID, + arg.ExternalID, + arg.SpecJson, + arg.ResponderUserIds, + arg.AllowSkip, + arg.ExpiresAt, + arg.CreatedAt, + ) + return err +} + const insertOAuthTransaction = `-- name: InsertOAuthTransaction :exec INSERT INTO oauth_transactions ( id, state_hash, browser_binding_hash, mode, pkce_verifier, desktop_challenge, @@ -3404,6 +3477,81 @@ func (q *Queries) ListBotHistoricalWorkspaces(ctx context.Context, botUserID str return items, nil } +const listBotUnresolvedQuestions = `-- name: ListBotUnresolvedQuestions :many +SELECT q.message_id, q.workspace_id, COALESCE(m.channel_id, '') AS channel_id, + COALESCE(m.direct_conversation_id, '') AS direct_conversation_id, m.thread_root_id, + q.external_id, q.status, q.expires_at, q.version +FROM message_questions q +JOIN messages m ON m.id = q.message_id +WHERE q.workspace_id = $1 + AND q.bot_user_id = $2 + AND q.status IN ('open', 'submitted') + AND m.deleted_at IS NULL + AND ($3::boolean OR m.direct_conversation_id IS NULL) + AND q.message_id > $4 +ORDER BY q.message_id +LIMIT $5 +` + +type ListBotUnresolvedQuestionsParams struct { + WorkspaceID string `json:"workspace_id"` + BotUserID string `json:"bot_user_id"` + IncludeDirect bool `json:"include_direct"` + AfterMessageID string `json:"after_message_id"` + RowLimit int32 `json:"row_limit"` +} + +type ListBotUnresolvedQuestionsRow struct { + MessageID string `json:"message_id"` + WorkspaceID string `json:"workspace_id"` + ChannelID string `json:"channel_id"` + DirectConversationID string `json:"direct_conversation_id"` + ThreadRootID string `json:"thread_root_id"` + ExternalID string `json:"external_id"` + Status string `json:"status"` + ExpiresAt string `json:"expires_at"` + Version int64 `json:"version"` +} + +func (q *Queries) ListBotUnresolvedQuestions(ctx context.Context, arg ListBotUnresolvedQuestionsParams) ([]ListBotUnresolvedQuestionsRow, error) { + rows, err := q.db.QueryContext(ctx, listBotUnresolvedQuestions, + arg.WorkspaceID, + arg.BotUserID, + arg.IncludeDirect, + arg.AfterMessageID, + arg.RowLimit, + ) + if err != nil { + return nil, err + } + defer rows.Close() + var items []ListBotUnresolvedQuestionsRow + for rows.Next() { + var i ListBotUnresolvedQuestionsRow + if err := rows.Scan( + &i.MessageID, + &i.WorkspaceID, + &i.ChannelID, + &i.DirectConversationID, + &i.ThreadRootID, + &i.ExternalID, + &i.Status, + &i.ExpiresAt, + &i.Version, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + const listBotsOwnedBy = `-- name: ListBotsOwnedBy :many SELECT u.id, @@ -6077,6 +6225,56 @@ func (q *Queries) RequireWorkspaceOwner(ctx context.Context, arg RequireWorkspac return role, err } +const resolveMessageQuestion = `-- name: ResolveMessageQuestion :execrows +UPDATE message_questions +SET status = $1, + note = $2, + response_json = $3, + response_source = $4, + responded_by = $5, + responded_at = $6, + response_nonce = $7, + resolved_at = $8, + version = version + 1, + updated_at = $9 +WHERE message_id = $10 + AND version = $11 +` + +type ResolveMessageQuestionParams struct { + Status string `json:"status"` + Note string `json:"note"` + ResponseJson string `json:"response_json"` + ResponseSource string `json:"response_source"` + RespondedBy sql.NullString `json:"responded_by"` + RespondedAt sql.NullString `json:"responded_at"` + ResponseNonce string `json:"response_nonce"` + ResolvedAt sql.NullString `json:"resolved_at"` + UpdatedAt string `json:"updated_at"` + MessageID string `json:"message_id"` + Version int64 `json:"version"` +} + +func (q *Queries) ResolveMessageQuestion(ctx context.Context, arg ResolveMessageQuestionParams) (int64, error) { + result, err := q.db.ExecContext(ctx, resolveMessageQuestion, + arg.Status, + arg.Note, + arg.ResponseJson, + arg.ResponseSource, + arg.RespondedBy, + arg.RespondedAt, + arg.ResponseNonce, + arg.ResolvedAt, + arg.UpdatedAt, + arg.MessageID, + arg.Version, + ) + if err != nil { + return 0, err + } + return result.RowsAffected() +} + const retireBotUser = `-- name: RetireBotUser :execrows UPDATE users SET handle = '' @@ -6289,6 +6487,46 @@ func (q *Queries) SetUserAvatarIfEmpty(ctx context.Context, arg SetUserAvatarIfE return err } +const submitMessageQuestionResponse = `-- name: SubmitMessageQuestionResponse :execrows +UPDATE message_questions +SET status = 'submitted', + note = '', + response_json = $1, + response_source = 'clickclack', + responded_by = $2, + responded_at = $3, + response_nonce = $4, + version = version + 1, + updated_at = $3 +WHERE message_id = $5 + AND status = 'open' + AND version = $6 +` + +type SubmitMessageQuestionResponseParams struct { + ResponseJson string `json:"response_json"` + RespondedBy sql.NullString `json:"responded_by"` + RespondedAt sql.NullString `json:"responded_at"` + ResponseNonce string `json:"response_nonce"` + MessageID string `json:"message_id"` + Version int64 `json:"version"` +} + +func (q *Queries) SubmitMessageQuestionResponse(ctx context.Context, arg SubmitMessageQuestionResponseParams) (int64, error) { + result, err := q.db.ExecContext(ctx, submitMessageQuestionResponse, + arg.ResponseJson, + arg.RespondedBy, + arg.RespondedAt, + arg.ResponseNonce, + arg.MessageID, + arg.Version, + ) + if err != nil { + return 0, err + } + return result.RowsAffected() +} + const threadNextSeq = `-- name: ThreadNextSeq :one SELECT CAST(COALESCE(MAX(thread_seq), 0) + 1 AS BIGINT) AS next_seq FROM messages diff --git a/apps/api/internal/store/postgres/thread_pages.go b/apps/api/internal/store/postgres/thread_pages.go index 37dd36abb..82affed69 100644 --- a/apps/api/internal/store/postgres/thread_pages.go +++ b/apps/api/internal/store/postgres/thread_pages.go @@ -78,6 +78,10 @@ func (s *Store) GetThreadPage(ctx context.Context, rootMessageID, userID string, if err != nil { return store.ThreadPage{}, err } + messages, err = s.hydrateQuestions(ctx, messages) + if err != nil { + return store.ThreadPage{}, err + } messages, err = s.hydrateReactions(ctx, userID, messages) if err != nil { return store.ThreadPage{}, err diff --git a/apps/api/internal/store/postgres/uploads.go b/apps/api/internal/store/postgres/uploads.go index bff4cb807..646998263 100644 --- a/apps/api/internal/store/postgres/uploads.go +++ b/apps/api/internal/store/postgres/uploads.go @@ -311,6 +311,9 @@ func hydrateMessageCreateReplay(ctx context.Context, tx *sql.Tx, message store.M if err != nil { return store.Message{}, err } + if messages, err = hydrateQuestions(ctx, tx, messages); err != nil { + return store.Message{}, err + } message = messages[0] uploadID = strings.TrimSpace(uploadID) if uploadID == "" { diff --git a/apps/api/internal/store/questions.go b/apps/api/internal/store/questions.go new file mode 100644 index 000000000..e47e7f64b --- /dev/null +++ b/apps/api/internal/store/questions.go @@ -0,0 +1,364 @@ +package store + +import ( + "errors" + "fmt" + "net/url" + "regexp" + "slices" + "strings" + "time" + "unicode/utf8" +) + +// Question lifecycle statuses. A bot posts an open question; the first valid +// answer moves it to submitted; the authoring bot records the outcome. +const ( + QuestionStatusOpen = "open" + QuestionStatusSubmitted = "submitted" + QuestionStatusAnswered = "answered" + QuestionStatusCancelled = "cancelled" + QuestionStatusExpired = "expired" + QuestionStatusFailed = "failed" +) + +const ( + QuestionResponseSourceClickClack = "clickclack" + QuestionResponseSourceExternal = "external" +) + +const ( + MaxQuestionItems = 5 + MaxQuestionOptions = 10 + MaxQuestionResponders = 50 + MaxQuestionTitleLength = 300 + MaxQuestionHeaderLength = 24 + MaxQuestionPromptLength = 1000 + MaxQuestionOptionLabelLength = 80 + MaxQuestionOptionDescription = 200 + MaxQuestionPlaceholderLength = 60 + MaxQuestionFreeTextLength = 2000 + MaxQuestionNoteLength = 200 + MaxQuestionExternalIDLength = 128 + MaxQuestionURLLength = 2048 + MaxBotQuestionPageSize = 200 + MinQuestionLifetime = 10 * time.Second + MaxQuestionLifetime = 7 * 24 * time.Hour + QuestionCapabilityHeader = "X-ClickClack-Questions" + QuestionCapabilityHeaderValue = "supported" +) + +var ( + // ErrInvalidQuestion wraps every question or answer validation failure. + ErrInvalidQuestion = errors.New("invalid question") + // ErrQuestionClosed is returned when a question no longer accepts answers. + ErrQuestionClosed = errors.New("question is no longer open") + // ErrQuestionResponderRequired is returned to people outside the responder list. + ErrQuestionResponderRequired = errors.New("only the listed responders can answer this question") + // ErrQuestionAuthorRequired is returned when a caller other than the authoring bot resolves a question. + ErrQuestionAuthorRequired = errors.New("only the bot that asked this question can resolve it") + // ErrQuestionConflict is returned for stale versions and transitions the current status does not allow. + ErrQuestionConflict = errors.New("question changed; reload it and try again") +) + +var questionItemIDPattern = regexp.MustCompile(`^[a-z][a-z0-9_]{0,63}$`) + +type QuestionOption struct { + Label string `json:"label"` + Description string `json:"description,omitempty"` +} + +type QuestionItem struct { + ID string `json:"id"` + Header string `json:"header"` + Prompt string `json:"prompt"` + URL string `json:"url,omitempty"` + Options []QuestionOption `json:"options,omitempty"` + MultiSelect bool `json:"multi_select,omitempty"` + AllowOther bool `json:"allow_other,omitempty"` + OtherPlaceholder string `json:"other_placeholder,omitempty"` +} + +// QuestionSpec is the question a bot attaches when it creates a message. +type QuestionSpec struct { + ExternalID string `json:"external_id,omitempty"` + Title string `json:"title,omitempty"` + ExpiresAt string `json:"expires_at"` + ResponderUserIDs []string `json:"responder_user_ids,omitempty"` + AllowSkip *bool `json:"allow_skip,omitempty"` + Items []QuestionItem `json:"items"` +} + +type QuestionResponse struct { + Answers map[string][]string `json:"answers,omitempty"` + Skipped bool `json:"skipped,omitempty"` + Source string `json:"source"` + Responder *User `json:"responder,omitempty"` + RespondedAt string `json:"responded_at,omitempty"` +} + +// MessageQuestion is the question facet hydrated on a message. +type MessageQuestion struct { + Status string `json:"status"` + ExternalID string `json:"external_id,omitempty"` + Title string `json:"title,omitempty"` + ExpiresAt string `json:"expires_at"` + AllowSkip bool `json:"allow_skip"` + Items []QuestionItem `json:"items"` + ResponderUserIDs []string `json:"responder_user_ids,omitempty"` + Response *QuestionResponse `json:"response,omitempty"` + Note string `json:"note,omitempty"` + ResolvedAt *string `json:"resolved_at,omitempty"` + Version int64 `json:"version"` +} + +type AnswerQuestionInput struct { + MessageID string + UserID string + Answers map[string][]string + Skip bool + Nonce string + // ExpectedVersion is the question version the person saw. A different + // current version, such as after a reopen, rejects the answer. + ExpectedVersion *int64 +} + +type ResolveQuestionInput struct { + MessageID string + BotUserID string + Status string + Note string + Answers map[string][]string + ExpectedVersion *int64 +} + +// BotQuestion summarizes one of a bot's questions for reconciliation. +type BotQuestion struct { + MessageID string `json:"message_id"` + WorkspaceID string `json:"workspace_id"` + ChannelID string `json:"channel_id,omitempty"` + DirectConversationID string `json:"direct_conversation_id,omitempty"` + ThreadRootID string `json:"thread_root_id"` + ExternalID string `json:"external_id,omitempty"` + Status string `json:"status"` + ExpiresAt string `json:"expires_at"` + Version int64 `json:"version"` +} + +func invalidQuestion(format string, args ...any) error { + return fmt.Errorf("%w: %s", ErrInvalidQuestion, fmt.Sprintf(format, args...)) +} + +// ValidateQuestionLifetime checks the deadline of a question that is about to +// be created. Replaying an existing question skips it, so a retry near the +// deadline still returns the original message. +func ValidateQuestionLifetime(spec QuestionSpec, now time.Time) error { + expiresAt, err := time.Parse(time.RFC3339Nano, spec.ExpiresAt) + if err != nil { + return invalidQuestion("expires_at must be an RFC 3339 timestamp") + } + if lifetime := expiresAt.Sub(now); lifetime < MinQuestionLifetime || lifetime > MaxQuestionLifetime { + return invalidQuestion("expires_at must be between %s and %s from now", MinQuestionLifetime, MaxQuestionLifetime) + } + return nil +} + +// NormalizeQuestionSpec trims and validates the shape of a bot-authored +// question. New questions also need ValidateQuestionLifetime. +func NormalizeQuestionSpec(input QuestionSpec) (QuestionSpec, error) { + spec := QuestionSpec{ + ExternalID: strings.TrimSpace(input.ExternalID), + Title: strings.TrimSpace(input.Title), + ExpiresAt: strings.TrimSpace(input.ExpiresAt), + } + if utf8.RuneCountInString(spec.ExternalID) > MaxQuestionExternalIDLength { + return QuestionSpec{}, invalidQuestion("external_id is longer than %d characters", MaxQuestionExternalIDLength) + } + if utf8.RuneCountInString(spec.Title) > MaxQuestionTitleLength { + return QuestionSpec{}, invalidQuestion("title is longer than %d characters", MaxQuestionTitleLength) + } + expiresAt, err := time.Parse(time.RFC3339, spec.ExpiresAt) + if err != nil { + return QuestionSpec{}, invalidQuestion("expires_at must be an RFC 3339 timestamp") + } + spec.ExpiresAt = expiresAt.UTC().Format(time.RFC3339Nano) + allowSkip := input.AllowSkip == nil || *input.AllowSkip + spec.AllowSkip = &allowSkip + for _, id := range input.ResponderUserIDs { + id = strings.TrimSpace(id) + if id == "" { + return QuestionSpec{}, invalidQuestion("responder_user_ids cannot contain empty IDs") + } + if !slices.Contains(spec.ResponderUserIDs, id) { + spec.ResponderUserIDs = append(spec.ResponderUserIDs, id) + } + } + if len(spec.ResponderUserIDs) > MaxQuestionResponders { + return QuestionSpec{}, invalidQuestion("responder_user_ids has more than %d users", MaxQuestionResponders) + } + if len(input.Items) == 0 || len(input.Items) > MaxQuestionItems { + return QuestionSpec{}, invalidQuestion("items must contain 1 to %d questions", MaxQuestionItems) + } + seenIDs := map[string]bool{} + for index, raw := range input.Items { + item, err := normalizeQuestionItem(raw) + if err != nil { + return QuestionSpec{}, fmt.Errorf("%w (items[%d])", err, index) + } + if seenIDs[item.ID] { + return QuestionSpec{}, invalidQuestion("items[%d].id %q is repeated", index, item.ID) + } + seenIDs[item.ID] = true + spec.Items = append(spec.Items, item) + } + return spec, nil +} + +func normalizeQuestionItem(raw QuestionItem) (QuestionItem, error) { + item := QuestionItem{ + ID: strings.TrimSpace(raw.ID), + Header: strings.TrimSpace(raw.Header), + Prompt: strings.TrimSpace(raw.Prompt), + URL: strings.TrimSpace(raw.URL), + MultiSelect: raw.MultiSelect, + AllowOther: raw.AllowOther, + OtherPlaceholder: strings.TrimSpace(raw.OtherPlaceholder), + } + if !questionItemIDPattern.MatchString(item.ID) { + return QuestionItem{}, invalidQuestion("id must match %s", questionItemIDPattern) + } + if item.Header == "" || utf8.RuneCountInString(item.Header) > MaxQuestionHeaderLength { + return QuestionItem{}, invalidQuestion("header must have 1 to %d characters", MaxQuestionHeaderLength) + } + if item.Prompt == "" || utf8.RuneCountInString(item.Prompt) > MaxQuestionPromptLength { + return QuestionItem{}, invalidQuestion("prompt must have 1 to %d characters", MaxQuestionPromptLength) + } + if item.URL != "" && !safeQuestionURL(item.URL) { + return QuestionItem{}, invalidQuestion("url must be an http or https URL of at most %d characters", MaxQuestionURLLength) + } + if utf8.RuneCountInString(item.OtherPlaceholder) > MaxQuestionPlaceholderLength { + return QuestionItem{}, invalidQuestion("other_placeholder is longer than %d characters", MaxQuestionPlaceholderLength) + } + if len(raw.Options) > MaxQuestionOptions { + return QuestionItem{}, invalidQuestion("options has more than %d choices", MaxQuestionOptions) + } + seenLabels := map[string]bool{} + for _, rawOption := range raw.Options { + option := QuestionOption{Label: strings.TrimSpace(rawOption.Label), Description: strings.TrimSpace(rawOption.Description)} + if option.Label == "" || utf8.RuneCountInString(option.Label) > MaxQuestionOptionLabelLength { + return QuestionItem{}, invalidQuestion("option labels must have 1 to %d characters", MaxQuestionOptionLabelLength) + } + if utf8.RuneCountInString(option.Description) > MaxQuestionOptionDescription { + return QuestionItem{}, invalidQuestion("option descriptions are limited to %d characters", MaxQuestionOptionDescription) + } + key := strings.ToLower(option.Label) + if seenLabels[key] { + return QuestionItem{}, invalidQuestion("option label %q is repeated", option.Label) + } + seenLabels[key] = true + item.Options = append(item.Options, option) + } + if item.MultiSelect && len(item.Options) < 2 { + return QuestionItem{}, invalidQuestion("multi_select needs at least two options") + } + return item, nil +} + +func safeQuestionURL(raw string) bool { + if len(raw) > MaxQuestionURLLength { + return false + } + parsed, err := url.Parse(raw) + return err == nil && (parsed.Scheme == "https" || parsed.Scheme == "http") && parsed.Host != "" +} + +// NormalizeQuestionAnswers validates answers against the question's items and +// returns them keyed by item ID with declared option labels in canonical form. +func NormalizeQuestionAnswers(items []QuestionItem, answers map[string][]string) (map[string][]string, error) { + known := map[string]bool{} + for _, item := range items { + known[item.ID] = true + } + for id := range answers { + if !known[id] { + return nil, invalidQuestion("answers contains unknown question %q", id) + } + } + normalized := make(map[string][]string, len(items)) + for _, item := range items { + values := answers[item.ID] + if len(values) == 0 { + return nil, invalidQuestion("question %q requires an answer", item.ID) + } + if !item.MultiSelect && len(values) > 1 { + return nil, invalidQuestion("question %q allows one answer", item.ID) + } + freeText := 0 + var canonical []string + for _, raw := range values { + value := strings.TrimSpace(raw) + if value == "" { + return nil, invalidQuestion("question %q contains an empty answer", item.ID) + } + if label, ok := declaredQuestionOption(item, value); ok { + value = label + } else { + if len(item.Options) > 0 && !item.AllowOther { + return nil, invalidQuestion("question %q only accepts its listed options", item.ID) + } + freeText++ + if freeText > 1 { + return nil, invalidQuestion("question %q accepts one free-text answer", item.ID) + } + if utf8.RuneCountInString(value) > MaxQuestionFreeTextLength { + return nil, invalidQuestion("answers are limited to %d characters", MaxQuestionFreeTextLength) + } + } + if slices.Contains(canonical, value) { + continue + } + canonical = append(canonical, value) + } + normalized[item.ID] = canonical + } + return normalized, nil +} + +func declaredQuestionOption(item QuestionItem, value string) (string, bool) { + for _, option := range item.Options { + if option.Label == value { + return option.Label, true + } + } + return "", false +} + +// EffectiveQuestionStatus reports an open question past its deadline as expired. +func EffectiveQuestionStatus(status, expiresAt string, now time.Time) string { + if status != QuestionStatusOpen { + return status + } + deadline, err := time.Parse(time.RFC3339Nano, expiresAt) + if err == nil && !now.Before(deadline) { + return QuestionStatusExpired + } + return status +} + +// QuestionResolutionAllowed reports whether the authoring bot may move a +// question from its stored status to the requested one. +func QuestionResolutionAllowed(from, to string) bool { + switch from { + case QuestionStatusOpen: + return to == QuestionStatusAnswered || to == QuestionStatusCancelled || to == QuestionStatusExpired || to == QuestionStatusFailed + case QuestionStatusSubmitted: + return to == QuestionStatusAnswered || to == QuestionStatusCancelled || to == QuestionStatusFailed || to == QuestionStatusOpen + default: + return false + } +} + +// IsTerminalQuestionStatus reports whether the question has a final outcome. +func IsTerminalQuestionStatus(status string) bool { + return status == QuestionStatusAnswered || status == QuestionStatusCancelled || status == QuestionStatusExpired || status == QuestionStatusFailed +} diff --git a/apps/api/internal/store/questions_test.go b/apps/api/internal/store/questions_test.go new file mode 100644 index 000000000..64d4bae73 --- /dev/null +++ b/apps/api/internal/store/questions_test.go @@ -0,0 +1,141 @@ +package store + +import ( + "errors" + "reflect" + "strings" + "testing" + "time" +) + +func validQuestionSpec(now time.Time) QuestionSpec { + return QuestionSpec{ + ExternalID: " ask_1 ", + Title: " Need three details ", + ExpiresAt: now.Add(15 * time.Minute).Format(time.RFC3339), + Items: []QuestionItem{ + {ID: "ship_date", Header: "Fecha", Prompt: "¿Qué día embarcamos?", Options: []QuestionOption{{Label: " Lun 15 sep ", Description: "AA 2231"}, {Label: "Mar 16 sep"}}, AllowOther: true}, + {ID: "boxes", Header: "Cajas", Prompt: "¿Cuántas cajas?"}, + {ID: "extras", Header: "Extras", Prompt: "¿Algo más?", MultiSelect: true, Options: []QuestionOption{{Label: "Capuchón"}, {Label: "UPC"}}}, + }, + } +} + +func TestNormalizeQuestionSpecTrimsAndDefaults(t *testing.T) { + now := time.Date(2026, 9, 12, 15, 0, 0, 0, time.UTC) + spec, err := NormalizeQuestionSpec(validQuestionSpec(now)) + if err != nil { + t.Fatal(err) + } + if spec.ExternalID != "ask_1" || spec.Title != "Need three details" || spec.AllowSkip == nil || !*spec.AllowSkip { + t.Fatalf("unexpected normalized spec: %#v", spec) + } + if spec.ExpiresAt != "2026-09-12T15:15:00Z" || spec.Items[0].Options[0].Label != "Lun 15 sep" { + t.Fatalf("unexpected normalized fields: %#v", spec) + } +} + +func TestNormalizeQuestionSpecRejectsInvalidQuestions(t *testing.T) { + now := time.Date(2026, 9, 12, 15, 0, 0, 0, time.UTC) + for name, mutate := range map[string]func(*QuestionSpec){ + "no items": func(spec *QuestionSpec) { spec.Items = nil }, + "too many items": func(spec *QuestionSpec) { spec.Items = append(spec.Items, spec.Items[1], spec.Items[1], spec.Items[1]) }, + "bad id": func(spec *QuestionSpec) { spec.Items[0].ID = "Ship-Date" }, + "repeated id": func(spec *QuestionSpec) { spec.Items[1].ID = "ship_date" }, + "empty header": func(spec *QuestionSpec) { spec.Items[0].Header = " " }, + "long header": func(spec *QuestionSpec) { spec.Items[0].Header = strings.Repeat("h", MaxQuestionHeaderLength+1) }, + "empty prompt": func(spec *QuestionSpec) { spec.Items[0].Prompt = "" }, + "unsafe url": func(spec *QuestionSpec) { spec.Items[0].URL = "javascript:alert(1)" }, + "repeated option": func(spec *QuestionSpec) { spec.Items[0].Options[1].Label = "lun 15 SEP" }, + "single multi-select": func(spec *QuestionSpec) { spec.Items[2].Options = spec.Items[2].Options[:1] }, + "past deadline": func(spec *QuestionSpec) { spec.ExpiresAt = now.Add(-time.Minute).Format(time.RFC3339) }, + "deadline too far": func(spec *QuestionSpec) { spec.ExpiresAt = now.Add(8 * 24 * time.Hour).Format(time.RFC3339) }, + "bad deadline": func(spec *QuestionSpec) { spec.ExpiresAt = "tomorrow" }, + "empty responder": func(spec *QuestionSpec) { spec.ResponderUserIDs = []string{" "} }, + } { + t.Run(name, func(t *testing.T) { + spec := validQuestionSpec(now) + mutate(&spec) + normalized, err := NormalizeQuestionSpec(spec) + if err == nil { + err = ValidateQuestionLifetime(normalized, now) + } + if !errors.Is(err, ErrInvalidQuestion) { + t.Fatalf("expected invalid question, got %v", err) + } + }) + } +} + +func TestNormalizeQuestionAnswers(t *testing.T) { + now := time.Now() + spec, err := NormalizeQuestionSpec(validQuestionSpec(now)) + if err != nil { + t.Fatal(err) + } + got, err := NormalizeQuestionAnswers(spec.Items, map[string][]string{ + "ship_date": {" Lun 15 sep "}, + "boxes": {" 120 "}, + "extras": {"UPC", "Capuchón", "UPC"}, + }) + if err != nil { + t.Fatal(err) + } + want := map[string][]string{"ship_date": {"Lun 15 sep"}, "boxes": {"120"}, "extras": {"UPC", "Capuchón"}} + if !reflect.DeepEqual(got, want) { + t.Fatalf("answers = %#v, want %#v", got, want) + } + if _, err := NormalizeQuestionAnswers(spec.Items, map[string][]string{"ship_date": {"Jue 18 sep"}, "boxes": {"1"}, "extras": {"UPC"}}); err != nil { + t.Fatalf("free text for a question that allows other: %v", err) + } + withOther := append([]QuestionItem(nil), spec.Items...) + withOther[2].AllowOther = true + if _, err := NormalizeQuestionAnswers(withOther, map[string][]string{"ship_date": {"Mar 16 sep"}, "boxes": {"1"}, "extras": {"UPC", "Comida", "Flores"}}); !errors.Is(err, ErrInvalidQuestion) { + t.Fatalf("expected one free-text answer per question, got %v", err) + } + for name, answers := range map[string]map[string][]string{ + "missing question": {"ship_date": {"Mar 16 sep"}, "boxes": {"1"}}, + "unknown question": {"ship_date": {"Mar 16 sep"}, "boxes": {"1"}, "extras": {"UPC"}, "color": {"red"}}, + "two single-select": {"ship_date": {"Lun 15 sep", "Mar 16 sep"}, "boxes": {"1"}, "extras": {"UPC"}}, + "unlisted option": {"ship_date": {"Mar 16 sep"}, "boxes": {"1"}, "extras": {"Comida"}}, + "empty value": {"ship_date": {"Mar 16 sep"}, "boxes": {" "}, "extras": {"UPC"}}, + "long free text": {"ship_date": {"Mar 16 sep"}, "boxes": {strings.Repeat("x", MaxQuestionFreeTextLength+1)}, "extras": {"UPC"}}, + } { + t.Run(name, func(t *testing.T) { + if _, err := NormalizeQuestionAnswers(spec.Items, answers); !errors.Is(err, ErrInvalidQuestion) { + t.Fatalf("expected invalid answer, got %v", err) + } + }) + } +} + +func TestQuestionStatusRules(t *testing.T) { + now := time.Date(2026, 9, 12, 15, 0, 0, 0, time.UTC) + deadline := now.Format(time.RFC3339Nano) + if EffectiveQuestionStatus(QuestionStatusOpen, deadline, now) != QuestionStatusExpired { + t.Fatal("an open question at its deadline must read as expired") + } + if EffectiveQuestionStatus(QuestionStatusOpen, now.Add(time.Second).Format(time.RFC3339Nano), now) != QuestionStatusOpen { + t.Fatal("an open question before its deadline must stay open") + } + if EffectiveQuestionStatus(QuestionStatusSubmitted, deadline, now) != QuestionStatusSubmitted { + t.Fatal("a submitted question keeps its status after the deadline") + } + for _, tc := range []struct { + from, to string + allowed bool + }{ + {QuestionStatusOpen, QuestionStatusAnswered, true}, + {QuestionStatusOpen, QuestionStatusOpen, false}, + {QuestionStatusSubmitted, QuestionStatusOpen, true}, + {QuestionStatusSubmitted, QuestionStatusExpired, false}, + {QuestionStatusAnswered, QuestionStatusFailed, false}, + } { + if QuestionResolutionAllowed(tc.from, tc.to) != tc.allowed { + t.Fatalf("%s -> %s allowed = %t", tc.from, tc.to, !tc.allowed) + } + } + if !IsTerminalQuestionStatus(QuestionStatusFailed) || IsTerminalQuestionStatus(QuestionStatusSubmitted) { + t.Fatal("unexpected terminal status classification") + } +} diff --git a/apps/api/internal/store/questiontest/questions.go b/apps/api/internal/store/questiontest/questions.go new file mode 100644 index 000000000..5774a6e46 --- /dev/null +++ b/apps/api/internal/store/questiontest/questions.go @@ -0,0 +1,559 @@ +// Package questiontest checks message questions against each SQL backend. +package questiontest + +import ( + "context" + "database/sql" + "errors" + "reflect" + "testing" + "time" + + "github.com/openclaw/clickclack/apps/api/internal/store" +) + +// Hooks give the shared checks access to backend-specific SQL. +type Hooks struct { + // ExpireQuestion moves a question's deadline into the past. + ExpireQuestion func(t *testing.T, messageID string) +} + +type fixture struct { + owner store.User + member store.User + bot store.User + otherBot store.User + workspace store.Workspace + channel store.Channel +} + +func QuestionLifecycle(t *testing.T, st store.Store) { + t.Helper() + ctx := context.Background() + f := newFixture(t, st) + spec := questionSpec(f.member.ID) + + message, created, err := st.CreateMessage(ctx, store.CreateMessageInput{ + ChannelID: f.channel.ID, + AuthorID: f.bot.ID, + Body: "Agent needs input: which day do we ship?", + Nonce: "question-create-1", + Question: &spec, + }) + if err != nil { + t.Fatal(err) + } + question := message.Question + if question == nil || question.Status != store.QuestionStatusOpen || question.Version != 1 || !question.AllowSkip || len(question.Items) != 2 || question.Items[0].Options[0].Label != "Lun 15 sep" { + t.Fatalf("unexpected created question: %#v", question) + } + if !reflect.DeepEqual(created.MentionedUserIDs, []string{f.member.ID}) { + t.Fatalf("responders must be mentioned, got %#v", created.MentionedUserIDs) + } + replayed, replayEvent, err := st.CreateMessage(ctx, store.CreateMessageInput{ChannelID: f.channel.ID, AuthorID: f.bot.ID, Body: message.Body, Nonce: "question-create-1", Question: &spec}) + if err != nil || replayed.ID != message.ID || replayEvent.ID != "" || replayed.Question == nil { + t.Fatalf("nonce replay = %#v, %#v, %v", replayed, replayEvent, err) + } + changed := questionSpec(f.member.ID) + changed.Items[0].Prompt = "A different question" + if _, _, err := st.CreateMessage(ctx, store.CreateMessageInput{ChannelID: f.channel.ID, AuthorID: f.bot.ID, Body: message.Body, Nonce: "question-create-1", Question: &changed}); !errors.Is(err, store.ErrClientNonceConflict) { + t.Fatalf("replay with a different question error = %v", err) + } + page, err := st.ListMessages(ctx, f.channel.ID, f.member.ID, store.MessagePageRequest{Limit: 20}) + if err != nil { + t.Fatal(err) + } + if listed := messageByID(page.Messages, message.ID); listed == nil || listed.Question == nil || listed.Question.Status != store.QuestionStatusOpen { + t.Fatalf("message page did not hydrate the question: %#v", listed) + } + + answers := map[string][]string{"ship_date": {"Lun 15 sep"}, "boxes": {"120"}} + if _, _, err := st.AnswerQuestion(ctx, store.AnswerQuestionInput{MessageID: message.ID, UserID: f.owner.ID, Answers: answers}); !errors.Is(err, store.ErrQuestionResponderRequired) { + t.Fatalf("non-responder answer error = %v", err) + } + if _, _, err := st.AnswerQuestion(ctx, store.AnswerQuestionInput{MessageID: message.ID, UserID: f.member.ID, Answers: map[string][]string{"ship_date": {"Lun 15 sep"}}}); !errors.Is(err, store.ErrInvalidQuestion) { + t.Fatalf("incomplete answer error = %v", err) + } + answered, events, err := st.AnswerQuestion(ctx, store.AnswerQuestionInput{MessageID: message.ID, UserID: f.member.ID, Answers: answers, Nonce: "answer-1"}) + if err != nil { + t.Fatal(err) + } + response := answered.Question.Response + if answered.Question.Status != store.QuestionStatusSubmitted || answered.Question.Version != 2 || response == nil || response.Responder == nil || response.Responder.ID != f.member.ID || response.Source != store.QuestionResponseSourceClickClack || !reflect.DeepEqual(response.Answers, answers) { + t.Fatalf("unexpected submitted question: %#v", answered.Question) + } + if len(events) != 2 || events[0].Type != "message.updated" || events[1].Type != "question.submitted" { + t.Fatalf("unexpected answer events: %#v", events) + } + if payload, ok := events[1].Payload.(map[string]any); !ok || payload["responder_id"] != f.member.ID || payload["message_id"] != message.ID || payload["external_id"] != "ask_1" || payload["answers"] != nil { + t.Fatalf("question.submitted must carry identifiers only: %#v", events[1].Payload) + } + if again, againEvents, err := st.AnswerQuestion(ctx, store.AnswerQuestionInput{MessageID: message.ID, UserID: f.member.ID, Answers: answers, Nonce: "answer-1"}); err != nil || len(againEvents) != 0 || again.Question.Version != 2 { + t.Fatalf("answer replay = %#v, %#v, %v", again.Question, againEvents, err) + } + if _, _, err := st.AnswerQuestion(ctx, store.AnswerQuestionInput{MessageID: message.ID, UserID: f.member.ID, Answers: answers, Nonce: "answer-2"}); !errors.Is(err, store.ErrQuestionClosed) { + t.Fatalf("second answer error = %v", err) + } + + if _, _, err := st.ResolveQuestion(ctx, store.ResolveQuestionInput{MessageID: message.ID, BotUserID: f.otherBot.ID, Status: store.QuestionStatusAnswered}); !errors.Is(err, store.ErrQuestionAuthorRequired) { + t.Fatalf("foreign bot resolve error = %v", err) + } + stale := int64(1) + if _, _, err := st.ResolveQuestion(ctx, store.ResolveQuestionInput{MessageID: message.ID, BotUserID: f.bot.ID, Status: store.QuestionStatusAnswered, ExpectedVersion: &stale}); !errors.Is(err, store.ErrQuestionConflict) { + t.Fatalf("stale version resolve error = %v", err) + } + current := int64(2) + resolved, resolveEvent, err := st.ResolveQuestion(ctx, store.ResolveQuestionInput{MessageID: message.ID, BotUserID: f.bot.ID, Status: store.QuestionStatusAnswered, ExpectedVersion: ¤t}) + if err != nil { + t.Fatal(err) + } + if resolved.Question.Status != store.QuestionStatusAnswered || resolved.Question.ResolvedAt == nil || resolved.Question.Version != 3 || resolved.Question.Response == nil || resolveEvent.Type != "message.updated" { + t.Fatalf("unexpected resolution: %#v %#v", resolved.Question, resolveEvent) + } + if repeated, repeatedEvent, err := st.ResolveQuestion(ctx, store.ResolveQuestionInput{MessageID: message.ID, BotUserID: f.bot.ID, Status: store.QuestionStatusAnswered}); err != nil || repeatedEvent.ID != "" || repeated.Question.Version != 3 { + t.Fatalf("repeated resolution = %#v, %#v, %v", repeated.Question, repeatedEvent, err) + } + if _, _, err := st.ResolveQuestion(ctx, store.ResolveQuestionInput{MessageID: message.ID, BotUserID: f.bot.ID, Status: store.QuestionStatusFailed}); !errors.Is(err, store.ErrQuestionConflict) { + t.Fatalf("changing a terminal status error = %v", err) + } + unresolved, err := st.ListBotUnresolvedQuestions(ctx, f.workspace.ID, f.bot.ID, "", true, 50) + if err != nil || len(unresolved) != 0 { + t.Fatalf("answered questions must not be listed: %#v, %v", unresolved, err) + } +} + +// QuestionResolutionRespectsModeration verifies current write authority, including +// the response and durable event effects of reopening a submitted question. +func QuestionResolutionRespectsModeration(t *testing.T, st store.Store) { + t.Helper() + ctx := context.Background() + f := newFixture(t, st) + conversation, err := st.CreateDirectConversation(ctx, store.CreateDirectConversationInput{ + WorkspaceID: f.workspace.ID, UserID: f.bot.ID, MemberIDs: []string{f.member.ID}, + }) + if err != nil { + t.Fatal(err) + } + for _, location := range []string{"channel", "direct"} { + for _, restriction := range []string{"blocked", "timeout"} { + for _, outcome := range []string{store.QuestionStatusAnswered, store.QuestionStatusOpen} { + t.Run(location+"/"+restriction+"/"+outcome, func(t *testing.T) { + spec := questionSpec(f.member.ID) + var message store.Message + var err error + if location == "direct" { + message, _, err = st.CreateDirectMessage(ctx, store.CreateDirectMessageInput{ + ConversationID: conversation.ID, AuthorID: f.bot.ID, Body: "Confirm shipping", Question: &spec, + }) + } else { + message, _, err = st.CreateMessage(ctx, store.CreateMessageInput{ + ChannelID: f.channel.ID, AuthorID: f.bot.ID, Body: "Confirm shipping", Question: &spec, + }) + } + if err != nil { + t.Fatal(err) + } + submitted, _, err := st.AnswerQuestion(ctx, store.AnswerQuestionInput{ + MessageID: message.ID, UserID: f.member.ID, + Answers: map[string][]string{"ship_date": {"Lun 15 sep"}, "boxes": {"120"}}, + }) + if err != nil { + t.Fatal(err) + } + moderation := store.UpdateMemberModerationInput{ + WorkspaceID: f.workspace.ID, ActorUserID: f.owner.ID, TargetUserID: f.bot.ID, + } + if restriction == "blocked" { + blocked := true + moderation.Blocked = &blocked + } else { + until := time.Now().Add(time.Hour).UTC().Format(time.RFC3339Nano) + moderation.TimeoutUntil = &until + } + _, boundary, err := st.UpdateMemberModeration(ctx, moderation) + if err != nil { + t.Fatal(err) + } + resolve := store.ResolveQuestionInput{ + MessageID: message.ID, BotUserID: f.bot.ID, Status: outcome, + Note: "Please select a shipping day", ExpectedVersion: &submitted.Question.Version, + } + if _, event, err := st.ResolveQuestion(ctx, resolve); !errors.Is(err, store.ErrModerationRestricted) || event.ID != "" { + t.Fatalf("restricted resolution: event=%#v err=%v", event, err) + } + unchanged, err := st.GetMessage(ctx, message.ID, f.member.ID) + if err != nil || !reflect.DeepEqual(unchanged.Question, submitted.Question) { + t.Fatalf("restricted resolution changed stored question: %#v, %v", unchanged.Question, err) + } + if events, err := st.ListEventsAfter(ctx, f.workspace.ID, f.owner.ID, boundary.Cursor, 100); err != nil || len(events) != 0 { + t.Fatalf("restricted resolution appended durable events: %#v, %v", events, err) + } + clear := false + if _, _, err := st.UpdateMemberModeration(ctx, store.UpdateMemberModerationInput{ + WorkspaceID: f.workspace.ID, ActorUserID: f.owner.ID, TargetUserID: f.bot.ID, + Blocked: &clear, ClearTimeout: true, + }); err != nil { + t.Fatal(err) + } + if allowed, event, err := st.ResolveQuestion(ctx, resolve); err != nil || allowed.Question.Status != outcome || event.ID == "" { + t.Fatalf("resolution after restriction cleared: %#v, %#v, %v", allowed.Question, event, err) + } + }) + } + } + } +} + +func QuestionSkipReopenAndExternalAnswers(t *testing.T, st store.Store) { + t.Helper() + ctx := context.Background() + f := newFixture(t, st) + ask := func(body string, mutate func(*store.QuestionSpec)) store.Message { + t.Helper() + spec := questionSpec() + if mutate != nil { + mutate(&spec) + } + message, _, err := st.CreateMessage(ctx, store.CreateMessageInput{ChannelID: f.channel.ID, AuthorID: f.bot.ID, Body: body, Question: &spec}) + if err != nil { + t.Fatal(err) + } + return message + } + + skipped := ask("skip me", nil) + submitted, _, err := st.AnswerQuestion(ctx, store.AnswerQuestionInput{MessageID: skipped.ID, UserID: f.owner.ID, Skip: true}) + if err != nil || !submitted.Question.Response.Skipped || submitted.Question.Status != store.QuestionStatusSubmitted { + t.Fatalf("skip = %#v, %v", submitted.Question, err) + } + if cancelled, _, err := st.ResolveQuestion(ctx, store.ResolveQuestionInput{MessageID: skipped.ID, BotUserID: f.bot.ID, Status: store.QuestionStatusCancelled}); err != nil || cancelled.Question.Status != store.QuestionStatusCancelled || !cancelled.Question.Response.Skipped { + t.Fatalf("cancel after skip = %#v, %v", cancelled.Question, err) + } + + required := ask("no skipping", func(spec *store.QuestionSpec) { + allowSkip := false + spec.AllowSkip = &allowSkip + }) + if _, _, err := st.AnswerQuestion(ctx, store.AnswerQuestionInput{MessageID: required.ID, UserID: f.member.ID, Skip: true}); !errors.Is(err, store.ErrInvalidQuestion) { + t.Fatalf("skipping a required question error = %v", err) + } + + reopened := ask("reopen me", nil) + if _, _, err := st.AnswerQuestion(ctx, store.AnswerQuestionInput{MessageID: reopened.ID, UserID: f.member.ID, Answers: map[string][]string{"ship_date": {"Otra fecha"}, "boxes": {"12"}}}); err != nil { + t.Fatal(err) + } + if _, _, err := st.ResolveQuestion(ctx, store.ResolveQuestionInput{MessageID: reopened.ID, BotUserID: f.bot.ID, Status: store.QuestionStatusOpen}); !errors.Is(err, store.ErrInvalidQuestion) { + t.Fatalf("reopen without a note error = %v", err) + } + open, _, err := st.ResolveQuestion(ctx, store.ResolveQuestionInput{MessageID: reopened.ID, BotUserID: f.bot.ID, Status: store.QuestionStatusOpen, Note: "Pick a listed date"}) + if err != nil || open.Question.Status != store.QuestionStatusOpen || open.Question.Response != nil || open.Question.Note != "Pick a listed date" { + t.Fatalf("reopened question = %#v, %v", open.Question, err) + } + if again, _, err := st.AnswerQuestion(ctx, store.AnswerQuestionInput{MessageID: reopened.ID, UserID: f.owner.ID, Answers: map[string][]string{"ship_date": {"Mar 16 sep"}, "boxes": {"12"}}}); err != nil || again.Question.Response.Responder.ID != f.owner.ID || again.Question.Note != "" { + t.Fatalf("answer after reopen = %#v, %v", again.Question, err) + } + + elsewhere := ask("answered elsewhere", nil) + external, _, err := st.ResolveQuestion(ctx, store.ResolveQuestionInput{MessageID: elsewhere.ID, BotUserID: f.bot.ID, Status: store.QuestionStatusAnswered, Note: "Answered elsewhere", Answers: map[string][]string{"ship_date": {"Mar 16 sep"}, "boxes": {"3"}}}) + if err != nil || external.Question.Response == nil || external.Question.Response.Source != store.QuestionResponseSourceExternal || external.Question.Response.Responder != nil { + t.Fatalf("external answer = %#v, %v", external.Question, err) + } + if _, _, err := st.AnswerQuestion(ctx, store.AnswerQuestionInput{MessageID: elsewhere.ID, UserID: f.member.ID, Answers: map[string][]string{"ship_date": {"Mar 16 sep"}, "boxes": {"3"}}}); !errors.Is(err, store.ErrQuestionClosed) { + t.Fatalf("answering a resolved question error = %v", err) + } + + unresolved, err := st.ListBotUnresolvedQuestions(ctx, f.workspace.ID, f.bot.ID, "", true, 50) + if err != nil { + t.Fatal(err) + } + statuses := map[string]string{} + for _, question := range unresolved { + statuses[question.MessageID] = question.Status + } + if !reflect.DeepEqual(statuses, map[string]string{required.ID: store.QuestionStatusOpen, reopened.ID: store.QuestionStatusSubmitted}) { + t.Fatalf("unexpected unresolved questions: %#v", unresolved) + } +} + +func QuestionExpiryAndAccess(t *testing.T, st store.Store, hooks Hooks) { + t.Helper() + ctx := context.Background() + f := newFixture(t, st) + outsider, err := st.CreateUser(ctx, store.CreateUserInput{DisplayName: "Outsider", Email: "question-outsider@example.com"}) + if err != nil { + t.Fatal(err) + } + invalid := questionSpec(outsider.ID) + if _, _, err := st.CreateMessage(ctx, store.CreateMessageInput{ChannelID: f.channel.ID, AuthorID: f.bot.ID, Body: "who?", Question: &invalid}); !errors.Is(err, store.ErrInvalidQuestion) { + t.Fatalf("responder outside the workspace error = %v", err) + } + guest, err := st.CreateUser(ctx, store.CreateUserInput{DisplayName: "Guest", Email: "question-guest@example.com"}) + if err != nil { + t.Fatal(err) + } + if err := st.AddWorkspaceMember(ctx, f.workspace.ID, guest.ID, store.WorkspaceRoleGuest); err != nil { + t.Fatal(err) + } + guestResponder := questionSpec(guest.ID) + if _, _, err := st.CreateMessage(ctx, store.CreateMessageInput{ChannelID: f.channel.ID, AuthorID: f.bot.ID, Body: "guest?", Question: &guestResponder}); !errors.Is(err, store.ErrInvalidQuestion) { + t.Fatalf("guest responder outside the guest channel error = %v", err) + } + + spec := questionSpec() + expiring, _, err := st.CreateMessage(ctx, store.CreateMessageInput{ChannelID: f.channel.ID, AuthorID: f.bot.ID, Body: "expires", Question: &spec}) + if err != nil { + t.Fatal(err) + } + hooks.ExpireQuestion(t, expiring.ID) + fetched, err := st.GetMessage(ctx, expiring.ID, f.member.ID) + if err != nil || fetched.Question.Status != store.QuestionStatusExpired { + t.Fatalf("expired question reads as %#v, %v", fetched.Question, err) + } + if _, _, err := st.AnswerQuestion(ctx, store.AnswerQuestionInput{MessageID: expiring.ID, UserID: f.member.ID, Skip: true}); !errors.Is(err, store.ErrQuestionClosed) { + t.Fatalf("answering an expired question error = %v", err) + } + if recorded, _, err := st.ResolveQuestion(ctx, store.ResolveQuestionInput{MessageID: expiring.ID, BotUserID: f.bot.ID, Status: store.QuestionStatusExpired}); err != nil || recorded.Question.Status != store.QuestionStatusExpired || recorded.Question.ResolvedAt == nil { + t.Fatalf("recording expiry = %#v, %v", recorded.Question, err) + } + + conversation, err := st.CreateDirectConversation(ctx, store.CreateDirectConversationInput{WorkspaceID: f.workspace.ID, UserID: f.bot.ID, MemberIDs: []string{f.member.ID}}) + if err != nil { + t.Fatal(err) + } + notInDirect := questionSpec(f.owner.ID) + if _, _, err := st.CreateDirectMessage(ctx, store.CreateDirectMessageInput{ConversationID: conversation.ID, AuthorID: f.bot.ID, Body: "private?", Question: ¬InDirect}); !errors.Is(err, store.ErrInvalidQuestion) { + t.Fatalf("responder outside the direct conversation error = %v", err) + } + directSpec := questionSpec() + direct, _, err := st.CreateDirectMessage(ctx, store.CreateDirectMessageInput{ConversationID: conversation.ID, AuthorID: f.bot.ID, Body: "private question", Question: &directSpec}) + if err != nil || direct.Question == nil { + t.Fatalf("direct question = %#v, %v", direct, err) + } + if _, _, err := st.AnswerQuestion(ctx, store.AnswerQuestionInput{MessageID: direct.ID, UserID: f.owner.ID, Skip: true}); err == nil { + t.Fatal("people outside a direct conversation must not answer its questions") + } + if answered, events, err := st.AnswerQuestion(ctx, store.AnswerQuestionInput{MessageID: direct.ID, UserID: f.member.ID, Skip: true}); err != nil || len(events) != 2 || len(events[1].RecipientUserIDs) != 2 { + t.Fatalf("direct answer = %#v, %#v, %v", answered.Question, events, err) + } + + root, _, err := st.CreateMessage(ctx, store.CreateMessageInput{ChannelID: f.channel.ID, AuthorID: f.member.ID, Body: "@bot ship it"}) + if err != nil { + t.Fatal(err) + } + threadSpec := questionSpec() + reply, _, _, err := st.CreateThreadReply(ctx, store.CreateThreadReplyInput{RootMessageID: root.ID, AuthorID: f.bot.ID, Body: "Which day?", Question: &threadSpec}) + if err != nil || reply.Question == nil { + t.Fatalf("thread question = %#v, %v", reply, err) + } + threadPage, err := st.GetThreadPage(ctx, root.ID, f.member.ID, store.ThreadPageRequest{MessagePageRequest: store.MessagePageRequest{Limit: 20}}) + if err != nil || len(threadPage.Replies) != 1 || threadPage.Replies[0].Question == nil { + t.Fatalf("thread page did not hydrate the question: %#v, %v", threadPage.Replies, err) + } + if _, _, err := st.AnswerQuestion(ctx, store.AnswerQuestionInput{MessageID: reply.ID, UserID: f.member.ID, Answers: map[string][]string{"ship_date": {"Lun 15 sep"}, "boxes": {"8"}}}); err != nil { + t.Fatalf("thread answer error = %v", err) + } + + gone, _, err := st.CreateMessage(ctx, store.CreateMessageInput{ChannelID: f.channel.ID, AuthorID: f.bot.ID, Body: "deleted soon", Question: &threadSpec}) + if err != nil { + t.Fatal(err) + } + if _, _, err := st.DeleteMessage(ctx, store.DeleteMessageInput{MessageID: gone.ID, UserID: f.bot.ID}); err != nil { + t.Fatal(err) + } + if _, _, err := st.AnswerQuestion(ctx, store.AnswerQuestionInput{MessageID: gone.ID, UserID: f.member.ID, Skip: true}); !errors.Is(err, sql.ErrNoRows) { + t.Fatalf("answering a deleted message error = %v", err) + } +} + +// QuestionConcurrentAnswers checks that exactly one of several racing answers wins. +func QuestionConcurrentAnswers(t *testing.T, st store.Store) { + t.Helper() + ctx := context.Background() + f := newFixture(t, st) + spec := questionSpec() + message, _, err := st.CreateMessage(ctx, store.CreateMessageInput{ChannelID: f.channel.ID, AuthorID: f.bot.ID, Body: "race", Question: &spec}) + if err != nil { + t.Fatal(err) + } + const racers = 8 + results := make(chan error, racers) + start := make(chan struct{}) + for index := range racers { + userID := f.member.ID + if index%2 == 0 { + userID = f.owner.ID + } + go func() { + <-start + _, _, err := st.AnswerQuestion(ctx, store.AnswerQuestionInput{ + MessageID: message.ID, + UserID: userID, + Answers: map[string][]string{"ship_date": {"Mar 16 sep"}, "boxes": {"1"}}, + Nonce: "race-" + string(rune('a'+index)), + }) + results <- err + }() + } + close(start) + wins := 0 + for range racers { + err := <-results + switch { + case err == nil: + wins++ + case !errors.Is(err, store.ErrQuestionClosed): + t.Fatalf("unexpected racing answer error: %v", err) + } + } + if wins != 1 { + t.Fatalf("expected exactly one winning answer, got %d", wins) + } + final, err := st.GetMessage(ctx, message.ID, f.owner.ID) + if err != nil || final.Question.Status != store.QuestionStatusSubmitted || final.Question.Version != 2 { + t.Fatalf("final question = %#v, %v", final.Question, err) + } +} + +func questionSpec(responders ...string) store.QuestionSpec { + return store.QuestionSpec{ + ExternalID: "ask_1", + ExpiresAt: time.Now().Add(15 * time.Minute).UTC().Format(time.RFC3339), + ResponderUserIDs: responders, + Items: []store.QuestionItem{ + {ID: "ship_date", Header: "Fecha", Prompt: "¿Qué día embarcamos?", Options: []store.QuestionOption{{Label: "Lun 15 sep"}, {Label: "Mar 16 sep"}}, AllowOther: true}, + {ID: "boxes", Header: "Cajas", Prompt: "¿Cuántas cajas?"}, + }, + } +} + +func messageByID(messages []store.Message, id string) *store.Message { + for index := range messages { + if messages[index].ID == id { + return &messages[index] + } + } + return nil +} + +func newFixture(t *testing.T, st store.Store) fixture { + t.Helper() + ctx := context.Background() + var f fixture + var err error + if f.owner, err = st.EnsureBootstrap(ctx, "Owner", "question-owner@example.com"); err != nil { + t.Fatal(err) + } + workspaces, err := st.ListWorkspaces(ctx, f.owner.ID) + if err != nil || len(workspaces) != 1 { + t.Fatalf("bootstrap workspaces = %#v, %v", workspaces, err) + } + f.workspace = workspaces[0] + channels, err := st.ListChannels(ctx, f.workspace.ID, f.owner.ID) + if err != nil || len(channels) == 0 { + t.Fatalf("bootstrap channels = %#v, %v", channels, err) + } + f.channel = channels[0] + if f.member, err = st.CreateUser(ctx, store.CreateUserInput{DisplayName: "Member", Email: "question-member@example.com"}); err != nil { + t.Fatal(err) + } + if err := st.AddWorkspaceMember(ctx, f.workspace.ID, f.member.ID, store.WorkspaceRoleMember); err != nil { + t.Fatal(err) + } + for _, name := range []string{"Question Bot", "Other Bot"} { + bot, _, err := st.CreateBot(ctx, store.CreateBotInput{WorkspaceID: f.workspace.ID, DisplayName: name, Scopes: []string{"bot:write"}, CreatedBy: f.owner.ID}) + if err != nil { + t.Fatal(err) + } + if name == "Question Bot" { + f.bot = bot + } else { + f.otherBot = bot + } + } + return f +} + +// QuestionReplayAndVersionGuards checks retries around deadlines and reopens, +// activity rows, and direct-message visibility in reconciliation. +func QuestionReplayAndVersionGuards(t *testing.T, st store.Store) { + t.Helper() + ctx := context.Background() + f := newFixture(t, st) + + // An identical retry after the deadline window shrinks still finds the original. + nearDeadline := questionSpec() + nearDeadline.ExpiresAt = time.Now().Add(store.MinQuestionLifetime + 1500*time.Millisecond).UTC().Format(time.RFC3339Nano) + original, _, err := st.CreateMessage(ctx, store.CreateMessageInput{ChannelID: f.channel.ID, AuthorID: f.bot.ID, Body: "retry me", Nonce: "question-near-deadline", Question: &nearDeadline}) + if err != nil { + t.Fatal(err) + } + time.Sleep(2 * time.Second) + replayed, _, err := st.CreateMessage(ctx, store.CreateMessageInput{ChannelID: f.channel.ID, AuthorID: f.bot.ID, Body: "retry me", Nonce: "question-near-deadline", Question: &nearDeadline}) + if err != nil || replayed.ID != original.ID { + t.Fatalf("replay near the deadline = %s, %v; want %s", replayed.ID, err, original.ID) + } + fresh := nearDeadline + if _, _, err := st.CreateMessage(ctx, store.CreateMessageInput{ChannelID: f.channel.ID, AuthorID: f.bot.ID, Body: "too late", Nonce: "question-too-late", Question: &fresh}); !errors.Is(err, store.ErrInvalidQuestion) { + t.Fatalf("new question inside the minimum lifetime error = %v", err) + } + + activitySpec := questionSpec() + if _, _, err := st.CreateMessage(ctx, store.CreateMessageInput{ChannelID: f.channel.ID, AuthorID: f.bot.ID, Body: "running a tool", Kind: store.MessageKindAgentTool, TurnID: "turn_question", Question: &activitySpec}); !errors.Is(err, store.ErrInvalidQuestion) { + t.Fatalf("question on an activity row error = %v", err) + } + + spec := questionSpec() + message, _, err := st.CreateMessage(ctx, store.CreateMessageInput{ChannelID: f.channel.ID, AuthorID: f.bot.ID, Body: "versioned", Question: &spec}) + if err != nil { + t.Fatal(err) + } + answers := map[string][]string{"ship_date": {"Otra fecha"}, "boxes": {"12"}} + stale := int64(7) + if _, _, err := st.AnswerQuestion(ctx, store.AnswerQuestionInput{MessageID: message.ID, UserID: f.member.ID, Answers: answers, Nonce: "versioned-answer", ExpectedVersion: &stale}); !errors.Is(err, store.ErrQuestionConflict) { + t.Fatalf("answer to a version the person did not see error = %v", err) + } + seen := int64(1) + if _, _, err := st.AnswerQuestion(ctx, store.AnswerQuestionInput{MessageID: message.ID, UserID: f.member.ID, Answers: answers, Nonce: "versioned-answer", ExpectedVersion: &seen}); err != nil { + t.Fatal(err) + } + if _, _, err := st.ResolveQuestion(ctx, store.ResolveQuestionInput{MessageID: message.ID, BotUserID: f.bot.ID, Status: store.QuestionStatusOpen, Note: "Pick a listed date"}); err != nil { + t.Fatal(err) + } + // The first response was lost and the bot reopened; the retry must not resubmit it. + if _, _, err := st.AnswerQuestion(ctx, store.AnswerQuestionInput{MessageID: message.ID, UserID: f.member.ID, Answers: answers, Nonce: "versioned-answer", ExpectedVersion: &seen}); !errors.Is(err, store.ErrQuestionConflict) { + t.Fatalf("retry after reopen error = %v", err) + } + if current, err := st.GetMessage(ctx, message.ID, f.member.ID); err != nil || current.Question.Status != store.QuestionStatusOpen || current.Question.Response != nil { + t.Fatalf("reopened question after a stale retry = %#v, %v", current.Question, err) + } + + conversation, err := st.CreateDirectConversation(ctx, store.CreateDirectConversationInput{WorkspaceID: f.workspace.ID, UserID: f.bot.ID, MemberIDs: []string{f.member.ID}}) + if err != nil { + t.Fatal(err) + } + directSpec := questionSpec() + direct, _, err := st.CreateDirectMessage(ctx, store.CreateDirectMessageInput{ConversationID: conversation.ID, AuthorID: f.bot.ID, Body: "private", Question: &directSpec}) + if err != nil { + t.Fatal(err) + } + withDirect, err := st.ListBotUnresolvedQuestions(ctx, f.workspace.ID, f.bot.ID, "", true, 50) + if err != nil { + t.Fatal(err) + } + withoutDirect, err := st.ListBotUnresolvedQuestions(ctx, f.workspace.ID, f.bot.ID, "", false, 50) + if err != nil { + t.Fatal(err) + } + if messageInQuestions(withDirect, direct.ID) == nil || messageInQuestions(withoutDirect, direct.ID) != nil || messageInQuestions(withoutDirect, message.ID) == nil { + t.Fatalf("direct questions with scope = %#v, without = %#v", withDirect, withoutDirect) + } +} + +func messageInQuestions(questions []store.BotQuestion, messageID string) *store.BotQuestion { + for index := range questions { + if questions[index].MessageID == messageID { + return &questions[index] + } + } + return nil +} diff --git a/apps/api/internal/store/sqlite/dms.go b/apps/api/internal/store/sqlite/dms.go index df525b062..8c88bd109 100644 --- a/apps/api/internal/store/sqlite/dms.go +++ b/apps/api/internal/store/sqlite/dms.go @@ -237,6 +237,10 @@ func (s *Store) CreateDirectMessage(ctx context.Context, input store.CreateDirec if err := requireDirectActivePeerTx(ctx, tx, input.ConversationID, input.AuthorID); err != nil { return store.Message{}, store.Event{}, err } + question, err := prepareQuestion(input.Question) + if err != nil { + return store.Message{}, store.Event{}, err + } seq, err := qtx.DirectNextSeq(ctx, input.ConversationID) if err != nil { return store.Message{}, store.Event{}, err @@ -263,11 +267,19 @@ func (s *Store) CreateDirectMessage(ctx context.Context, input store.CreateDirec if existing.DirectConversationID != input.ConversationID || existing.ChannelID != "" || existing.ParentMessageID != nil || existing.Body != body || existing.Kind != kind || existing.TurnID != input.TurnID || !sameQuotedMessageID(existing, quotedID) { return store.Message{}, store.Event{}, store.ErrClientNonceConflict } + if matches, err := questionReplayMatchesTx(ctx, tx, existing.ID, question); err != nil { + return store.Message{}, store.Event{}, err + } else if !matches { + return store.Message{}, store.Event{}, store.ErrClientNonceConflict + } existing, err = hydrateMessageCreateReplay(ctx, tx, existing, input.UploadID) return existing, store.Event{}, err } else if !errors.Is(err, sql.ErrNoRows) { return store.Message{}, store.Event{}, err } + if err := validateNewQuestionTx(ctx, tx, workspaceID, "", input.ConversationID, kind, question); err != nil { + return store.Message{}, store.Event{}, err + } if quotedID != "" { snap, authorID, err := resolveQuoteRefTx(ctx, tx, quotedID, quoteScope{kind: "dm", directConversationID: input.ConversationID}) if err != nil { @@ -314,6 +326,10 @@ func (s *Store) CreateDirectMessage(ctx context.Context, input store.CreateDirec } attachedUpload = &upload } + createdQuestion, err := insertMessageQuestionTx(ctx, tx, id, workspaceID, input.AuthorID, createdAt, question) + if err != nil { + return store.Message{}, store.Event{}, err + } if err := qtx.UnhideDirectConversationForMembers(ctx, input.ConversationID); err != nil { return store.Message{}, store.Event{}, err } @@ -328,7 +344,7 @@ func (s *Store) CreateDirectMessage(ctx context.Context, input store.CreateDirec if input.TurnID != "" { dmEventFields["turn_id"] = input.TurnID } - event, err := insertEventWithRecipients(ctx, tx, workspaceID, "", "message.created", &seq, eventPayload(ctx, dmEventFields, nonce), recipients) + event, err := insertEventWithRecipientsAndMentions(ctx, tx, workspaceID, "", "message.created", &seq, eventPayload(ctx, dmEventFields, nonce), recipients, mergeMentionedUserIDs(nil, question)) if err != nil { return store.Message{}, store.Event{}, err } @@ -339,6 +355,7 @@ func (s *Store) CreateDirectMessage(ctx context.Context, input store.CreateDirec if attachedUpload != nil { msg.Attachments = []store.Upload{*attachedUpload} } + msg.Question = createdQuestion return msg, event, tx.Commit() } diff --git a/apps/api/internal/store/sqlite/message_pages.go b/apps/api/internal/store/sqlite/message_pages.go index e180fe17a..063b17b92 100644 --- a/apps/api/internal/store/sqlite/message_pages.go +++ b/apps/api/internal/store/sqlite/message_pages.go @@ -81,6 +81,10 @@ func (s *Store) listMessagePage(ctx context.Context, scope messagePageScope, req if err != nil { return store.MessagePage{}, err } + messages, err = s.hydrateQuestions(ctx, messages) + if err != nil { + return store.MessagePage{}, err + } messages, err = s.hydrateThreadStates(ctx, messages) if err != nil { return store.MessagePage{}, err diff --git a/apps/api/internal/store/sqlite/migrations/0043_message_questions.sql b/apps/api/internal/store/sqlite/migrations/0043_message_questions.sql new file mode 100644 index 000000000..fd43d6924 --- /dev/null +++ b/apps/api/internal/store/sqlite/migrations/0043_message_questions.sql @@ -0,0 +1,28 @@ +-- Structured questions that bots attach to their messages. The question itself +-- is immutable; the first valid answer and the bot's recorded outcome live on +-- the same row so each transition is one conditional update. +CREATE TABLE IF NOT EXISTS message_questions ( + message_id TEXT PRIMARY KEY REFERENCES messages(id) ON DELETE CASCADE, + workspace_id TEXT NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE, + bot_user_id TEXT NOT NULL REFERENCES users(id), + external_id TEXT NOT NULL DEFAULT '', + spec_json TEXT NOT NULL, + responder_user_ids TEXT NOT NULL DEFAULT '[]', + allow_skip INTEGER NOT NULL DEFAULT 1 CHECK (allow_skip IN (0, 1)), + expires_at TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'open' + CHECK (status IN ('open', 'submitted', 'answered', 'cancelled', 'expired', 'failed')), + response_json TEXT NOT NULL DEFAULT '', + response_source TEXT NOT NULL DEFAULT '', + responded_by TEXT REFERENCES users(id) ON DELETE SET NULL, + responded_at TEXT, + response_nonce TEXT NOT NULL DEFAULT '', + note TEXT NOT NULL DEFAULT '', + resolved_at TEXT, + version INTEGER NOT NULL DEFAULT 1, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL +); + +CREATE INDEX IF NOT EXISTS idx_message_questions_bot_status + ON message_questions(workspace_id, bot_user_id, status, message_id); diff --git a/apps/api/internal/store/sqlite/pins.go b/apps/api/internal/store/sqlite/pins.go index 14ba7fd59..2b220547d 100644 --- a/apps/api/internal/store/sqlite/pins.go +++ b/apps/api/internal/store/sqlite/pins.go @@ -183,6 +183,11 @@ func (s *Store) ListPinnedMessages(ctx context.Context, channelID, userID string return nil, err } + msgs, err = s.hydrateQuestions(ctx, msgs) + if err != nil { + return nil, err + } + msgs, err = s.hydrateReactions(ctx, userID, msgs) if err != nil { return nil, err diff --git a/apps/api/internal/store/sqlite/questions.go b/apps/api/internal/store/sqlite/questions.go new file mode 100644 index 000000000..601adc887 --- /dev/null +++ b/apps/api/internal/store/sqlite/questions.go @@ -0,0 +1,542 @@ +package sqlite + +import ( + "context" + "database/sql" + "encoding/json" + "errors" + "fmt" + "slices" + "strings" + "time" + "unicode/utf8" + + "github.com/openclaw/clickclack/apps/api/internal/store" + "github.com/openclaw/clickclack/apps/api/internal/store/sqlite/storedb" +) + +// questionDocument is the immutable part of a question stored in spec_json. +type questionDocument struct { + Title string `json:"title,omitempty"` + Items []store.QuestionItem `json:"items"` +} + +type storedQuestionResponse struct { + Answers map[string][]string `json:"answers,omitempty"` + Skipped bool `json:"skipped,omitempty"` +} + +// prepareQuestion normalizes a bot's question. It reads nothing, so a nonce +// replay can compare it before any rule that depends on the current time or +// membership. +func prepareQuestion(input *store.QuestionSpec) (*store.QuestionSpec, error) { + if input == nil { + return nil, nil + } + spec, err := store.NormalizeQuestionSpec(*input) + if err != nil { + return nil, err + } + return &spec, nil +} + +// validateNewQuestionTx checks what a newly created question must satisfy now: +// an ordinary message, its deadline, and responders who can read the conversation. +func validateNewQuestionTx(ctx context.Context, tx *sql.Tx, workspaceID, channelID, directConversationID, kind string, spec *store.QuestionSpec) error { + if spec == nil { + return nil + } + if store.IsActivityMessageKind(kind) { + return fmt.Errorf("%w: %s", store.ErrInvalidQuestion, "questions attach only to ordinary messages") + } + if err := store.ValidateQuestionLifetime(*spec, time.Now()); err != nil { + return err + } + for _, userID := range spec.ResponderUserIDs { + var one int + query := ` + SELECT 1 + FROM workspace_members wm + JOIN users u ON u.id = wm.user_id + WHERE wm.workspace_id = ? AND wm.user_id = ? AND u.kind = 'human'` + args := []any{workspaceID, userID} + if directConversationID != "" { + query += ` AND EXISTS (SELECT 1 FROM direct_conversation_members dcm WHERE dcm.conversation_id = ? AND dcm.user_id = wm.user_id)` + args = append(args, directConversationID) + } + err := tx.QueryRowContext(ctx, query, args...).Scan(&one) + if err == nil && directConversationID == "" { + err = requireGuestChannelAccessTx(ctx, tx, workspaceID, channelID, userID) + } + if errors.Is(err, sql.ErrNoRows) || errors.Is(err, store.ErrModerationRestricted) { + return fmt.Errorf("%w: %s", store.ErrInvalidQuestion, "responder_user_ids must be people who can read this conversation") + } else if err != nil { + return err + } + } + return nil +} + +func insertMessageQuestionTx(ctx context.Context, tx *sql.Tx, messageID, workspaceID, botUserID, createdAt string, spec *store.QuestionSpec) (*store.MessageQuestion, error) { + if spec == nil { + return nil, nil + } + document, err := json.Marshal(questionDocument{Title: spec.Title, Items: spec.Items}) + if err != nil { + return nil, err + } + responders, err := json.Marshal(nonNilStrings(spec.ResponderUserIDs)) + if err != nil { + return nil, err + } + allowSkip := int64(0) + if spec.AllowSkip == nil || *spec.AllowSkip { + allowSkip = 1 + } + if err := storedb.New(tx).InsertMessageQuestion(ctx, storedb.InsertMessageQuestionParams{ + MessageID: messageID, + WorkspaceID: workspaceID, + BotUserID: botUserID, + ExternalID: spec.ExternalID, + SpecJson: string(document), + ResponderUserIds: string(responders), + AllowSkip: allowSkip, + ExpiresAt: spec.ExpiresAt, + CreatedAt: createdAt, + }); err != nil { + return nil, err + } + return &store.MessageQuestion{ + Status: store.QuestionStatusOpen, + ExternalID: spec.ExternalID, + Title: spec.Title, + ExpiresAt: spec.ExpiresAt, + AllowSkip: allowSkip == 1, + Items: spec.Items, + ResponderUserIDs: spec.ResponderUserIDs, + Version: 1, + }, nil +} + +// questionReplayMatchesTx reports whether a nonce replay repeats the same question. +func questionReplayMatchesTx(ctx context.Context, tx *sql.Tx, messageID string, spec *store.QuestionSpec) (bool, error) { + row, err := storedb.New(tx).GetMessageQuestion(ctx, messageID) + if errors.Is(err, sql.ErrNoRows) { + return spec == nil, nil + } + if err != nil || spec == nil { + return false, err + } + document, err := json.Marshal(questionDocument{Title: spec.Title, Items: spec.Items}) + if err != nil { + return false, err + } + var responders []string + if err := json.Unmarshal([]byte(row.ResponderUserIds), &responders); err != nil { + return false, err + } + allowSkip := spec.AllowSkip == nil || *spec.AllowSkip + return row.SpecJson == string(document) && row.ExternalID == spec.ExternalID && row.ExpiresAt == spec.ExpiresAt && + (row.AllowSkip == 1) == allowSkip && slices.Equal(responders, nonNilStrings(spec.ResponderUserIDs)), nil +} + +func (s *Store) hydrateQuestions(ctx context.Context, messages []store.Message) ([]store.Message, error) { + return hydrateQuestions(ctx, s.db, messages) +} + +func hydrateQuestions(ctx context.Context, db storedb.DBTX, messages []store.Message) ([]store.Message, error) { + indexByID := make(map[string]int, len(messages)) + args := make([]any, 0, len(messages)) + for index, message := range messages { + if message.DeletedAt != nil { + continue + } + indexByID[message.ID] = index + args = append(args, message.ID) + } + if len(args) == 0 { + return messages, nil + } + rows, err := db.QueryContext(ctx, ` + SELECT q.message_id, q.external_id, q.spec_json, q.responder_user_ids, q.allow_skip, q.expires_at, q.status, + q.response_json, q.response_source, q.responded_at, q.note, q.resolved_at, q.version, + u.id, u.kind, u.owner_user_id, u.display_name, u.handle, u.avatar_url, u.created_at + FROM message_questions q + LEFT JOIN users u ON u.id = q.responded_by + WHERE q.message_id IN (`+strings.TrimRight(strings.Repeat("?,", len(args)), ",")+`)`, args...) + if err != nil { + return nil, err + } + defer rows.Close() + now := time.Now() + for rows.Next() { + var row storedb.MessageQuestion + var responderID, responderKind, responderOwner, responderName, responderHandle, responderAvatar, responderCreated sql.NullString + if err := rows.Scan(&row.MessageID, &row.ExternalID, &row.SpecJson, &row.ResponderUserIds, &row.AllowSkip, &row.ExpiresAt, &row.Status, + &row.ResponseJson, &row.ResponseSource, &row.RespondedAt, &row.Note, &row.ResolvedAt, &row.Version, + &responderID, &responderKind, &responderOwner, &responderName, &responderHandle, &responderAvatar, &responderCreated); err != nil { + return nil, err + } + var responder *store.User + if responderID.Valid { + user := storeUserFromDB(responderID.String, responderKind.String, responderOwner, responderName.String, responderHandle.String, responderAvatar.String, responderCreated.String) + responder = &user + } + question, err := messageQuestionFromRow(row, responder, now) + if err != nil { + return nil, err + } + if index, ok := indexByID[row.MessageID]; ok { + messages[index].Question = &question + } + } + return messages, rows.Err() +} + +func messageQuestionFromRow(row storedb.MessageQuestion, responder *store.User, now time.Time) (store.MessageQuestion, error) { + var document questionDocument + if err := json.Unmarshal([]byte(row.SpecJson), &document); err != nil { + return store.MessageQuestion{}, err + } + question := store.MessageQuestion{ + Status: store.EffectiveQuestionStatus(row.Status, row.ExpiresAt, now), + ExternalID: row.ExternalID, + Title: document.Title, + ExpiresAt: row.ExpiresAt, + AllowSkip: row.AllowSkip == 1, + Items: document.Items, + Note: row.Note, + ResolvedAt: ptrFromNull(row.ResolvedAt), + Version: row.Version, + } + if err := json.Unmarshal([]byte(row.ResponderUserIds), &question.ResponderUserIDs); err != nil { + return store.MessageQuestion{}, err + } + if len(question.ResponderUserIDs) == 0 { + question.ResponderUserIDs = nil + } + if row.ResponseJson != "" { + var response storedQuestionResponse + if err := json.Unmarshal([]byte(row.ResponseJson), &response); err != nil { + return store.MessageQuestion{}, err + } + question.Response = &store.QuestionResponse{ + Answers: response.Answers, + Skipped: response.Skipped, + Source: row.ResponseSource, + Responder: responder, + RespondedAt: row.RespondedAt.String, + } + } + return question, nil +} + +func (s *Store) AnswerQuestion(ctx context.Context, input store.AnswerQuestionInput) (store.Message, []store.Event, error) { + nonce, err := store.NormalizeClientNonce(input.Nonce) + if err != nil { + return store.Message{}, nil, err + } + tx, err := s.db.BeginTx(ctx, nil) + if err != nil { + return store.Message{}, nil, err + } + defer tx.Rollback() + qtx := s.q.WithTx(tx) + msg, err := getMessageTx(ctx, tx, input.MessageID) + if err != nil { + return store.Message{}, nil, err + } + if msg.DeletedAt != nil { + return store.Message{}, nil, sql.ErrNoRows + } + row, err := qtx.GetMessageQuestion(ctx, msg.ID) + if err != nil { + return store.Message{}, nil, err + } + if err := requireMessageAccessTx(ctx, tx, msg, input.UserID); err != nil { + return store.Message{}, nil, err + } + if msg.DirectConversationID != "" { + err = requireCanSendDirectTx(ctx, tx, msg.WorkspaceID, input.UserID) + } else { + err = requireNoModerationBlockTx(ctx, tx, msg.WorkspaceID, input.UserID) + } + if err != nil { + return store.Message{}, nil, err + } + var responders []string + if err := json.Unmarshal([]byte(row.ResponderUserIds), &responders); err != nil { + return store.Message{}, nil, err + } + if len(responders) > 0 && !slices.Contains(responders, input.UserID) { + return store.Message{}, nil, store.ErrQuestionResponderRequired + } + if nonce != "" && row.ResponseNonce == nonce && row.RespondedBy.String == input.UserID { + // Release the transaction first: reads below use the store's connection pool. + if err := tx.Rollback(); err != nil { + return store.Message{}, nil, err + } + message, err := s.hydrateQuestionMessage(ctx, msg, input.UserID) + return message, nil, err + } + if input.ExpectedVersion != nil && *input.ExpectedVersion != row.Version { + return store.Message{}, nil, store.ErrQuestionConflict + } + if store.EffectiveQuestionStatus(row.Status, row.ExpiresAt, time.Now()) != store.QuestionStatusOpen { + return store.Message{}, nil, store.ErrQuestionClosed + } + response := storedQuestionResponse{Skipped: input.Skip} + if input.Skip { + if row.AllowSkip != 1 { + return store.Message{}, nil, fmt.Errorf("%w: %s", store.ErrInvalidQuestion, "this question cannot be skipped") + } + } else { + var document questionDocument + if err := json.Unmarshal([]byte(row.SpecJson), &document); err != nil { + return store.Message{}, nil, err + } + if response.Answers, err = store.NormalizeQuestionAnswers(document.Items, input.Answers); err != nil { + return store.Message{}, nil, err + } + } + responseJSON, err := json.Marshal(response) + if err != nil { + return store.Message{}, nil, err + } + respondedAt := now() + affected, err := qtx.SubmitMessageQuestionResponse(ctx, storedb.SubmitMessageQuestionResponseParams{ + ResponseJson: string(responseJSON), + RespondedBy: sqlText(input.UserID), + RespondedAt: sqlText(respondedAt), + ResponseNonce: nonce, + MessageID: msg.ID, + Version: row.Version, + }) + if err != nil { + return store.Message{}, nil, err + } + if affected == 0 { + return store.Message{}, nil, store.ErrQuestionClosed + } + events, err := insertQuestionEventsTx(ctx, tx, msg, map[string]any{ + "external_id": row.ExternalID, + "responder_id": input.UserID, + "skipped": input.Skip, + "version": row.Version + 1, + }) + if err != nil { + return store.Message{}, nil, err + } + if err := tx.Commit(); err != nil { + return store.Message{}, nil, err + } + message, err := s.hydrateQuestionMessage(ctx, msg, input.UserID) + return message, events, err +} + +func (s *Store) ResolveQuestion(ctx context.Context, input store.ResolveQuestionInput) (store.Message, store.Event, error) { + status := strings.TrimSpace(input.Status) + note := strings.TrimSpace(input.Note) + if utf8.RuneCountInString(note) > store.MaxQuestionNoteLength { + return store.Message{}, store.Event{}, fmt.Errorf("%w: %s", store.ErrInvalidQuestion, "note is too long") + } + tx, err := s.db.BeginTx(ctx, nil) + if err != nil { + return store.Message{}, store.Event{}, err + } + defer tx.Rollback() + qtx := s.q.WithTx(tx) + msg, err := getMessageTx(ctx, tx, input.MessageID) + if err != nil { + return store.Message{}, store.Event{}, err + } + if msg.DeletedAt != nil { + return store.Message{}, store.Event{}, sql.ErrNoRows + } + row, err := qtx.GetMessageQuestion(ctx, msg.ID) + if err != nil { + return store.Message{}, store.Event{}, err + } + if row.BotUserID != input.BotUserID { + return store.Message{}, store.Event{}, store.ErrQuestionAuthorRequired + } + if err := requireMessageAccessTx(ctx, tx, msg, input.BotUserID); err != nil { + return store.Message{}, store.Event{}, err + } + if msg.DirectConversationID != "" { + err = requireCanSendDirectTx(ctx, tx, msg.WorkspaceID, input.BotUserID) + } else { + err = requireNoModerationBlockTx(ctx, tx, msg.WorkspaceID, input.BotUserID) + } + if err != nil { + return store.Message{}, store.Event{}, err + } + if input.ExpectedVersion != nil && *input.ExpectedVersion != row.Version { + return store.Message{}, store.Event{}, store.ErrQuestionConflict + } + if row.Status == status && store.IsTerminalQuestionStatus(status) { + if err := tx.Rollback(); err != nil { + return store.Message{}, store.Event{}, err + } + message, err := s.hydrateQuestionMessage(ctx, msg, input.BotUserID) + return message, store.Event{}, err + } + if !store.QuestionResolutionAllowed(row.Status, status) { + return store.Message{}, store.Event{}, store.ErrQuestionConflict + } + params := storedb.ResolveMessageQuestionParams{ + Status: status, + Note: note, + ResponseJson: row.ResponseJson, + ResponseSource: row.ResponseSource, + RespondedBy: row.RespondedBy, + RespondedAt: row.RespondedAt, + ResponseNonce: row.ResponseNonce, + UpdatedAt: now(), + MessageID: msg.ID, + Version: row.Version, + } + switch { + case status == store.QuestionStatusOpen: + if note == "" { + return store.Message{}, store.Event{}, fmt.Errorf("%w: %s", store.ErrInvalidQuestion, "reopening a question requires a note") + } + params.ResponseJson, params.ResponseSource, params.ResponseNonce = "", "", "" + params.RespondedBy, params.RespondedAt = sql.NullString{}, sql.NullString{} + case row.Status == store.QuestionStatusOpen && status == store.QuestionStatusAnswered && len(input.Answers) > 0: + var document questionDocument + if err := json.Unmarshal([]byte(row.SpecJson), &document); err != nil { + return store.Message{}, store.Event{}, err + } + answers, err := store.NormalizeQuestionAnswers(document.Items, input.Answers) + if err != nil { + return store.Message{}, store.Event{}, err + } + responseJSON, err := json.Marshal(storedQuestionResponse{Answers: answers}) + if err != nil { + return store.Message{}, store.Event{}, err + } + params.ResponseJson = string(responseJSON) + params.ResponseSource = store.QuestionResponseSourceExternal + params.RespondedAt = sqlText(params.UpdatedAt) + } + if store.IsTerminalQuestionStatus(status) { + params.ResolvedAt = sqlText(params.UpdatedAt) + } + affected, err := qtx.ResolveMessageQuestion(ctx, params) + if err != nil { + return store.Message{}, store.Event{}, err + } + if affected == 0 { + return store.Message{}, store.Event{}, store.ErrQuestionConflict + } + events, err := insertQuestionEventsTx(ctx, tx, msg, nil) + if err != nil { + return store.Message{}, store.Event{}, err + } + if err := tx.Commit(); err != nil { + return store.Message{}, store.Event{}, err + } + message, err := s.hydrateQuestionMessage(ctx, msg, input.BotUserID) + return message, events[0], err +} + +// insertQuestionEventsTx appends message.updated so every client refreshes the +// card and, for a submitted answer, question.submitted for the authoring bot. +// Neither payload carries answer content. +func insertQuestionEventsTx(ctx context.Context, tx *sql.Tx, msg store.Message, submitted map[string]any) ([]store.Event, error) { + recipients, err := eventRecipientsForMessageTx(ctx, tx, msg) + if err != nil { + return nil, err + } + updated, err := insertEventWithRecipients(ctx, tx, msg.WorkspaceID, msg.ChannelID, "message.updated", msg.ChannelSeq, messagePayload(msg), recipients) + if err != nil || submitted == nil { + return []store.Event{updated}, err + } + submitted["message_id"] = msg.ID + submitted["root_message_id"] = msg.ThreadRootID + if msg.DirectConversationID != "" { + submitted["direct_conversation_id"] = msg.DirectConversationID + } + event, err := insertEventWithRecipients(ctx, tx, msg.WorkspaceID, msg.ChannelID, "question.submitted", msg.ChannelSeq, submitted, recipients) + if err != nil { + return nil, err + } + return []store.Event{updated, event}, nil +} + +func (s *Store) hydrateQuestionMessage(ctx context.Context, msg store.Message, userID string) (store.Message, error) { + fresh, err := getMessage(ctx, s.db, msg.ID) + if err != nil { + return store.Message{}, err + } + messages, err := s.hydrateAttachments(ctx, []store.Message{fresh}) + if err != nil { + return store.Message{}, err + } + if messages, err = s.hydrateReactions(ctx, userID, messages); err != nil { + return store.Message{}, err + } + if messages, err = s.hydrateQuestions(ctx, messages); err != nil { + return store.Message{}, err + } + return messages[0], nil +} + +func (s *Store) ListBotUnresolvedQuestions(ctx context.Context, workspaceID, botUserID, afterMessageID string, includeDirect bool, limit int) ([]store.BotQuestion, error) { + // One row past the largest page lets callers detect a next page. + if limit <= 0 || limit > store.MaxBotQuestionPageSize+1 { + limit = store.MaxBotQuestionPageSize + 1 + } + directFlag := int64(0) + if includeDirect { + directFlag = 1 + } + rows, err := s.q.ListBotUnresolvedQuestions(ctx, storedb.ListBotUnresolvedQuestionsParams{ + WorkspaceID: workspaceID, + BotUserID: botUserID, + AfterMessageID: afterMessageID, + IncludeDirect: directFlag, + RowLimit: int64(limit), + }) + if err != nil { + return nil, err + } + questions := make([]store.BotQuestion, 0, len(rows)) + now := time.Now() + for _, row := range rows { + questions = append(questions, store.BotQuestion{ + MessageID: row.MessageID, + WorkspaceID: row.WorkspaceID, + ChannelID: row.ChannelID, + DirectConversationID: row.DirectConversationID, + ThreadRootID: row.ThreadRootID, + ExternalID: row.ExternalID, + Status: store.EffectiveQuestionStatus(row.Status, row.ExpiresAt, now), + ExpiresAt: row.ExpiresAt, + Version: row.Version, + }) + } + return questions, nil +} + +func nonNilStrings(values []string) []string { + if values == nil { + return []string{} + } + return values +} + +// mergeMentionedUserIDs adds a question's responders to the resolved mentions so +// mention-only channels still alert the people who must answer. +func mergeMentionedUserIDs(mentioned []string, spec *store.QuestionSpec) []string { + if spec == nil { + return mentioned + } + for _, userID := range spec.ResponderUserIDs { + if !slices.Contains(mentioned, userID) { + mentioned = append(mentioned, userID) + } + } + return mentioned +} diff --git a/apps/api/internal/store/sqlite/questions_test.go b/apps/api/internal/store/sqlite/questions_test.go new file mode 100644 index 000000000..0cedc3fc6 --- /dev/null +++ b/apps/api/internal/store/sqlite/questions_test.go @@ -0,0 +1,48 @@ +package sqlite + +import ( + "context" + "testing" + "time" + + "github.com/openclaw/clickclack/apps/api/internal/store/questiontest" +) + +func TestQuestionLifecycle(t *testing.T) { + t.Parallel() + questiontest.QuestionLifecycle(t, newTestStore(t)) +} + +func TestQuestionResolutionRespectsModeration(t *testing.T) { + t.Parallel() + questiontest.QuestionResolutionRespectsModeration(t, newTestStore(t)) +} + +func TestQuestionSkipReopenAndExternalAnswers(t *testing.T) { + t.Parallel() + questiontest.QuestionSkipReopenAndExternalAnswers(t, newTestStore(t)) +} + +func TestQuestionExpiryAndAccess(t *testing.T) { + t.Parallel() + st := newTestStore(t) + questiontest.QuestionExpiryAndAccess(t, st, questiontest.Hooks{ + ExpireQuestion: func(t *testing.T, messageID string) { + t.Helper() + past := time.Now().Add(-time.Minute).UTC().Format(time.RFC3339Nano) + if _, err := st.db.ExecContext(context.Background(), `UPDATE message_questions SET expires_at = ? WHERE message_id = ?`, past, messageID); err != nil { + t.Fatal(err) + } + }, + }) +} + +func TestQuestionConcurrentAnswers(t *testing.T) { + t.Parallel() + questiontest.QuestionConcurrentAnswers(t, newTestStore(t)) +} + +func TestQuestionReplayAndVersionGuards(t *testing.T) { + t.Parallel() + questiontest.QuestionReplayAndVersionGuards(t, newTestStore(t)) +} diff --git a/apps/api/internal/store/sqlite/sqlc/queries.sql b/apps/api/internal/store/sqlite/sqlc/queries.sql index be3ef1529..6275dea6e 100644 --- a/apps/api/internal/store/sqlite/sqlc/queries.sql +++ b/apps/api/internal/store/sqlite/sqlc/queries.sql @@ -1848,6 +1848,67 @@ WHERE user_id = sqlc.arg(user_id) AND token_hash = sqlc.arg(token_hash) AND revoked_at IS NULL; +-- name: InsertMessageQuestion :exec +INSERT INTO message_questions ( + message_id, workspace_id, bot_user_id, external_id, spec_json, responder_user_ids, + allow_skip, expires_at, created_at, updated_at +) VALUES ( + sqlc.arg(message_id), sqlc.arg(workspace_id), sqlc.arg(bot_user_id), sqlc.arg(external_id), + sqlc.arg(spec_json), sqlc.arg(responder_user_ids), sqlc.arg(allow_skip), sqlc.arg(expires_at), + sqlc.arg(created_at), sqlc.arg(created_at) +); + +-- name: GetMessageQuestion :one +SELECT message_id, workspace_id, bot_user_id, external_id, spec_json, responder_user_ids, allow_skip, + expires_at, status, response_json, response_source, responded_by, responded_at, response_nonce, + note, resolved_at, version, created_at, updated_at +FROM message_questions +WHERE message_id = sqlc.arg(message_id); + +-- name: SubmitMessageQuestionResponse :execrows +UPDATE message_questions +SET status = 'submitted', + note = '', + response_json = sqlc.arg(response_json), + response_source = 'clickclack', + responded_by = sqlc.arg(responded_by), + responded_at = sqlc.arg(responded_at), + response_nonce = sqlc.arg(response_nonce), + version = version + 1, + updated_at = sqlc.arg(responded_at) +WHERE message_id = sqlc.arg(message_id) + AND status = 'open' + AND version = sqlc.arg(version); + +-- name: ResolveMessageQuestion :execrows +UPDATE message_questions +SET status = sqlc.arg(status), + note = sqlc.arg(note), + response_json = sqlc.arg(response_json), + response_source = sqlc.arg(response_source), + responded_by = sqlc.narg(responded_by), + responded_at = sqlc.narg(responded_at), + response_nonce = sqlc.arg(response_nonce), + resolved_at = sqlc.narg(resolved_at), + version = version + 1, + updated_at = sqlc.arg(updated_at) +WHERE message_id = sqlc.arg(message_id) + AND version = sqlc.arg(version); + +-- name: ListBotUnresolvedQuestions :many +SELECT q.message_id, q.workspace_id, COALESCE(m.channel_id, '') AS channel_id, + COALESCE(m.direct_conversation_id, '') AS direct_conversation_id, m.thread_root_id, + q.external_id, q.status, q.expires_at, q.version +FROM message_questions q +JOIN messages m ON m.id = q.message_id +WHERE q.workspace_id = sqlc.arg(workspace_id) + AND q.bot_user_id = sqlc.arg(bot_user_id) + AND q.status IN ('open', 'submitted') + AND m.deleted_at IS NULL + AND (CAST(sqlc.arg(include_direct) AS INTEGER) = 1 OR m.direct_conversation_id IS NULL) + AND q.message_id > sqlc.arg(after_message_id) +ORDER BY q.message_id +LIMIT sqlc.arg(row_limit); -- name: UpsertPushSubscription :exec INSERT INTO user_push_subscriptions ( diff --git a/apps/api/internal/store/sqlite/sqlc/schema.sql b/apps/api/internal/store/sqlite/sqlc/schema.sql index 7d80e3e51..244097fc6 100644 --- a/apps/api/internal/store/sqlite/sqlc/schema.sql +++ b/apps/api/internal/store/sqlite/sqlc/schema.sql @@ -168,6 +168,32 @@ CREATE UNIQUE INDEX idx_messages_direct_unique_seq ON messages(direct_conversati CREATE UNIQUE INDEX idx_messages_thread_unique_seq ON messages(thread_root_id, thread_seq) WHERE parent_message_id IS NOT NULL AND thread_seq IS NOT NULL; +CREATE TABLE message_questions ( + message_id TEXT PRIMARY KEY REFERENCES messages(id) ON DELETE CASCADE, + workspace_id TEXT NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE, + bot_user_id TEXT NOT NULL REFERENCES users(id), + external_id TEXT NOT NULL DEFAULT '', + spec_json TEXT NOT NULL, + responder_user_ids TEXT NOT NULL DEFAULT '[]', + allow_skip INTEGER NOT NULL DEFAULT 1 CHECK (allow_skip IN (0, 1)), + expires_at TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'open' + CHECK (status IN ('open', 'submitted', 'answered', 'cancelled', 'expired', 'failed')), + response_json TEXT NOT NULL DEFAULT '', + response_source TEXT NOT NULL DEFAULT '', + responded_by TEXT REFERENCES users(id) ON DELETE SET NULL, + responded_at TEXT, + response_nonce TEXT NOT NULL DEFAULT '', + note TEXT NOT NULL DEFAULT '', + resolved_at TEXT, + version INTEGER NOT NULL DEFAULT 1, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL +); + +CREATE INDEX idx_message_questions_bot_status + ON message_questions(workspace_id, bot_user_id, status, message_id); + CREATE TABLE thread_state ( root_message_id TEXT PRIMARY KEY REFERENCES messages(id) ON DELETE CASCADE, reply_count INTEGER NOT NULL DEFAULT 0, diff --git a/apps/api/internal/store/sqlite/sqlite.go b/apps/api/internal/store/sqlite/sqlite.go index a72f80443..271e5efc5 100644 --- a/apps/api/internal/store/sqlite/sqlite.go +++ b/apps/api/internal/store/sqlite/sqlite.go @@ -630,6 +630,10 @@ func (s *Store) GetMessage(ctx context.Context, messageID, userID string) (store if err != nil { return store.Message{}, err } + messages, err = s.hydrateQuestions(ctx, messages) + if err != nil { + return store.Message{}, err + } return messages[0], nil } @@ -685,6 +689,10 @@ func (s *Store) CreateMessage(ctx context.Context, input store.CreateMessageInpu if err := requireTopicTx(ctx, tx, workspaceID, input.ChannelID, input.TopicID); err != nil { return store.Message{}, store.Event{}, err } + question, err := prepareQuestion(input.Question) + if err != nil { + return store.Message{}, store.Event{}, err + } seq, err := qtx.ChannelNextSeq(ctx, input.ChannelID) if err != nil { return store.Message{}, store.Event{}, err @@ -711,6 +719,11 @@ func (s *Store) CreateMessage(ctx context.Context, input store.CreateMessageInpu if existing.ChannelID != input.ChannelID || existing.DirectConversationID != "" || existing.ParentMessageID != nil || existing.Body != body || existing.TopicID != input.TopicID || existing.Kind != kind || existing.TurnID != input.TurnID || !sameQuotedMessageID(existing, quotedID) { return store.Message{}, store.Event{}, store.ErrClientNonceConflict } + if matches, err := questionReplayMatchesTx(ctx, tx, existing.ID, question); err != nil { + return store.Message{}, store.Event{}, err + } else if !matches { + return store.Message{}, store.Event{}, store.ErrClientNonceConflict + } if err := requireMessageAccessTx(ctx, tx, existing, input.AuthorID); err != nil { return store.Message{}, store.Event{}, err } @@ -719,6 +732,9 @@ func (s *Store) CreateMessage(ctx context.Context, input store.CreateMessageInpu } else if !errors.Is(err, sql.ErrNoRows) { return store.Message{}, store.Event{}, err } + if err := validateNewQuestionTx(ctx, tx, workspaceID, input.ChannelID, "", kind, question); err != nil { + return store.Message{}, store.Event{}, err + } if err := requireCanPostTx(ctx, tx, workspaceID, input.ChannelID, input.AuthorID); err != nil { return store.Message{}, store.Event{}, err } @@ -772,6 +788,10 @@ func (s *Store) CreateMessage(ctx context.Context, input store.CreateMessageInpu } attachedUpload = &upload } + createdQuestion, err := insertMessageQuestionTx(ctx, tx, id, workspaceID, input.AuthorID, createdAt, question) + if err != nil { + return store.Message{}, store.Event{}, err + } eventFields := map[string]string{"message_id": id, "author_id": input.AuthorID} if input.TopicID != "" { eventFields["topic_id"] = input.TopicID @@ -786,6 +806,7 @@ func (s *Store) CreateMessage(ctx context.Context, input store.CreateMessageInpu if err != nil { return store.Message{}, store.Event{}, err } + mentionedIDs = mergeMentionedUserIDs(mentionedIDs, question) event, err := insertEventWithRecipientsAndMentions(ctx, tx, workspaceID, input.ChannelID, "message.created", &seq, eventPayload(ctx, eventFields, nonce), nil, mentionedIDs) if err != nil { return store.Message{}, store.Event{}, err @@ -797,6 +818,7 @@ func (s *Store) CreateMessage(ctx context.Context, input store.CreateMessageInpu if attachedUpload != nil { msg.Attachments = []store.Upload{*attachedUpload} } + msg.Question = createdQuestion return msg, event, tx.Commit() } @@ -824,6 +846,10 @@ func (s *Store) CreateThreadReply(ctx context.Context, input store.CreateThreadR if err != nil { return store.Message{}, store.ThreadState{}, nil, err } + question, err := prepareQuestion(input.Question) + if err != nil { + return store.Message{}, store.ThreadState{}, nil, err + } seq, err := qtx.ThreadNextSeq(ctx, storedb.ThreadNextSeqParams{ThreadRootID: root.ID, ParentMessageID: sqlText(root.ID)}) if err != nil { return store.Message{}, store.ThreadState{}, nil, err @@ -846,6 +872,16 @@ func (s *Store) CreateThreadReply(ctx context.Context, input store.CreateThreadR if existing.ThreadRootID != root.ID || existing.ParentMessageID == nil || *existing.ParentMessageID != root.ID || existing.Body != body || !sameQuotedMessageID(existing, quotedID) { return store.Message{}, store.ThreadState{}, nil, store.ErrClientNonceConflict } + if matches, err := questionReplayMatchesTx(ctx, tx, existing.ID, question); err != nil { + return store.Message{}, store.ThreadState{}, nil, err + } else if !matches { + return store.Message{}, store.ThreadState{}, nil, store.ErrClientNonceConflict + } + if hydrated, err := hydrateQuestions(ctx, tx, []store.Message{existing}); err != nil { + return store.Message{}, store.ThreadState{}, nil, err + } else { + existing = hydrated[0] + } stateRow, err := qtx.GetThreadState(ctx, root.ID) if err != nil { return store.Message{}, store.ThreadState{}, nil, err @@ -854,6 +890,9 @@ func (s *Store) CreateThreadReply(ctx context.Context, input store.CreateThreadR } else if !errors.Is(err, sql.ErrNoRows) { return store.Message{}, store.ThreadState{}, nil, err } + if err := validateNewQuestionTx(ctx, tx, root.WorkspaceID, root.ChannelID, root.DirectConversationID, "", question); err != nil { + return store.Message{}, store.ThreadState{}, nil, err + } if root.DirectConversationID != "" { if err := requireCanSendDirectTx(ctx, tx, root.WorkspaceID, input.AuthorID); err != nil { return store.Message{}, store.ThreadState{}, nil, err @@ -911,6 +950,10 @@ func (s *Store) CreateThreadReply(ctx context.Context, input store.CreateThreadR if err != nil { return store.Message{}, store.ThreadState{}, nil, err } + createdQuestion, err := insertMessageQuestionTx(ctx, tx, id, root.WorkspaceID, input.AuthorID, createdAt, question) + if err != nil { + return store.Message{}, store.ThreadState{}, nil, err + } replyPayload := eventPayload(ctx, map[string]string{ "message_id": id, "root_message_id": root.ID, @@ -929,6 +972,7 @@ func (s *Store) CreateThreadReply(ctx context.Context, input store.CreateThreadR if err != nil { return store.Message{}, store.ThreadState{}, nil, err } + mentionedIDs = mergeMentionedUserIDs(mentionedIDs, question) // recipients is a privacy boundary for direct conversations, not a thread // follower list. Mention metadata must never grant a workspace user access // to a DM they are not already participating in. @@ -944,6 +988,7 @@ func (s *Store) CreateThreadReply(ctx context.Context, input store.CreateThreadR if err != nil { return store.Message{}, store.ThreadState{}, nil, err } + msg.Question = createdQuestion return msg, state, []store.Event{replyEvent, stateEvent}, tx.Commit() } diff --git a/apps/api/internal/store/sqlite/storedb/models.go b/apps/api/internal/store/sqlite/storedb/models.go index c3688cbcd..3eb46b4a5 100644 --- a/apps/api/internal/store/sqlite/storedb/models.go +++ b/apps/api/internal/store/sqlite/storedb/models.go @@ -276,6 +276,28 @@ type MessageAttachment struct { CreatedAt string `json:"created_at"` } +type MessageQuestion struct { + MessageID string `json:"message_id"` + WorkspaceID string `json:"workspace_id"` + BotUserID string `json:"bot_user_id"` + ExternalID string `json:"external_id"` + SpecJson string `json:"spec_json"` + ResponderUserIds string `json:"responder_user_ids"` + AllowSkip int64 `json:"allow_skip"` + ExpiresAt string `json:"expires_at"` + Status string `json:"status"` + ResponseJson string `json:"response_json"` + ResponseSource string `json:"response_source"` + RespondedBy sql.NullString `json:"responded_by"` + RespondedAt sql.NullString `json:"responded_at"` + ResponseNonce string `json:"response_nonce"` + Note string `json:"note"` + ResolvedAt sql.NullString `json:"resolved_at"` + Version int64 `json:"version"` + CreatedAt string `json:"created_at"` + UpdatedAt string `json:"updated_at"` +} + type OauthTransaction struct { ID string `json:"id"` StateHash string `json:"state_hash"` diff --git a/apps/api/internal/store/sqlite/storedb/queries.sql.go b/apps/api/internal/store/sqlite/storedb/queries.sql.go index d49909cee..9954c0879 100644 --- a/apps/api/internal/store/sqlite/storedb/queries.sql.go +++ b/apps/api/internal/store/sqlite/storedb/queries.sql.go @@ -1647,6 +1647,41 @@ func (q *Queries) GetMessageIDByAuthorNonce(ctx context.Context, arg GetMessageI return id, err } +const getMessageQuestion = `-- name: GetMessageQuestion :one +SELECT message_id, workspace_id, bot_user_id, external_id, spec_json, responder_user_ids, allow_skip, + expires_at, status, response_json, response_source, responded_by, responded_at, response_nonce, + note, resolved_at, version, created_at, updated_at +FROM message_questions +WHERE message_id = ?1 +` + +func (q *Queries) GetMessageQuestion(ctx context.Context, messageID string) (MessageQuestion, error) { + row := q.db.QueryRowContext(ctx, getMessageQuestion, messageID) + var i MessageQuestion + err := row.Scan( + &i.MessageID, + &i.WorkspaceID, + &i.BotUserID, + &i.ExternalID, + &i.SpecJson, + &i.ResponderUserIds, + &i.AllowSkip, + &i.ExpiresAt, + &i.Status, + &i.ResponseJson, + &i.ResponseSource, + &i.RespondedBy, + &i.RespondedAt, + &i.ResponseNonce, + &i.Note, + &i.ResolvedAt, + &i.Version, + &i.CreatedAt, + &i.UpdatedAt, + ) + return i, err +} + const getNotificationSettings = `-- name: GetNotificationSettings :one SELECT pushover_enabled, pushover_user_key FROM user_notification_settings @@ -2813,6 +2848,44 @@ func (q *Queries) InsertMagicLink(ctx context.Context, arg InsertMagicLinkParams return err } +const insertMessageQuestion = `-- name: InsertMessageQuestion :exec +INSERT INTO message_questions ( + message_id, workspace_id, bot_user_id, external_id, spec_json, responder_user_ids, + allow_skip, expires_at, created_at, updated_at +) VALUES ( + ?1, ?2, ?3, ?4, + ?5, ?6, ?7, ?8, + ?9, ?9 +) +` + +type InsertMessageQuestionParams struct { + MessageID string `json:"message_id"` + WorkspaceID string `json:"workspace_id"` + BotUserID string `json:"bot_user_id"` + ExternalID string `json:"external_id"` + SpecJson string `json:"spec_json"` + ResponderUserIds string `json:"responder_user_ids"` + AllowSkip int64 `json:"allow_skip"` + ExpiresAt string `json:"expires_at"` + CreatedAt string `json:"created_at"` +} + +func (q *Queries) InsertMessageQuestion(ctx context.Context, arg InsertMessageQuestionParams) error { + _, err := q.db.ExecContext(ctx, insertMessageQuestion, + arg.MessageID, + arg.WorkspaceID, + arg.BotUserID, + arg.ExternalID, + arg.SpecJson, + arg.ResponderUserIds, + arg.AllowSkip, + arg.ExpiresAt, + arg.CreatedAt, + ) + return err +} + const insertOAuthTransaction = `-- name: InsertOAuthTransaction :exec INSERT INTO oauth_transactions ( id, state_hash, browser_binding_hash, mode, pkce_verifier, desktop_challenge, @@ -3388,6 +3461,81 @@ func (q *Queries) ListBotHistoricalWorkspaces(ctx context.Context, botUserID str return items, nil } +const listBotUnresolvedQuestions = `-- name: ListBotUnresolvedQuestions :many +SELECT q.message_id, q.workspace_id, COALESCE(m.channel_id, '') AS channel_id, + COALESCE(m.direct_conversation_id, '') AS direct_conversation_id, m.thread_root_id, + q.external_id, q.status, q.expires_at, q.version +FROM message_questions q +JOIN messages m ON m.id = q.message_id +WHERE q.workspace_id = ?1 + AND q.bot_user_id = ?2 + AND q.status IN ('open', 'submitted') + AND m.deleted_at IS NULL + AND (CAST(?3 AS INTEGER) = 1 OR m.direct_conversation_id IS NULL) + AND q.message_id > ?4 +ORDER BY q.message_id +LIMIT ?5 +` + +type ListBotUnresolvedQuestionsParams struct { + WorkspaceID string `json:"workspace_id"` + BotUserID string `json:"bot_user_id"` + IncludeDirect int64 `json:"include_direct"` + AfterMessageID string `json:"after_message_id"` + RowLimit int64 `json:"row_limit"` +} + +type ListBotUnresolvedQuestionsRow struct { + MessageID string `json:"message_id"` + WorkspaceID string `json:"workspace_id"` + ChannelID string `json:"channel_id"` + DirectConversationID string `json:"direct_conversation_id"` + ThreadRootID string `json:"thread_root_id"` + ExternalID string `json:"external_id"` + Status string `json:"status"` + ExpiresAt string `json:"expires_at"` + Version int64 `json:"version"` +} + +func (q *Queries) ListBotUnresolvedQuestions(ctx context.Context, arg ListBotUnresolvedQuestionsParams) ([]ListBotUnresolvedQuestionsRow, error) { + rows, err := q.db.QueryContext(ctx, listBotUnresolvedQuestions, + arg.WorkspaceID, + arg.BotUserID, + arg.IncludeDirect, + arg.AfterMessageID, + arg.RowLimit, + ) + if err != nil { + return nil, err + } + defer rows.Close() + var items []ListBotUnresolvedQuestionsRow + for rows.Next() { + var i ListBotUnresolvedQuestionsRow + if err := rows.Scan( + &i.MessageID, + &i.WorkspaceID, + &i.ChannelID, + &i.DirectConversationID, + &i.ThreadRootID, + &i.ExternalID, + &i.Status, + &i.ExpiresAt, + &i.Version, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + const listBotsOwnedBy = `-- name: ListBotsOwnedBy :many SELECT u.id, @@ -5807,6 +5955,56 @@ func (q *Queries) RequireWorkspaceOwner(ctx context.Context, arg RequireWorkspac return role, err } +const resolveMessageQuestion = `-- name: ResolveMessageQuestion :execrows +UPDATE message_questions +SET status = ?1, + note = ?2, + response_json = ?3, + response_source = ?4, + responded_by = ?5, + responded_at = ?6, + response_nonce = ?7, + resolved_at = ?8, + version = version + 1, + updated_at = ?9 +WHERE message_id = ?10 + AND version = ?11 +` + +type ResolveMessageQuestionParams struct { + Status string `json:"status"` + Note string `json:"note"` + ResponseJson string `json:"response_json"` + ResponseSource string `json:"response_source"` + RespondedBy sql.NullString `json:"responded_by"` + RespondedAt sql.NullString `json:"responded_at"` + ResponseNonce string `json:"response_nonce"` + ResolvedAt sql.NullString `json:"resolved_at"` + UpdatedAt string `json:"updated_at"` + MessageID string `json:"message_id"` + Version int64 `json:"version"` +} + +func (q *Queries) ResolveMessageQuestion(ctx context.Context, arg ResolveMessageQuestionParams) (int64, error) { + result, err := q.db.ExecContext(ctx, resolveMessageQuestion, + arg.Status, + arg.Note, + arg.ResponseJson, + arg.ResponseSource, + arg.RespondedBy, + arg.RespondedAt, + arg.ResponseNonce, + arg.ResolvedAt, + arg.UpdatedAt, + arg.MessageID, + arg.Version, + ) + if err != nil { + return 0, err + } + return result.RowsAffected() +} + const retireBotUser = `-- name: RetireBotUser :execrows UPDATE users SET handle = '' @@ -6019,6 +6217,46 @@ func (q *Queries) SetUserAvatarIfEmpty(ctx context.Context, arg SetUserAvatarIfE return err } +const submitMessageQuestionResponse = `-- name: SubmitMessageQuestionResponse :execrows +UPDATE message_questions +SET status = 'submitted', + note = '', + response_json = ?1, + response_source = 'clickclack', + responded_by = ?2, + responded_at = ?3, + response_nonce = ?4, + version = version + 1, + updated_at = ?3 +WHERE message_id = ?5 + AND status = 'open' + AND version = ?6 +` + +type SubmitMessageQuestionResponseParams struct { + ResponseJson string `json:"response_json"` + RespondedBy sql.NullString `json:"responded_by"` + RespondedAt sql.NullString `json:"responded_at"` + ResponseNonce string `json:"response_nonce"` + MessageID string `json:"message_id"` + Version int64 `json:"version"` +} + +func (q *Queries) SubmitMessageQuestionResponse(ctx context.Context, arg SubmitMessageQuestionResponseParams) (int64, error) { + result, err := q.db.ExecContext(ctx, submitMessageQuestionResponse, + arg.ResponseJson, + arg.RespondedBy, + arg.RespondedAt, + arg.ResponseNonce, + arg.MessageID, + arg.Version, + ) + if err != nil { + return 0, err + } + return result.RowsAffected() +} + const threadNextSeq = `-- name: ThreadNextSeq :one SELECT CAST(COALESCE(MAX(thread_seq), 0) + 1 AS INTEGER) AS next_seq FROM messages diff --git a/apps/api/internal/store/sqlite/thread_pages.go b/apps/api/internal/store/sqlite/thread_pages.go index 1d1577daf..5befa06cd 100644 --- a/apps/api/internal/store/sqlite/thread_pages.go +++ b/apps/api/internal/store/sqlite/thread_pages.go @@ -78,6 +78,10 @@ func (s *Store) GetThreadPage(ctx context.Context, rootMessageID, userID string, if err != nil { return store.ThreadPage{}, err } + messages, err = s.hydrateQuestions(ctx, messages) + if err != nil { + return store.ThreadPage{}, err + } messages, err = s.hydrateReactions(ctx, userID, messages) if err != nil { return store.ThreadPage{}, err diff --git a/apps/api/internal/store/sqlite/uploads.go b/apps/api/internal/store/sqlite/uploads.go index be3e0c0ed..c8f4c380f 100644 --- a/apps/api/internal/store/sqlite/uploads.go +++ b/apps/api/internal/store/sqlite/uploads.go @@ -283,6 +283,9 @@ func hydrateMessageCreateReplay(ctx context.Context, tx *sql.Tx, message store.M if err != nil { return store.Message{}, err } + if messages, err = hydrateQuestions(ctx, tx, messages); err != nil { + return store.Message{}, err + } message = messages[0] uploadID = strings.TrimSpace(uploadID) if uploadID == "" { diff --git a/apps/api/internal/store/types.go b/apps/api/internal/store/types.go index 802653772..235f7453f 100644 --- a/apps/api/internal/store/types.go +++ b/apps/api/internal/store/types.go @@ -367,6 +367,8 @@ type Message struct { // response. Nonce string `json:"nonce,omitempty"` Reactions []ReactionSummary `json:"reactions,omitempty"` + // Question is the structured question a bot attached to this message. + Question *MessageQuestion `json:"question,omitempty"` } type MessagePageRequest struct { @@ -940,6 +942,8 @@ type CreateMessageInput struct { // API layer by AgentActivityWriteScope. Kind string TurnID string + // Question is only accepted from bot tokens; the API layer enforces that. + Question *QuestionSpec } type UpdateMessageInput struct { @@ -959,6 +963,7 @@ type CreateThreadReplyInput struct { Body string QuotedMessageID *string Nonce string + Question *QuestionSpec } type CreateReactionInput struct { @@ -1105,6 +1110,7 @@ type CreateDirectMessageInput struct { UploadID string Kind string TurnID string + Question *QuestionSpec } type Topic struct { @@ -1345,6 +1351,9 @@ type Store interface { CreateThreadReply(ctx context.Context, input CreateThreadReplyInput) (Message, ThreadState, []Event, error) AddReaction(ctx context.Context, input CreateReactionInput) (Event, error) RemoveReaction(ctx context.Context, input CreateReactionInput) (Event, error) + AnswerQuestion(ctx context.Context, input AnswerQuestionInput) (Message, []Event, error) + ResolveQuestion(ctx context.Context, input ResolveQuestionInput) (Message, Event, error) + ListBotUnresolvedQuestions(ctx context.Context, workspaceID, botUserID, afterMessageID string, includeDirect bool, limit int) ([]BotQuestion, error) PinMessage(ctx context.Context, channelID, messageID, userID string) (PinnedMessage, Event, error) UnpinMessage(ctx context.Context, channelID, messageID, userID string) (Event, error) ListPinnedMessages(ctx context.Context, channelID, userID string, limit int) ([]Message, error) diff --git a/apps/api/internal/webassets/dist/200.html b/apps/api/internal/webassets/dist/200.html index 8c698c40c..be9478b15 100644 --- a/apps/api/internal/webassets/dist/200.html +++ b/apps/api/internal/webassets/dist/200.html @@ -110,16 +110,16 @@ - + - + - +
`),Ke=A(`
`),Qe=A(`
`),_t=N(`
`),yt=N(`
Blocked.
`),Q=N(``),bt=N(``),xt=N(``),St=N(``),Ct=N(`Profile
No GIFs found
`),nt=I(``+(n?i:Si(i,!0))+`
+`:``+(n?i:Si(i,!0))+`
+`}blockquote({tokens:e}){return`+${this.parser.parse(e)}+`}html({text:e}){return e}def(e){return``}heading({tokens:e,depth:t}){return`
${this.parser.parseInline(e)}
+`}table(e){let t=``,n=``;for(let t=0;t${Si(e,!0)}`}br(e){return`An error occurred:
`+Si(n.message+``,!0)+``;return t?Promise.resolve(e):e}if(t)return Promise.reject(n);throw n}}};function $(e,t){return Bi.parse(e,t)}$.options=$.setOptions=function(e){return Bi.setOptions(e),$.defaults=Bi.defaults,tr($.defaults),$},$.getDefaults=$n,$.defaults=er;function Vi(...e){return Bi.use(...e),$.defaults=Bi.defaults,tr($.defaults),$}$.use=Vi,$.walkTokens=function(e,t){return Bi.walkTokens(e,t)},$.parseInline=Bi.parseInline,$.Parser=Ri,$.parser=Ri.parse,$.Renderer=Ii,$.TextRenderer=Li,$.Lexer=Fi,$.lexer=Fi.lex,$.Tokenizer=Pi,$.Hooks=zi,$.parse=$,$.options,$.setOptions,$.walkTokens,$.parseInline,Ri.parse,Fi.lex;function Hi(e){return zn.sanitize($.parse(e,{async:!1,breaks:!0,gfm:!0}))}function Ui(e){return new Intl.DateTimeFormat(void 0,{hour:`2-digit`,minute:`2-digit`}).format(new Date(e))}var Wi=I(``);function Gi(e,t){a(t,!0);let r=F(null),i=F(null),o=F(null),l=typeof document<`u`&&document.activeElement instanceof HTMLElement?document.activeElement:null,u=l?.closest(`.thread`),d=l?.closest(`.timeline`),f=u?.querySelector(`[aria-label="Reply body"]:not(:disabled)`)??u?.querySelector(`[aria-label="Close thread"]`)??d?.querySelector(`[aria-label="Message body"]:not(:disabled)`)??d?.querySelector(`[aria-label="Search messages"]`)??(typeof document<`u`?document.querySelector(`[aria-label="Reply body"]:not(:disabled)`)??document.querySelector(`[aria-label="Message body"]:not(:disabled)`)??document.querySelector(`[aria-label="Close thread"]`)??document.querySelector(`[aria-label="Search messages"]`):null),p=new Set;A(()=>{let e=y(r)?.parentElement;if(e)for(let t of e.children)t instanceof HTMLElement&&t!==y(r)&&!t.inert&&(t.inert=!0,p.add(t));y(o)?.focus({preventScroll:!0})}),te(()=>{for(let e of p)e.inert=!1;p.clear(),!(l?.isConnected&&l!==document.body&&(l.focus({preventScroll:!0}),document.activeElement===l))&&f?.isConnected&&f.focus({preventScroll:!0})});function m(e){if(e.key===`Escape`){e.preventDefault(),e.stopPropagation(),t.onClose();return}if(e.key!==`Tab`||!y(i))return;let n=Array.from(y(i).querySelectorAll(`a[href], button:not([disabled]), [tabindex]:not([tabindex="-1"])`)).filter(e=>!e.inert&&e.getClientRects().length>0);if(n.length===0){e.preventDefault(),y(i).focus();return}let r=n[0],a=n[n.length-1];e.shiftKey&&(document.activeElement===r||!y(i).contains(document.activeElement))?(e.preventDefault(),a.focus()):!e.shiftKey&&(document.activeElement===a||!y(i).contains(document.activeElement))&&(e.preventDefault(),r.focus())}var _=Wi(),v=C(_),b=D(v,2),x=C(b),S=C(x),T=k(S,!0),E=D(S,2),ee=C(E),O=D(ee,2);M(O,e=>N(o,e),()=>y(o)),n(E),n(x);var ne=D(x,2),re=k(ne);n(b),M(b,e=>N(i,e),()=>y(i)),n(_),M(_,e=>N(r,e),()=>y(r)),j(()=>{w(b,`aria-label`,`Image viewer: ${t.title}`),s(T,t.title),w(ee,`href`,t.url),w(re,`src`,t.url),w(re,`alt`,t.title)}),g(`click`,v,function(...e){t.onClose?.apply(this,e)}),g(`keydown`,b,m),g(`click`,O,function(...e){t.onClose?.apply(this,e)}),c(e,_),h()}o([`click`,`keydown`]);var Ki=I(``);function qi(e,t){let n=b(t,`size`,3,44),i=b(t,`class`,3,``);var a=Ki();j(()=>{r(a,1,`keystroke-mark ${i()??``}`,`svelte-1bx7eka`),f(a,`--keystroke-size: ${n()??``}px`)}),c(e,a)}var Ji=I(`
`),wa=I(` `,1),Ta=I(``),Ea=I(``),Da=I(``);function Oa(e,t){a(t,!0);let i=b(t,`canReact`,3,!1),o=b(t,`canReply`,3,!1),d=b(t,`showOpenThread`,3,!0),f=b(t,`canOpenThread`,3,!1),p=b(t,`canEdit`,3,!1),_=b(t,`canPin`,3,!1),v=b(t,`pinned`,3,!1),x=b(t,`pinning`,3,!1),S=b(t,`pinError`,3,``),T=b(t,`canDelete`,3,!1),E=b(t,`deleting`,3,!1),O=b(t,`copyStatus`,3,``),re=b(t,`canCopyLink`,3,!1),P=b(t,`copyLinkStatus`,3,``),I=F(void 0),L=F(void 0),ie=!0,R=new Set;function ae(e){if(e.key===`Escape`){e.preventDefault(),e.stopPropagation(),t.onClose();return}if(e.key!==`Tab`||!y(L))return;let n=[...y(L).querySelectorAll(`a[href], button:not([disabled]), [tabindex]:not([tabindex="-1"])`)].filter(e=>!e.inert&&e.getClientRects().length>0);if(n.length===0){e.preventDefault(),y(L).focus();return}let r=n[0],i=n[n.length-1];e.shiftKey&&(document.activeElement===r||!y(L).contains(document.activeElement))?(e.preventDefault(),i.focus()):!e.shiftKey&&(document.activeElement===i||!y(L).contains(document.activeElement))&&(e.preventDefault(),r.focus())}function oe(e,t=!1){ie=t,e()}A(()=>{let e=y(I)?.parentElement;if(e)for(let t of e.children)t instanceof HTMLElement&&t!==y(I)&&!t.inert&&(t.inert=!0,R.add(t));y(L)?.querySelector(`button:not(:disabled)`)?.focus({preventScroll:!0})}),te(()=>{for(let e of R)e.inert=!1;R.clear(),ie&&t.returnFocus?.isConnected&&t.returnFocus.focus({preventScroll:!0})});var z=Da(),se=C(z),ce=D(se,2),le=D(C(ce),2);ee(le,21,()=>sa,l,(e,n)=>{var r=va(),a=k(r,!0);j(()=>{w(r,`aria-label`,`React with ${y(n)}`),r.disabled=!i(),s(a,y(n))}),g(`click`,r,()=>oe(()=>t.onReact(y(n)),!0)),c(e,r)}),n(le);var B=D(le,2),ue=C(B),V=e=>{var n=ya();j(()=>n.disabled=!f()),g(`click`,n,()=>oe(t.onOpenThread)),c(e,n)};u(ue,e=>{d()&&e(V)});var H=D(ue,2),U=D(H,2),de=D(C(U),2),fe=e=>{var t=ba();let n;var i=k(t,!0);j(()=>{n=r(t,1,`sheet-copy-status`,null,n,{"is-error":O()===`failed`}),s(i,O()===`copied`?`Copied`:`Couldn't copy`)}),c(e,t)};u(de,e=>{O()&&e(fe)}),n(U);var W=D(U,2),pe=e=>{var r=Sa(),i=D(C(r)),a=D(i),o=e=>{var t=xa();c(e,t)};u(a,e=>{P()===`failed`&&e(o)}),n(r),j(()=>{r.disabled=P()===`pending`,s(i,` ${P()===`pending`?`Creating link…`:`Copy link`} `)}),g(`click`,r,function(...e){t.onCopyLink?.apply(this,e)}),c(e,r)};u(W,e=>{re()&&t.onCopyLink&&e(pe)});var me=D(W,2),he=e=>{var r=wa(),i=ne(r),a=D(C(i));n(i);var o=D(i,2),l=e=>{var t=Ca(),n=k(t,!0);j(()=>s(n,S())),c(e,t)};u(o,e=>{S()&&e(l)}),j(()=>{i.disabled=x(),s(a,` ${v()?`Unpin message`:`Pin message`}`)}),g(`click`,i,function(...e){t.onTogglePin?.apply(this,e)}),c(e,r)};u(me,e=>{_()&&t.onTogglePin&&e(he)});var ge=D(me,2),G=e=>{var n=Ta();g(`click`,n,()=>oe(t.onEdit)),c(e,n)};u(ge,e=>{p()&&e(G)});var _e=D(ge,2),ve=e=>{var n=Ea();j(()=>n.disabled=E()),g(`click`,n,()=>oe(t.onDelete)),c(e,n)};u(_e,e=>{T()&&e(ve)}),n(B),n(ce),M(ce,e=>N(L,e),()=>y(L)),n(z),M(z,e=>N(I,e),()=>y(I)),m(z,e=>_a?.(e)),j(()=>{w(ce,`id`,t.id),H.disabled=!o()}),g(`keydown`,z,ae),g(`click`,se,function(...e){t.onClose?.apply(this,e)}),g(`click`,H,()=>oe(t.onReply)),g(`click`,U,function(...e){t.onCopy?.apply(this,e)}),c(e,z),h()}o([`keydown`,`click`]);var ka=I(`
Your browser blocked clipboard access. Copy the selected link below.
`),go=I(`
`),_o=I(``),vo=I(`Choose any`),yo=I(` `),bo=I(` `),xo=I(``),So=I(``),Co=I(``),wo=I(`
`),Ao=I(` `),jo=I(``),Mo=I(``),No=I(``),Po=I(``),Fo=I(` `),Io=I(``),Lo=I(` `,1),Ro=I(` `),zo=I(`
`),Ho=I(` `,1),Uo=I(`
Preparing a safe preview.
Download original
You can still download the original file.
Download originalPreview is limited to 10,000 cells, the first 1,000 rows, and the first 100 columns. Download the original to inspect omitted cells.
`),Sr=W(``),Cr=W(``),Er=W(`
`),Dr=W(`
Some slide content was omitted by preview limits.
`),Or=W(` `,1),kr=W(``),Ar=W(``),jr=W(``),Mr=W(` `),Nr=W(`or
`),Rr=W(` Continue with GitHub`,1),zr=W(` Sign in with OpenClaw ID`),Br=W(``),Vr=W(`
`),Hr=W(`
`),Gr=W(``);function Kr(e,t){o(t,!0);let n=C(t,`deleting`,3,!1),i=C(t,`error`,3,``),a=m(()=>t.message.author?.display_name||`Local User`);function s(){n()||t.onClose()}var l=Gr(),d=A(l),p=N(d,2),h=A(p),v=N(A(h),2);r(h);var y=N(h,2),x=N(A(y),2),S=A(x);{let e=m(()=>t.message.author?.id||t.message.author_id),n=m(()=>t.message.author?.avatar_url);pt(S,{class:`avatar`,get id(){return b(e)},get name(){return b(a)},get src(){return b(n)},size:36,loading:`eager`,fetchPriority:`auto`})}var w=N(S,2),T=A(w),E=A(T),D=R(E,!0),O=N(E,2),k=R(O,!0);r(T);var ee=N(T,2),M=R(ee,!0);r(w),r(x);var P=N(x,2),F=e=>{var t=Wr(),n=R(t,!0);B(()=>c(n,i())),u(e,t)};f(P,e=>{i()&&e(F)});var I=N(P,2),L=A(I),te=N(L,2),ne=R(te,!0);r(I),r(y),r(p),r(l),B(e=>{d.disabled=n(),v.disabled=n(),c(D,b(a)),j(O,`datetime`,t.message.created_at),c(k,e),c(M,t.message.body),L.disabled=n(),te.disabled=n(),c(ne,n()?`Deleting...`:`Delete`)},[()=>X(t.message.created_at)]),_(`click`,d,s),_(`click`,v,s),_(`click`,L,s),_(`click`,te,function(...e){t.onConfirm?.apply(this,e)}),u(e,l),g()}s([`click`]);var qr=6500,Jr=W(`
`),ii=W(`
Channels
`),li=W(`
`),ui=W(`
Direct messages
`),gi=W(`
Creating workspace…
`),_i=W(``),vi=W(``);function yi(e,t){o(t,!0);let n=C(t,`homeHref`,3,`/`),a=C(t,`homeLabel`,3,`cc`),s=C(t,`homeTitle`,3,`ClickClack home`);function l(e){return e.button===0&&!e.metaKey&&!e.ctrlKey&&!e.shiftKey&&!e.altKey}var d=vi(),p=A(d);let h;var v=A(p),x=e=>{Xe(e,{size:48})},S=m(()=>jt(a())),w=e=>{var t=fi(),n=R(t,!0);B(()=>c(n,a())),u(e,t)};f(v,e=>{b(S)?e(x):e(w,-1)}),r(p);var T=N(p,4),E=A(T);F(E,17,()=>t.workspaces,e=>e.id,(e,n)=>{var a=mi();let o;var s=A(a),d=A(s),p=e=>{var t=pi();B(e=>j(t,`src`,e),[()=>_e(b(n).icon_url)]),u(e,t)},m=e=>{var t=fi(),r=R(t,!0);B(e=>c(r,e),[()=>dt(b(n).name)]),u(e,t)};f(d,e=>{b(n).icon_url?e(p):e(m,-1)}),r(s),r(a),B(e=>{o=i(a,1,`guild-wrap`,null,o,{active:b(n).id===t.selectedWorkspaceID}),j(s,`title`,b(n).name),j(s,`aria-label`,b(n).name),j(s,`href`,e)},[()=>t.hrefForWorkspace(b(n).id)]),_(`click`,s,e=>{l(e)&&(e.preventDefault(),t.onSelectWorkspace(b(n).id))}),u(e,a)});var D=N(E,2);r(T);var O=N(T,2),k=e=>{var n=_i(),i=A(n);M(i);var a=N(i,2),o=e=>{var n=hi(),r=R(n,!0);B(()=>c(r,t.createError)),u(e,n)};f(a,e=>{t.createError&&e(o)});var s=N(a,2),l=e=>{var t=gi();u(e,t)};f(s,e=>{t.createPending&&e(l)}),r(n),B(()=>{le(i,t.workspaceName),i.disabled=t.createPending}),y(`submit`,n,e=>{e.preventDefault(),t.onCreateWorkspace()}),_(`input`,i,e=>t.onWorkspaceName(e.currentTarget.value)),u(e,n)};f(O,e=>{t.showWorkspaceCreate&&e(k)}),r(d),B(e=>{h=i(p,1,`guild home`,null,h,{"home--mark":e}),j(p,`title`,s()),j(p,`aria-label`,s()),j(p,`href`,n())},[()=>jt(a())]),_(`click`,D,function(...e){t.onToggleWorkspaceCreate?.apply(this,e)}),u(e,d),g()}s([`click`,`input`]);var bi=`clickclack:sidebar-channel-order:v1:`,xi=new Map,Si=new Map;function Ci(e,t){return`${bi}${t}:${e}`}function wi(e){if(!e||e.length>1e6)return[];try{let t=JSON.parse(e);return Array.isArray(t)&&t.length<=1e4&&t.every(e=>typeof e==`string`&&e.length<=128)?[...new Set(t)]:[]}catch{return[]}}function Ti(e,t){try{window.localStorage.setItem(e,JSON.stringify(t))}catch{}}function Ei(e,t,n){e?.startsWith(`${bi}${n}:`)&&xi.set(e,wi(t))}function Di(e,t){if(!e?.id||!t)return[];let n=Ci(t,e.id),r=xi.get(n);if(r)return r;let i=[];try{i=wi(window.localStorage.getItem(n))}catch{}let a=e.sidebar_preferences?.channel_order?.[t];if(a===void 0)return i;let o=a.length?[...new Set([...a,...i])].slice(0,1e4):[];return Ti(n,o),o}function Oi(e,t,n,r,i){let a=Ci(t,e);xi.set(a,n),Ti(a,n);let o=(Si.get(a)??Promise.resolve()).then(async()=>{if(xi.get(a)===n&&i())try{let e=JSON.stringify({sidebar_preferences:{channel_order:{[t]:n.slice(0,500)}}});await r(`/api/me`,{method:`PATCH`,keepalive:new TextEncoder().encode(e).length<6e4,body:e})}catch{}});return Si.set(a,o),o.then(()=>{Si.get(a)===o&&Si.delete(a)}),o}var ki=W(``),Ai=W(` `,1),ji=W(``),Mi=W(` `),Ni=W(``),Pi=W(`No pinned messages
Pin important messages to keep them easily accessible.
Try another word or phrase.
`),ka=W(`
Channel
Archived channels move to the Archived section and keep their full message history.
`),Fa=W(``),$=W(``);function Ia(e,t){o(t,!0);let n=U(!1),a=U(``),s=U(!1),l=new AbortController;re(()=>l.abort());async function d(){if(!b(n)){V(n,!0),V(a,``),V(s,!1);try{let e=await Ce({method:`PATCH`,body:JSON.stringify(t.payload()),signal:l.signal});l.signal.throwIfAborted(),t.onUserUpdated(e.user),V(a,`Saved`),t.onSaved?.()}catch(e){if(l.signal.aborted)return;V(a,xe(e,`Could not save ${t.section}`),!0),V(s,!0)}finally{V(n,!1)}}}var p=$(),m=A(p),h=A(m);L(h,()=>t.children),r(m);var _=N(m,2),v=A(_),x=e=>{var t=Pa();let n;var r=R(t,!0);B(()=>{n=i(t,1,`settings-status`,null,n,{"is-error":b(s)}),c(r,b(a))}),u(e,t)},S=e=>{var t=Fa();u(e,t)};f(v,e=>{b(a)?e(x):e(S,-1)});var C=N(v,2),w=R(C,!0);r(_),r(p),B(()=>{m.disabled=b(n),C.disabled=b(n),c(w,b(n)?`Saving...`:`Save ${t.section}`)}),y(`submit`,p,e=>{e.preventDefault(),d()}),u(e,p),g()}var La=W(`
Browser notifications are not supported on this device.
`),Ra=W(`Browser notifications are blocked by this browser.
`),za=W(``),Ba=W(`
Show alerts when ClickClack is in the background.
Shown in messages, mentions, and your profile card.
Used in mentions and the quick switcher. Must be unique.
Paste a public image URL. Your initials show when empty.
Keep agent reasoning summaries out of the message timeline.
Hide tool execution details while keeping ordinary messages visible.
Choose which side of the timeline shows your messages.
Choose which side of the timeline shows messages from other people and agents.
Add ClickClack to your Home Screen from the Share menu, then open it from there to turn this on.
`),Ka=W(`This browser does not support push notifications.
`),qa=W(``),Ja=W(`
`),Ya=W(`
Receive alerts when the app is closed.
Send push notifications to your phone via Pushover.
Find this in your Pushover dashboard under "Your User Key".
Loading…
`),eo=W(``),to=W(`
Account
Bots you own across your workspaces. Tokens live with the workspace where the bot was created.
`),fo=W(``),po=W(``);function mo(e,t){o(t,!0);let n=U(``),a=U(``),s=U(``),l=U(``),d=U(!1),p=U(!1);async function m(){if(b(p))return;let e=lo(b(n),b(a),b(s));if(e){V(l,e,!0),V(d,!0);return}V(p,!0),V(l,``),V(d,!1);try{await G(`/api/auth/password/change`,{method:`POST`,body:JSON.stringify({current_password:b(n),new_password:b(a)})}),V(n,``),V(a,``),V(s,``),V(l,`Password updated. Your other devices were signed out.`)}catch(e){V(l,xe(e,`Could not change your password`),!0),V(d,!0)}finally{V(p,!1)}}var h=po(),_=N(A(h),2),v=A(_),x=N(A(v),2),S=A(x);M(S),r(x),r(v);var C=N(v,2),w=N(A(C),2),T=A(w);M(T),r(w),r(C);var E=N(C,2),D=N(A(E),2),O=A(D);M(O),r(D),r(E),r(_);var k=N(_,2),j=A(k),ee=e=>{var t=uo();let n;var r=R(t,!0);B(()=>{n=i(t,1,`settings-status`,null,n,{"is-error":b(d)}),c(r,b(l))}),u(e,t)},P=e=>{var t=fo();u(e,t)};f(j,e=>{b(l)?e(ee):e(P,-1)});var F=N(j,2),I=R(F,!0);r(k),r(h),B(()=>{F.disabled=b(p),c(I,b(p)?`Updating...`:`Update password`)}),y(`submit`,h,e=>{e.preventDefault(),m()}),z(S,()=>b(n),e=>V(n,e)),z(T,()=>b(a),e=>V(a,e)),z(O,()=>b(s),e=>V(s,e)),u(e,h),g()}var ho=W(``),go=W(``),_o=W(`
Account
Changes apply instantly and follow your account on every device.
Live preview — updates as you pick
Workspace
`,1),Eo=W(``),Do=W(``),Oo=W(`
Loading...
`),Ao=W(``),jo=W(`
`),Mo=W(`
Account
How you appear across ClickClack.
End this session on this device.
Account
Decide when and how ClickClack should reach you.
`),Cs=W(`
`,1),Os=W(`
Loading thread…
`),ks=W(`Preparing a safe preview.
Download original
You can still download the original file.
Download originalPreview is limited to 10,000 cells, the first 1,000 rows, and the first 100 columns. Download the original to inspect omitted cells.
`),Cr=W(``),wr=W(``),Dr=W(`
`),Or=W(`
Some slide content was omitted by preview limits.
`),kr=W(` `,1),Ar=W(``),jr=W(``),Mr=W(``),Nr=W(` `),Pr=W(`or
`),zr=W(` Continue with GitHub`,1),Br=W(` Sign in with OpenClaw ID`),Vr=W(``),Hr=W(`
`),Ur=W(`
`),Kr=W(``);function qr(e,t){o(t,!0);let n=C(t,`deleting`,3,!1),i=C(t,`error`,3,``),a=m(()=>t.message.author?.display_name||`Local User`);function s(){n()||t.onClose()}var l=Kr(),d=A(l),p=N(d,2),h=A(p),v=N(A(h),2);r(h);var y=N(h,2),x=N(A(y),2),S=A(x);{let e=m(()=>t.message.author?.id||t.message.author_id),n=m(()=>t.message.author?.avatar_url);pt(S,{class:`avatar`,get id(){return b(e)},get name(){return b(a)},get src(){return b(n)},size:36,loading:`eager`,fetchPriority:`auto`})}var w=N(S,2),T=A(w),E=A(T),D=R(E,!0),O=N(E,2),k=R(O,!0);r(T);var ee=N(T,2),M=R(ee,!0);r(w),r(x);var P=N(x,2),F=e=>{var t=Gr(),n=R(t,!0);B(()=>c(n,i())),u(e,t)};f(P,e=>{i()&&e(F)});var I=N(P,2),L=A(I),te=N(L,2),ne=R(te,!0);r(I),r(y),r(p),r(l),B(e=>{d.disabled=n(),v.disabled=n(),c(D,b(a)),j(O,`datetime`,t.message.created_at),c(k,e),c(M,t.message.body),L.disabled=n(),te.disabled=n(),c(ne,n()?`Deleting...`:`Delete`)},[()=>$e(t.message.created_at)]),_(`click`,d,s),_(`click`,v,s),_(`click`,L,s),_(`click`,te,function(...e){t.onConfirm?.apply(this,e)}),u(e,l),g()}s([`click`]);var Jr=6500,Yr=W(`
`),ai=W(`
Channels
`),ui=W(`
`),di=W(`
Direct messages
`),_i=W(`
Creating workspace…
`),vi=W(``),yi=W(``);function bi(e,t){o(t,!0);let n=C(t,`homeHref`,3,`/`),a=C(t,`homeLabel`,3,`cc`),s=C(t,`homeTitle`,3,`ClickClack home`);function l(e){return e.button===0&&!e.metaKey&&!e.ctrlKey&&!e.shiftKey&&!e.altKey}var d=yi(),p=A(d);let h;var v=A(p),x=e=>{Z(e,{size:48})},S=m(()=>jt(a())),w=e=>{var t=pi(),n=R(t,!0);B(()=>c(n,a())),u(e,t)};f(v,e=>{b(S)?e(x):e(w,-1)}),r(p);var T=N(p,4),E=A(T);F(E,17,()=>t.workspaces,e=>e.id,(e,n)=>{var a=hi();let o;var s=A(a),d=A(s),p=e=>{var t=mi();B(e=>j(t,`src`,e),[()=>_e(b(n).icon_url)]),u(e,t)},m=e=>{var t=pi(),r=R(t,!0);B(e=>c(r,e),[()=>dt(b(n).name)]),u(e,t)};f(d,e=>{b(n).icon_url?e(p):e(m,-1)}),r(s),r(a),B(e=>{o=i(a,1,`guild-wrap`,null,o,{active:b(n).id===t.selectedWorkspaceID}),j(s,`title`,b(n).name),j(s,`aria-label`,b(n).name),j(s,`href`,e)},[()=>t.hrefForWorkspace(b(n).id)]),_(`click`,s,e=>{l(e)&&(e.preventDefault(),t.onSelectWorkspace(b(n).id))}),u(e,a)});var D=N(E,2);r(T);var O=N(T,2),k=e=>{var n=vi(),i=A(n);M(i);var a=N(i,2),o=e=>{var n=gi(),r=R(n,!0);B(()=>c(r,t.createError)),u(e,n)};f(a,e=>{t.createError&&e(o)});var s=N(a,2),l=e=>{var t=_i();u(e,t)};f(s,e=>{t.createPending&&e(l)}),r(n),B(()=>{le(i,t.workspaceName),i.disabled=t.createPending}),y(`submit`,n,e=>{e.preventDefault(),t.onCreateWorkspace()}),_(`input`,i,e=>t.onWorkspaceName(e.currentTarget.value)),u(e,n)};f(O,e=>{t.showWorkspaceCreate&&e(k)}),r(d),B(e=>{h=i(p,1,`guild home`,null,h,{"home--mark":e}),j(p,`title`,s()),j(p,`aria-label`,s()),j(p,`href`,n())},[()=>jt(a())]),_(`click`,D,function(...e){t.onToggleWorkspaceCreate?.apply(this,e)}),u(e,d),g()}s([`click`,`input`]);var xi=`clickclack:sidebar-channel-order:v1:`,Si=new Map,Ci=new Map;function wi(e,t){return`${xi}${t}:${e}`}function Ti(e){if(!e||e.length>1e6)return[];try{let t=JSON.parse(e);return Array.isArray(t)&&t.length<=1e4&&t.every(e=>typeof e==`string`&&e.length<=128)?[...new Set(t)]:[]}catch{return[]}}function Ei(e,t){try{window.localStorage.setItem(e,JSON.stringify(t))}catch{}}function Di(e,t,n){e?.startsWith(`${xi}${n}:`)&&Si.set(e,Ti(t))}function Oi(e,t){if(!e?.id||!t)return[];let n=wi(t,e.id),r=Si.get(n);if(r)return r;let i=[];try{i=Ti(window.localStorage.getItem(n))}catch{}let a=e.sidebar_preferences?.channel_order?.[t];if(a===void 0)return i;let o=a.length?[...new Set([...a,...i])].slice(0,1e4):[];return Ei(n,o),o}function ki(e,t,n,r,i){let a=wi(t,e);Si.set(a,n),Ei(a,n);let o=(Ci.get(a)??Promise.resolve()).then(async()=>{if(Si.get(a)===n&&i())try{let e=JSON.stringify({sidebar_preferences:{channel_order:{[t]:n.slice(0,500)}}});await r(`/api/me`,{method:`PATCH`,keepalive:new TextEncoder().encode(e).length<6e4,body:e})}catch{}});return Ci.set(a,o),o.then(()=>{Ci.get(a)===o&&Ci.delete(a)}),o}var Ai=W(``),ji=W(` `,1),Mi=W(``),Ni=W(` `),Pi=W(``),Fi=W(`No pinned messages
Pin important messages to keep them easily accessible.
Try another word or phrase.
`),Aa=W(`
Channel
Archived channels move to the Archived section and keep their full message history.
`),$=W(``),Ia=W(``);function La(e,t){o(t,!0);let n=U(!1),a=U(``),s=U(!1),l=new AbortController;re(()=>l.abort());async function d(){if(!b(n)){V(n,!0),V(a,``),V(s,!1);try{let e=await Ce({method:`PATCH`,body:JSON.stringify(t.payload()),signal:l.signal});l.signal.throwIfAborted(),t.onUserUpdated(e.user),V(a,`Saved`),t.onSaved?.()}catch(e){if(l.signal.aborted)return;V(a,xe(e,`Could not save ${t.section}`),!0),V(s,!0)}finally{V(n,!1)}}}var p=Ia(),m=A(p),h=A(m);L(h,()=>t.children),r(m);var _=N(m,2),v=A(_),x=e=>{var t=Fa();let n;var r=R(t,!0);B(()=>{n=i(t,1,`settings-status`,null,n,{"is-error":b(s)}),c(r,b(a))}),u(e,t)},S=e=>{var t=$();u(e,t)};f(v,e=>{b(a)?e(x):e(S,-1)});var C=N(v,2),w=R(C,!0);r(_),r(p),B(()=>{m.disabled=b(n),C.disabled=b(n),c(w,b(n)?`Saving...`:`Save ${t.section}`)}),y(`submit`,p,e=>{e.preventDefault(),d()}),u(e,p),g()}var Ra=W(`
Browser notifications are not supported on this device.
`),za=W(`Browser notifications are blocked by this browser.
`),Ba=W(``),Va=W(`
Show alerts when ClickClack is in the background.
Shown in messages, mentions, and your profile card.
Used in mentions and the quick switcher. Must be unique.
Paste a public image URL. Your initials show when empty.
Keep agent reasoning summaries out of the message timeline.
Hide tool execution details while keeping ordinary messages visible.
Choose which side of the timeline shows your messages.
Choose which side of the timeline shows messages from other people and agents.
Add ClickClack to your Home Screen from the Share menu, then open it from there to turn this on.
`),qa=W(`This browser does not support push notifications.
`),Ja=W(``),Ya=W(`
`),Xa=W(`
Receive alerts when the app is closed.
Send push notifications to your phone via Pushover.
Find this in your Pushover dashboard under "Your User Key".
Loading…
`),to=W(``),no=W(`
Account
Bots you own across your workspaces. Tokens live with the workspace where the bot was created.
`),po=W(``),mo=W(``);function ho(e,t){o(t,!0);let n=U(``),a=U(``),s=U(``),l=U(``),d=U(!1),p=U(!1);async function m(){if(b(p))return;let e=uo(b(n),b(a),b(s));if(e){V(l,e,!0),V(d,!0);return}V(p,!0),V(l,``),V(d,!1);try{await G(`/api/auth/password/change`,{method:`POST`,body:JSON.stringify({current_password:b(n),new_password:b(a)})}),V(n,``),V(a,``),V(s,``),V(l,`Password updated. Your other devices were signed out.`)}catch(e){V(l,xe(e,`Could not change your password`),!0),V(d,!0)}finally{V(p,!1)}}var h=mo(),_=N(A(h),2),v=A(_),x=N(A(v),2),S=A(x);M(S),r(x),r(v);var C=N(v,2),w=N(A(C),2),T=A(w);M(T),r(w),r(C);var E=N(C,2),D=N(A(E),2),O=A(D);M(O),r(D),r(E),r(_);var k=N(_,2),j=A(k),ee=e=>{var t=fo();let n;var r=R(t,!0);B(()=>{n=i(t,1,`settings-status`,null,n,{"is-error":b(d)}),c(r,b(l))}),u(e,t)},P=e=>{var t=po();u(e,t)};f(j,e=>{b(l)?e(ee):e(P,-1)});var F=N(j,2),I=R(F,!0);r(k),r(h),B(()=>{F.disabled=b(p),c(I,b(p)?`Updating...`:`Update password`)}),y(`submit`,h,e=>{e.preventDefault(),m()}),z(S,()=>b(n),e=>V(n,e)),z(T,()=>b(a),e=>V(a,e)),z(O,()=>b(s),e=>V(s,e)),u(e,h),g()}var go=W(``),_o=W(``),vo=W(`
Account
Changes apply instantly and follow your account on every device.
Live preview — updates as you pick
Workspace
`,1),Do=W(``),Oo=W(``),ko=W(`
Loading...
`),jo=W(``),Mo=W(`
`),No=W(`
Account
How you appear across ClickClack.
End this session on this device.
Account
Decide when and how ClickClack should reach you.
`),ws=W(`
`,1),ks=W(`
Loading thread…
`),As=W(``),He=A(`
`),Je=A(`
`),bt=P(`
`),xt=P(`
Blocked.
`),St=P(``),Ct=P(``),wt=P(``),Tt=P(``),Et=P(`Profile
No GIFs found
`),tt=N(``+(n?i:bi(i,!0))+`
-`:``+(n?i:bi(i,!0))+`
-`}blockquote({tokens:e}){return`-${this.parser.parse(e)}-`}html({text:e}){return e}def(e){return``}heading({tokens:e,depth:t}){return`
${this.parser.parseInline(e)}
-`}table(e){let t=``,n=``;for(let t=0;t${bi(e,!0)}`}br(e){return`An error occurred:
`+bi(n.message+``,!0)+``;return t?Promise.resolve(e):e}if(t)return Promise.reject(n);throw n}}};function $(e,t){return Ri.parse(e,t)}$.options=$.setOptions=function(e){return Ri.setOptions(e),$.defaults=Ri.defaults,$n($.defaults),$},$.getDefaults=Zn,$.defaults=Qn;function zi(...e){return Ri.use(...e),$.defaults=Ri.defaults,$n($.defaults),$}$.use=zi,$.walkTokens=function(e,t){return Ri.walkTokens(e,t)},$.parseInline=Ri.parseInline,$.Parser=Ii,$.parser=Ii.parse,$.Renderer=Pi,$.TextRenderer=Fi,$.Lexer=Ni,$.lexer=Ni.lex,$.Tokenizer=Mi,$.Hooks=Li,$.parse=$,$.options,$.setOptions,$.walkTokens,$.parseInline,Ii.parse,Ni.lex;function Bi(e){return Ln.sanitize($.parse(e,{async:!1,breaks:!0,gfm:!0}))}function Vi(e){return new Intl.DateTimeFormat(void 0,{hour:`2-digit`,minute:`2-digit`}).format(new Date(e))}var Hi=N(``);function Ui(e,n){i(n,!0);let r=M(null),a=M(null),c=M(null),l=typeof document<`u`&&document.activeElement instanceof HTMLElement?document.activeElement:null,u=l?.closest(`.thread`),d=l?.closest(`.timeline`),f=u?.querySelector(`[aria-label="Reply body"]:not(:disabled)`)??u?.querySelector(`[aria-label="Close thread"]`)??d?.querySelector(`[aria-label="Message body"]:not(:disabled)`)??d?.querySelector(`[aria-label="Search messages"]`)??(typeof document<`u`?document.querySelector(`[aria-label="Reply body"]:not(:disabled)`)??document.querySelector(`[aria-label="Message body"]:not(:disabled)`)??document.querySelector(`[aria-label="Close thread"]`)??document.querySelector(`[aria-label="Search messages"]`):null),p=new Set;ne(()=>{let e=v(r)?.parentElement;if(e)for(let t of e.children)t instanceof HTMLElement&&t!==v(r)&&!t.inert&&(t.inert=!0,p.add(t));v(c)?.focus({preventScroll:!0})}),ee(()=>{for(let e of p)e.inert=!1;p.clear(),!(l?.isConnected&&l!==document.body&&(l.focus({preventScroll:!0}),document.activeElement===l))&&f?.isConnected&&f.focus({preventScroll:!0})});function g(e){if(e.key===`Escape`){e.preventDefault(),e.stopPropagation(),n.onClose();return}if(e.key!==`Tab`||!v(a))return;let t=Array.from(v(a).querySelectorAll(`a[href], button:not([disabled]), [tabindex]:not([tabindex="-1"])`)).filter(e=>!e.inert&&e.getClientRects().length>0);if(t.length===0){e.preventDefault(),v(a).focus();return}let r=t[0],i=t[t.length-1];e.shiftKey&&(document.activeElement===r||!v(a).contains(document.activeElement))?(e.preventDefault(),i.focus()):!e.shiftKey&&(document.activeElement===i||!v(a).contains(document.activeElement))&&(e.preventDefault(),r.focus())}var _=Hi(),y=x(_),b=T(y,2),C=x(b),w=x(C),E=O(w,!0),D=T(w,2),te=x(D),re=T(te,2);A(re,e=>j(c,e),()=>v(c)),t(D),t(C);var ie=T(C,2),N=O(ie);t(b),A(b,e=>j(a,e),()=>v(a)),t(_),A(_,e=>j(r,e),()=>v(r)),k(()=>{S(b,`aria-label`,`Image viewer: ${n.title}`),o(E,n.title),S(te,`href`,n.url),S(N,`src`,n.url),S(N,`alt`,n.title)}),h(`click`,y,function(...e){n.onClose?.apply(this,e)}),h(`keydown`,b,g),h(`click`,re,function(...e){n.onClose?.apply(this,e)}),s(e,_),m()}a([`click`,`keydown`]);var Wi=N(``);function Gi(e,t){let r=y(t,`size`,3,44),i=y(t,`class`,3,``);var a=Wi();k(()=>{n(a,1,`keystroke-mark ${i()??``}`,`svelte-1bx7eka`),d(a,`--keystroke-size: ${r()??``}px`)}),s(e,a)}var Ki=N(`
`),Sa=N(` `,1),Ca=N(``),wa=N(``),Ta=N(``);function Ea(e,r){i(r,!0);let a=y(r,`canReact`,3,!1),u=y(r,`canReply`,3,!1),d=y(r,`showOpenThread`,3,!0),f=y(r,`canOpenThread`,3,!1),g=y(r,`canEdit`,3,!1),_=y(r,`canPin`,3,!1),b=y(r,`pinned`,3,!1),C=y(r,`pinning`,3,!1),w=y(r,`pinError`,3,``),D=y(r,`canDelete`,3,!1),re=y(r,`deleting`,3,!1),ie=y(r,`copyStatus`,3,``),N=y(r,`canCopyLink`,3,!1),P=y(r,`copyLinkStatus`,3,``),F=M(void 0),ae=M(void 0),oe=!0,I=new Set;function se(e){if(e.key===`Escape`){e.preventDefault(),e.stopPropagation(),r.onClose();return}if(e.key!==`Tab`||!v(ae))return;let t=[...v(ae).querySelectorAll(`a[href], button:not([disabled]), [tabindex]:not([tabindex="-1"])`)].filter(e=>!e.inert&&e.getClientRects().length>0);if(t.length===0){e.preventDefault(),v(ae).focus();return}let n=t[0],i=t[t.length-1];e.shiftKey&&(document.activeElement===n||!v(ae).contains(document.activeElement))?(e.preventDefault(),i.focus()):!e.shiftKey&&(document.activeElement===i||!v(ae).contains(document.activeElement))&&(e.preventDefault(),n.focus())}function ce(e,t=!1){oe=t,e()}ne(()=>{let e=v(F)?.parentElement;if(e)for(let t of e.children)t instanceof HTMLElement&&t!==v(F)&&!t.inert&&(t.inert=!0,I.add(t));v(ae)?.querySelector(`button:not(:disabled)`)?.focus({preventScroll:!0})}),ee(()=>{for(let e of I)e.inert=!1;I.clear(),oe&&r.returnFocus?.isConnected&&r.returnFocus.focus({preventScroll:!0})});var le=Ta(),ue=x(le),L=T(ue,2),R=T(x(L),2);E(R,21,()=>aa,c,(e,t)=>{var n=ga(),i=O(n,!0);k(()=>{S(n,`aria-label`,`React with ${v(t)}`),n.disabled=!a(),o(i,v(t))}),h(`click`,n,()=>ce(()=>r.onReact(v(t)),!0)),s(e,n)}),t(R);var z=T(R,2),de=x(z),B=e=>{var t=_a();k(()=>t.disabled=!f()),h(`click`,t,()=>ce(r.onOpenThread)),s(e,t)};l(de,e=>{d()&&e(B)});var V=T(de,2),H=T(V,2),fe=T(x(H),2),pe=e=>{var t=va();let r;var i=O(t,!0);k(()=>{r=n(t,1,`sheet-copy-status`,null,r,{"is-error":ie()===`failed`}),o(i,ie()===`copied`?`Copied`:`Couldn't copy`)}),s(e,t)};l(fe,e=>{ie()&&e(pe)}),t(H);var me=T(H,2),he=e=>{var n=ba(),i=T(x(n)),a=T(i),c=e=>{var t=ya();s(e,t)};l(a,e=>{P()===`failed`&&e(c)}),t(n),k(()=>{n.disabled=P()===`pending`,o(i,` ${P()===`pending`?`Creating link…`:`Copy link`} `)}),h(`click`,n,function(...e){r.onCopyLink?.apply(this,e)}),s(e,n)};l(me,e=>{N()&&r.onCopyLink&&e(he)});var ge=T(me,2),_e=e=>{var n=Sa(),i=te(n),a=T(x(i));t(i);var c=T(i,2),u=e=>{var t=xa(),n=O(t,!0);k(()=>o(n,w())),s(e,t)};l(c,e=>{w()&&e(u)}),k(()=>{i.disabled=C(),o(a,` ${b()?`Unpin message`:`Pin message`}`)}),h(`click`,i,function(...e){r.onTogglePin?.apply(this,e)}),s(e,n)};l(ge,e=>{_()&&r.onTogglePin&&e(_e)});var ve=T(ge,2),ye=e=>{var t=Ca();h(`click`,t,()=>ce(r.onEdit)),s(e,t)};l(ve,e=>{g()&&e(ye)});var be=T(ve,2),xe=e=>{var t=wa();k(()=>t.disabled=re()),h(`click`,t,()=>ce(r.onDelete)),s(e,t)};l(be,e=>{D()&&e(xe)}),t(z),t(L),A(L,e=>j(ae,e),()=>v(ae)),t(le),A(le,e=>j(F,e),()=>v(F)),p(le,e=>ha?.(e)),k(()=>{S(L,`id`,r.id),V.disabled=!u()}),h(`keydown`,le,se),h(`click`,ue,function(...e){r.onClose?.apply(this,e)}),h(`click`,V,()=>ce(r.onReply)),h(`click`,H,function(...e){r.onCopy?.apply(this,e)}),s(e,le),m()}a([`keydown`,`click`]);var Da=N(`
Your browser blocked clipboard access. Copy the selected link below.
{shown.title}
+ {/if} + {#if view.note && view.status === "open"} + +{view.note}
+ {/if} + + {#if view.status === "open"} + {#each shown.items as item, itemIndex (item.id)} + {@const entry = draft[item.id]} + {@const answered = questionItemAnswers(item, entry).length > 0} ++ {#if !currentUserID} + Sign in to answer this question. + {:else} + Only {responderNames.join(", ")} can answer this question. + {/if} +
+ {/if} + + {#if error} +{error}
+ {/if} + + {#if answerable} + + {/if} + {:else} + {#if summary.length > 0} +No answer before {new Date(shown.expires_at).toLocaleTimeString([], { hour: "numeric", minute: "2-digit" })}.
+ {/if} + {#if view.note} +{view.note}
+ {/if} + {/if} +