Files
mozaa 3a4d0a762a 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.
2026-06-26 13:20:12 +07:00

261 lines
7.9 KiB
Go

package telegram
import (
"context"
"fmt"
"log/slog"
"strings"
"github.com/google/uuid"
"github.com/mymmrac/telego"
tu "github.com/mymmrac/telego/telegoutil"
"github.com/nextlevelbuilder/goclaw/internal/bus"
"github.com/nextlevelbuilder/goclaw/internal/store"
)
// resolveAgentUUID looks up the agent UUID from the channel's agent key.
// Returns uuid.Nil if the agent key is empty or not found.
func (c *Channel) resolveAgentUUID(ctx context.Context) (uuid.UUID, error) {
key := c.AgentID()
if key == "" {
return uuid.Nil, fmt.Errorf("no agent key configured")
}
// Try direct UUID parse first (future-proofing).
if id, err := uuid.Parse(key); err == nil {
return id, nil
}
// Inject tenant scope so the store can filter by tenant_id.
ctx = store.WithTenantID(ctx, c.TenantID())
agent, err := c.agentStore.GetByKey(ctx, key)
if err != nil {
return uuid.Nil, fmt.Errorf("agent %q not found: %w", key, err)
}
return agent.ID, nil
}
// handleBotCommand checks if the message is a known bot command and handles it.
// Returns true if the message was handled as a command.
func (c *Channel) handleBotCommand(ctx context.Context, message *telego.Message, chatID int64, chatIDStr, localKey, text, senderID string, isGroup, isForum bool, messageThreadID int) bool {
if len(text) == 0 || text[0] != '/' {
return false
}
// Extract command (strip @botname suffix if present)
cmd := strings.SplitN(text, " ", 2)[0]
cmd = strings.ToLower(cmd)
// In groups, ignore commands addressed to other bots (e.g. /help@other_bot)
if isGroup {
if parts := strings.SplitN(cmd, "@", 2); len(parts) == 2 {
if !strings.EqualFold(parts[1], c.bot.Username()) {
return false
}
}
}
cmd = strings.SplitN(cmd, "@", 2)[0]
// Inject tenant scope so all command handlers have tenant_id in context.
ctx = store.WithTenantID(ctx, c.TenantID())
chatIDObj := tu.ID(chatID)
// Helper: set MessageThreadID on outgoing messages for forum topics.
// TS ref: buildTelegramThreadParams() — General topic (1) must be omitted.
setThread := func(msg *telego.SendMessageParams) {
sendThreadID := resolveThreadIDForSend(messageThreadID)
if sendThreadID > 0 {
msg.MessageThreadID = sendThreadID
}
}
switch cmd {
case "/start":
// Don't intercept /start — let it pass through to agent loop.
return false
case "/help":
helpText := "Available commands:\n" +
"/start — Start chatting with the bot\n" +
"/help — Show this help message\n" +
"/stop — Stop current running task\n" +
"/stopall — Stop all running tasks\n" +
"/reset — Reset conversation history\n" +
"/status — Show bot status\n" +
"/reactions — Show reaction emoji legend\n" +
"/tasks — List team tasks\n" +
"/task_detail <id> — View task detail\n" +
"/subagents — List subagent tasks\n" +
"/subagent <id> — View subagent task detail\n" +
"/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)
c.bot.SendMessage(ctx, msg)
return true
case "/reset":
// In group chats, only file writers can reset conversation history.
if isGroup && c.configPermStore != nil {
agentID, err := c.resolveAgentUUID(ctx)
if err == nil {
groupID := fmt.Sprintf("group:%s:%s", c.Name(), chatIDStr)
senderNumericID := strings.SplitN(senderID, "|", 2)[0]
isWriter, err := c.configPermStore.CheckPermission(ctx, agentID, groupID, store.ConfigTypeFileWriter, senderNumericID)
if err != nil {
slog.Warn("security.reset_writer_check_failed", "error", err, "sender", senderNumericID)
// fail-open: allow reset if DB check fails
} else if !isWriter {
msg := tu.Message(chatIDObj, "Only file writers can reset conversation history in this group.")
setThread(msg)
c.bot.SendMessage(ctx, msg)
return true
}
}
}
// Fix: use correct PeerKind so the gateway consumer builds the right session key.
peerKind := "direct"
if isGroup {
peerKind = "group"
}
c.Bus().PublishInbound(bus.InboundMessage{
Channel: c.Name(),
SenderID: senderID,
ChatID: chatIDStr,
Content: "/reset",
PeerKind: peerKind,
AgentID: c.AgentID(),
UserID: strings.SplitN(senderID, "|", 2)[0],
TenantID: c.TenantID(),
Metadata: map[string]string{
"command": "reset",
"local_key": localKey,
"is_forum": fmt.Sprintf("%t", isForum),
"message_thread_id": fmt.Sprintf("%d", messageThreadID),
},
})
msg := tu.Message(chatIDObj, "Conversation history has been reset.")
setThread(msg)
c.bot.SendMessage(ctx, msg)
return true
case "/stop":
peerKind := "direct"
if isGroup {
peerKind = "group"
}
c.Bus().PublishInbound(bus.InboundMessage{
Channel: c.Name(),
SenderID: senderID,
ChatID: chatIDStr,
Content: "/stop",
PeerKind: peerKind,
AgentID: c.AgentID(),
UserID: strings.SplitN(senderID, "|", 2)[0],
TenantID: c.TenantID(),
Metadata: map[string]string{
"command": "stop",
"local_key": localKey,
"is_forum": fmt.Sprintf("%t", isForum),
"message_thread_id": fmt.Sprintf("%d", messageThreadID),
},
})
// Feedback is sent by the consumer after cancel result is known.
return true
case "/stopall":
peerKind := "direct"
if isGroup {
peerKind = "group"
}
c.Bus().PublishInbound(bus.InboundMessage{
Channel: c.Name(),
SenderID: senderID,
ChatID: chatIDStr,
Content: "/stopall",
PeerKind: peerKind,
AgentID: c.AgentID(),
UserID: strings.SplitN(senderID, "|", 2)[0],
TenantID: c.TenantID(),
Metadata: map[string]string{
"command": "stopall",
"local_key": localKey,
"is_forum": fmt.Sprintf("%t", isForum),
"message_thread_id": fmt.Sprintf("%d", messageThreadID),
},
})
// Feedback is sent by the consumer after cancel result is known.
return true
case "/status":
statusText := fmt.Sprintf("Bot status: Running\nChannel: Telegram\nBot: @%s", c.bot.Username())
msg := tu.Message(chatIDObj, statusText)
setThread(msg)
c.bot.SendMessage(ctx, msg)
return true
case "/tasks":
c.handleTasksList(ctx, chatID, isGroup, setThread)
return true
case "/task_detail":
c.handleTaskDetail(ctx, chatID, text, isGroup, setThread)
return true
case "/subagents":
c.handleSubagentsList(ctx, chatID, isGroup, setThread)
return true
case "/subagent":
c.handleSubagentDetail(ctx, chatID, text, isGroup, setThread)
return true
case "/addwriter":
c.handleWriterCommand(ctx, message, chatID, chatIDStr, senderID, isGroup, setThread, "add")
return true
case "/removewriter":
c.handleWriterCommand(ctx, message, chatID, chatIDStr, senderID, isGroup, setThread, "remove")
return true
case "/writers":
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 {
lines.WriteString(fmt.Sprintf("%s %s\n", r.Emoji, r.Desc))
}
reactText := fmt.Sprintf("<b>Reaction Emoji Legend</b>\n\n<pre>%s</pre>\nReaction level: <b>%s</b>", lines.String(), c.config.ReactionLevel)
msg := tu.Message(chatIDObj, reactText)
msg.ParseMode = telego.ModeHTML
setThread(msg)
c.bot.SendMessage(ctx, msg)
return true
}
return false
}