From 3a4d0a762a3a8ce7270ac75fd1f63d9fbbbd39fb Mon Sep 17 00:00:00 2001 From: mozaa Date: Fri, 26 Jun 2026 13:20:12 +0700 Subject: [PATCH] feat(telegram): /addcron /removecron /croners group cron-permission commands (#1106) Mirror the existing /addwriter /removewriter /writers UX for the cron permission type so group admins can grant cron-management access without elevating to full file_writer. Bootstrap policy: first call in a group is free (no cron managers yet); subsequent calls require an existing cron manager OR a file_writer. - new commands_cron_perm.go: handleCronPermCommand (add/remove), handleListCronPerm; uses store.ConfigTypeCron with scope "group::". - commands.go: route /addcron, /removecron, /croners. - commands_pairing.go: register the 3 commands in the bot menu. --- internal/channels/telegram/commands.go | 15 ++ .../channels/telegram/commands_cron_perm.go | 197 ++++++++++++++++++ .../channels/telegram/commands_pairing.go | 3 + 3 files changed, 215 insertions(+) create mode 100644 internal/channels/telegram/commands_cron_perm.go diff --git a/internal/channels/telegram/commands.go b/internal/channels/telegram/commands.go index 35a85360..bba70203 100644 --- a/internal/channels/telegram/commands.go +++ b/internal/channels/telegram/commands.go @@ -93,6 +93,9 @@ func (c *Channel) handleBotCommand(ctx context.Context, message *telego.Message, "/writers — List file writers for this group\n" + "/addwriter — Add a file writer (reply to their message)\n" + "/removewriter — Remove a file writer (reply to their message)\n" + + "/croners — List cron managers for this group\n" + + "/addcron — Add a cron manager (reply to their message)\n" + + "/removecron — Remove a cron manager (reply to their message)\n" + "\nJust send a message to chat with the AI." msg := tu.Message(chatIDObj, helpText) setThread(msg) @@ -228,6 +231,18 @@ func (c *Channel) handleBotCommand(ctx context.Context, message *telego.Message, c.handleListWriters(ctx, chatID, chatIDStr, isGroup, setThread) return true + case "/addcron": + c.handleCronPermCommand(ctx, message, chatID, chatIDStr, senderID, isGroup, setThread, "add") + return true + + case "/removecron": + c.handleCronPermCommand(ctx, message, chatID, chatIDStr, senderID, isGroup, setThread, "remove") + return true + + case "/croners": + c.handleListCronPerm(ctx, chatID, chatIDStr, isGroup, setThread) + return true + case "/reactions": var lines strings.Builder for _, r := range reactionLegend { diff --git a/internal/channels/telegram/commands_cron_perm.go b/internal/channels/telegram/commands_cron_perm.go new file mode 100644 index 00000000..b5251fa8 --- /dev/null +++ b/internal/channels/telegram/commands_cron_perm.go @@ -0,0 +1,197 @@ +package telegram + +import ( + "context" + "encoding/json" + "fmt" + "log/slog" + "strings" + + "github.com/mymmrac/telego" + tu "github.com/mymmrac/telego/telegoutil" + + "github.com/nextlevelbuilder/goclaw/internal/channels" + "github.com/nextlevelbuilder/goclaw/internal/store" +) + +// handleCronPermCommand handles /addcron and /removecron commands. +// Mirrors handleWriterCommand but operates on ConfigTypeCron — explicit cron-only +// grant so admins can give users cron access without granting full file_writer. +// +// Bootstrap policy: +// - First /addcron caller in the group: bootstrap allowed (matches /addwriter). +// - Subsequent: existing croner OR file_writer (full-access fallback) can grant. +// +// Target user: identified by replying to one of their messages. +func (c *Channel) handleCronPermCommand(ctx context.Context, message *telego.Message, chatID int64, chatIDStr, senderID string, isGroup bool, setThread func(*telego.SendMessageParams), action string) { + chatIDObj := tu.ID(chatID) + + send := func(text string) { + msg := tu.Message(chatIDObj, text) + setThread(msg) + c.bot.SendMessage(ctx, msg) + } + + if !isGroup { + send("This command only works in group chats.") + return + } + + if c.configPermStore == nil { + send("Cron permission management is not available.") + return + } + + agentID, err := c.resolveAgentUUID(ctx) + if err != nil { + slog.Debug("cron-perm command: agent resolve failed", "error", err) + send("Cron permission management is not available (no agent).") + return + } + + groupID := fmt.Sprintf("group:%s:%s", c.Name(), chatIDStr) + senderNumericID := strings.SplitN(senderID, "|", 2)[0] + + // Bootstrap exception: if no croners AND no file_writers exist, the first + // caller can bootstrap. Otherwise either an existing croner or file_writer + // (full-access role) can manage the croner list. + existingCroners, _ := c.configPermStore.List(ctx, agentID, store.ConfigTypeCron, groupID) + existingWriters, _ := c.configPermStore.ListFileWriters(ctx, agentID, groupID) + + if len(existingCroners) > 0 || len(existingWriters) > 0 { + isAuthorized := false + for _, w := range existingCroners { + if w.UserID == senderNumericID && w.Permission == "allow" { + isAuthorized = true + break + } + } + if !isAuthorized { + for _, w := range existingWriters { + if w.UserID == senderNumericID && w.Permission == "allow" { + isAuthorized = true + break + } + } + } + if !isAuthorized { + send("Only existing cron managers (or file writers) can manage the cron list.") + return + } + } else if action == "remove" { + send("No cron managers configured yet. Use /addcron to add the first one.") + return + } + + // Extract target user from reply-to message + if message.ReplyToMessage == nil || message.ReplyToMessage.From == nil { + verb := "add" + if action == "remove" { + verb = "remove" + } + send(fmt.Sprintf("To %s a cron manager: find a message from that person, swipe to reply it, then type /%scron.", verb, verb)) + return + } + + targetUser := message.ReplyToMessage.From + targetID := fmt.Sprintf("%d", targetUser.ID) + targetName := targetUser.FirstName + if targetUser.Username != "" { + targetName = "@" + targetUser.Username + } + + switch action { + case "add": + meta, _ := json.Marshal(map[string]string{"displayName": targetUser.FirstName, "username": targetUser.Username}) + if err := c.configPermStore.Grant(ctx, &store.ConfigPermission{ + AgentID: agentID, + Scope: groupID, + ConfigType: store.ConfigTypeCron, + UserID: targetID, + Permission: "allow", + Metadata: meta, + }); err != nil { + slog.Warn("add cron permission failed", "error", err, "target", targetID) + send("Failed to add cron manager. Please try again.") + return + } + send(fmt.Sprintf("Added %s as a cron manager.", targetName)) + + case "remove": + // Prevent removing the last croner ONLY if no file_writers can fall back. + // File writers retain cron access via the fallback in CheckCronPermission, + // so removing the last croner is safe when at least one writer exists. + if len(existingCroners) <= 1 && len(existingWriters) == 0 { + send("Cannot remove the last cron manager (no file_writers to fall back on).") + return + } + if err := c.configPermStore.Revoke(ctx, agentID, groupID, store.ConfigTypeCron, targetID); err != nil { + slog.Warn("remove cron permission failed", "error", err, "target", targetID) + send("Failed to remove cron manager. Please try again.") + return + } + send(fmt.Sprintf("Removed %s from cron managers.", targetName)) + } +} + +// handleListCronPerm handles the /croners command — lists users with explicit +// cron grants in this group. Note: file_writers also have implicit cron access +// via CheckCronPermission's fallback; this list shows ONLY explicit cron grants. +func (c *Channel) handleListCronPerm(ctx context.Context, chatID int64, chatIDStr string, isGroup bool, setThread func(*telego.SendMessageParams)) { + chatIDObj := tu.ID(chatID) + + send := func(text string) { + msg := tu.Message(chatIDObj, text) + setThread(msg) + c.bot.SendMessage(ctx, msg) + } + + if !isGroup { + send("This command only works in group chats.") + return + } + + if c.configPermStore == nil { + send("Cron permission management is not available.") + return + } + + agentID, err := c.resolveAgentUUID(ctx) + if err != nil { + slog.Debug("list cron managers: agent resolve failed", "error", err) + send("Cron permission management is not available (no agent).") + return + } + + groupID := fmt.Sprintf("group:%s:%s", c.Name(), chatIDStr) + + croners, err := c.configPermStore.List(ctx, agentID, store.ConfigTypeCron, groupID) + if err != nil { + slog.Warn("list cron managers failed", "error", err) + send("Failed to list cron managers. Please try again.") + return + } + + // Also fetch writers for the trailing note (they have implicit cron access). + writers, _ := c.configPermStore.ListFileWriters(ctx, agentID, groupID) + + if len(croners) == 0 && len(writers) == 0 { + send("No cron managers configured for this group. Use /addcron to add one (or /addwriter for full file access).") + return + } + + var sb strings.Builder + if len(croners) == 0 { + sb.WriteString("No explicit cron managers configured.\n") + } else { + sb.WriteString(fmt.Sprintf("Cron managers for this group (%d):\n", len(croners))) + for i, w := range croners { + label := channels.WriterLabel(w.Metadata, w.UserID) + sb.WriteString(fmt.Sprintf("%d. %s (ID: %s) — %s\n", i+1, label, w.UserID, w.Permission)) + } + } + if len(writers) > 0 { + sb.WriteString(fmt.Sprintf("\nPlus %d file_writer(s) with implicit cron access (use /writers to list).", len(writers))) + } + send(sb.String()) +} diff --git a/internal/channels/telegram/commands_pairing.go b/internal/channels/telegram/commands_pairing.go index 4e54f8c6..708fcd47 100644 --- a/internal/channels/telegram/commands_pairing.go +++ b/internal/channels/telegram/commands_pairing.go @@ -167,5 +167,8 @@ func DefaultMenuCommands() []telego.BotCommand { {Command: "writers", Description: "List file writers for this group"}, {Command: "addwriter", Description: "Add a file writer (reply to their message)"}, {Command: "removewriter", Description: "Remove a file writer (reply to their message)"}, + {Command: "croners", Description: "List cron managers for this group"}, + {Command: "addcron", Description: "Add a cron manager (reply to their message)"}, + {Command: "removecron", Description: "Remove a cron manager (reply to their message)"}, } }