feat(command): extend slash command system with new commands and UX improvements

Add 9 new command groups (/model, /memory, /search, /browser, /usage,
/email, /heartbeat, /skill, /fs) and improve existing commands by hiding
internal UUIDs, resolving IDs to human-readable names in /settings, and
switching /schedule to name-based references.
This commit is contained in:
Acbox
2026-03-11 18:57:08 +08:00
parent 0ec211f3d0
commit ab82a72639
24 changed files with 2467 additions and 1 deletions
+88
View File
@@ -9,6 +9,7 @@ import (
"log/slog"
"net/http"
"os"
stdpath "path"
"strings"
"time"
@@ -34,6 +35,7 @@ import (
"github.com/memohai/memoh/internal/channel/identities"
"github.com/memohai/memoh/internal/channel/inbound"
"github.com/memohai/memoh/internal/channel/route"
"github.com/memohai/memoh/internal/command"
"github.com/memohai/memoh/internal/config"
ctr "github.com/memohai/memoh/internal/containerd"
"github.com/memohai/memoh/internal/conversation"
@@ -423,6 +425,21 @@ func provideChannelRouter(
bindService *bind.Service,
mediaService *media.Service,
inboxService *inbox.Service,
subagentService *subagent.Service,
scheduleService *schedule.Service,
settingsService *settings.Service,
mcpConnService *mcp.ConnectionService,
modelsService *models.Service,
providersService *providers.Service,
memProvService *memprovider.Service,
searchProvService *searchproviders.Service,
browserCtxService *browsercontexts.Service,
emailService *emailpkg.Service,
emailOutboxService *emailpkg.OutboxService,
heartbeatService *heartbeat.Service,
queries *dbsqlc.Queries,
containerdHandler *handlers.ContainerdHandler,
manager *mcp.Manager,
rc *boot.RuntimeConfig,
) *inbound.ChannelInboundProcessor {
adapter, ok := registry.Get(qq.Type)
@@ -440,6 +457,26 @@ func provideChannelRouter(
processor.SetMediaService(mediaService)
processor.SetStreamObserver(local.NewRouteHubBroadcaster(hub))
processor.SetInboxService(inboxService)
processor.SetCommandHandler(command.NewHandler(
log,
&command.BotMemberRoleAdapter{BotService: botService},
subagentService,
scheduleService,
settingsService,
mcpConnService,
inboxService,
modelsService,
providersService,
memProvService,
searchProvService,
browserCtxService,
emailService,
emailOutboxService,
heartbeatService,
queries,
&commandSkillLoaderAdapter{handler: containerdHandler},
&commandContainerFSAdapter{manager: manager},
))
return processor
}
@@ -928,3 +965,54 @@ func (a *gatewayAssetLoaderAdapter) OpenForGateway(ctx context.Context, botID, c
}
return reader, strings.TrimSpace(asset.Mime), nil
}
// commandSkillLoaderAdapter bridges handlers.ContainerdHandler to command.SkillLoader.
type commandSkillLoaderAdapter struct {
handler *handlers.ContainerdHandler
}
func (a *commandSkillLoaderAdapter) LoadSkills(ctx context.Context, botID string) ([]command.Skill, error) {
items, err := a.handler.LoadSkills(ctx, botID)
if err != nil {
return nil, err
}
skills := make([]command.Skill, len(items))
for i, item := range items {
skills[i] = command.Skill{Name: item.Name, Description: item.Description}
}
return skills, nil
}
// commandContainerFSAdapter bridges mcp.Manager to command.ContainerFS.
type commandContainerFSAdapter struct {
manager *mcp.Manager
}
func (a *commandContainerFSAdapter) ListDir(ctx context.Context, botID, dirPath string) ([]command.FSEntry, error) {
client, err := a.manager.MCPClient(ctx, botID)
if err != nil {
return nil, err
}
entries, err := client.ListDir(ctx, dirPath, false)
if err != nil {
return nil, err
}
result := make([]command.FSEntry, len(entries))
for i, e := range entries {
name := stdpath.Base(e.GetPath())
result[i] = command.FSEntry{Name: name, IsDir: e.GetIsDir(), Size: e.GetSize()}
}
return result, nil
}
func (a *commandContainerFSAdapter) ReadFile(ctx context.Context, botID, filePath string) (string, error) {
client, err := a.manager.MCPClient(ctx, botID)
if err != nil {
return "", err
}
resp, err := client.ReadFile(ctx, filePath, 0, 0)
if err != nil {
return "", err
}
return resp.GetContent(), nil
}
+88 -1
View File
@@ -8,6 +8,7 @@ import (
"log/slog"
"net/http"
"os"
stdpath "path"
"strings"
"time"
@@ -34,6 +35,7 @@ import (
"github.com/memohai/memoh/internal/channel/identities"
"github.com/memohai/memoh/internal/channel/inbound"
"github.com/memohai/memoh/internal/channel/route"
"github.com/memohai/memoh/internal/command"
"github.com/memohai/memoh/internal/config"
ctr "github.com/memohai/memoh/internal/containerd"
"github.com/memohai/memoh/internal/conversation"
@@ -46,8 +48,10 @@ import (
emailmailgun "github.com/memohai/memoh/internal/email/adapters/mailgun"
"github.com/memohai/memoh/internal/handlers"
"github.com/memohai/memoh/internal/healthcheck"
"github.com/memohai/memoh/internal/browsercontexts"
channelchecker "github.com/memohai/memoh/internal/healthcheck/checkers/channel"
mcpchecker "github.com/memohai/memoh/internal/healthcheck/checkers/mcp"
"github.com/memohai/memoh/internal/heartbeat"
"github.com/memohai/memoh/internal/inbox"
"github.com/memohai/memoh/internal/logger"
"github.com/memohai/memoh/internal/mcp"
@@ -122,8 +126,11 @@ func runServe() {
provideChannelManager,
provideChannelLifecycleService,
provideChatResolver,
browsercontexts.NewService,
provideScheduleTriggerer,
schedule.NewService,
provideHeartbeatTriggerer,
heartbeat.NewService,
provideContainerdHandler,
provideFederationGateway,
provideToolGatewayService,
@@ -162,6 +169,7 @@ func runServe() {
fx.Invoke(
startMemoryProviderBootstrap,
startScheduleService,
startHeartbeatService,
startChannelManager,
startEmailManager,
startContainerReconciliation,
@@ -267,6 +275,10 @@ func provideScheduleTriggerer(resolver *flow.Resolver) schedule.Triggerer {
return flow.NewScheduleGateway(resolver)
}
func provideHeartbeatTriggerer(resolver *flow.Resolver) heartbeat.Triggerer {
return flow.NewHeartbeatGateway(resolver)
}
func provideChatResolver(log *slog.Logger, cfg config.Config, modelsService *models.Service, queries *dbsqlc.Queries, chatService *conversation.Service, msgService *message.DBService, settingsService *settings.Service, mediaService *media.Service, containerdHandler *handlers.ContainerdHandler, inboxService *inbox.Service, memoryRegistry *memprovider.Registry) *flow.Resolver {
resolver := flow.NewResolver(log, modelsService, queries, chatService, msgService, settingsService, cfg.AgentGateway.BaseURL(), 120*time.Second)
resolver.SetMemoryRegistry(memoryRegistry)
@@ -292,11 +304,31 @@ func provideChannelRegistry(log *slog.Logger, hub *local.RouteHub, mediaService
return registry
}
func provideChannelRouter(log *slog.Logger, registry *channel.Registry, hub *local.RouteHub, routeService *route.DBService, msgService *message.DBService, resolver *flow.Resolver, identityService *identities.Service, botService *bots.Service, policyService *policy.Service, preauthService *preauth.Service, bindService *bind.Service, mediaService *media.Service, inboxService *inbox.Service, rc *boot.RuntimeConfig) *inbound.ChannelInboundProcessor {
func provideChannelRouter(log *slog.Logger, registry *channel.Registry, hub *local.RouteHub, routeService *route.DBService, msgService *message.DBService, resolver *flow.Resolver, identityService *identities.Service, botService *bots.Service, policyService *policy.Service, preauthService *preauth.Service, bindService *bind.Service, mediaService *media.Service, inboxService *inbox.Service, subagentService *subagent.Service, scheduleService *schedule.Service, settingsService *settings.Service, mcpConnService *mcp.ConnectionService, modelsService *models.Service, providersService *providers.Service, memProvService *memprovider.Service, searchProvService *searchproviders.Service, browserCtxService *browsercontexts.Service, emailService *emailpkg.Service, emailOutboxService *emailpkg.OutboxService, heartbeatService *heartbeat.Service, queries *dbsqlc.Queries, containerdHandler *handlers.ContainerdHandler, manager *mcp.Manager, rc *boot.RuntimeConfig) *inbound.ChannelInboundProcessor {
processor := inbound.NewChannelInboundProcessor(log, registry, routeService, msgService, resolver, identityService, botService, policyService, preauthService, bindService, rc.JwtSecret, 5*time.Minute)
processor.SetMediaService(mediaService)
processor.SetStreamObserver(local.NewRouteHubBroadcaster(hub))
processor.SetInboxService(inboxService)
processor.SetCommandHandler(command.NewHandler(
log,
&command.BotMemberRoleAdapter{BotService: botService},
subagentService,
scheduleService,
settingsService,
mcpConnService,
inboxService,
modelsService,
providersService,
memProvService,
searchProvService,
browserCtxService,
emailService,
emailOutboxService,
heartbeatService,
queries,
&commandSkillLoaderAdapter{handler: containerdHandler},
&commandContainerFSAdapter{manager: manager},
))
return processor
}
@@ -515,6 +547,10 @@ func startScheduleService(lc fx.Lifecycle, scheduleService *schedule.Service) {
lc.Append(fx.Hook{OnStart: func(ctx context.Context) error { return scheduleService.Bootstrap(ctx) }})
}
func startHeartbeatService(lc fx.Lifecycle, heartbeatService *heartbeat.Service) {
lc.Append(fx.Hook{OnStart: func(ctx context.Context) error { return heartbeatService.Bootstrap(ctx) }})
}
func startChannelManager(lc fx.Lifecycle, channelManager *channel.Manager) {
ctx, cancel := context.WithCancel(context.Background())
lc.Append(fx.Hook{
@@ -818,3 +854,54 @@ func (a *gatewayAssetLoaderAdapter) OpenForGateway(ctx context.Context, botID, c
}
return reader, strings.TrimSpace(asset.Mime), nil
}
// commandSkillLoaderAdapter bridges handlers.ContainerdHandler to command.SkillLoader.
type commandSkillLoaderAdapter struct {
handler *handlers.ContainerdHandler
}
func (a *commandSkillLoaderAdapter) LoadSkills(ctx context.Context, botID string) ([]command.Skill, error) {
items, err := a.handler.LoadSkills(ctx, botID)
if err != nil {
return nil, err
}
skills := make([]command.Skill, len(items))
for i, item := range items {
skills[i] = command.Skill{Name: item.Name, Description: item.Description}
}
return skills, nil
}
// commandContainerFSAdapter bridges mcp.Manager to command.ContainerFS.
type commandContainerFSAdapter struct {
manager *mcp.Manager
}
func (a *commandContainerFSAdapter) ListDir(ctx context.Context, botID, dirPath string) ([]command.FSEntry, error) {
client, err := a.manager.MCPClient(ctx, botID)
if err != nil {
return nil, err
}
entries, err := client.ListDir(ctx, dirPath, false)
if err != nil {
return nil, err
}
result := make([]command.FSEntry, len(entries))
for i, e := range entries {
name := stdpath.Base(e.GetPath())
result[i] = command.FSEntry{Name: name, IsDir: e.GetIsDir(), Size: e.GetSize()}
}
return result, nil
}
func (a *commandContainerFSAdapter) ReadFile(ctx context.Context, botID, filePath string) (string, error) {
client, err := a.manager.MCPClient(ctx, botID)
if err != nil {
return "", err
}
resp, err := client.ReadFile(ctx, filePath, 0, 0)
if err != nil {
return "", err
}
return resp.GetContent(), nil
}
+60
View File
@@ -0,0 +1,60 @@
package command
import (
"fmt"
"strings"
"github.com/memohai/memoh/internal/settings"
)
func (h *Handler) buildBrowserGroup() *CommandGroup {
g := newCommandGroup("browser", "Manage browser context")
g.Register(SubCommand{
Name: "list",
Usage: "list - List all browser contexts",
Handler: func(cc CommandContext) (string, error) {
items, err := h.browserCtxService.List(cc.Ctx)
if err != nil {
return "", err
}
if len(items) == 0 {
return "No browser contexts found.", nil
}
records := make([][]kv, 0, len(items))
for _, item := range items {
records = append(records, []kv{
{"Name", item.Name},
})
}
return formatItems(records), nil
},
})
g.Register(SubCommand{
Name: "set",
Usage: "set <name> - Set the browser context for this bot",
IsWrite: true,
Handler: func(cc CommandContext) (string, error) {
if len(cc.Args) < 1 {
return "Usage: /browser set <name>", nil
}
name := cc.Args[0]
items, err := h.browserCtxService.List(cc.Ctx)
if err != nil {
return "", err
}
for _, item := range items {
if strings.EqualFold(item.Name, name) {
_, err := h.settingsService.UpsertBot(cc.Ctx, cc.BotID, settings.UpsertRequest{
BrowserContextID: item.ID,
})
if err != nil {
return "", err
}
return fmt.Sprintf("Browser context set to %q.", item.Name), nil
}
}
return fmt.Sprintf("Browser context %q not found.", name), nil
},
})
return g
}
+21
View File
@@ -0,0 +1,21 @@
package command
// buildRegistry constructs the full command registry with all resource groups.
func (h *Handler) buildRegistry() *Registry {
r := newRegistry()
r.RegisterGroup(h.buildSubagentGroup())
r.RegisterGroup(h.buildScheduleGroup())
r.RegisterGroup(h.buildMCPGroup())
r.RegisterGroup(h.buildInboxGroup())
r.RegisterGroup(h.buildSettingsGroup())
r.RegisterGroup(h.buildModelGroup())
r.RegisterGroup(h.buildMemoryGroup())
r.RegisterGroup(h.buildSearchGroup())
r.RegisterGroup(h.buildBrowserGroup())
r.RegisterGroup(h.buildUsageGroup())
r.RegisterGroup(h.buildEmailGroup())
r.RegisterGroup(h.buildHeartbeatGroup())
r.RegisterGroup(h.buildSkillGroup())
r.RegisterGroup(h.buildFSGroup())
return r
}
+95
View File
@@ -0,0 +1,95 @@
package command
import (
"fmt"
"strings"
)
func (h *Handler) buildEmailGroup() *CommandGroup {
g := newCommandGroup("email", "View email configuration")
g.Register(SubCommand{
Name: "providers",
Usage: "providers - List email providers",
Handler: func(cc CommandContext) (string, error) {
items, err := h.emailService.ListProviders(cc.Ctx, "")
if err != nil {
return "", err
}
if len(items) == 0 {
return "No email providers found.", nil
}
records := make([][]kv, 0, len(items))
for _, item := range items {
records = append(records, []kv{
{"Name", item.Name},
{"Provider", item.Provider},
})
}
return formatItems(records), nil
},
})
g.Register(SubCommand{
Name: "bindings",
Usage: "bindings - List bot email bindings",
Handler: func(cc CommandContext) (string, error) {
items, err := h.emailService.ListBindings(cc.Ctx, cc.BotID)
if err != nil {
return "", err
}
if len(items) == 0 {
return "No email bindings found.", nil
}
records := make([][]kv, 0, len(items))
for _, item := range items {
perms := buildPermString(item.CanRead, item.CanWrite, item.CanDelete)
records = append(records, []kv{
{"Address", item.EmailAddress},
{"Permissions", perms},
})
}
return formatItems(records), nil
},
})
g.Register(SubCommand{
Name: "outbox",
Usage: "outbox - List recently sent emails",
Handler: func(cc CommandContext) (string, error) {
items, _, err := h.emailOutboxService.ListByBot(cc.Ctx, cc.BotID, 10, 0)
if err != nil {
return "", err
}
if len(items) == 0 {
return "Outbox is empty.", nil
}
records := make([][]kv, 0, len(items))
for _, item := range items {
to := strings.Join(item.To, ", ")
records = append(records, []kv{
{"Subject", truncate(item.Subject, 40)},
{"To", truncate(to, 40)},
{"Status", item.Status},
{"Sent", item.SentAt.Format("01-02 15:04")},
})
}
return formatItems(records), nil
},
})
return g
}
func buildPermString(read, write, del bool) string {
var parts []string
if read {
parts = append(parts, "read")
}
if write {
parts = append(parts, "write")
}
if del {
parts = append(parts, "delete")
}
if len(parts) == 0 {
return "none"
}
return fmt.Sprintf("%s", strings.Join(parts, ", "))
}
+77
View File
@@ -0,0 +1,77 @@
package command
import (
"fmt"
"strings"
)
// formatItems renders a list of records as a Markdown-style list.
// Each record is a slice of kv pairs; the first pair's value is used as the
// bullet title, and subsequent pairs are indented beneath it.
//
// Example output:
//
// - mybot
// Description: A helpful assistant
// ID: abc123
//
// - another
// Description: Something else
// ID: def456
func formatItems(items [][]kv) string {
if len(items) == 0 {
return ""
}
var b strings.Builder
for i, record := range items {
if len(record) == 0 {
continue
}
if i > 0 {
b.WriteByte('\n')
}
fmt.Fprintf(&b, "- %s\n", record[0].value)
for _, pair := range record[1:] {
fmt.Fprintf(&b, " %s: %s\n", pair.key, pair.value)
}
}
return b.String()
}
// formatKV renders key-value pairs as a simple Markdown list.
//
// Example output:
//
// - ID: abc123
// - Name: mybot
func formatKV(pairs []kv) string {
var b strings.Builder
for _, p := range pairs {
fmt.Fprintf(&b, "- %s: %s\n", p.key, p.value)
}
return b.String()
}
type kv struct {
key string
value string
}
// truncate shortens a string to maxLen, appending "..." if truncated.
func truncate(s string, maxLen int) string {
if len(s) <= maxLen {
return s
}
if maxLen <= 3 {
return s[:maxLen]
}
return s[:maxLen-3] + "..."
}
// boolStr returns "yes" or "no".
func boolStr(b bool) string {
if b {
return "yes"
}
return "no"
}
+62
View File
@@ -0,0 +1,62 @@
package command
import (
"fmt"
"strings"
)
func (h *Handler) buildFSGroup() *CommandGroup {
g := newCommandGroup("fs", "Browse container filesystem")
g.Register(SubCommand{
Name: "list",
Usage: "list [path] - List files in the container",
Handler: func(cc CommandContext) (string, error) {
if h.containerFS == nil {
return "Container filesystem is not available.", nil
}
dir := "/"
if len(cc.Args) > 0 {
dir = cc.Args[0]
}
entries, err := h.containerFS.ListDir(cc.Ctx, cc.BotID, dir)
if err != nil {
return "", err
}
if len(entries) == 0 {
return fmt.Sprintf("Directory %q is empty.", dir), nil
}
var b strings.Builder
fmt.Fprintf(&b, "%s:\n", dir)
for _, e := range entries {
if e.IsDir {
fmt.Fprintf(&b, " %s/\n", e.Name)
} else {
fmt.Fprintf(&b, " %s (%d bytes)\n", e.Name, e.Size)
}
}
return b.String(), nil
},
})
g.Register(SubCommand{
Name: "read",
Usage: "read <path> - Read a file from the container",
Handler: func(cc CommandContext) (string, error) {
if h.containerFS == nil {
return "Container filesystem is not available.", nil
}
if len(cc.Args) < 1 {
return "Usage: /fs read <path>", nil
}
content, err := h.containerFS.ReadFile(cc.Ctx, cc.BotID, cc.Args[0])
if err != nil {
return "", err
}
const maxLen = 2000
if len(content) > maxLen {
content = content[:maxLen] + "\n... (truncated)"
}
return fmt.Sprintf("```\n%s\n```", content), nil
},
})
return g
}
+209
View File
@@ -0,0 +1,209 @@
package command
import (
"context"
"fmt"
"log/slog"
"github.com/memohai/memoh/internal/bots"
"github.com/memohai/memoh/internal/browsercontexts"
dbsqlc "github.com/memohai/memoh/internal/db/sqlc"
emailpkg "github.com/memohai/memoh/internal/email"
"github.com/memohai/memoh/internal/heartbeat"
"github.com/memohai/memoh/internal/inbox"
"github.com/memohai/memoh/internal/mcp"
memprovider "github.com/memohai/memoh/internal/memory/provider"
"github.com/memohai/memoh/internal/models"
"github.com/memohai/memoh/internal/providers"
"github.com/memohai/memoh/internal/schedule"
"github.com/memohai/memoh/internal/searchproviders"
"github.com/memohai/memoh/internal/settings"
"github.com/memohai/memoh/internal/subagent"
)
// MemberRoleResolver resolves a user's role within a bot.
type MemberRoleResolver interface {
GetMemberRole(ctx context.Context, botID, channelIdentityID string) (string, error)
}
// BotMemberRoleAdapter adapts bots.Service to MemberRoleResolver.
type BotMemberRoleAdapter struct {
BotService *bots.Service
}
func (a *BotMemberRoleAdapter) GetMemberRole(ctx context.Context, botID, channelIdentityID string) (string, error) {
member, err := a.BotService.GetMember(ctx, botID, channelIdentityID)
if err != nil {
return "", err
}
return member.Role, nil
}
// Handler processes slash commands intercepted before they reach the LLM.
type Handler struct {
registry *Registry
roleResolver MemberRoleResolver
subagentService *subagent.Service
scheduleService *schedule.Service
settingsService *settings.Service
mcpConnService *mcp.ConnectionService
inboxService *inbox.Service
modelsService *models.Service
providersService *providers.Service
memProvService *memprovider.Service
searchProvService *searchproviders.Service
browserCtxService *browsercontexts.Service
emailService *emailpkg.Service
emailOutboxService *emailpkg.OutboxService
heartbeatService *heartbeat.Service
queries *dbsqlc.Queries
skillLoader SkillLoader
containerFS ContainerFS
logger *slog.Logger
}
// NewHandler creates a Handler with all required services.
func NewHandler(
log *slog.Logger,
roleResolver MemberRoleResolver,
subagentService *subagent.Service,
scheduleService *schedule.Service,
settingsService *settings.Service,
mcpConnService *mcp.ConnectionService,
inboxService *inbox.Service,
modelsService *models.Service,
providersService *providers.Service,
memProvService *memprovider.Service,
searchProvService *searchproviders.Service,
browserCtxService *browsercontexts.Service,
emailService *emailpkg.Service,
emailOutboxService *emailpkg.OutboxService,
heartbeatService *heartbeat.Service,
queries *dbsqlc.Queries,
skillLoader SkillLoader,
containerFS ContainerFS,
) *Handler {
if log == nil {
log = slog.Default()
}
h := &Handler{
roleResolver: roleResolver,
subagentService: subagentService,
scheduleService: scheduleService,
settingsService: settingsService,
mcpConnService: mcpConnService,
inboxService: inboxService,
modelsService: modelsService,
providersService: providersService,
memProvService: memProvService,
searchProvService: searchProvService,
browserCtxService: browserCtxService,
emailService: emailService,
emailOutboxService: emailOutboxService,
heartbeatService: heartbeatService,
queries: queries,
skillLoader: skillLoader,
containerFS: containerFS,
logger: log.With(slog.String("component", "command")),
}
h.registry = h.buildRegistry()
return h
}
// IsCommand reports whether the text contains a slash command.
// Handles both direct commands ("/help") and mention-prefixed commands ("@bot /help").
func (h *Handler) IsCommand(text string) bool {
cmdText := ExtractCommandText(text)
if cmdText == "" || len(cmdText) < 2 {
return false
}
// Validate that it refers to a known command, not arbitrary "/path/to/file".
parsed, err := Parse(cmdText)
if err != nil {
return false
}
if parsed.Resource == "help" {
return true
}
_, ok := h.registry.groups[parsed.Resource]
return ok
}
// Execute parses and runs a slash command, returning the text reply.
func (h *Handler) Execute(ctx context.Context, botID, channelIdentityID, text string) (string, error) {
cmdText := ExtractCommandText(text)
if cmdText == "" {
return h.registry.GlobalHelp(), nil
}
parsed, err := Parse(cmdText)
if err != nil {
return h.registry.GlobalHelp(), nil
}
// Resolve the user's role in this bot.
role := ""
if h.roleResolver != nil && channelIdentityID != "" {
r, err := h.roleResolver.GetMemberRole(ctx, botID, channelIdentityID)
if err != nil {
h.logger.Warn("failed to resolve member role",
slog.String("bot_id", botID),
slog.String("channel_identity_id", channelIdentityID),
slog.Any("error", err),
)
} else {
role = r
}
}
cc := CommandContext{
Ctx: ctx,
BotID: botID,
Role: role,
Args: parsed.Args,
}
// /help
if parsed.Resource == "help" {
return h.registry.GlobalHelp(), nil
}
group, ok := h.registry.groups[parsed.Resource]
if !ok {
return fmt.Sprintf("Unknown command: /%s\n\n%s", parsed.Resource, h.registry.GlobalHelp()), nil
}
if parsed.Action == "" {
if group.DefaultAction != "" {
parsed.Action = group.DefaultAction
} else {
return group.Usage(), nil
}
}
sub, ok := group.commands[parsed.Action]
if !ok {
return fmt.Sprintf("Unknown action \"%s\" for /%s.\n\n%s", parsed.Action, parsed.Resource, group.Usage()), nil
}
if sub.IsWrite && role != bots.MemberRoleOwner {
return "Permission denied: only the bot owner can execute this command.", nil
}
result, handlerErr := safeExecute(sub.Handler, cc)
if handlerErr != nil {
return fmt.Sprintf("Error: %s", handlerErr.Error()), nil
}
return result, nil
}
// safeExecute runs a sub-command handler and recovers from panics.
func safeExecute(fn func(CommandContext) (string, error), cc CommandContext) (result string, err error) {
defer func() {
if r := recover(); r != nil {
err = fmt.Errorf("internal error: %v", r)
}
}()
return fn(cc)
}
+335
View File
@@ -0,0 +1,335 @@
package command
import (
"context"
"strings"
"testing"
"time"
"github.com/memohai/memoh/internal/bots"
"github.com/memohai/memoh/internal/inbox"
"github.com/memohai/memoh/internal/mcp"
"github.com/memohai/memoh/internal/schedule"
"github.com/memohai/memoh/internal/settings"
"github.com/memohai/memoh/internal/subagent"
)
// --- fake services ---
type fakeRoleResolver struct {
role string
err error
}
func (f *fakeRoleResolver) GetMemberRole(_ context.Context, _, _ string) (string, error) {
return f.role, f.err
}
type fakeSubagentService struct {
items []subagent.Subagent
}
func (f *fakeSubagentService) list() []subagent.Subagent {
return f.items
}
type fakeScheduleService struct {
items []schedule.Schedule
}
type fakeInboxService struct {
count inbox.CountResult
}
// newTestHandler creates a Handler with nil services for use in tests.
func newTestHandler(roleResolver MemberRoleResolver) *Handler {
return NewHandler(nil, roleResolver, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil)
}
// --- tests ---
func TestIsCommand(t *testing.T) {
t.Parallel()
h := newTestHandler(nil)
tests := []struct {
input string
want bool
}{
{"/help", true},
{"/subagent list", true},
{" /schedule list", true},
{"@BotName /help", true},
{"@_user_1 /schedule list", true},
{"<@123456> /mcp list", true},
{"/help@MemohBot", true},
{"hello", false},
{"", false},
{"/", false},
{"/ ", false},
{"/unknown_cmd", false},
{"check https://example.com/help", false},
{"@bot hello", false},
}
for _, tt := range tests {
t.Run(tt.input, func(t *testing.T) {
t.Parallel()
if got := h.IsCommand(tt.input); got != tt.want {
t.Errorf("IsCommand(%q) = %v, want %v", tt.input, got, tt.want)
}
})
}
}
func TestExecute_Help(t *testing.T) {
t.Parallel()
h := newTestHandler(&fakeRoleResolver{role: bots.MemberRoleOwner})
result, err := h.Execute(context.Background(), "bot-1", "user-1", "/help")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !strings.Contains(result, "Available commands") {
t.Errorf("expected help text, got: %s", result)
}
if !strings.Contains(result, "/subagent") {
t.Errorf("expected /subagent in help, got: %s", result)
}
}
func TestExecute_UnknownCommand(t *testing.T) {
t.Parallel()
h := newTestHandler(&fakeRoleResolver{role: bots.MemberRoleOwner})
result, err := h.Execute(context.Background(), "bot-1", "user-1", "/foobar")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !strings.Contains(result, "Unknown command") {
t.Errorf("expected unknown command message, got: %s", result)
}
}
func TestExecute_WithMentionPrefix(t *testing.T) {
t.Parallel()
h := newTestHandler(&fakeRoleResolver{role: bots.MemberRoleOwner})
result, err := h.Execute(context.Background(), "bot-1", "user-1", "@BotName /help")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !strings.Contains(result, "Available commands") {
t.Errorf("expected help text from mention-prefixed command, got: %s", result)
}
}
func TestExecute_TelegramBotSuffix(t *testing.T) {
t.Parallel()
h := newTestHandler(&fakeRoleResolver{role: bots.MemberRoleOwner})
result, err := h.Execute(context.Background(), "bot-1", "user-1", "/help@MemohBot")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !strings.Contains(result, "Available commands") {
t.Errorf("expected help text from telegram-style command, got: %s", result)
}
}
func TestExecute_UnknownAction(t *testing.T) {
t.Parallel()
h := newTestHandler(&fakeRoleResolver{role: bots.MemberRoleOwner})
result, err := h.Execute(context.Background(), "bot-1", "user-1", "/subagent foobar")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !strings.Contains(result, "Unknown action") {
t.Errorf("expected unknown action message, got: %s", result)
}
if !strings.Contains(result, "/subagent") {
t.Errorf("expected subagent usage in message, got: %s", result)
}
}
func TestExecute_WritePermissionDenied(t *testing.T) {
t.Parallel()
h := newTestHandler(&fakeRoleResolver{role: bots.MemberRoleMember})
result, err := h.Execute(context.Background(), "bot-1", "user-1", "/subagent create test desc")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !strings.Contains(result, "Permission denied") {
t.Errorf("expected permission denied, got: %s", result)
}
}
func TestExecute_WritePermissionAllowedForOwner(t *testing.T) {
t.Parallel()
h := newTestHandler(&fakeRoleResolver{role: bots.MemberRoleOwner})
result, err := h.Execute(context.Background(), "bot-1", "user-1", "/subagent create")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if strings.Contains(result, "Permission denied") {
t.Errorf("owner should not get permission denied, got: %s", result)
}
if !strings.Contains(result, "Usage:") {
t.Errorf("expected usage hint for missing args, got: %s", result)
}
}
func TestExecute_SettingsDefaultAction(t *testing.T) {
t.Parallel()
h := newTestHandler(&fakeRoleResolver{role: bots.MemberRoleMember})
result, err := h.Execute(context.Background(), "bot-1", "user-1", "/settings")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if strings.Contains(result, "Unknown action") {
t.Errorf("expected settings get attempt, not unknown action, got: %s", result)
}
}
func TestExecute_MissingArgs(t *testing.T) {
t.Parallel()
h := newTestHandler(&fakeRoleResolver{role: bots.MemberRoleOwner})
tests := []struct {
cmd string
contains string
}{
{"/subagent get", "Usage:"},
{"/subagent create", "Usage:"},
{"/subagent delete", "Usage:"},
{"/schedule get", "Usage:"},
{"/schedule create", "Usage:"},
{"/schedule delete", "Usage:"},
{"/mcp get", "Usage:"},
{"/mcp delete", "Usage:"},
{"/fs read", "not available"},
{"/model set", "Usage:"},
{"/model set-heartbeat", "Usage:"},
{"/memory set", "Usage:"},
{"/search set", "Usage:"},
{"/browser set", "Usage:"},
}
for _, tt := range tests {
t.Run(tt.cmd, func(t *testing.T) {
t.Parallel()
result, err := h.Execute(context.Background(), "bot-1", "user-1", tt.cmd)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !strings.Contains(result, tt.contains) {
t.Errorf("expected %q in result, got: %s", tt.contains, result)
}
})
}
}
func TestFormatItems(t *testing.T) {
t.Parallel()
result := formatItems([][]kv{
{{"Name", "foo"}, {"Type", "bar"}},
{{"Name", "longname"}, {"Type", "x"}},
})
if !strings.Contains(result, "- foo") {
t.Errorf("expected '- foo' bullet, got: %s", result)
}
if !strings.Contains(result, " Type: bar") {
t.Errorf("expected indented 'Type: bar', got: %s", result)
}
if !strings.Contains(result, "- longname") {
t.Errorf("expected '- longname' bullet, got: %s", result)
}
}
func TestFormatItems_Empty(t *testing.T) {
t.Parallel()
result := formatItems(nil)
if result != "" {
t.Errorf("expected empty string for nil items, got: %q", result)
}
}
func TestFormatKV(t *testing.T) {
t.Parallel()
result := formatKV([]kv{
{"Name", "test"},
{"ID", "123"},
})
if !strings.Contains(result, "- Name: test") {
t.Errorf("expected '- Name: test', got: %s", result)
}
if !strings.Contains(result, "- ID: 123") {
t.Errorf("expected '- ID: 123', got: %s", result)
}
}
func TestTruncate(t *testing.T) {
t.Parallel()
if got := truncate("hello world", 5); got != "he..." {
t.Errorf("truncate: got %q", got)
}
if got := truncate("hi", 5); got != "hi" {
t.Errorf("truncate short: got %q", got)
}
}
// Verify that the global help includes all resource groups.
func TestGlobalHelp_AllGroups(t *testing.T) {
t.Parallel()
h := newTestHandler(nil)
help := h.registry.GlobalHelp()
for _, group := range []string{
"subagent", "schedule", "mcp", "inbox", "settings",
"model", "memory", "search", "browser", "usage",
"email", "heartbeat", "skill", "fs",
} {
if !strings.Contains(help, "/"+group) {
t.Errorf("missing /%s in global help", group)
}
}
}
// Verify write commands are tagged with [owner] in usage.
func TestUsage_OwnerTag(t *testing.T) {
t.Parallel()
h := newTestHandler(nil)
for _, name := range h.registry.order {
group := h.registry.groups[name]
usage := group.Usage()
for _, subName := range group.order {
sub := group.commands[subName]
if sub.IsWrite && !strings.Contains(usage, "[owner]") {
t.Errorf("/%s %s is a write command but usage missing [owner] tag", name, subName)
}
}
}
}
// Verify new commands with nil services return graceful errors, not panics.
func TestNewCommands_NilServices(t *testing.T) {
t.Parallel()
h := newTestHandler(&fakeRoleResolver{role: bots.MemberRoleOwner})
cmds := []string{
"/skill list",
"/fs list",
"/fs read /test.txt",
}
for _, cmd := range cmds {
t.Run(cmd, func(t *testing.T) {
t.Parallel()
result, err := h.Execute(context.Background(), "bot-1", "user-1", cmd)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if result == "" {
t.Error("expected non-empty result")
}
})
}
}
// suppress unused warnings
var (
_ = fakeSubagentService{items: []subagent.Subagent{{ID: "1", Name: "test", CreatedAt: time.Now(), UpdatedAt: time.Now()}}}
_ = fakeScheduleService{items: []schedule.Schedule{{ID: "1", Name: "test"}}}
_ = fakeInboxService{count: inbox.CountResult{Unread: 1, Total: 2}}
_ = mcp.Connection{}
_ = settings.Settings{}
)
+47
View File
@@ -0,0 +1,47 @@
package command
import (
"fmt"
)
func (h *Handler) buildHeartbeatGroup() *CommandGroup {
g := newCommandGroup("heartbeat", "View heartbeat logs")
g.DefaultAction = "logs"
g.Register(SubCommand{
Name: "logs",
Usage: "logs - List recent heartbeat logs",
Handler: func(cc CommandContext) (string, error) {
items, err := h.heartbeatService.ListLogs(cc.Ctx, cc.BotID, nil, 10)
if err != nil {
return "", err
}
if len(items) == 0 {
return "No heartbeat logs found.", nil
}
records := make([][]kv, 0, len(items))
for _, item := range items {
dur := ""
if item.CompletedAt != nil {
dur = fmt.Sprintf("%.1fs", item.CompletedAt.Sub(item.StartedAt).Seconds())
}
errMsg := ""
if item.ErrorMessage != "" {
errMsg = truncate(item.ErrorMessage, 50)
}
rec := []kv{
{"Time", item.StartedAt.Format("01-02 15:04:05")},
{"Status", item.Status},
}
if dur != "" {
rec = append(rec, kv{"Duration", dur})
}
if errMsg != "" {
rec = append(rec, kv{"Error", errMsg})
}
records = append(records, rec)
}
return formatItems(records), nil
},
})
return g
}
+58
View File
@@ -0,0 +1,58 @@
package command
import (
"fmt"
"strings"
"github.com/memohai/memoh/internal/inbox"
)
func (h *Handler) buildInboxGroup() *CommandGroup {
g := newCommandGroup("inbox", "View bot inbox")
g.Register(SubCommand{
Name: "list",
Usage: "list [--unread] - List inbox items",
Handler: func(cc CommandContext) (string, error) {
filter := inbox.ListFilter{Limit: 20}
for _, arg := range cc.Args {
if strings.EqualFold(arg, "--unread") {
unread := false
filter.IsRead = &unread
}
}
items, err := h.inboxService.List(cc.Ctx, cc.BotID, filter)
if err != nil {
return "", err
}
if len(items) == 0 {
return "Inbox is empty.", nil
}
records := make([][]kv, 0, len(items))
for _, item := range items {
status := "unread"
if item.IsRead {
status = "read"
}
records = append(records, []kv{
{"Source", item.Source},
{"Content", truncate(item.Content, 50)},
{"Status", status},
{"Time", item.CreatedAt.Format("01-02 15:04")},
})
}
return formatItems(records), nil
},
})
g.Register(SubCommand{
Name: "count",
Usage: "count - Show inbox counts",
Handler: func(cc CommandContext) (string, error) {
result, err := h.inboxService.Count(cc.Ctx, cc.BotID)
if err != nil {
return "", err
}
return fmt.Sprintf("Unread: %d / Total: %d", result.Unread, result.Total), nil
},
})
return g
}
+27
View File
@@ -0,0 +1,27 @@
package command
import "context"
// Skill represents a single skill loaded from a bot's container.
type Skill struct {
Name string
Description string
}
// SkillLoader loads skills for a bot.
type SkillLoader interface {
LoadSkills(ctx context.Context, botID string) ([]Skill, error)
}
// FSEntry represents a file or directory in a container filesystem.
type FSEntry struct {
Name string
IsDir bool
Size int64
}
// ContainerFS provides read-only access to a bot's container filesystem.
type ContainerFS interface {
ListDir(ctx context.Context, botID, path string) ([]FSEntry, error)
ReadFile(ctx context.Context, botID, path string) (string, error)
}
+96
View File
@@ -0,0 +1,96 @@
package command
import (
"fmt"
"strings"
)
func (h *Handler) buildMCPGroup() *CommandGroup {
g := newCommandGroup("mcp", "Manage MCP connections")
g.Register(SubCommand{
Name: "list",
Usage: "list - List all MCP connections",
Handler: func(cc CommandContext) (string, error) {
items, err := h.mcpConnService.ListByBot(cc.Ctx, cc.BotID)
if err != nil {
return "", err
}
if len(items) == 0 {
return "No MCP connections found.", nil
}
records := make([][]kv, 0, len(items))
for _, item := range items {
records = append(records, []kv{
{"Name", item.Name},
{"Type", item.Type},
{"Active", boolStr(item.Active)},
{"Status", item.Status},
})
}
return formatItems(records), nil
},
})
g.Register(SubCommand{
Name: "get",
Usage: "get <name> - Get MCP connection details",
Handler: func(cc CommandContext) (string, error) {
if len(cc.Args) < 1 {
return "Usage: /mcp get <name>", nil
}
name := cc.Args[0]
items, err := h.mcpConnService.ListByBot(cc.Ctx, cc.BotID)
if err != nil {
return "", err
}
for _, item := range items {
if strings.EqualFold(item.Name, name) {
toolNames := make([]string, 0, len(item.ToolsCache))
for _, t := range item.ToolsCache {
toolNames = append(toolNames, t.Name)
}
toolsStr := "none"
if len(toolNames) > 0 {
toolsStr = strings.Join(toolNames, ", ")
}
return formatKV([]kv{
{"Name", item.Name},
{"Type", item.Type},
{"Active", boolStr(item.Active)},
{"Status", item.Status},
{"Status Message", item.StatusMessage},
{"Auth Type", item.AuthType},
{"Tools", toolsStr},
{"Created", item.CreatedAt.Format("2006-01-02 15:04:05")},
{"Updated", item.UpdatedAt.Format("2006-01-02 15:04:05")},
}), nil
}
}
return fmt.Sprintf("MCP connection %q not found.", name), nil
},
})
g.Register(SubCommand{
Name: "delete",
Usage: "delete <name> - Delete an MCP connection",
IsWrite: true,
Handler: func(cc CommandContext) (string, error) {
if len(cc.Args) < 1 {
return "Usage: /mcp delete <name>", nil
}
name := cc.Args[0]
items, err := h.mcpConnService.ListByBot(cc.Ctx, cc.BotID)
if err != nil {
return "", err
}
for _, item := range items {
if strings.EqualFold(item.Name, name) {
if err := h.mcpConnService.Delete(cc.Ctx, cc.BotID, item.ID); err != nil {
return "", err
}
return fmt.Sprintf("MCP connection %q deleted.", name), nil
}
}
return fmt.Sprintf("MCP connection %q not found.", name), nil
},
})
return g
}
+65
View File
@@ -0,0 +1,65 @@
package command
import (
"fmt"
"strings"
"github.com/memohai/memoh/internal/settings"
)
func (h *Handler) buildMemoryGroup() *CommandGroup {
g := newCommandGroup("memory", "Manage memory provider")
g.Register(SubCommand{
Name: "list",
Usage: "list - List all memory providers",
Handler: func(cc CommandContext) (string, error) {
items, err := h.memProvService.List(cc.Ctx)
if err != nil {
return "", err
}
if len(items) == 0 {
return "No memory providers found.", nil
}
records := make([][]kv, 0, len(items))
for _, item := range items {
def := ""
if item.IsDefault {
def = " (default)"
}
records = append(records, []kv{
{"Name", item.Name + def},
{"Provider", item.Provider},
})
}
return formatItems(records), nil
},
})
g.Register(SubCommand{
Name: "set",
Usage: "set <name> - Set the memory provider for this bot",
IsWrite: true,
Handler: func(cc CommandContext) (string, error) {
if len(cc.Args) < 1 {
return "Usage: /memory set <name>", nil
}
name := cc.Args[0]
items, err := h.memProvService.List(cc.Ctx)
if err != nil {
return "", err
}
for _, item := range items {
if strings.EqualFold(item.Name, name) {
_, err := h.settingsService.UpsertBot(cc.Ctx, cc.BotID, settings.UpsertRequest{
MemoryProviderID: item.ID,
})
if err != nil {
return "", err
}
return fmt.Sprintf("Memory provider set to %q.", item.Name), nil
}
}
return fmt.Sprintf("Memory provider %q not found.", name), nil
},
})
return g
}
+107
View File
@@ -0,0 +1,107 @@
package command
import (
"fmt"
"strings"
"github.com/memohai/memoh/internal/models"
"github.com/memohai/memoh/internal/settings"
)
func (h *Handler) buildModelGroup() *CommandGroup {
g := newCommandGroup("model", "Manage bot models")
g.Register(SubCommand{
Name: "list",
Usage: "list - List all available chat models",
Handler: func(cc CommandContext) (string, error) {
items, err := h.modelsService.ListByType(cc.Ctx, models.ModelTypeChat)
if err != nil {
return "", err
}
if len(items) == 0 {
return "No chat models found.", nil
}
records := make([][]kv, 0, len(items))
for _, item := range items {
provName := h.resolveProviderName(cc, item.LlmProviderID)
records = append(records, []kv{
{"Model", item.Name},
{"Provider", provName},
{"Model ID", item.ModelID},
})
}
return formatItems(records), nil
},
})
g.Register(SubCommand{
Name: "set",
Usage: "set <provider_name> <model_name> - Set the chat model",
IsWrite: true,
Handler: func(cc CommandContext) (string, error) {
if len(cc.Args) < 2 {
return "Usage: /model set <provider_name> <model_name>", nil
}
modelResp, err := h.findModelByProviderAndName(cc, cc.Args[0], cc.Args[1])
if err != nil {
return "", err
}
_, err = h.settingsService.UpsertBot(cc.Ctx, cc.BotID, settings.UpsertRequest{
ChatModelID: modelResp.ID,
})
if err != nil {
return "", err
}
return fmt.Sprintf("Chat model set to %s (%s).", modelResp.Name, cc.Args[0]), nil
},
})
g.Register(SubCommand{
Name: "set-heartbeat",
Usage: "set-heartbeat <provider_name> <model_name> - Set the heartbeat model",
IsWrite: true,
Handler: func(cc CommandContext) (string, error) {
if len(cc.Args) < 2 {
return "Usage: /model set-heartbeat <provider_name> <model_name>", nil
}
modelResp, err := h.findModelByProviderAndName(cc, cc.Args[0], cc.Args[1])
if err != nil {
return "", err
}
_, err = h.settingsService.UpsertBot(cc.Ctx, cc.BotID, settings.UpsertRequest{
HeartbeatModelID: modelResp.ID,
})
if err != nil {
return "", err
}
return fmt.Sprintf("Heartbeat model set to %s (%s).", modelResp.Name, cc.Args[0]), nil
},
})
return g
}
func (h *Handler) resolveProviderName(cc CommandContext, providerID string) string {
if h.providersService == nil || providerID == "" {
return providerID
}
p, err := h.providersService.Get(cc.Ctx, providerID)
if err != nil {
return providerID
}
return p.Name
}
func (h *Handler) findModelByProviderAndName(cc CommandContext, providerName, modelName string) (models.GetResponse, error) {
provider, err := h.providersService.GetByName(cc.Ctx, providerName)
if err != nil {
return models.GetResponse{}, fmt.Errorf("provider %q not found", providerName)
}
chatModels, err := h.modelsService.ListByProviderIDAndType(cc.Ctx, provider.ID, models.ModelTypeChat)
if err != nil {
return models.GetResponse{}, err
}
for _, m := range chatModels {
if strings.EqualFold(m.Name, modelName) || strings.EqualFold(m.ModelID, modelName) {
return m, nil
}
}
return models.GetResponse{}, fmt.Errorf("model %q not found under provider %q", modelName, providerName)
}
+98
View File
@@ -0,0 +1,98 @@
package command
import (
"errors"
"strings"
)
// ParsedCommand holds the parsed components of a slash command.
type ParsedCommand struct {
Resource string // e.g. "schedule", "subagent", "help"
Action string // e.g. "list", "get", "create"
Args []string // remaining positional arguments
}
// Parse parses a raw command string into its components.
// Expected format: /resource [action] [args...]
func Parse(text string) (ParsedCommand, error) {
text = strings.TrimSpace(text)
if !strings.HasPrefix(text, "/") {
return ParsedCommand{}, errors.New("command must start with /")
}
text = text[1:] // strip leading /
tokens := tokenize(text)
if len(tokens) == 0 {
return ParsedCommand{}, errors.New("empty command")
}
resource := strings.ToLower(tokens[0])
// Strip Telegram-style @botname suffix (e.g. "help@MemohBot" -> "help").
if idx := strings.IndexByte(resource, '@'); idx > 0 {
resource = resource[:idx]
}
cmd := ParsedCommand{
Resource: resource,
}
if len(tokens) > 1 {
cmd.Action = strings.ToLower(tokens[1])
}
if len(tokens) > 2 {
cmd.Args = tokens[2:]
}
return cmd, nil
}
// ExtractCommandText finds and extracts a slash command from text that may
// contain a leading @mention (e.g. "@BotName /help arg1" -> "/help arg1").
// Returns the command text starting with "/", or empty string if none found.
func ExtractCommandText(text string) string {
trimmed := strings.TrimSpace(text)
if strings.HasPrefix(trimmed, "/") {
return trimmed
}
// Look for " /" pattern — a slash preceded by whitespace.
idx := strings.Index(trimmed, " /")
if idx >= 0 {
return strings.TrimSpace(trimmed[idx+1:])
}
return ""
}
// tokenize splits a command string respecting quoted segments.
func tokenize(input string) []string {
var tokens []string
var current strings.Builder
inQuote := false
quoteChar := byte(0)
for i := 0; i < len(input); i++ {
ch := input[i]
if inQuote {
if ch == quoteChar {
inQuote = false
continue
}
current.WriteByte(ch)
continue
}
if ch == '"' || ch == '\'' {
inQuote = true
quoteChar = ch
continue
}
if ch == ' ' || ch == '\t' {
if current.Len() > 0 {
tokens = append(tokens, current.String())
current.Reset()
}
continue
}
current.WriteByte(ch)
}
if current.Len() > 0 {
tokens = append(tokens, current.String())
}
return tokens
}
+107
View File
@@ -0,0 +1,107 @@
package command
import (
"testing"
)
func TestParse_Basic(t *testing.T) {
t.Parallel()
tests := []struct {
input string
resource string
action string
args []string
}{
{"/help", "help", "", nil},
{"/subagent list", "subagent", "list", nil},
{"/subagent get mybot", "subagent", "get", []string{"mybot"}},
{"/schedule create daily \"0 9 * * *\" Send report", "schedule", "create", []string{"daily", "0 9 * * *", "Send", "report"}},
{" /settings ", "settings", "", nil},
{"/HELP", "help", "", nil},
{"/Schedule List", "schedule", "list", nil},
{"/help@MemohBot", "help", "", nil},
{"/schedule@BotName list", "schedule", "list", nil},
}
for _, tt := range tests {
t.Run(tt.input, func(t *testing.T) {
t.Parallel()
parsed, err := Parse(tt.input)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if parsed.Resource != tt.resource {
t.Errorf("resource: got %q, want %q", parsed.Resource, tt.resource)
}
if parsed.Action != tt.action {
t.Errorf("action: got %q, want %q", parsed.Action, tt.action)
}
if len(parsed.Args) != len(tt.args) {
t.Fatalf("args length: got %d, want %d", len(parsed.Args), len(tt.args))
}
for i, arg := range tt.args {
if parsed.Args[i] != arg {
t.Errorf("arg[%d]: got %q, want %q", i, parsed.Args[i], arg)
}
}
})
}
}
func TestExtractCommandText(t *testing.T) {
t.Parallel()
tests := []struct {
input string
want string
}{
{"/help", "/help"},
{" /subagent list", "/subagent list"},
{"@BotName /help", "/help"},
{"@_user_1 /schedule list arg1", "/schedule list arg1"},
{"<@123456> /mcp list", "/mcp list"},
{"@bot hello", ""},
{"hello world", ""},
{"", ""},
{"some text with no slash", ""},
}
for _, tt := range tests {
t.Run(tt.input, func(t *testing.T) {
t.Parallel()
got := ExtractCommandText(tt.input)
if got != tt.want {
t.Errorf("ExtractCommandText(%q) = %q, want %q", tt.input, got, tt.want)
}
})
}
}
func TestParse_Errors(t *testing.T) {
t.Parallel()
tests := []string{
"",
"hello",
"no slash",
}
for _, input := range tests {
t.Run(input, func(t *testing.T) {
t.Parallel()
_, err := Parse(input)
if err == nil {
t.Error("expected error, got nil")
}
})
}
}
func TestTokenize_Quotes(t *testing.T) {
t.Parallel()
tokens := tokenize(`create daily "0 9 * * *" 'Send report now'`)
expected := []string{"create", "daily", "0 9 * * *", "Send report now"}
if len(tokens) != len(expected) {
t.Fatalf("tokens length: got %d, want %d (%v)", len(tokens), len(expected), tokens)
}
for i, tok := range expected {
if tokens[i] != tok {
t.Errorf("token[%d]: got %q, want %q", i, tokens[i], tok)
}
}
}
+92
View File
@@ -0,0 +1,92 @@
package command
import (
"context"
"fmt"
"strings"
)
// CommandContext carries execution context for a sub-command.
type CommandContext struct {
Ctx context.Context
BotID string
Role string // "owner", "admin", "member", or "" (guest)
Args []string
}
// SubCommand describes a single sub-command within a resource group.
type SubCommand struct {
Name string
Usage string
IsWrite bool
Handler func(cc CommandContext) (string, error)
}
// CommandGroup groups sub-commands under a resource name.
type CommandGroup struct {
Name string
Description string
DefaultAction string
commands map[string]SubCommand
order []string // preserves registration order for help output
}
func newCommandGroup(name, description string) *CommandGroup {
return &CommandGroup{
Name: name,
Description: description,
commands: make(map[string]SubCommand),
}
}
func (g *CommandGroup) Register(sub SubCommand) {
g.commands[sub.Name] = sub
g.order = append(g.order, sub.Name)
}
// Usage returns the usage text for this resource group.
func (g *CommandGroup) Usage() string {
var b strings.Builder
fmt.Fprintf(&b, "/%s - %s\n", g.Name, g.Description)
for _, name := range g.order {
sub := g.commands[name]
perm := ""
if sub.IsWrite {
perm = " [owner]"
}
fmt.Fprintf(&b, "- %s%s\n", sub.Usage, perm)
}
return b.String()
}
// Registry holds all registered command groups.
type Registry struct {
groups map[string]*CommandGroup
order []string
}
func newRegistry() *Registry {
return &Registry{
groups: make(map[string]*CommandGroup),
}
}
func (r *Registry) RegisterGroup(group *CommandGroup) {
r.groups[group.Name] = group
r.order = append(r.order, group.Name)
}
// GlobalHelp returns the top-level help text listing all commands.
func (r *Registry) GlobalHelp() string {
var b strings.Builder
b.WriteString("Available commands:\n\n")
b.WriteString("/help - Show this help message\n\n")
for i, name := range r.order {
if i > 0 {
b.WriteByte('\n')
}
group := r.groups[name]
b.WriteString(group.Usage())
}
return strings.TrimRight(b.String(), "\n")
}
+201
View File
@@ -0,0 +1,201 @@
package command
import (
"fmt"
"strings"
"github.com/memohai/memoh/internal/schedule"
)
func (h *Handler) buildScheduleGroup() *CommandGroup {
g := newCommandGroup("schedule", "Manage scheduled tasks")
g.Register(SubCommand{
Name: "list",
Usage: "list - List all schedules",
Handler: func(cc CommandContext) (string, error) {
items, err := h.scheduleService.List(cc.Ctx, cc.BotID)
if err != nil {
return "", err
}
if len(items) == 0 {
return "No schedules found.", nil
}
records := make([][]kv, 0, len(items))
for _, item := range items {
records = append(records, []kv{
{"Name", item.Name},
{"Pattern", item.Pattern},
{"Enabled", boolStr(item.Enabled)},
{"Description", truncate(item.Description, 30)},
})
}
return formatItems(records), nil
},
})
g.Register(SubCommand{
Name: "get",
Usage: "get <name> - Get schedule details",
Handler: func(cc CommandContext) (string, error) {
if len(cc.Args) < 1 {
return "Usage: /schedule get <name>", nil
}
item, err := h.findScheduleByName(cc, cc.Args[0])
if err != nil {
return "", err
}
maxCalls := "unlimited"
if item.MaxCalls != nil {
maxCalls = fmt.Sprintf("%d", *item.MaxCalls)
}
return formatKV([]kv{
{"Name", item.Name},
{"Description", item.Description},
{"Pattern", item.Pattern},
{"Command", item.Command},
{"Enabled", boolStr(item.Enabled)},
{"Max Calls", maxCalls},
{"Current Calls", fmt.Sprintf("%d", item.CurrentCalls)},
{"Created", item.CreatedAt.Format("2006-01-02 15:04:05")},
{"Updated", item.UpdatedAt.Format("2006-01-02 15:04:05")},
}), nil
},
})
g.Register(SubCommand{
Name: "create",
Usage: "create <name> <pattern> <command> - Create a schedule",
IsWrite: true,
Handler: func(cc CommandContext) (string, error) {
if len(cc.Args) < 3 {
return "Usage: /schedule create <name> <pattern> <command>\nExample: /schedule create daily-report \"0 9 * * *\" \"Send daily report\"", nil
}
name := cc.Args[0]
pattern := cc.Args[1]
command := strings.Join(cc.Args[2:], " ")
item, err := h.scheduleService.Create(cc.Ctx, cc.BotID, schedule.CreateRequest{
Name: name,
Description: name,
Pattern: pattern,
Command: command,
})
if err != nil {
return "", err
}
return fmt.Sprintf("Schedule %q created.", item.Name), nil
},
})
g.Register(SubCommand{
Name: "update",
Usage: "update <name> [--pattern P] [--command C] - Update a schedule",
IsWrite: true,
Handler: func(cc CommandContext) (string, error) {
if len(cc.Args) < 1 {
return "Usage: /schedule update <name> [--pattern P] [--command C]", nil
}
item, err := h.findScheduleByName(cc, cc.Args[0])
if err != nil {
return "", err
}
req := schedule.UpdateRequest{}
args := cc.Args[1:]
for i := 0; i < len(args); i++ {
if i+1 >= len(args) {
break
}
switch args[i] {
case "--name":
i++
req.Name = &args[i]
case "--pattern":
i++
req.Pattern = &args[i]
case "--command":
i++
val := strings.Join(args[i:], " ")
req.Command = &val
i = len(args)
case "--enabled":
i++
v := strings.ToLower(args[i]) == "true"
req.Enabled = &v
}
}
updated, err := h.scheduleService.Update(cc.Ctx, item.ID, req)
if err != nil {
return "", err
}
return fmt.Sprintf("Schedule %q updated.", updated.Name), nil
},
})
g.Register(SubCommand{
Name: "delete",
Usage: "delete <name> - Delete a schedule",
IsWrite: true,
Handler: func(cc CommandContext) (string, error) {
if len(cc.Args) < 1 {
return "Usage: /schedule delete <name>", nil
}
item, err := h.findScheduleByName(cc, cc.Args[0])
if err != nil {
return "", err
}
if err := h.scheduleService.Delete(cc.Ctx, item.ID); err != nil {
return "", err
}
return fmt.Sprintf("Schedule %q deleted.", cc.Args[0]), nil
},
})
g.Register(SubCommand{
Name: "enable",
Usage: "enable <name> - Enable a schedule",
IsWrite: true,
Handler: func(cc CommandContext) (string, error) {
if len(cc.Args) < 1 {
return "Usage: /schedule enable <name>", nil
}
item, err := h.findScheduleByName(cc, cc.Args[0])
if err != nil {
return "", err
}
enabled := true
_, err = h.scheduleService.Update(cc.Ctx, item.ID, schedule.UpdateRequest{Enabled: &enabled})
if err != nil {
return "", err
}
return fmt.Sprintf("Schedule %q enabled.", cc.Args[0]), nil
},
})
g.Register(SubCommand{
Name: "disable",
Usage: "disable <name> - Disable a schedule",
IsWrite: true,
Handler: func(cc CommandContext) (string, error) {
if len(cc.Args) < 1 {
return "Usage: /schedule disable <name>", nil
}
item, err := h.findScheduleByName(cc, cc.Args[0])
if err != nil {
return "", err
}
enabled := false
_, err = h.scheduleService.Update(cc.Ctx, item.ID, schedule.UpdateRequest{Enabled: &enabled})
if err != nil {
return "", err
}
return fmt.Sprintf("Schedule %q disabled.", cc.Args[0]), nil
},
})
return g
}
func (h *Handler) findScheduleByName(cc CommandContext, name string) (schedule.Schedule, error) {
items, err := h.scheduleService.List(cc.Ctx, cc.BotID)
if err != nil {
return schedule.Schedule{}, err
}
for _, item := range items {
if strings.EqualFold(item.Name, name) {
return item, nil
}
}
return schedule.Schedule{}, fmt.Errorf("schedule %q not found", name)
}
+61
View File
@@ -0,0 +1,61 @@
package command
import (
"fmt"
"strings"
"github.com/memohai/memoh/internal/settings"
)
func (h *Handler) buildSearchGroup() *CommandGroup {
g := newCommandGroup("search", "Manage search provider")
g.Register(SubCommand{
Name: "list",
Usage: "list - List all search providers",
Handler: func(cc CommandContext) (string, error) {
items, err := h.searchProvService.List(cc.Ctx, "")
if err != nil {
return "", err
}
if len(items) == 0 {
return "No search providers found.", nil
}
records := make([][]kv, 0, len(items))
for _, item := range items {
records = append(records, []kv{
{"Name", item.Name},
{"Provider", item.Provider},
})
}
return formatItems(records), nil
},
})
g.Register(SubCommand{
Name: "set",
Usage: "set <name> - Set the search provider for this bot",
IsWrite: true,
Handler: func(cc CommandContext) (string, error) {
if len(cc.Args) < 1 {
return "Usage: /search set <name>", nil
}
name := cc.Args[0]
items, err := h.searchProvService.List(cc.Ctx, "")
if err != nil {
return "", err
}
for _, item := range items {
if strings.EqualFold(item.Name, name) {
_, err := h.settingsService.UpsertBot(cc.Ctx, cc.BotID, settings.UpsertRequest{
SearchProviderID: item.ID,
})
if err != nil {
return "", err
}
return fmt.Sprintf("Search provider set to %q.", item.Name), nil
}
}
return fmt.Sprintf("Search provider %q not found.", name), nil
},
})
return g
}
+204
View File
@@ -0,0 +1,204 @@
package command
import (
"fmt"
"strconv"
"strings"
"github.com/memohai/memoh/internal/settings"
)
func (h *Handler) buildSettingsGroup() *CommandGroup {
g := newCommandGroup("settings", "View and update bot settings")
g.DefaultAction = "get"
g.Register(SubCommand{
Name: "get",
Usage: "get - View current settings",
Handler: func(cc CommandContext) (string, error) {
s, err := h.settingsService.GetBot(cc.Ctx, cc.BotID)
if err != nil {
return "", err
}
return formatKV([]kv{
{"Language", s.Language},
{"Allow Guest", boolStr(s.AllowGuest)},
{"Max Context Load Time", fmt.Sprintf("%d min", s.MaxContextLoadTime)},
{"Max Context Tokens", fmt.Sprintf("%d", s.MaxContextTokens)},
{"Max Inbox Items", fmt.Sprintf("%d", s.MaxInboxItems)},
{"Reasoning Enabled", boolStr(s.ReasoningEnabled)},
{"Reasoning Effort", s.ReasoningEffort},
{"Heartbeat Enabled", boolStr(s.HeartbeatEnabled)},
{"Heartbeat Interval", fmt.Sprintf("%d min", s.HeartbeatInterval)},
{"Chat Model", h.resolveModelName(cc, s.ChatModelID)},
{"Heartbeat Model", h.resolveModelName(cc, s.HeartbeatModelID)},
{"Search Provider", h.resolveSearchProviderName(cc, s.SearchProviderID)},
{"Memory Provider", h.resolveMemoryProviderName(cc, s.MemoryProviderID)},
{"Browser Context", h.resolveBrowserContextName(cc, s.BrowserContextID)},
}), nil
},
})
g.Register(SubCommand{
Name: "update",
Usage: "update [--language L] [--allow_guest true|false] ... - Update settings",
IsWrite: true,
Handler: func(cc CommandContext) (string, error) {
if len(cc.Args) == 0 {
return settingsUpdateUsage(), nil
}
req := settings.UpsertRequest{}
args := cc.Args
for i := 0; i < len(args); i++ {
if i+1 >= len(args) {
return fmt.Sprintf("Missing value for %s.\n\n%s", args[i], settingsUpdateUsage()), nil
}
switch args[i] {
case "--language":
i++
req.Language = args[i]
case "--allow_guest":
i++
v := strings.ToLower(args[i]) == "true"
req.AllowGuest = &v
case "--reasoning_enabled":
i++
v := strings.ToLower(args[i]) == "true"
req.ReasoningEnabled = &v
case "--reasoning_effort":
i++
req.ReasoningEffort = &args[i]
case "--heartbeat_enabled":
i++
v := strings.ToLower(args[i]) == "true"
req.HeartbeatEnabled = &v
case "--heartbeat_interval":
i++
val, err := strconv.Atoi(args[i])
if err != nil {
return fmt.Sprintf("Invalid heartbeat_interval: %s", args[i]), nil
}
req.HeartbeatInterval = &val
case "--max_context_load_time":
i++
val, err := strconv.Atoi(args[i])
if err != nil {
return fmt.Sprintf("Invalid max_context_load_time: %s", args[i]), nil
}
req.MaxContextLoadTime = &val
case "--max_context_tokens":
i++
val, err := strconv.Atoi(args[i])
if err != nil {
return fmt.Sprintf("Invalid max_context_tokens: %s", args[i]), nil
}
req.MaxContextTokens = &val
case "--chat_model_id":
i++
req.ChatModelID = args[i]
case "--heartbeat_model_id":
i++
req.HeartbeatModelID = args[i]
default:
return fmt.Sprintf("Unknown option: %s\n\n%s", args[i], settingsUpdateUsage()), nil
}
}
_, err := h.settingsService.UpsertBot(cc.Ctx, cc.BotID, req)
if err != nil {
return "", err
}
return "Settings updated.", nil
},
})
return g
}
func settingsUpdateUsage() string {
return "Usage: /settings update [options]\n\n" +
"Options:\n" +
"- --language <value>\n" +
"- --allow_guest <true|false>\n" +
"- --reasoning_enabled <true|false>\n" +
"- --reasoning_effort <low|medium|high>\n" +
"- --heartbeat_enabled <true|false>\n" +
"- --heartbeat_interval <minutes>\n" +
"- --max_context_load_time <minutes>\n" +
"- --max_context_tokens <count>\n" +
"- --chat_model_id <id>\n" +
"- --heartbeat_model_id <id>"
}
func valueOrNone(s string) string {
if s == "" {
return "(none)"
}
return s
}
// resolveModelName resolves a model UUID to "model_name (provider_name)".
func (h *Handler) resolveModelName(cc CommandContext, modelID string) string {
if modelID == "" {
return "(none)"
}
if h.modelsService == nil {
return modelID
}
m, err := h.modelsService.GetByID(cc.Ctx, modelID)
if err != nil {
return modelID
}
provName := ""
if h.providersService != nil {
p, err := h.providersService.Get(cc.Ctx, m.LlmProviderID)
if err == nil {
provName = p.Name
}
}
if provName != "" {
return fmt.Sprintf("%s (%s)", m.Name, provName)
}
return m.Name
}
// resolveSearchProviderName resolves a search provider UUID to its name.
func (h *Handler) resolveSearchProviderName(cc CommandContext, id string) string {
if id == "" {
return "(none)"
}
if h.searchProvService == nil {
return id
}
p, err := h.searchProvService.Get(cc.Ctx, id)
if err != nil {
return id
}
return p.Name
}
// resolveMemoryProviderName resolves a memory provider UUID to its name.
func (h *Handler) resolveMemoryProviderName(cc CommandContext, id string) string {
if id == "" {
return "(none)"
}
if h.memProvService == nil {
return id
}
p, err := h.memProvService.Get(cc.Ctx, id)
if err != nil {
return id
}
return p.Name
}
// resolveBrowserContextName resolves a browser context UUID to its name.
func (h *Handler) resolveBrowserContextName(cc CommandContext, id string) string {
if id == "" {
return "(none)"
}
if h.browserCtxService == nil {
return id
}
p, err := h.browserCtxService.GetByID(cc.Ctx, id)
if err != nil {
return id
}
return p.Name
}
+31
View File
@@ -0,0 +1,31 @@
package command
func (h *Handler) buildSkillGroup() *CommandGroup {
g := newCommandGroup("skill", "View bot skills")
g.DefaultAction = "list"
g.Register(SubCommand{
Name: "list",
Usage: "list - List all skills",
Handler: func(cc CommandContext) (string, error) {
if h.skillLoader == nil {
return "Skill loading is not available.", nil
}
items, err := h.skillLoader.LoadSkills(cc.Ctx, cc.BotID)
if err != nil {
return "", err
}
if len(items) == 0 {
return "No skills found.", nil
}
records := make([][]kv, 0, len(items))
for _, item := range items {
records = append(records, []kv{
{"Name", item.Name},
{"Description", truncate(item.Description, 60)},
})
}
return formatItems(records), nil
},
})
return g
}
+94
View File
@@ -0,0 +1,94 @@
package command
import (
"fmt"
"strings"
"github.com/memohai/memoh/internal/subagent"
)
func (h *Handler) buildSubagentGroup() *CommandGroup {
g := newCommandGroup("subagent", "Manage subagents")
g.Register(SubCommand{
Name: "list",
Usage: "list - List all subagents",
Handler: func(cc CommandContext) (string, error) {
items, err := h.subagentService.List(cc.Ctx, cc.BotID)
if err != nil {
return "", err
}
if len(items) == 0 {
return "No subagents found.", nil
}
records := make([][]kv, 0, len(items))
for _, item := range items {
records = append(records, []kv{
{"Name", item.Name},
{"Description", truncate(item.Description, 40)},
})
}
return formatItems(records), nil
},
})
g.Register(SubCommand{
Name: "get",
Usage: "get <name> - Get subagent details",
Handler: func(cc CommandContext) (string, error) {
if len(cc.Args) < 1 {
return "Usage: /subagent get <name>", nil
}
name := cc.Args[0]
item, err := h.subagentService.GetByBotAndName(cc.Ctx, cc.BotID, name)
if err != nil {
return "", fmt.Errorf("subagent %q not found", name)
}
return formatKV([]kv{
{"Name", item.Name},
{"Description", item.Description},
{"Skills", fmt.Sprintf("%v", item.Skills)},
{"Created", item.CreatedAt.Format("2006-01-02 15:04:05")},
{"Updated", item.UpdatedAt.Format("2006-01-02 15:04:05")},
}), nil
},
})
g.Register(SubCommand{
Name: "create",
Usage: "create <name> <description> - Create a subagent",
IsWrite: true,
Handler: func(cc CommandContext) (string, error) {
if len(cc.Args) < 2 {
return "Usage: /subagent create <name> <description>", nil
}
name := cc.Args[0]
description := strings.Join(cc.Args[1:], " ")
item, err := h.subagentService.Create(cc.Ctx, cc.BotID, subagent.CreateRequest{
Name: name,
Description: description,
})
if err != nil {
return "", err
}
return fmt.Sprintf("Subagent %q created (ID: %s).", item.Name, item.ID), nil
},
})
g.Register(SubCommand{
Name: "delete",
Usage: "delete <name> - Delete a subagent",
IsWrite: true,
Handler: func(cc CommandContext) (string, error) {
if len(cc.Args) < 1 {
return "Usage: /subagent delete <name>", nil
}
name := cc.Args[0]
item, err := h.subagentService.GetByBotAndName(cc.Ctx, cc.BotID, name)
if err != nil {
return "", fmt.Errorf("subagent %q not found", name)
}
if err := h.subagentService.Delete(cc.Ctx, item.ID); err != nil {
return "", err
}
return fmt.Sprintf("Subagent %q deleted.", name), nil
},
})
return g
}
+144
View File
@@ -0,0 +1,144 @@
package command
import (
"fmt"
"strings"
"time"
"github.com/google/uuid"
"github.com/jackc/pgx/v5/pgtype"
dbsqlc "github.com/memohai/memoh/internal/db/sqlc"
)
func (h *Handler) buildUsageGroup() *CommandGroup {
g := newCommandGroup("usage", "View token usage")
g.DefaultAction = "summary"
g.Register(SubCommand{
Name: "summary",
Usage: "summary - Token usage summary (last 7 days)",
Handler: func(cc CommandContext) (string, error) {
botUUID, err := parseBotUUID(cc.BotID)
if err != nil {
return "", err
}
now := time.Now().UTC()
from := now.AddDate(0, 0, -7)
fromTS := pgtype.Timestamptz{Time: from, Valid: true}
toTS := pgtype.Timestamptz{Time: now, Valid: true}
nullModel := pgtype.UUID{Valid: false}
chatRows, err := h.queries.GetMessageTokenUsageByDay(cc.Ctx, dbsqlc.GetMessageTokenUsageByDayParams{
BotID: botUUID, FromTime: fromTS, ToTime: toTS, ModelID: nullModel,
})
if err != nil {
return "", err
}
hbRows, err := h.queries.GetHeartbeatTokenUsageByDay(cc.Ctx, dbsqlc.GetHeartbeatTokenUsageByDayParams{
BotID: botUUID, FromTime: fromTS, ToTime: toTS, ModelID: nullModel,
})
if err != nil {
return "", err
}
if len(chatRows) == 0 && len(hbRows) == 0 {
return "No token usage in the last 7 days.", nil
}
var b strings.Builder
b.WriteString("Token usage (last 7 days):\n\n")
if len(chatRows) > 0 {
b.WriteString("Chat:\n")
var totalIn, totalOut int64
for _, r := range chatRows {
day := r.Day.Time.Format("01-02")
fmt.Fprintf(&b, " %s: in=%d out=%d\n", day, r.InputTokens, r.OutputTokens)
totalIn += r.InputTokens
totalOut += r.OutputTokens
}
fmt.Fprintf(&b, " Total: in=%d out=%d\n", totalIn, totalOut)
}
if len(hbRows) > 0 {
if len(chatRows) > 0 {
b.WriteByte('\n')
}
b.WriteString("Heartbeat:\n")
var totalIn, totalOut int64
for _, r := range hbRows {
day := r.Day.Time.Format("01-02")
fmt.Fprintf(&b, " %s: in=%d out=%d\n", day, r.InputTokens, r.OutputTokens)
totalIn += r.InputTokens
totalOut += r.OutputTokens
}
fmt.Fprintf(&b, " Total: in=%d out=%d\n", totalIn, totalOut)
}
return strings.TrimRight(b.String(), "\n"), nil
},
})
g.Register(SubCommand{
Name: "by-model",
Usage: "by-model - Token usage grouped by model",
Handler: func(cc CommandContext) (string, error) {
botUUID, err := parseBotUUID(cc.BotID)
if err != nil {
return "", err
}
now := time.Now().UTC()
from := now.AddDate(0, 0, -7)
fromTS := pgtype.Timestamptz{Time: from, Valid: true}
toTS := pgtype.Timestamptz{Time: now, Valid: true}
chatRows, err := h.queries.GetMessageTokenUsageByModel(cc.Ctx, dbsqlc.GetMessageTokenUsageByModelParams{
BotID: botUUID, FromTime: fromTS, ToTime: toTS,
})
if err != nil {
return "", err
}
hbRows, err := h.queries.GetHeartbeatTokenUsageByModel(cc.Ctx, dbsqlc.GetHeartbeatTokenUsageByModelParams{
BotID: botUUID, FromTime: fromTS, ToTime: toTS,
})
if err != nil {
return "", err
}
if len(chatRows) == 0 && len(hbRows) == 0 {
return "No token usage in the last 7 days.", nil
}
var b strings.Builder
b.WriteString("Token usage by model (last 7 days):\n\n")
if len(chatRows) > 0 {
b.WriteString("Chat:\n")
for _, r := range chatRows {
fmt.Fprintf(&b, " %s (%s): in=%d out=%d\n", r.ModelName, r.ProviderName, r.InputTokens, r.OutputTokens)
}
}
if len(hbRows) > 0 {
if len(chatRows) > 0 {
b.WriteByte('\n')
}
b.WriteString("Heartbeat:\n")
for _, r := range hbRows {
fmt.Fprintf(&b, " %s (%s): in=%d out=%d\n", r.ModelName, r.ProviderName, r.InputTokens, r.OutputTokens)
}
}
return strings.TrimRight(b.String(), "\n"), nil
},
})
return g
}
func parseBotUUID(botID string) (pgtype.UUID, error) {
parsed, err := uuid.Parse(botID)
if err != nil {
return pgtype.UUID{}, fmt.Errorf("invalid bot ID: %w", err)
}
return pgtype.UUID{Bytes: parsed, Valid: true}, nil
}