Files
OwnCord/Server/ws/handlers_command.go
T
J3vb 9116a880a3 feat(phase-bc): pass 5 — pgdbgen, postgres EventStore/PluginStore, plugin hub wiring, OTel stack, reconnect DB tier
- Generate Server/db/dbgen/{events,plugins}.sql.go and full Server/db/pgdbgen/ (//go:build postgres gated)
- Implement PostgresStore EventStore and PluginStore methods in store/postgres.go
- Wire plugin host_events.go EventSink into hub broadcast path (SetPluginEventSink)
- Wire host_commands.go slash-command dispatcher: chat_command V1 handler + hub.SetPluginRegistry
- Add handlers_command.go + handlers_command_test.go for plugin slash-command dispatch
- Add reconnect_db_test.go: TestReconnect_BufferMiss_FallsBackToDBTier (cold-tier DB replay)
- Add otel-up/otel-down Makefile targets; docker-compose.otel.yml + prometheus.dev.yml
- Update PHASE_BC_LOCAL_TODO.md: mark in-session items complete; document remaining network-blocked steps
- Minor fixes: channel_handler access-control, router plugin handler wiring, service span instrumentation
2026-04-06 22:48:59 +02:00

120 lines
3.5 KiB
Go

// Phase C Step 9 — plugin slash-command dispatcher.
//
// chat_command routes a slash command from a WS client to a registered plugin.
// If no plugin owns the command, an error is returned to the sender. If the
// plugin returns a Reply, it is sent only to the invoking client (ephemeral).
// If the plugin returns a Broadcast string, it is broadcast to the channel.
package ws
import (
"context"
"encoding/json"
"fmt"
"log/slog"
"strings"
)
const MsgTypeChatCommand = "chat_command"
// chatCommandPayload is the client-supplied payload for a chat_command message.
type chatCommandPayload struct {
ChannelID int64 `json:"channel_id"`
Command string `json:"command"` // including leading slash, e.g. "/hello"
Args []string `json:"args"`
}
// registerPluginCommandHandler registers the chat_command V1 handler.
func registerPluginCommandHandler(r *HandlerRegistry) {
r.Register(MsgTypeChatCommand, handlePluginCommand)
}
// handlePluginCommand dispatches a slash command to the owning plugin via
// hub.pluginRegistry. Returns an error to the client when:
// - the payload is malformed,
// - the command name is empty,
// - no plugin owns the command (unknown command),
// - the plugin returns an error reply.
func handlePluginCommand(ctx context.Context, h *Hub, c *Client, reqID string, payload json.RawMessage) {
var p chatCommandPayload
if err := json.Unmarshal(payload, &p); err != nil {
c.sendMsg(buildErrorMsg(ErrCodeBadRequest, "invalid chat_command payload"))
return
}
cmd := strings.TrimSpace(p.Command)
if cmd == "" {
c.sendMsg(buildErrorMsg(ErrCodeBadRequest, "command must not be empty"))
return
}
if h.pluginRegistry == nil {
c.sendMsg(buildErrorMsg(ErrCodeBadRequest, fmt.Sprintf("unknown command: %s (no plugins loaded)", cmd)))
return
}
result, handled := h.pluginRegistry.DispatchCommand(ctx, c.userID, p.ChannelID, cmd, p.Args)
if !handled {
c.sendMsg(buildErrorMsg(ErrCodeBadRequest, fmt.Sprintf("unknown command: %s", cmd)))
return
}
if result == nil {
// Plugin acknowledged with no output.
return
}
if result.Reply != "" {
// Ephemeral reply — sent only to the invoking client.
c.sendMsg(buildCommandReply(reqID, result.Reply))
}
if result.Broadcast != "" && p.ChannelID != 0 {
// Channel broadcast — visible to everyone in the channel.
msg := buildCommandBroadcast(p.ChannelID, c.userID, cmd, result.Broadcast)
h.BroadcastToChannel(p.ChannelID, msg)
slog.Info("plugin command broadcast", "cmd", cmd, "channel_id", p.ChannelID, "user_id", c.userID)
}
}
// buildCommandReply builds an ephemeral command_reply envelope.
func buildCommandReply(reqID, text string) []byte {
type payload struct {
Text string `json:"text"`
}
type envelope struct {
Type string `json:"type"`
ReqID string `json:"req_id,omitempty"`
Payload payload `json:"payload"`
}
raw, _ := json.Marshal(envelope{
Type: "command_reply",
ReqID: reqID,
Payload: payload{Text: text},
})
return raw
}
// buildCommandBroadcast builds a plugin_broadcast envelope sent to a channel.
func buildCommandBroadcast(channelID, userID int64, cmd, text string) []byte {
type payload struct {
ChannelID int64 `json:"channel_id"`
UserID int64 `json:"user_id"`
Command string `json:"command"`
Text string `json:"text"`
}
type envelope struct {
Type string `json:"type"`
Payload payload `json:"payload"`
}
raw, _ := json.Marshal(envelope{
Type: "plugin_broadcast",
Payload: payload{
ChannelID: channelID,
UserID: userID,
Command: cmd,
Text: text,
},
})
return raw
}