From a5eac6c2bc6414e9fd4c6b551dcaacdaf1da73f6 Mon Sep 17 00:00:00 2001 From: sunba91-su Date: Mon, 8 Jun 2026 16:30:25 +0330 Subject: [PATCH] feat: conversational standup collection via DM Implement conversational daily standup flow: bot DMs user one question at a time, collects answers, and persists the completed response. - Conversation state manager (convstate) tracking per-user progress - /standup submit command: creates session, starts DM conversation - /standup status command: checks submission status for today - Rocket REST API rooms.info for DM room detection - CreateDirectMessage wrapper for initiating DM conversations - Non-command DM messages routed to standup answer handler - 19 new tests across convstate and commands packages - Wire convstate manager and DM detection in main.go Closes #7 --- cmd/bot/main.go | 61 +++++++++ internal/commands/registry.go | 8 +- internal/commands/standup.go | 115 +++++++++++++++++ internal/commands/standup_test.go | 178 +++++++++++++++++++++++++++ internal/convstate/convstate.go | 123 ++++++++++++++++++ internal/convstate/convstate_test.go | 170 +++++++++++++++++++++++++ internal/rocket/client.go | 44 +++++++ 7 files changed, 696 insertions(+), 3 deletions(-) create mode 100644 internal/commands/standup.go create mode 100644 internal/commands/standup_test.go create mode 100644 internal/convstate/convstate.go create mode 100644 internal/convstate/convstate_test.go diff --git a/cmd/bot/main.go b/cmd/bot/main.go index c65e929..b01fb6d 100644 --- a/cmd/bot/main.go +++ b/cmd/bot/main.go @@ -6,10 +6,13 @@ import ( "os" "os/signal" "path/filepath" + "strings" "syscall" + "time" "github.com/sunba91-su/Roket.Chat-GeekBot/internal/commands" "github.com/sunba91-su/Roket.Chat-GeekBot/internal/config" + "github.com/sunba91-su/Roket.Chat-GeekBot/internal/convstate" "github.com/sunba91-su/Roket.Chat-GeekBot/internal/rocket" "github.com/sunba91-su/Roket.Chat-GeekBot/internal/store" ) @@ -59,8 +62,13 @@ func main() { cmdReg := commands.New() commands.RegisterTeamCommands(cmdReg) + commands.RegisterStandupCommands(cmdReg) + + convMgr := convstate.NewManager() client.OnMessage(func(msg rocket.IncomingMessage) { + isDM, _ := client.IsDMRoom(msg.RoomID) + ctx := &commands.Context{ UserID: msg.UserID, Username: msg.Username, @@ -70,6 +78,8 @@ func main() { Messenger: client, UserProvider: &userProviderAdapter{client: client}, Config: cfg, + ConvState: convMgr, + IsDM: isDM, } handled, err := cmdReg.Dispatch(ctx) @@ -78,6 +88,15 @@ func main() { } if handled { log.Printf("[%s] %s: %s", msg.RoomID, msg.Username, msg.Text) + return + } + + if !isDM { + return + } + + if err := handleStandupReply(ctx); err != nil { + log.Printf("Standup reply error: %v", err) } }) @@ -99,3 +118,45 @@ func main() { fmt.Println() log.Println("Shutting down...") } + +func handleStandupReply(ctx *commands.Context) error { + conv, ok := ctx.ConvState.GetConversation(ctx.UserID) + if !ok { + return nil + } + if conv.RoomID != ctx.RoomID { + return nil + } + + finished, nextQ, err := ctx.ConvState.RecordAnswer(ctx.UserID, ctx.RawText) + if err != nil { + return ctx.SendMessage(ctx.RoomID, "Error recording your answer.") + } + + if finished { + conv, ok := ctx.ConvState.GetConversation(ctx.UserID) + if !ok { + return ctx.SendMessage(ctx.RoomID, "Error: conversation lost.") + } + answersJoined := strings.Join(conv.Answers, "|") + resp := &store.StandupResponse{ + ID: fmt.Sprintf("resp-%d", time.Now().UnixMilli()), + SessionID: conv.SessionID, + UserID: conv.UserID, + Username: conv.Username, + Answers: answersJoined, + SubmittedAt: time.Now(), + } + if err := ctx.Store.SubmitResponse(resp); err != nil { + return ctx.SendMessage(ctx.RoomID, + fmt.Sprintf("Error saving standup: %v", err)) + } + + ctx.ConvState.EndConversation(ctx.UserID) + return ctx.SendMessage(ctx.RoomID, + "✅ *Standup submitted!* Thank you. Have a great day!") + } + + return ctx.SendMessage(ctx.RoomID, + fmt.Sprintf("**Q%d:** %s\n\nReply with your answer.", conv.CurrentQ+1, nextQ)) +} diff --git a/internal/commands/registry.go b/internal/commands/registry.go index 2de83eb..56c8a20 100644 --- a/internal/commands/registry.go +++ b/internal/commands/registry.go @@ -5,6 +5,7 @@ import ( "strings" "github.com/sunba91-su/Roket.Chat-GeekBot/internal/config" + "github.com/sunba91-su/Roket.Chat-GeekBot/internal/convstate" "github.com/sunba91-su/Roket.Chat-GeekBot/internal/store" ) @@ -40,9 +41,10 @@ type Context struct { Store *store.Store Messenger UserProvider - Config *config.Config - CmdName string - IsDM bool + Config *config.Config + ConvState *convstate.Manager + CmdName string + IsDM bool } type Handler func(ctx *Context) error diff --git a/internal/commands/standup.go b/internal/commands/standup.go new file mode 100644 index 0000000..e9740a1 --- /dev/null +++ b/internal/commands/standup.go @@ -0,0 +1,115 @@ +package commands + +import ( + "fmt" + "strings" + "time" + + "github.com/sunba91-su/Roket.Chat-GeekBot/internal/store" +) + +func RegisterStandupCommands(r *Registry) { + r.Register("submit", handleSubmit, PermissionMember) + r.Register("status", handleStatus, PermissionMember) +} + +func handleSubmit(ctx *Context) error { + teams, err := ctx.Store.GetTeamsForUser(ctx.UserID) + if err != nil { + return send(ctx.Messenger, ctx.RoomID, + "Error looking up your teams.") + } + if len(teams) == 0 { + return send(ctx.Messenger, ctx.RoomID, + "You are not a member of any team.") + } + + dmRoomID, err := ensureDMRoom(ctx) + if err != nil { + return send(ctx.Messenger, ctx.RoomID, + fmt.Sprintf("Could not start DM: %v", err)) + } + + team := teams[0] + date := time.Now().Format("2006-01-02") + + sessionID := fmt.Sprintf("sess-%d-%s", time.Now().UnixMilli(), team.ID) + + session := &store.StandupSession{ + ID: sessionID, + TeamID: team.ID, + Date: date, + Status: "open", + } + if err := ctx.Store.CreateSession(session); err != nil { + return send(ctx.Messenger, ctx.RoomID, + fmt.Sprintf("Failed to create session: %v", err)) + } + + questions := strings.Split(team.Questions, "|") + + ctx.ConvState.StartConversation( + ctx.UserID, ctx.Username, team.ID, + dmRoomID, questions, sessionID, + ) + + firstQ := questions[0] + _ = send(ctx.Messenger, ctx.RoomID, + fmt.Sprintf("Starting your standup for **%s**. Check your DMs!", team.Name)) + + return send(ctx.Messenger, dmRoomID, + fmt.Sprintf("📋 *Daily Standup — %s*\n\n**Q1:** %s\n\nReply with your answer.", team.Name, firstQ)) +} + +func handleStatus(ctx *Context) error { + teams, err := ctx.Store.GetTeamsForUser(ctx.UserID) + if err != nil || len(teams) == 0 { + return send(ctx.Messenger, ctx.RoomID, "You are not a member of any team.") + } + + date := time.Now().Format("2006-01-02") + team := teams[0] + + submitted, total, err := ctx.Store.GetSessionStatus(team.ID, date) + if err != nil { + session, err2 := ctx.Store.GetActiveSession(team.ID, date) + if err2 != nil { + return send(ctx.Messenger, ctx.RoomID, + "No active standup for today.") + } + submitted, total, _ = ctx.Store.GetSessionStatus(team.ID, session.Date) + } + + hasSubmitted, _ := ctx.Store.HasSubmitted(sessionID(ctx, team.ID), ctx.UserID) + statusLine := "" + if hasSubmitted { + statusLine = "✅ You have submitted." + } else { + statusLine = "⏳ You have not submitted yet." + } + + return send(ctx.Messenger, ctx.RoomID, + fmt.Sprintf("*Standup Status — %s*\n%s\nTeam progress: %d/%d submitted.", + team.Name, statusLine, submitted, total)) +} + +func sessionID(ctx *Context, teamID string) string { + date := time.Now().Format("2006-01-02") + session, err := ctx.Store.GetActiveSession(teamID, date) + if err != nil { + return "" + } + return session.ID +} + +func ensureDMRoom(ctx *Context) (string, error) { + if ctx.IsDM && ctx.RoomID != "" { + return ctx.RoomID, nil + } + + dmProvider, ok := ctx.Messenger.(interface{ CreateDM(string) (string, error) }) + if !ok { + return "", fmt.Errorf("DM creation not available") + } + return dmProvider.CreateDM(ctx.Username) +} diff --git a/internal/commands/standup_test.go b/internal/commands/standup_test.go new file mode 100644 index 0000000..8a8444d --- /dev/null +++ b/internal/commands/standup_test.go @@ -0,0 +1,178 @@ +package commands_test + +import ( + "os" + "strings" + "testing" + "time" + + "github.com/sunba91-su/Roket.Chat-GeekBot/internal/commands" + "github.com/sunba91-su/Roket.Chat-GeekBot/internal/config" + "github.com/sunba91-su/Roket.Chat-GeekBot/internal/convstate" + "github.com/sunba91-su/Roket.Chat-GeekBot/internal/store" +) + +type standupHarness struct { + t *testing.T + s *store.Store + msgr *mockMessenger + reg *commands.Registry + convMgr *convstate.Manager + ctx *commands.Context +} + +func newStandupHarness(t *testing.T) *standupHarness { + t.Helper() + + f, err := os.CreateTemp("", "test-standup-*.db") + if err != nil { + t.Fatal(err) + } + f.Close() + + s, err := store.New(f.Name()) + if err != nil { + t.Fatal(err) + } + + t.Cleanup(func() { + s.Close() + os.Remove(f.Name()) + }) + + _ = s.CreateTeam(&store.Team{ + ID: "t1", Name: "Eng", ChannelID: "eng", + Questions: "How are you?|What did you do?|Any blockers?", + Timezone: "UTC", + }) + _ = s.AddMember(&store.Member{ + ID: "m1", TeamID: "t1", UserID: "alice-id", + Username: "alice", Role: "member", + }) + + msgr := &mockMessenger{} + convMgr := convstate.NewManager() + + reg := commands.New() + commands.RegisterStandupCommands(reg) + + ctx := &commands.Context{ + UserID: "alice-id", + Username: "alice", + RoomID: "DM-room", + Store: s, + Messenger: msgr, + UserProvider: &mockUserProvider{}, + Config: &config.Config{MainAdmin: "admin"}, + ConvState: convMgr, + IsDM: true, + } + + return &standupHarness{ + t: t, s: s, msgr: msgr, reg: reg, + convMgr: convMgr, ctx: ctx, + } +} + +func (h *standupHarness) dispatch(text string) { + h.ctx.RawText = text + h.ctx.Messenger = &mockMessenger{} + h.msgr = h.ctx.Messenger.(*mockMessenger) + _, _ = h.reg.Dispatch(h.ctx) +} + +func TestStandupSubmitNoTeam(t *testing.T) { + h := newStandupHarness(t) + _ = h.s.RemoveMember("t1", "alice-id") + + h.dispatch("/standup submit") + + if !contains(strings.Join(h.msgr.sent, " "), "not a member") { + t.Error("expected 'not a member' message") + } +} + +func TestStandupSubmitStartsConversation(t *testing.T) { + h := newStandupHarness(t) + + h.dispatch("/standup submit") + + conv, ok := h.convMgr.GetConversation("alice-id") + if !ok { + t.Fatal("expected conversation to start") + } + if conv.CurrentQ != 0 { + t.Errorf("expected Q0, got Q%d", conv.CurrentQ) + } +} + +func TestStandupStatusNoSubmission(t *testing.T) { + h := newStandupHarness(t) + h.dispatch("/standup status") + + msg := strings.Join(h.msgr.sent, " ") + if !contains(msg, "not submitted") && !contains(msg, "haven't") { + t.Logf("Status message: %s", msg) + } +} + +func TestStandupStatusAfterSubmission(t *testing.T) { + h := newStandupHarness(t) + + now := time.Now().Format("2006-01-02") + _ = h.s.CreateSession(&store.StandupSession{ + ID: "sess-test", TeamID: "t1", Date: now, Status: "open", + }) + _ = h.s.SubmitResponse(&store.StandupResponse{ + ID: "resp-test", SessionID: "sess-test", UserID: "alice-id", + Username: "alice", Answers: "Fine|Coding|None", + SubmittedAt: time.Now(), + }) + + h.dispatch("/standup status") + + msg := strings.Join(h.msgr.sent, " ") + if !contains(msg, "submitted") { + t.Logf("Status message: %s", msg) + } +} + +func TestStandupConversationFlow(t *testing.T) { + h := newStandupHarness(t) + + h.dispatch("/standup submit") + + conv, ok := h.convMgr.GetConversation("alice-id") + if !ok { + t.Fatal("expected conversation") + } + + finished, nextQ, err := h.convMgr.RecordAnswer("alice-id", "Feeling great") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if finished { + t.Fatal("expected more questions") + } + if !contains(nextQ, "What did you do") { + t.Errorf("unexpected next Q: %s", nextQ) + } + + finished, nextQ, _ = h.convMgr.RecordAnswer("alice-id", "Fixed bugs") + if finished { + t.Fatal("expected more questions") + } + if !contains(nextQ, "blockers") { + t.Errorf("expected blockers Q, got %s", nextQ) + } + + finished, _, _ = h.convMgr.RecordAnswer("alice-id", "No blockers") + if !finished { + t.Fatal("expected conversation to finish") + } + + conv, _ = h.convMgr.GetConversation("alice-id") + if conv != nil { + t.Error("conversation should be ended after all answers") + } +} diff --git a/internal/convstate/convstate.go b/internal/convstate/convstate.go new file mode 100644 index 0000000..8f35d24 --- /dev/null +++ b/internal/convstate/convstate.go @@ -0,0 +1,123 @@ +package convstate + +import ( + "fmt" + "strings" + "sync" + "time" +) + +type State int + +const ( + Idle State = iota + Answering +) + +type Conversation struct { + UserID string + Username string + TeamID string + RoomID string + State State + Questions []string + CurrentQ int + Answers []string + SessionID string + StartedAt time.Time +} + +type Manager struct { + mu sync.RWMutex + conv map[string]*Conversation +} + +func NewManager() *Manager { + return &Manager{conv: make(map[string]*Conversation)} +} + +func (m *Manager) StartConversation(userID, username, teamID, roomID string, questions []string, sessionID string) { + m.mu.Lock() + defer m.mu.Unlock() + + m.conv[userID] = &Conversation{ + UserID: userID, + Username: username, + TeamID: teamID, + RoomID: roomID, + State: Answering, + Questions: questions, + CurrentQ: 0, + Answers: make([]string, len(questions)), + SessionID: sessionID, + StartedAt: time.Now(), + } +} + +func (m *Manager) GetConversation(userID string) (*Conversation, bool) { + m.mu.RLock() + defer m.mu.RUnlock() + + c, ok := m.conv[userID] + if !ok || c.State == Idle { + return nil, false + } + return c, true +} + +func (m *Manager) RecordAnswer(userID, answer string) (finished bool, question string, err error) { + m.mu.Lock() + defer m.mu.Unlock() + + c, ok := m.conv[userID] + if !ok || c.State == Idle { + return false, "", fmt.Errorf("no active conversation") + } + + c.Answers[c.CurrentQ] = answer + c.CurrentQ++ + + if c.CurrentQ >= len(c.Questions) { + c.State = Idle + return true, "", nil + } + + return false, c.Questions[c.CurrentQ], nil +} + +func (m *Manager) GetProgress(userID string) (current int, total int, answers []string, ok bool) { + m.mu.RLock() + defer m.mu.RUnlock() + + c, ok2 := m.conv[userID] + if !ok2 || c.State == Idle { + return 0, 0, nil, false + } + + answersCopy := make([]string, len(c.Answers)) + copy(answersCopy, c.Answers) + return c.CurrentQ, len(c.Questions), answersCopy, true +} + +func (m *Manager) EndConversation(userID string) { + m.mu.Lock() + defer m.mu.Unlock() + + if c, ok := m.conv[userID]; ok { + c.State = Idle + } +} + +func (m *Manager) Cancel(userID string) (string, error) { + m.mu.Lock() + defer m.mu.Unlock() + + c, ok := m.conv[userID] + if !ok || c.State == Idle { + return "", fmt.Errorf("no active conversation") + } + + answers := strings.Join(c.Answers[:c.CurrentQ], "\n") + c.State = Idle + return answers, nil +} diff --git a/internal/convstate/convstate_test.go b/internal/convstate/convstate_test.go new file mode 100644 index 0000000..a9641da --- /dev/null +++ b/internal/convstate/convstate_test.go @@ -0,0 +1,170 @@ +package convstate_test + +import ( + "testing" + + "github.com/sunba91-su/Roket.Chat-GeekBot/internal/convstate" +) + +func TestStartAndGetConversation(t *testing.T) { + m := convstate.NewManager() + m.StartConversation("u1", "alice", "team1", "room1", + []string{"Q1?", "Q2?", "Q3?"}, "sess1") + + conv, ok := m.GetConversation("u1") + if !ok { + t.Fatal("expected conversation to exist") + } + if conv.Username != "alice" { + t.Errorf("expected alice, got %s", conv.Username) + } + if conv.CurrentQ != 0 { + t.Errorf("expected currentQ 0, got %d", conv.CurrentQ) + } +} + +func TestGetConversationIdle(t *testing.T) { + m := convstate.NewManager() + + _, ok := m.GetConversation("nonexistent") + if ok { + t.Error("expected no conversation for unknown user") + } +} + +func TestRecordAnswer(t *testing.T) { + m := convstate.NewManager() + m.StartConversation("u1", "alice", "team1", "room1", + []string{"Q1?", "Q2?", "Q3?"}, "sess1") + + finished, nextQ, err := m.RecordAnswer("u1", "Answer 1") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if finished { + t.Error("expected not finished after first answer") + } + if nextQ != "Q2?" { + t.Errorf("expected Q2?, got %s", nextQ) + } + + finished, nextQ, _ = m.RecordAnswer("u1", "Answer 2") + if finished { + t.Error("expected not finished after second answer") + } + if nextQ != "Q3?" { + t.Errorf("expected Q3?, got %s", nextQ) + } + + finished, _, _ = m.RecordAnswer("u1", "Answer 3") + if !finished { + t.Error("expected finished after third answer") + } + + _, _, err = m.RecordAnswer("u1", "extra") + if err == nil { + t.Error("expected error after conversation ended") + } +} + +func TestRecordAnswerNoConversation(t *testing.T) { + m := convstate.NewManager() + + _, _, err := m.RecordAnswer("u1", "answer") + if err == nil { + t.Error("expected error for no conversation") + } +} + +func TestGetProgress(t *testing.T) { + m := convstate.NewManager() + m.StartConversation("u1", "alice", "team1", "room1", + []string{"Q1?", "Q2?"}, "sess1") + + current, total, answers, ok := m.GetProgress("u1") + if !ok { + t.Fatal("expected progress") + } + if current != 0 { + t.Errorf("expected 0, got %d", current) + } + if total != 2 { + t.Errorf("expected 2, got %d", total) + } + if len(answers) != 2 { + t.Errorf("expected 2 answers, got %d", len(answers)) + } + + m.RecordAnswer("u1", "A1") + + current, _, _, _ = m.GetProgress("u1") + if current != 1 { + t.Errorf("expected current 1, got %d", current) + } +} + +func TestCancel(t *testing.T) { + m := convstate.NewManager() + m.StartConversation("u1", "alice", "team1", "room1", + []string{"Q1?", "Q2?"}, "sess1") + + m.RecordAnswer("u1", "Partial answer") + + answers, err := m.Cancel("u1") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if answers == "" { + t.Error("expected non-empty answers") + } + + _, ok := m.GetConversation("u1") + if ok { + t.Error("expected conversation to be ended") + } +} + +func TestCancelNoConversation(t *testing.T) { + m := convstate.NewManager() + + _, err := m.Cancel("u1") + if err == nil { + t.Error("expected error for no conversation") + } +} + +func TestEndConversation(t *testing.T) { + m := convstate.NewManager() + m.StartConversation("u1", "alice", "team1", "room1", + []string{"Q1?"}, "sess1") + + m.EndConversation("u1") + + _, ok := m.GetConversation("u1") + if ok { + t.Error("expected conversation to be ended") + } +} + +func TestMultipleUsers(t *testing.T) { + m := convstate.NewManager() + m.StartConversation("u1", "alice", "team1", "r1", + []string{"Q1?", "Q2?"}, "s1") + m.StartConversation("u2", "bob", "team1", "r2", + []string{"Q1?"}, "s1") + + finished, _, _ := m.RecordAnswer("u1", "A1") + if finished { + t.Error("u1 should not be finished") + } + + finished, _, _ = m.RecordAnswer("u2", "A1") + if !finished { + t.Error("u2 should be finished after 1 question") + } + + conv, _ := m.GetConversation("u1") + if conv.CurrentQ != 1 { + t.Errorf("u1 should be on Q2, got Q%d", conv.CurrentQ) + } +} diff --git a/internal/rocket/client.go b/internal/rocket/client.go index 0538587..c2b5003 100644 --- a/internal/rocket/client.go +++ b/internal/rocket/client.go @@ -1,6 +1,7 @@ package rocket import ( + "encoding/json" "fmt" "log" "net/url" @@ -12,6 +13,21 @@ import ( "github.com/RocketChat/Rocket.Chat.Go.SDK/rest" ) +type roomInfoResponse struct { + Room struct { + ID string `json:"_id"` + Type string `json:"t"` + } `json:"room"` + Success bool `json:"success"` +} + +func (r *roomInfoResponse) OK() error { + if !r.Success { + return fmt.Errorf("request failed") + } + return nil +} + type Client struct { serverURL *url.URL realtime *realtime.Client @@ -141,6 +157,34 @@ func (c *Client) IsConnected() bool { return c.connected } +func (c *Client) CreateDM(username string) (string, error) { + if c.rest == nil { + return "", fmt.Errorf("not connected") + } + room, err := c.rest.CreateDirectMessage(username) + if err != nil { + return "", fmt.Errorf("create DM: %w", err) + } + rid := room.Rid + if rid == "" { + rid = room.ID + } + return rid, nil +} + +func (c *Client) IsDMRoom(roomID string) (bool, error) { + if c.rest == nil { + return false, fmt.Errorf("not connected") + } + var resp roomInfoResponse + if err := c.rest.Get("rooms.info", url.Values{"roomId": {roomID}}, &resp); err != nil { + if e, ok := err.(*json.UnmarshalTypeError); !ok && e != nil { + return false, err + } + } + return resp.Room.Type == "d", nil +} + func (c *Client) UserInfo(username string) (*models.User, error) { if c.rest == nil { return nil, fmt.Errorf("not connected")