From 26d6a160320d77602f6dace79db2e61a66e7f786 Mon Sep 17 00:00:00 2001 From: sunba91-su Date: Mon, 8 Jun 2026 16:25:56 +0330 Subject: [PATCH] feat: team admin commands with role-based management Implement slash commands for team CRUD and configuration by main admin and team leads, with automatic team resolution for lead-owned teams. - Team create/delete/set-lead: admin-only commands to manage teams - Team add/remove/members: team-lead commands for roster management - Team set schedule/channel/questions/timezone: per-team configuration - Automatic team context resolution for team leads (single-team focus) - UserProvider interface for Rocket.Chat user lookup via REST API - Rocket.Client.UserInfo method wrapping rest.UserInfo - 12 new tests covering all command paths and edge cases Closes #6 --- cmd/bot/main.go | 32 +++- internal/commands/registry.go | 11 ++ internal/commands/teams.go | 322 ++++++++++++++++++++++++++++++++ internal/commands/teams_test.go | 261 ++++++++++++++++++++++++++ internal/rocket/client.go | 7 + 5 files changed, 626 insertions(+), 7 deletions(-) create mode 100644 internal/commands/teams.go create mode 100644 internal/commands/teams_test.go diff --git a/cmd/bot/main.go b/cmd/bot/main.go index 4b91ece..c65e929 100644 --- a/cmd/bot/main.go +++ b/cmd/bot/main.go @@ -14,6 +14,22 @@ import ( "github.com/sunba91-su/Roket.Chat-GeekBot/internal/store" ) +type userProviderAdapter struct { + client *rocket.Client +} + +func (a *userProviderAdapter) UserInfo(username string) (*commands.UserInfo, error) { + user, err := a.client.UserInfo(username) + if err != nil { + return nil, err + } + return &commands.UserInfo{ + ID: user.ID, + Username: user.UserName, + Name: user.Name, + }, nil +} + func main() { cfg, err := config.Load() if err != nil { @@ -42,16 +58,18 @@ func main() { } cmdReg := commands.New() + commands.RegisterTeamCommands(cmdReg) client.OnMessage(func(msg rocket.IncomingMessage) { ctx := &commands.Context{ - UserID: msg.UserID, - Username: msg.Username, - RoomID: msg.RoomID, - RawText: msg.Text, - Store: st, - Messenger: client, - Config: cfg, + UserID: msg.UserID, + Username: msg.Username, + RoomID: msg.RoomID, + RawText: msg.Text, + Store: st, + Messenger: client, + UserProvider: &userProviderAdapter{client: client}, + Config: cfg, } handled, err := cmdReg.Dispatch(ctx) diff --git a/internal/commands/registry.go b/internal/commands/registry.go index 32b907e..2de83eb 100644 --- a/internal/commands/registry.go +++ b/internal/commands/registry.go @@ -12,6 +12,16 @@ type Messenger interface { SendMessage(roomID, text string) error } +type UserInfo struct { + ID string + Username string + Name string +} + +type UserProvider interface { + UserInfo(username string) (*UserInfo, error) +} + type Permission int const ( @@ -29,6 +39,7 @@ type Context struct { Args []string Store *store.Store Messenger + UserProvider Config *config.Config CmdName string IsDM bool diff --git a/internal/commands/teams.go b/internal/commands/teams.go new file mode 100644 index 0000000..d7fab80 --- /dev/null +++ b/internal/commands/teams.go @@ -0,0 +1,322 @@ +package commands + +import ( + "fmt" + "strings" + "time" + + "github.com/sunba91-su/Roket.Chat-GeekBot/internal/store" +) + +func RegisterTeamCommands(r *Registry) { + r.Register("team", handleTeam, PermissionAny) +} + +func handleTeam(ctx *Context) error { + if len(ctx.Args) < 1 { + return send(ctx.Messenger, ctx.RoomID, + "Usage: /standup team ...") + } + + sub := strings.ToLower(ctx.Args[0]) + args := ctx.Args[1:] + + switch sub { + case "create": + return handleTeamCreate(ctx, args) + case "delete": + return handleTeamDelete(ctx, args) + case "set-lead": + return handleTeamSetLead(ctx, args) + case "add": + return handleTeamAdd(ctx, args) + case "remove": + return handleTeamRemove(ctx, args) + case "members": + return handleTeamMembers(ctx, args) + case "set": + return handleTeamSet(ctx, args) + default: + return send(ctx.Messenger, ctx.RoomID, + fmt.Sprintf("Unknown subcommand: %s. Try /standup help.", sub)) + } +} + +func handleTeamCreate(ctx *Context, args []string) error { + if ctx.Username != ctx.Config.MainAdmin { + return send(ctx.Messenger, ctx.RoomID, + "Only the main admin can create teams.") + } + + if len(args) < 1 { + return send(ctx.Messenger, ctx.RoomID, + "Usage: /standup team create [--channel #ch]") + } + + name := args[0] + channelID := "" + for i, a := range args { + if a == "--channel" && i+1 < len(args) { + ch := args[i+1] + channelID = strings.TrimPrefix(ch, "#") + } + } + + if channelID == "" { + channelID = strings.ToLower(strings.ReplaceAll(name, " ", "-")) + } + + team := &store.Team{ + ID: fmt.Sprintf("team-%d", time.Now().UnixMilli()), + Name: name, + ChannelID: channelID, + Questions: "How are you feeling?|What did you do yesterday?|What are you doing today?|Any blockers?", + Timezone: "UTC", + } + + if err := ctx.Store.CreateTeam(team); err != nil { + return send(ctx.Messenger, ctx.RoomID, + fmt.Sprintf("Failed to create team: %v", err)) + } + + return send(ctx.Messenger, ctx.RoomID, + fmt.Sprintf("Team %q created (channel: #%s). Set a lead with `/standup team set-lead %s @user`.", + name, channelID, name)) +} + +func handleTeamDelete(ctx *Context, args []string) error { + if ctx.Username != ctx.Config.MainAdmin { + return send(ctx.Messenger, ctx.RoomID, + "Only the main admin can delete teams.") + } + if len(args) < 1 { + return send(ctx.Messenger, ctx.RoomID, + "Usage: /standup team delete ") + } + + team, err := ctx.Store.GetTeamByName(args[0]) + if err != nil { + return send(ctx.Messenger, ctx.RoomID, + fmt.Sprintf("Team %q not found.", args[0])) + } + + if err := ctx.Store.DeleteTeam(team.ID); err != nil { + return send(ctx.Messenger, ctx.RoomID, + fmt.Sprintf("Failed to delete team: %v", err)) + } + + return send(ctx.Messenger, ctx.RoomID, + fmt.Sprintf("Team %q deleted.", args[0])) +} + +func handleTeamSetLead(ctx *Context, args []string) error { + if ctx.Username != ctx.Config.MainAdmin { + return send(ctx.Messenger, ctx.RoomID, + "Only the main admin can set team leads.") + } + if len(args) < 2 { + return send(ctx.Messenger, ctx.RoomID, + "Usage: /standup team set-lead @user") + } + + teamName := args[0] + username := strings.TrimPrefix(args[1], "@") + + team, err := ctx.Store.GetTeamByName(teamName) + if err != nil { + return send(ctx.Messenger, ctx.RoomID, + fmt.Sprintf("Team %q not found.", teamName)) + } + + user, err := ctx.UserProvider.UserInfo(username) + if err != nil { + return send(ctx.Messenger, ctx.RoomID, + fmt.Sprintf("User %q not found on the server.", username)) + } + + isMember, _ := ctx.Store.IsMember(team.ID, user.ID) + if !isMember { + if err := ctx.Store.AddMember(&store.Member{ + ID: fmt.Sprintf("mem-%d", time.Now().UnixMilli()), + TeamID: team.ID, + UserID: user.ID, + Username: user.Username, + Role: "lead", + }); err != nil { + return send(ctx.Messenger, ctx.RoomID, + fmt.Sprintf("Failed to add lead: %v", err)) + } + } else { + if err := ctx.Store.SetRole(team.ID, user.ID, "lead"); err != nil { + return send(ctx.Messenger, ctx.RoomID, + fmt.Sprintf("Failed to set role: %v", err)) + } + } + + return send(ctx.Messenger, ctx.RoomID, + fmt.Sprintf("@%s is now the lead of %s.", user.Username, teamName)) +} + +func handleTeamAdd(ctx *Context, args []string) error { + if len(args) < 1 { + return send(ctx.Messenger, ctx.RoomID, + "Usage: /standup team add @user") + } + + username := strings.TrimPrefix(args[0], "@") + + team, err := resolveTeam(ctx) + if err != nil { + return send(ctx.Messenger, ctx.RoomID, err.Error()) + } + + user, err := ctx.UserProvider.UserInfo(username) + if err != nil { + return send(ctx.Messenger, ctx.RoomID, + fmt.Sprintf("User %q not found on the server.", username)) + } + + if err := ctx.Store.AddMember(&store.Member{ + ID: fmt.Sprintf("mem-%d", time.Now().UnixMilli()), + TeamID: team.ID, + UserID: user.ID, + Username: user.Username, + Role: "member", + }); err != nil { + return send(ctx.Messenger, ctx.RoomID, + fmt.Sprintf("Failed to add member: %v", err)) + } + + return send(ctx.Messenger, ctx.RoomID, + fmt.Sprintf("@%s added to %s.", user.Username, team.Name)) +} + +func handleTeamRemove(ctx *Context, args []string) error { + if len(args) < 1 { + return send(ctx.Messenger, ctx.RoomID, + "Usage: /standup team remove @user") + } + + username := strings.TrimPrefix(args[0], "@") + + team, err := resolveTeam(ctx) + if err != nil { + return send(ctx.Messenger, ctx.RoomID, err.Error()) + } + + user, err := ctx.UserProvider.UserInfo(username) + if err != nil { + return send(ctx.Messenger, ctx.RoomID, + fmt.Sprintf("User %q not found on the server.", username)) + } + + if err := ctx.Store.RemoveMember(team.ID, user.ID); err != nil { + return send(ctx.Messenger, ctx.RoomID, + fmt.Sprintf("Failed to remove member: %v", err)) + } + + return send(ctx.Messenger, ctx.RoomID, + fmt.Sprintf("@%s removed from %s.", user.Username, team.Name)) +} + +func handleTeamMembers(ctx *Context, args []string) error { + team, err := resolveTeam(ctx) + if err != nil { + return send(ctx.Messenger, ctx.RoomID, err.Error()) + } + + members, err := ctx.Store.GetMembers(team.ID) + if err != nil { + return send(ctx.Messenger, ctx.RoomID, + fmt.Sprintf("Failed to get members: %v", err)) + } + + if len(members) == 0 { + return send(ctx.Messenger, ctx.RoomID, + fmt.Sprintf("No members in %s.", team.Name)) + } + + var lines []string + for _, m := range members { + if m.Role == "lead" { + lines = append(lines, fmt.Sprintf(" @%s (lead)", m.Username)) + } else { + lines = append(lines, fmt.Sprintf(" @%s", m.Username)) + } + } + + return send(ctx.Messenger, ctx.RoomID, + fmt.Sprintf("Members of %s:\n%s", team.Name, strings.Join(lines, "\n"))) +} + +func handleTeamSet(ctx *Context, args []string) error { + if len(args) < 2 { + return send(ctx.Messenger, ctx.RoomID, + "Usage: /standup team set ") + } + + field := strings.ToLower(args[0]) + value := strings.Join(args[1:], " ") + + team, err := resolveTeam(ctx) + if err != nil { + return send(ctx.Messenger, ctx.RoomID, err.Error()) + } + + switch field { + case "schedule": + team.ScheduleCron = value + case "channel": + team.ChannelID = strings.TrimPrefix(value, "#") + case "questions": + if !strings.Contains(value, "|") { + return send(ctx.Messenger, ctx.RoomID, + "Questions must be pipe-separated, e.g. \"Q1|Q2|Q3\"") + } + team.Questions = value + case "timezone": + team.Timezone = value + default: + return send(ctx.Messenger, ctx.RoomID, + fmt.Sprintf("Unknown field: %s. Use schedule, channel, questions, or timezone.", field)) + } + + if err := ctx.Store.UpdateTeam(team); err != nil { + return send(ctx.Messenger, ctx.RoomID, + fmt.Sprintf("Failed to update: %v", err)) + } + + return send(ctx.Messenger, ctx.RoomID, + fmt.Sprintf("%s team %s updated to %q.", team.Name, field, value)) +} + +func resolveTeam(ctx *Context) (*store.Team, error) { + if ctx.Username == ctx.Config.MainAdmin { + if len(ctx.Args) < 2 { + return nil, fmt.Errorf("As admin, specify the team name: /standup team ...") + } + return ctx.Store.GetTeamByName(ctx.Args[1]) + } + + teams, err := ctx.Store.GetTeamsForUser(ctx.UserID) + if err != nil { + return nil, fmt.Errorf("Error looking up your teams.") + } + + var leadTeams []*store.Team + for _, t := range teams { + isLead, _ := ctx.Store.IsTeamLead(t.ID, ctx.UserID) + if isLead { + leadTeams = append(leadTeams, t) + } + } + + if len(leadTeams) == 0 { + return nil, fmt.Errorf("You are not a team lead for any team.") + } + if len(leadTeams) > 1 { + return nil, fmt.Errorf("You lead multiple teams. As admin, specify the team name.") + } + + return leadTeams[0], nil +} diff --git a/internal/commands/teams_test.go b/internal/commands/teams_test.go new file mode 100644 index 0000000..2af30fb --- /dev/null +++ b/internal/commands/teams_test.go @@ -0,0 +1,261 @@ +package commands_test + +import ( + "os" + "testing" + + "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/store" +) + +type mockUserProvider struct{} + +func (m *mockUserProvider) UserInfo(username string) (*commands.UserInfo, error) { + return &commands.UserInfo{ + ID: username + "-id", + Username: username, + Name: username, + }, nil +} + +type teamTestHarness struct { + t *testing.T + s *store.Store + msgr *mockMessenger + reg *commands.Registry + admin *commands.Context + lead *commands.Context + member *commands.Context +} + +func newTeamTestHarness(t *testing.T) *teamTestHarness { + t.Helper() + + f, err := os.CreateTemp("", "test-team-*.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()) + }) + + reg := commands.New() + commands.RegisterTeamCommands(reg) + + cfg := &config.Config{MainAdmin: "admin"} + + makeCtx := func(username, userID string) *commands.Context { + return &commands.Context{ + Username: username, + UserID: userID, + RoomID: "GENERAL", + Store: s, + Messenger: &mockMessenger{}, + UserProvider: &mockUserProvider{}, + Config: cfg, + } + } + + return &teamTestHarness{ + t: t, + s: s, + msgr: &mockMessenger{}, + reg: reg, + admin: makeCtx("admin", "admin-id"), + lead: makeCtx("alice", "alice-id"), + member: makeCtx("bob", "bob-id"), + } +} + +func (h *teamTestHarness) dispatch(ctx *commands.Context, text string) { + ctx.RawText = text + ctx.Messenger = &mockMessenger{} + _, _ = h.reg.Dispatch(ctx) +} + +func TestTeamCreateAdminOnly(t *testing.T) { + h := newTeamTestHarness(t) + + h.dispatch(h.member, "/standup team create Eng --channel #eng") + + if _, err := h.s.GetTeamByName("Eng"); err == nil { + t.Error("non-admin should not be able to create team") + } +} + +func TestTeamCreateSuccess(t *testing.T) { + h := newTeamTestHarness(t) + + h.dispatch(h.admin, "/standup team create Engineering --channel #engineering") + + team, err := h.s.GetTeamByName("Engineering") + if err != nil { + t.Fatalf("team should exist: %v", err) + } + if team.ChannelID != "engineering" { + t.Errorf("expected channel engineering, got %s", team.ChannelID) + } + if team.Questions == "" { + t.Error("expected default questions") + } +} + +func TestTeamCreateWithoutChannel(t *testing.T) { + h := newTeamTestHarness(t) + + h.dispatch(h.admin, "/standup team create My-Team") + + team, err := h.s.GetTeamByName("My-Team") + if err != nil { + t.Fatalf("team should exist: %v", err) + } + if team.ChannelID != "my-team" { + t.Errorf("expected channel my-team, got %s", team.ChannelID) + } +} + +func TestTeamSetLeadCreatesMember(t *testing.T) { + h := newTeamTestHarness(t) + + _ = h.s.CreateTeam(&store.Team{ID: "t1", Name: "Eng", ChannelID: "eng"}) + + h.dispatch(h.admin, "/standup team set-lead Eng alice") + + isLead, _ := h.s.IsTeamLead("t1", "alice-id") + if !isLead { + t.Error("alice should be lead of Eng") + } +} + +func TestTeamSetLeadUpdatesRole(t *testing.T) { + h := newTeamTestHarness(t) + + _ = h.s.CreateTeam(&store.Team{ID: "t1", Name: "Eng", ChannelID: "eng"}) + _ = h.s.AddMember(&store.Member{ID: "m1", TeamID: "t1", UserID: "alice-id", Username: "alice", Role: "member"}) + + h.dispatch(h.admin, "/standup team set-lead Eng alice") + + isLead, _ := h.s.IsTeamLead("t1", "alice-id") + if !isLead { + t.Error("alice should now be lead") + } +} + +func TestTeamAddAndList(t *testing.T) { + h := newTeamTestHarness(t) + + _ = h.s.CreateTeam(&store.Team{ID: "t1", Name: "Eng", ChannelID: "eng"}) + _ = h.s.AddMember(&store.Member{ID: "m1", TeamID: "t1", UserID: "alice-id", Username: "alice", Role: "lead"}) + + h.dispatch(h.lead, "/standup team add bob") + + members, _ := h.s.GetMembers("t1") + if len(members) != 2 { + t.Errorf("expected 2 members, got %d", len(members)) + } + + h.lead.Messenger = &mockMessenger{} + h.dispatch(h.lead, "/standup team members") + + found := false + for _, msg := range h.lead.Messenger.(*mockMessenger).sent { + if contains(msg, "bob") { + found = true + break + } + } + if !found { + t.Error("expected bob in members output") + } +} + +func TestTeamRemove(t *testing.T) { + h := newTeamTestHarness(t) + + _ = h.s.CreateTeam(&store.Team{ID: "t1", Name: "Eng", ChannelID: "eng"}) + _ = h.s.AddMember(&store.Member{ID: "m1", TeamID: "t1", UserID: "alice-id", Username: "alice", Role: "lead"}) + _ = h.s.AddMember(&store.Member{ID: "m2", TeamID: "t1", UserID: "bob-id", Username: "bob", Role: "member"}) + + h.dispatch(h.lead, "/standup team remove bob") + + members, _ := h.s.GetMembers("t1") + if len(members) != 1 { + t.Errorf("expected 1 member after removal, got %d", len(members)) + } +} + +func TestTeamSetSchedule(t *testing.T) { + h := newTeamTestHarness(t) + + _ = h.s.CreateTeam(&store.Team{ID: "t1", Name: "Eng", ChannelID: "eng", Questions: "Q1|Q2", Timezone: "UTC"}) + _ = h.s.AddMember(&store.Member{ID: "m1", TeamID: "t1", UserID: "alice-id", Username: "alice", Role: "lead"}) + + h.dispatch(h.lead, "/standup team set schedule 0 9 * * 1-5") + + team, _ := h.s.GetTeam("t1") + if team.ScheduleCron != "0 9 * * 1-5" { + t.Errorf("expected cron '0 9 * * 1-5', got %s", team.ScheduleCron) + } +} + +func TestTeamSetChannel(t *testing.T) { + h := newTeamTestHarness(t) + + _ = h.s.CreateTeam(&store.Team{ID: "t1", Name: "Eng", ChannelID: "eng", Questions: "Q1|Q2", Timezone: "UTC"}) + _ = h.s.AddMember(&store.Member{ID: "m1", TeamID: "t1", UserID: "alice-id", Username: "alice", Role: "lead"}) + + h.dispatch(h.lead, "/standup team set channel #rocket") + + team, _ := h.s.GetTeam("t1") + if team.ChannelID != "rocket" { + t.Errorf("expected channel 'rocket', got %s", team.ChannelID) + } +} + +func TestTeamSetTimezone(t *testing.T) { + h := newTeamTestHarness(t) + + _ = h.s.CreateTeam(&store.Team{ID: "t1", Name: "Eng", ChannelID: "eng", Questions: "Q1|Q2", Timezone: "UTC"}) + _ = h.s.AddMember(&store.Member{ID: "m1", TeamID: "t1", UserID: "alice-id", Username: "alice", Role: "lead"}) + + h.dispatch(h.lead, "/standup team set timezone America/New_York") + + team, _ := h.s.GetTeam("t1") + if team.Timezone != "America/New_York" { + t.Errorf("expected 'America/New_York', got %s", team.Timezone) + } +} + +func TestTeamDelete(t *testing.T) { + h := newTeamTestHarness(t) + + _ = h.s.CreateTeam(&store.Team{ID: "t1", Name: "Eng", ChannelID: "eng"}) + + h.dispatch(h.admin, "/standup team delete Eng") + + if _, err := h.s.GetTeam("t1"); err == nil { + t.Error("team should be deleted") + } +} + +func contains(s, substr string) bool { + return len(s) >= len(substr) && containsStr(s, substr) +} + +func containsStr(s, substr string) bool { + for i := 0; i <= len(s)-len(substr); i++ { + if s[i:i+len(substr)] == substr { + return true + } + } + return false +} diff --git a/internal/rocket/client.go b/internal/rocket/client.go index d407353..0538587 100644 --- a/internal/rocket/client.go +++ b/internal/rocket/client.go @@ -141,6 +141,13 @@ func (c *Client) IsConnected() bool { return c.connected } +func (c *Client) UserInfo(username string) (*models.User, error) { + if c.rest == nil { + return nil, fmt.Errorf("not connected") + } + return c.rest.UserInfo(username) +} + func (c *Client) listenForMessages() { for { select {