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:<channel>:<chatID>".
  - commands.go: route /addcron, /removecron, /croners.
  - commands_pairing.go: register the 3 commands in the bot menu.
This commit is contained in:
mozaa authored and GitHub committed 2026-06-26 13:20:12 +07:00
1 parent e0f40a3878
commit 3a4d0a762a
3 files changed
+215

No files matched your search

+15
View File
@@ -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 {
@@ -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())
}
@@ -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)"},
}
}