mirror of
https://github.com/memohai/Memoh.git
synced 2026-04-25 07:00:48 +09:00
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:
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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, ", "))
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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{}
|
||||
)
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user