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 6f1ac2f86..e687ce5db 100644 --- a/apps/api/internal/httpapi/server.go +++ b/apps/api/internal/httpapi/server.go @@ -211,6 +211,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) @@ -247,6 +248,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 2ec10f881..a58cd3e5c 100644 --- a/apps/api/internal/store/postgres/postgres.go +++ b/apps/api/internal/store/postgres/postgres.go @@ -595,6 +595,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 } @@ -647,6 +651,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 } @@ -676,6 +684,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 } @@ -684,6 +697,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 } @@ -737,6 +753,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 @@ -751,6 +771,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 @@ -762,6 +783,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() } @@ -786,6 +808,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 } @@ -811,6 +837,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 @@ -819,6 +855,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 @@ -876,6 +915,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, @@ -894,6 +937,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. @@ -909,6 +953,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..b4dfd0f29 --- /dev/null +++ b/apps/api/internal/store/postgres/questions.go @@ -0,0 +1,527 @@ +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 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..be50b7244 --- /dev/null +++ b/apps/api/internal/store/postgres/questions_test.go @@ -0,0 +1,47 @@ +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 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 4a2567c3e..a5e4bf937 100644 --- a/apps/api/internal/store/postgres/sqlc/queries.sql +++ b/apps/api/internal/store/postgres/sqlc/queries.sql @@ -1916,3 +1916,65 @@ WHERE user_id = sqlc.arg(user_id) AND token_hash = sqlc.arg(token_hash) 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); diff --git a/apps/api/internal/store/postgres/sqlc/schema.sql b/apps/api/internal/store/postgres/sqlc/schema.sql index a55588f63..cdaf31a71 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 d7ab08dbd..dd94eeae9 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 714bf3581..be965f909 100644 --- a/apps/api/internal/store/postgres/storedb/queries.sql.go +++ b/apps/api/internal/store/postgres/storedb/queries.sql.go @@ -1642,6 +1642,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 @@ -2724,6 +2759,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, @@ -3302,6 +3375,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, @@ -5661,6 +5809,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 = '' @@ -5856,6 +6054,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..e9c9592e5 --- /dev/null +++ b/apps/api/internal/store/questiontest/questions.go @@ -0,0 +1,477 @@ +// 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) + } +} + +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..19aecdc90 --- /dev/null +++ b/apps/api/internal/store/sqlite/questions.go @@ -0,0 +1,531 @@ +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 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..1e433e689 --- /dev/null +++ b/apps/api/internal/store/sqlite/questions_test.go @@ -0,0 +1,43 @@ +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 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 5f3131b59..107b180d2 100644 --- a/apps/api/internal/store/sqlite/sqlc/queries.sql +++ b/apps/api/internal/store/sqlite/sqlc/queries.sql @@ -1820,3 +1820,65 @@ FROM sessions 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); diff --git a/apps/api/internal/store/sqlite/sqlc/schema.sql b/apps/api/internal/store/sqlite/sqlc/schema.sql index f517a403d..a5434eca9 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 cefff3167..c7bce1696 100644 --- a/apps/api/internal/store/sqlite/sqlite.go +++ b/apps/api/internal/store/sqlite/sqlite.go @@ -609,6 +609,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 } @@ -664,6 +668,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 @@ -690,6 +698,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 } @@ -698,6 +711,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 } @@ -751,6 +767,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 @@ -765,6 +785,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 @@ -776,6 +797,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() } @@ -803,6 +825,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 @@ -825,6 +851,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 @@ -833,6 +869,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 @@ -890,6 +929,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, @@ -908,6 +951,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. @@ -923,6 +967,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 92cf3f499..0d8e7ed68 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 ef6923470..097cd815c 100644 --- a/apps/api/internal/store/sqlite/storedb/queries.sql.go +++ b/apps/api/internal/store/sqlite/storedb/queries.sql.go @@ -1632,6 +1632,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 @@ -2711,6 +2746,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, @@ -3286,6 +3359,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, @@ -5399,6 +5547,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 = '' @@ -5594,6 +5792,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 982b618fc..73efacedf 100644 --- a/apps/api/internal/store/types.go +++ b/apps/api/internal/store/types.go @@ -328,6 +328,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 { @@ -899,6 +901,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 { @@ -918,6 +922,7 @@ type CreateThreadReplyInput struct { Body string QuotedMessageID *string Nonce string + Question *QuestionSpec } type CreateReactionInput struct { @@ -1064,6 +1069,7 @@ type CreateDirectMessageInput struct { UploadID string Kind string TurnID string + Question *QuestionSpec } type Topic struct { @@ -1295,6 +1301,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 c005b693a..4325f97fc 100644 --- a/apps/api/internal/webassets/dist/200.html +++ b/apps/api/internal/webassets/dist/200.html @@ -103,15 +103,15 @@ - + - + - +
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.
`),Kn=l(``),qn=l(``),Xn=l(`
`),Zn=l(`
Some slide content was omitted by preview limits.
`),Qn=l(` `,1),$n=l(``),er=l(``),tr=l(``),nr=l(` `),rr=l(`or
`),cr=l(` Continue with GitHub`,1),lr=l(` Sign in with OpenClaw ID`),ur=l(``),dr=l(`
`),fr=l(`
`),hr=l(``);function gr(e,r){o(r,!0);let i=S(r,`deleting`,3,!1),a=S(r,`error`,3,``),s=U(()=>r.message.author?.display_name||`Local User`);function c(){i()||r.onClose()}var l=hr(),f=I(l),m=k(f,2),h=I(m),g=k(I(h),2);t(h);var v=k(h,2),y=k(I(v),2),b=I(y);{let e=U(()=>r.message.author?.id||r.message.author_id),t=U(()=>r.message.author?.avatar_url);ut(b,{class:`avatar`,get id(){return u(e)},get name(){return u(s)},get src(){return u(t)},size:36,loading:`eager`,fetchPriority:`auto`})}var x=k(b,2),C=I(x),w=I(C),T=D(w,!0),E=k(w,2),O=D(E,!0);t(C);var A=k(C,2),j=D(A,!0);t(x),t(y);var M=k(y,2),F=e=>{var t=mr(),n=D(t,!0);N(()=>d(n,a())),p(e,t)};P(M,e=>{a()&&e(F)});var ee=k(M,2),te=I(ee),L=k(te,2),R=D(L,!0);t(ee),t(v),t(m),t(l),N(e=>{f.disabled=i(),g.disabled=i(),d(T,u(s)),z(E,`datetime`,r.message.created_at),d(O,e),d(j,r.message.body),te.disabled=i(),L.disabled=i(),d(R,i()?`Deleting...`:`Delete`)},[()=>Ye(r.message.created_at)]),n(`click`,f,c),n(`click`,g,c),n(`click`,te,c),n(`click`,L,function(...e){r.onConfirm?.apply(this,e)}),p(e,l),_()}W([`click`]);var _r=6500,vr=l(`
`),Or=l(`
Channels
`),Nr=l(`
`),Pr=l(`
Direct messages
`),Br=l(`
Creating workspace…
`),Vr=l(``),Hr=l(``);function Ur(e,r){o(r,!0);let i=S(r,`homeHref`,3,`/`),a=S(r,`homeLabel`,3,`cc`),s=S(r,`homeTitle`,3,`ClickClack home`);function c(e){return e.button===0&&!e.metaKey&&!e.ctrlKey&&!e.shiftKey&&!e.altKey}var l=Hr(),m=I(l);let h;var g=I(m),y=e=>{Je(e,{size:48})},b=U(()=>Ot(a())),x=e=>{var t=Ir(),n=D(t,!0);N(()=>d(n,a())),p(e,t)};P(g,e=>{u(b)?e(y):e(x,-1)}),t(m);var C=k(m,4),T=I(C);f(T,17,()=>r.workspaces,e=>e.id,(e,i)=>{var a=Rr();let o;var s=I(a),l=I(s),f=e=>{var t=Lr();N(e=>z(t,`src`,e),[()=>_e(u(i).icon_url)]),p(e,t)},m=e=>{var t=Ir(),n=D(t,!0);N(e=>d(n,e),[()=>ct(u(i).name)]),p(e,t)};P(l,e=>{u(i).icon_url?e(f):e(m,-1)}),t(s),t(a),N(e=>{o=w(a,1,`guild-wrap`,null,o,{active:u(i).id===r.selectedWorkspaceID}),z(s,`title`,u(i).name),z(s,`aria-label`,u(i).name),z(s,`href`,e)},[()=>r.hrefForWorkspace(u(i).id)]),n(`click`,s,e=>{c(e)&&(e.preventDefault(),r.onSelectWorkspace(u(i).id))}),p(e,a)});var E=k(T,2);t(C);var A=k(C,2),M=e=>{var i=Vr(),a=I(i);O(a);var o=k(a,2),s=e=>{var t=zr(),n=D(t,!0);N(()=>d(n,r.createError)),p(e,t)};P(o,e=>{r.createError&&e(s)});var c=k(o,2),l=e=>{var t=Br();p(e,t)};P(c,e=>{r.createPending&&e(l)}),t(i),N(()=>{j(a,r.workspaceName),a.disabled=r.createPending}),v(`submit`,i,e=>{e.preventDefault(),r.onCreateWorkspace()}),n(`input`,a,e=>r.onWorkspaceName(e.currentTarget.value)),p(e,i)};P(A,e=>{r.showWorkspaceCreate&&e(M)}),t(l),N(e=>{h=w(m,1,`guild home`,null,h,{"home--mark":e}),z(m,`title`,s()),z(m,`aria-label`,s()),z(m,`href`,i())},[()=>Ot(a())]),n(`click`,E,function(...e){r.onToggleWorkspaceCreate?.apply(this,e)}),p(e,l),_()}W([`click`,`input`]);var Wr=l(``),Gr=l(` `,1),Kr=l(``),qr=l(` `),Jr=l(``),Yr=l(`No pinned messages
Pin important messages to keep them easily accessible.
Try another word or phrase.
`),Wi=l(`
Channel
Archived channels move to the Archived section and keep their full message history.
`),Xi=l(``),Zi=l(``);function Qi(e,n){o(n,!0);let r=A(!1),i=A(``),s=A(!1),c=new AbortController;ne(()=>c.abort());async function l(){if(!u(r)){M(r,!0),M(i,``),M(s,!1);try{let e=await Ce({method:`PATCH`,body:JSON.stringify(n.payload()),signal:c.signal});c.signal.throwIfAborted(),n.onUserUpdated(e.user),M(i,`Saved`),n.onSaved?.()}catch(e){if(c.signal.aborted)return;M(i,xe(e,`Could not save ${n.section}`),!0),M(s,!0)}finally{M(r,!1)}}}var f=Zi(),m=I(f),h=I(m);a(h,()=>n.children),t(m);var g=k(m,2),y=I(g),b=e=>{var t=Yi();let n;var r=D(t,!0);N(()=>{n=w(t,1,`settings-status`,null,n,{"is-error":u(s)}),d(r,u(i))}),p(e,t)},x=e=>{var t=Xi();p(e,t)};P(y,e=>{u(i)?e(b):e(x,-1)});var S=k(y,2),C=D(S,!0);t(g),t(f),N(()=>{m.disabled=u(r),S.disabled=u(r),d(C,u(r)?`Saving...`:`Save ${n.section}`)}),v(`submit`,f,e=>{e.preventDefault(),l()}),p(e,f),_()}var $i=l(`
Browser notifications are not supported on this device.
`),ea=l(`Browser notifications are blocked by this browser.
`),ta=l(``),na=l(`
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.
Send push notifications to your phone via Pushover.
Find this in your Pushover dashboard under "Your User Key".
Loading…
`),ua=l(``),da=l(`
Account
Bots you own across your workspaces. Tokens live with the workspace where the bot was created.
`),xa=l(``),Sa=l(``);function Ca(e,n){o(n,!0);let r=A(``),i=A(``),a=A(``),s=A(``),c=A(!1),l=A(!1);async function f(){if(u(l))return;let e=ya(u(r),u(i),u(a));if(e){M(s,e,!0),M(c,!0);return}M(l,!0),M(s,``),M(c,!1);try{await G(`/api/auth/password/change`,{method:`POST`,body:JSON.stringify({current_password:u(r),new_password:u(i)})}),M(r,``),M(i,``),M(a,``),M(s,`Password updated. Your other devices were signed out.`)}catch(e){M(s,xe(e,`Could not change your password`),!0),M(c,!0)}finally{M(l,!1)}}var m=Sa(),h=k(I(m),2),g=I(h),y=k(I(g),2),b=I(y);O(b),t(y),t(g);var x=k(g,2),S=k(I(x),2),C=I(S);O(C),t(S),t(x);var T=k(x,2),E=k(I(T),2),j=I(E);O(j),t(E),t(T),t(h);var F=k(h,2),ee=I(F),te=e=>{var t=ba();let n;var r=D(t,!0);N(()=>{n=w(t,1,`settings-status`,null,n,{"is-error":u(c)}),d(r,u(s))}),p(e,t)},L=e=>{var t=xa();p(e,t)};P(ee,e=>{u(s)?e(te):e(L,-1)});var R=k(ee,2),ne=D(R,!0);t(F),t(m),N(()=>{R.disabled=u(l),d(ne,u(l)?`Updating...`:`Update password`)}),v(`submit`,m,e=>{e.preventDefault(),f()}),se(b,()=>u(r),e=>M(r,e)),se(C,()=>u(i),e=>M(i,e)),se(j,()=>u(a),e=>M(a,e)),p(e,m),_()}var wa=l(``),Ta=l(``),Ea=l(`
Account
Changes apply instantly and follow your account on every device.
Live preview — updates as you pick
Loading...
`),Fa=l(``),Ia=l(`
`),La=l(`
Account
How you appear across ClickClack.
End this session on this device.
Account
Decide when and how ClickClack should reach you.
`),ko=l(`
`,1),Po=l(`
Loading thread…
`),Fo=l(`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.
`),qn=l(``),Jn=l(``),Zn=l(`
`),Qn=l(`
Some slide content was omitted by preview limits.
`),$n=l(` `,1),er=l(``),tr=l(``),nr=l(``),rr=l(` `),ir=l(`or
`),lr=l(` Continue with GitHub`,1),ur=l(` Sign in with OpenClaw ID`),dr=l(``),fr=l(`
`),pr=l(`
`),gr=l(``);function _r(e,r){o(r,!0);let i=S(r,`deleting`,3,!1),a=S(r,`error`,3,``),s=U(()=>r.message.author?.display_name||`Local User`);function c(){i()||r.onClose()}var l=gr(),f=I(l),m=k(f,2),h=I(m),g=k(I(h),2);t(h);var v=k(h,2),y=k(I(v),2),b=I(y);{let e=U(()=>r.message.author?.id||r.message.author_id),t=U(()=>r.message.author?.avatar_url);ut(b,{class:`avatar`,get id(){return u(e)},get name(){return u(s)},get src(){return u(t)},size:36,loading:`eager`,fetchPriority:`auto`})}var x=k(b,2),C=I(x),w=I(C),T=D(w,!0),E=k(w,2),O=D(E,!0);t(C);var A=k(C,2),j=D(A,!0);t(x),t(y);var M=k(y,2),F=e=>{var t=hr(),n=D(t,!0);N(()=>d(n,a())),p(e,t)};P(M,e=>{a()&&e(F)});var ee=k(M,2),te=I(ee),L=k(te,2),R=D(L,!0);t(ee),t(v),t(m),t(l),N(e=>{f.disabled=i(),g.disabled=i(),d(T,u(s)),z(E,`datetime`,r.message.created_at),d(O,e),d(j,r.message.body),te.disabled=i(),L.disabled=i(),d(R,i()?`Deleting...`:`Delete`)},[()=>Ze(r.message.created_at)]),n(`click`,f,c),n(`click`,g,c),n(`click`,te,c),n(`click`,L,function(...e){r.onConfirm?.apply(this,e)}),p(e,l),_()}W([`click`]);var vr=6500,yr=l(`
`),kr=l(`
Channels
`),Pr=l(`
`),Fr=l(`
Direct messages
`),Vr=l(`
Creating workspace…
`),Hr=l(``),Ur=l(``);function Wr(e,r){o(r,!0);let i=S(r,`homeHref`,3,`/`),a=S(r,`homeLabel`,3,`cc`),s=S(r,`homeTitle`,3,`ClickClack home`);function c(e){return e.button===0&&!e.metaKey&&!e.ctrlKey&&!e.shiftKey&&!e.altKey}var l=Ur(),m=I(l);let h;var g=I(m),y=e=>{Y(e,{size:48})},b=U(()=>Ot(a())),x=e=>{var t=Lr(),n=D(t,!0);N(()=>d(n,a())),p(e,t)};P(g,e=>{u(b)?e(y):e(x,-1)}),t(m);var C=k(m,4),T=I(C);f(T,17,()=>r.workspaces,e=>e.id,(e,i)=>{var a=zr();let o;var s=I(a),l=I(s),f=e=>{var t=Rr();N(e=>z(t,`src`,e),[()=>_e(u(i).icon_url)]),p(e,t)},m=e=>{var t=Lr(),n=D(t,!0);N(e=>d(n,e),[()=>ct(u(i).name)]),p(e,t)};P(l,e=>{u(i).icon_url?e(f):e(m,-1)}),t(s),t(a),N(e=>{o=w(a,1,`guild-wrap`,null,o,{active:u(i).id===r.selectedWorkspaceID}),z(s,`title`,u(i).name),z(s,`aria-label`,u(i).name),z(s,`href`,e)},[()=>r.hrefForWorkspace(u(i).id)]),n(`click`,s,e=>{c(e)&&(e.preventDefault(),r.onSelectWorkspace(u(i).id))}),p(e,a)});var E=k(T,2);t(C);var A=k(C,2),M=e=>{var i=Hr(),a=I(i);O(a);var o=k(a,2),s=e=>{var t=Br(),n=D(t,!0);N(()=>d(n,r.createError)),p(e,t)};P(o,e=>{r.createError&&e(s)});var c=k(o,2),l=e=>{var t=Vr();p(e,t)};P(c,e=>{r.createPending&&e(l)}),t(i),N(()=>{j(a,r.workspaceName),a.disabled=r.createPending}),v(`submit`,i,e=>{e.preventDefault(),r.onCreateWorkspace()}),n(`input`,a,e=>r.onWorkspaceName(e.currentTarget.value)),p(e,i)};P(A,e=>{r.showWorkspaceCreate&&e(M)}),t(l),N(e=>{h=w(m,1,`guild home`,null,h,{"home--mark":e}),z(m,`title`,s()),z(m,`aria-label`,s()),z(m,`href`,i())},[()=>Ot(a())]),n(`click`,E,function(...e){r.onToggleWorkspaceCreate?.apply(this,e)}),p(e,l),_()}W([`click`,`input`]);var Gr=l(``),Kr=l(` `,1),qr=l(``),Jr=l(` `),Yr=l(``),Xr=l(`No pinned messages
Pin important messages to keep them easily accessible.
Try another word or phrase.
`),Gi=l(`
Channel
Archived channels move to the Archived section and keep their full message history.
`),Zi=l(``),Qi=l(``);function $i(e,n){o(n,!0);let r=A(!1),i=A(``),s=A(!1),c=new AbortController;ne(()=>c.abort());async function l(){if(!u(r)){M(r,!0),M(i,``),M(s,!1);try{let e=await Ce({method:`PATCH`,body:JSON.stringify(n.payload()),signal:c.signal});c.signal.throwIfAborted(),n.onUserUpdated(e.user),M(i,`Saved`),n.onSaved?.()}catch(e){if(c.signal.aborted)return;M(i,xe(e,`Could not save ${n.section}`),!0),M(s,!0)}finally{M(r,!1)}}}var f=Qi(),m=I(f),h=I(m);a(h,()=>n.children),t(m);var g=k(m,2),y=I(g),b=e=>{var t=Xi();let n;var r=D(t,!0);N(()=>{n=w(t,1,`settings-status`,null,n,{"is-error":u(s)}),d(r,u(i))}),p(e,t)},x=e=>{var t=Zi();p(e,t)};P(y,e=>{u(i)?e(b):e(x,-1)});var S=k(y,2),C=D(S,!0);t(g),t(f),N(()=>{m.disabled=u(r),S.disabled=u(r),d(C,u(r)?`Saving...`:`Save ${n.section}`)}),v(`submit`,f,e=>{e.preventDefault(),l()}),p(e,f),_()}var ea=l(`
Browser notifications are not supported on this device.
`),ta=l(`Browser notifications are blocked by this browser.
`),na=l(``),ra=l(`
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.
Send push notifications to your phone via Pushover.
Find this in your Pushover dashboard under "Your User Key".
Loading…
`),da=l(``),fa=l(`
Account
Bots you own across your workspaces. Tokens live with the workspace where the bot was created.
`),Sa=l(``),Ca=l(``);function wa(e,n){o(n,!0);let r=A(``),i=A(``),a=A(``),s=A(``),c=A(!1),l=A(!1);async function f(){if(u(l))return;let e=ba(u(r),u(i),u(a));if(e){M(s,e,!0),M(c,!0);return}M(l,!0),M(s,``),M(c,!1);try{await G(`/api/auth/password/change`,{method:`POST`,body:JSON.stringify({current_password:u(r),new_password:u(i)})}),M(r,``),M(i,``),M(a,``),M(s,`Password updated. Your other devices were signed out.`)}catch(e){M(s,xe(e,`Could not change your password`),!0),M(c,!0)}finally{M(l,!1)}}var m=Ca(),h=k(I(m),2),g=I(h),y=k(I(g),2),b=I(y);O(b),t(y),t(g);var x=k(g,2),S=k(I(x),2),C=I(S);O(C),t(S),t(x);var T=k(x,2),E=k(I(T),2),j=I(E);O(j),t(E),t(T),t(h);var F=k(h,2),ee=I(F),te=e=>{var t=xa();let n;var r=D(t,!0);N(()=>{n=w(t,1,`settings-status`,null,n,{"is-error":u(c)}),d(r,u(s))}),p(e,t)},L=e=>{var t=Sa();p(e,t)};P(ee,e=>{u(s)?e(te):e(L,-1)});var R=k(ee,2),ne=D(R,!0);t(F),t(m),N(()=>{R.disabled=u(l),d(ne,u(l)?`Updating...`:`Update password`)}),v(`submit`,m,e=>{e.preventDefault(),f()}),se(b,()=>u(r),e=>M(r,e)),se(C,()=>u(i),e=>M(i,e)),se(j,()=>u(a),e=>M(a,e)),p(e,m),_()}var Ta=l(``),Ea=l(``),Da=l(`
Account
Changes apply instantly and follow your account on every device.
Live preview — updates as you pick
Loading...
`),Ia=l(``),La=l(`
`),Ra=l(`
Account
How you appear across ClickClack.
End this session on this device.
Account
Decide when and how ClickClack should reach you.
`),Ao=l(`
`,1),Fo=l(`
Loading thread…
`),Io=l(``),Ge=o(`
`),Ze=o(`
`),bt=o(`
`),Q=o(`
Blocked.
`),xt=o(``),St=o(``),Ct=o(``),wt=o(``),Tt=o(`Profile
No GIFs found
`),tt=i(``+(n?i:mi(i,!0))+`
-`:``+(n?i:mi(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${mi(e,!0)}`}br(e){return`An error occurred:
`+mi(n.message+``,!0)+``;return t?Promise.resolve(e):e}if(t)return Promise.reject(n);throw n}}};function $(e,t){return ki.parse(e,t)}$.options=$.setOptions=function(e){return ki.setOptions(e),$.defaults=ki.defaults,Zn($.defaults),$},$.getDefaults=Yn,$.defaults=Xn;function Ai(...e){return ki.use(...e),$.defaults=ki.defaults,Zn($.defaults),$}$.use=Ai,$.walkTokens=function(e,t){return ki.walkTokens(e,t)},$.parseInline=ki.parseInline,$.Parser=Di,$.parser=Di.parse,$.Renderer=Ti,$.TextRenderer=Ei,$.Lexer=wi,$.lexer=wi.lex,$.Tokenizer=Ci,$.Hooks=Oi,$.parse=$,$.options,$.setOptions,$.walkTokens,$.parseInline,Di.parse,wi.lex;function ji(e){return Fn.sanitize($.parse(e,{async:!1,breaks:!0,gfm:!0}))}function Mi(e){return new Intl.DateTimeFormat(void 0,{hour:`2-digit`,minute:`2-digit`}).format(new Date(e))}var Ni=i(``);function Pi(r,i){n(i,!0);let s=x(null),l=x(null),u=x(null),f=typeof document<`u`&&document.activeElement instanceof HTMLElement?document.activeElement:null,p=f?.closest(`.thread`),m=f?.closest(`.timeline`),h=p?.querySelector(`[aria-label="Reply body"]:not(:disabled)`)??p?.querySelector(`[aria-label="Close thread"]`)??m?.querySelector(`[aria-label="Message body"]:not(:disabled)`)??m?.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),g=new Set;ee(()=>{let e=a(s)?.parentElement;if(e)for(let t of e.children)!(t instanceof HTMLElement)||t===a(s)||t.inert||(t.inert=!0,g.add(t));a(u)?.focus({preventScroll:!0})}),k(()=>{for(let e of g)e.inert=!1;g.clear(),!(f?.isConnected&&f!==document.body&&(f.focus({preventScroll:!0}),document.activeElement===f))&&h?.isConnected&&h.focus({preventScroll:!0})});function _(e){if(e.key===`Escape`){e.preventDefault(),e.stopPropagation(),i.onClose();return}if(e.key!==`Tab`||!a(l))return;let t=Array.from(a(l).querySelectorAll(`a[href], button:not([disabled]), [tabindex]:not([tabindex="-1"])`)).filter(e=>!e.inert&&e.getClientRects().length>0);if(t.length===0){e.preventDefault(),a(l).focus();return}let n=t[0],r=t[t.length-1];e.shiftKey&&(document.activeElement===n||!a(l).contains(document.activeElement))?(e.preventDefault(),r.focus()):!e.shiftKey&&(document.activeElement===r||!a(l).contains(document.activeElement))&&(e.preventDefault(),n.focus())}var y=Ni(),S=O(y),T=b(S,2),E=O(T),D=O(E),j=v(D,!0),ne=b(D,2),re=O(ne),ie=b(re,2);te(ie,e=>C(u,e),()=>a(u)),e(ne),e(E);var M=b(E,2),N=v(M);e(T),te(T,e=>C(l,e),()=>a(l)),e(y),te(y,e=>C(s,e),()=>a(s)),w(()=>{A(T,`aria-label`,`Image viewer: ${i.title}`),o(j,i.title),A(re,`href`,i.url),A(N,`src`,i.url),A(N,`alt`,i.title)}),t(`click`,S,function(...e){i.onClose?.apply(this,e)}),t(`keydown`,T,_),t(`click`,ie,function(...e){i.onClose?.apply(this,e)}),c(r,y),d()}N([`click`,`keydown`]);var Fi=i(``);function Ii(e,t){let n=m(t,`size`,3,44),r=m(t,`class`,3,``);var i=Fi();w(()=>{g(i,1,`keystroke-mark ${r()??``}`,`svelte-1bx7eka`),ie(i,`--keystroke-size: ${n()??``}px`)}),c(e,i)}var Li=i(`
`),pa=i(` `,1),ma=i(``),ha=i(``),ga=i(``);function _a(r,i){n(i,!0);let l=m(i,`canReact`,3,!1),f=m(i,`canReply`,3,!1),p=m(i,`showOpenThread`,3,!0),h=m(i,`canOpenThread`,3,!1),_=m(i,`canEdit`,3,!1),y=m(i,`canPin`,3,!1),S=m(i,`pinned`,3,!1),D=m(i,`pinning`,3,!1),j=m(i,`pinError`,3,``),re=m(i,`canDelete`,3,!1),ie=m(i,`deleting`,3,!1),M=m(i,`copyStatus`,3,``),N=m(i,`canCopyLink`,3,!1),P=m(i,`copyLinkStatus`,3,``),F=x(void 0),ae=x(void 0),oe=!0,I=new Set;function se(e){if(e.key===`Escape`){e.preventDefault(),e.stopPropagation(),i.onClose();return}if(e.key!==`Tab`||!a(ae))return;let t=[...a(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(),a(ae).focus();return}let n=t[0],r=t[t.length-1];e.shiftKey&&(document.activeElement===n||!a(ae).contains(document.activeElement))?(e.preventDefault(),r.focus()):!e.shiftKey&&(document.activeElement===r||!a(ae).contains(document.activeElement))&&(e.preventDefault(),n.focus())}function ce(e,t=!1){oe=t,e()}ee(()=>{let e=a(F)?.parentElement;if(e)for(let t of e.children)!(t instanceof HTMLElement)||t===a(F)||t.inert||(t.inert=!0,I.add(t));a(ae)?.querySelector(`button:not(:disabled)`)?.focus({preventScroll:!0})}),k(()=>{for(let e of I)e.inert=!1;I.clear(),oe&&i.returnFocus?.isConnected&&i.returnFocus.focus({preventScroll:!0})});var le=ga(),ue=O(le),L=b(ue,2),R=b(O(L),2);s(R,21,()=>Xi,E,(e,n)=>{var r=sa(),s=v(r,!0);w(()=>{A(r,`aria-label`,`React with ${a(n)}`),r.disabled=!l(),o(s,a(n))}),t(`click`,r,()=>ce(()=>i.onReact(a(n)),!0)),c(e,r)}),e(R);var z=b(R,2),de=O(z),B=e=>{var n=ca();w(()=>n.disabled=!h()),t(`click`,n,()=>ce(i.onOpenThread)),c(e,n)};T(de,e=>{p()&&e(B)});var V=b(de,2),H=b(V,2),fe=b(O(H),2),pe=e=>{var t=la();let n;var r=v(t,!0);w(()=>{n=g(t,1,`sheet-copy-status`,null,n,{"is-error":M()===`failed`}),o(r,M()===`copied`?`Copied`:`Couldn't copy`)}),c(e,t)};T(fe,e=>{M()&&e(pe)}),e(H);var me=b(H,2),he=n=>{var r=da(),a=b(O(r)),s=b(a),l=e=>{var t=ua();c(e,t)};T(s,e=>{P()===`failed`&&e(l)}),e(r),w(()=>{r.disabled=P()===`pending`,o(a,` ${P()===`pending`?`Creating link…`:`Copy link`} `)}),t(`click`,r,function(...e){i.onCopyLink?.apply(this,e)}),c(n,r)};T(me,e=>{N()&&i.onCopyLink&&e(he)});var ge=b(me,2),_e=n=>{var r=pa(),a=ne(r),s=b(O(a));e(a);var l=b(a,2),u=e=>{var t=fa(),n=v(t,!0);w(()=>o(n,j())),c(e,t)};T(l,e=>{j()&&e(u)}),w(()=>{a.disabled=D(),o(s,` ${S()?`Unpin message`:`Pin message`}`)}),t(`click`,a,function(...e){i.onTogglePin?.apply(this,e)}),c(n,r)};T(ge,e=>{y()&&i.onTogglePin&&e(_e)});var ve=b(ge,2),ye=e=>{var n=ma();t(`click`,n,()=>ce(i.onEdit)),c(e,n)};T(ve,e=>{_()&&e(ye)});var be=b(ve,2),xe=e=>{var n=ha();w(()=>n.disabled=ie()),t(`click`,n,()=>ce(i.onDelete)),c(e,n)};T(be,e=>{re()&&e(xe)}),e(z),e(L),te(L,e=>C(ae,e),()=>a(ae)),e(le),te(le,e=>C(F,e),()=>a(F)),u(le,e=>oa?.(e)),w(()=>{A(L,`id`,i.id),V.disabled=!f()}),t(`keydown`,le,se),t(`click`,ue,function(...e){i.onClose?.apply(this,e)}),t(`click`,V,()=>ce(i.onReply)),t(`click`,H,function(...e){i.onCopy?.apply(this,e)}),c(r,le),d()}N([`keydown`,`click`]);var va=i(`
Your browser blocked clipboard access. Copy the selected link below.
No GIFs found
`),tt=a(``+(n?i:gi(i,!0))+`
+`:``+(n?i:gi(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${gi(e,!0)}`}br(e){return`An error occurred:
`+gi(n.message+``,!0)+``;return t?Promise.resolve(e):e}if(t)return Promise.reject(n);throw n}}};function $(e,t){return ji.parse(e,t)}$.options=$.setOptions=function(e){return ji.setOptions(e),$.defaults=ji.defaults,Qn($.defaults),$},$.getDefaults=Xn,$.defaults=Zn;function Mi(...e){return ji.use(...e),$.defaults=ji.defaults,Qn($.defaults),$}$.use=Mi,$.walkTokens=function(e,t){return ji.walkTokens(e,t)},$.parseInline=ji.parseInline,$.Parser=ki,$.parser=ki.parse,$.Renderer=Di,$.TextRenderer=Oi,$.Lexer=Ei,$.lexer=Ei.lex,$.Tokenizer=Ti,$.Hooks=Ai,$.parse=$,$.options,$.setOptions,$.walkTokens,$.parseInline,ki.parse,Ei.lex;function Ni(e){return In.sanitize($.parse(e,{async:!1,breaks:!0,gfm:!0}))}function Pi(e){return new Intl.DateTimeFormat(void 0,{hour:`2-digit`,minute:`2-digit`}).format(new Date(e))}var Fi=a(``);function Ii(n,i){r(i,!0);let a=C(null),c=C(null),u=C(null),d=typeof document<`u`&&document.activeElement instanceof HTMLElement?document.activeElement:null,p=d?.closest(`.thread`),m=d?.closest(`.timeline`),h=p?.querySelector(`[aria-label="Reply body"]:not(:disabled)`)??p?.querySelector(`[aria-label="Close thread"]`)??m?.querySelector(`[aria-label="Message body"]:not(:disabled)`)??m?.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),g=new Set;N(()=>{let e=o(a)?.parentElement;if(e)for(let t of e.children)!(t instanceof HTMLElement)||t===o(a)||t.inert||(t.inert=!0,g.add(t));o(u)?.focus({preventScroll:!0})}),j(()=>{for(let e of g)e.inert=!1;g.clear(),!(d?.isConnected&&d!==document.body&&(d.focus({preventScroll:!0}),document.activeElement===d))&&h?.isConnected&&h.focus({preventScroll:!0})});function _(e){if(e.key===`Escape`){e.preventDefault(),e.stopPropagation(),i.onClose();return}if(e.key!==`Tab`||!o(c))return;let t=Array.from(o(c).querySelectorAll(`a[href], button:not([disabled]), [tabindex]:not([tabindex="-1"])`)).filter(e=>!e.inert&&e.getClientRects().length>0);if(t.length===0){e.preventDefault(),o(c).focus();return}let n=t[0],r=t[t.length-1];e.shiftKey&&(document.activeElement===n||!o(c).contains(document.activeElement))?(e.preventDefault(),r.focus()):!e.shiftKey&&(document.activeElement===r||!o(c).contains(document.activeElement))&&(e.preventDefault(),n.focus())}var v=Fi(),y=A(v),x=S(y,2),w=A(x),D=A(w),O=b(D,!0),k=S(D,2),ee=A(k),te=S(ee,2);P(te,e=>T(u,e),()=>o(u)),e(k),e(w);var ne=S(w,2),F=b(ne);e(x),P(x,e=>T(c,e),()=>o(c)),e(v),P(v,e=>T(a,e),()=>o(a)),E(()=>{M(x,`aria-label`,`Image viewer: ${i.title}`),s(O,i.title),M(ee,`href`,i.url),M(F,`src`,i.url),M(F,`alt`,i.title)}),t(`click`,y,function(...e){i.onClose?.apply(this,e)}),t(`keydown`,x,_),t(`click`,te,function(...e){i.onClose?.apply(this,e)}),l(n,v),f()}L([`click`,`keydown`]);var Li=a(``);function Ri(e,t){let n=g(t,`size`,3,44),r=g(t,`class`,3,``);var i=Li();E(()=>{v(i,1,`keystroke-mark ${r()??``}`,`svelte-1bx7eka`),F(i,`--keystroke-size: ${n()??``}px`)}),l(e,i)}var zi=a(`
`),ha=a(` `,1),ga=a(``),_a=a(``),va=a(``);function ya(n,i){r(i,!0);let a=g(i,`canReact`,3,!1),u=g(i,`canReply`,3,!1),p=g(i,`showOpenThread`,3,!0),m=g(i,`canOpenThread`,3,!1),h=g(i,`canEdit`,3,!1),_=g(i,`canPin`,3,!1),y=g(i,`pinned`,3,!1),x=g(i,`pinning`,3,!1),w=g(i,`pinError`,3,``),k=g(i,`canDelete`,3,!1),ee=g(i,`deleting`,3,!1),ne=g(i,`copyStatus`,3,``),F=g(i,`canCopyLink`,3,!1),I=g(i,`copyLinkStatus`,3,``),L=C(void 0),R=C(void 0),re=!0,z=new Set;function ie(e){if(e.key===`Escape`){e.preventDefault(),e.stopPropagation(),i.onClose();return}if(e.key!==`Tab`||!o(R))return;let t=[...o(R).querySelectorAll(`a[href], button:not([disabled]), [tabindex]:not([tabindex="-1"])`)].filter(e=>!e.inert&&e.getClientRects().length>0);if(t.length===0){e.preventDefault(),o(R).focus();return}let n=t[0],r=t[t.length-1];e.shiftKey&&(document.activeElement===n||!o(R).contains(document.activeElement))?(e.preventDefault(),r.focus()):!e.shiftKey&&(document.activeElement===r||!o(R).contains(document.activeElement))&&(e.preventDefault(),n.focus())}function ae(e,t=!1){re=t,e()}N(()=>{let e=o(L)?.parentElement;if(e)for(let t of e.children)!(t instanceof HTMLElement)||t===o(L)||t.inert||(t.inert=!0,z.add(t));o(R)?.querySelector(`button:not(:disabled)`)?.focus({preventScroll:!0})}),j(()=>{for(let e of z)e.inert=!1;z.clear(),re&&i.returnFocus?.isConnected&&i.returnFocus.focus({preventScroll:!0})});var B=va(),oe=A(B),se=S(oe,2),ce=S(A(se),2);c(ce,21,()=>Qi,O,(e,n)=>{var r=la(),c=b(r,!0);E(()=>{M(r,`aria-label`,`React with ${o(n)}`),r.disabled=!a(),s(c,o(n))}),t(`click`,r,()=>ae(()=>i.onReact(o(n)),!0)),l(e,r)}),e(ce);var V=S(ce,2),le=A(V),H=e=>{var n=ua();E(()=>n.disabled=!m()),t(`click`,n,()=>ae(i.onOpenThread)),l(e,n)};D(le,e=>{p()&&e(H)});var U=S(le,2),W=S(U,2),ue=S(A(W),2),de=e=>{var t=da();let n;var r=b(t,!0);E(()=>{n=v(t,1,`sheet-copy-status`,null,n,{"is-error":ne()===`failed`}),s(r,ne()===`copied`?`Copied`:`Couldn't copy`)}),l(e,t)};D(ue,e=>{ne()&&e(de)}),e(W);var G=S(W,2),fe=n=>{var r=pa(),a=S(A(r)),o=S(a),c=e=>{var t=fa();l(e,t)};D(o,e=>{I()===`failed`&&e(c)}),e(r),E(()=>{r.disabled=I()===`pending`,s(a,` ${I()===`pending`?`Creating link…`:`Copy link`} `)}),t(`click`,r,function(...e){i.onCopyLink?.apply(this,e)}),l(n,r)};D(G,e=>{F()&&i.onCopyLink&&e(fe)});var pe=S(G,2),me=n=>{var r=ha(),a=te(r),o=S(A(a));e(a);var c=S(a,2),u=e=>{var t=ma(),n=b(t,!0);E(()=>s(n,w())),l(e,t)};D(c,e=>{w()&&e(u)}),E(()=>{a.disabled=x(),s(o,` ${y()?`Unpin message`:`Pin message`}`)}),t(`click`,a,function(...e){i.onTogglePin?.apply(this,e)}),l(n,r)};D(pe,e=>{_()&&i.onTogglePin&&e(me)});var he=S(pe,2),K=e=>{var n=ga();t(`click`,n,()=>ae(i.onEdit)),l(e,n)};D(he,e=>{h()&&e(K)});var ge=S(he,2),_e=e=>{var n=_a();E(()=>n.disabled=ee()),t(`click`,n,()=>ae(i.onDelete)),l(e,n)};D(ge,e=>{k()&&e(_e)}),e(V),e(se),P(se,e=>T(R,e),()=>o(R)),e(B),P(B,e=>T(L,e),()=>o(L)),d(B,e=>ca?.(e)),E(()=>{M(se,`id`,i.id),U.disabled=!u()}),t(`keydown`,B,ie),t(`click`,oe,function(...e){i.onClose?.apply(this,e)}),t(`click`,U,()=>ae(i.onReply)),t(`click`,W,function(...e){i.onCopy?.apply(this,e)}),l(n,B),f()}L([`keydown`,`click`]);var ba=a(`
Your browser blocked clipboard access. Copy the selected link below.
`),oo=a(`
`),so=a(``),co=a(`Choose any`),lo=a(` `),uo=a(` `),fo=a(``),po=a(``),mo=a(``),ho=a(`
`),xo=a(` `),So=a(``),Co=a(``),wo=a(``),To=a(``),Eo=a(` `),Do=a(``),Oo=a(` `,1),ko=a(` `),Ao=a(`
`),No=a(` `,1),Po=a(`
`),Ve=a(`
`),qe=a(`
`),_t=a(`
`),vt=a(`
Blocked.
`),yt=a(``),bt=a(``),xt=a(``),St=a(``),Q=a(`Profile
{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} +