Files
OwnCord/Server/ws/handlers_command.go
T
J3vbandClaude Fable 5 5c91d2492b feat(ws): finish the V2 dispatch migration and delete V1
Port the last three V1 message types to typed V2 handlers, then remove
the V1 registry and fallback path so handleMessage has a single dispatch
generation (audit A-2026-07-09 / backlog item 11). Server-internal only —
the envelope wire format is unchanged, no client/protocol edits.

- chat_command: ChatCommandCmd + constructor (empty/args guards) and a
  V2 handler returning an ephemeral Reply plus a channel-routed
  PluginBroadcastEvent gated by MessageService.CanPost; PluginDeps reads
  the registry live (wired post-construction)
- voice_join/voice_leave: V2 handlers gate parse/rate-limit and hand off
  to the still-hub-internal handleVoiceJoin/handleVoiceLeave routines via
  new Result.JoinVoice / Result.LeaveVoice appliers (those routines are
  also called un-throttled on disconnect and channel switch)
- delete HandlerRegistry.handlers/Register/Dispatch/RegisteredTypes/
  IsRegisteredV1/hasV2, the MessageHandler type, and the V1-shadowing
  guard; NewHub registers only V2
- tests: per-handler V2 tests + a parity guard asserting every command
  constructor has a V2 handler and vice versa (locks the migration shut)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-20 11:41:58 +02:00

145 lines
5.2 KiB
Go

// Phase C Step 9 — plugin slash-command dispatcher (V2).
//
// 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
// only after verifying the invoking client holds SEND_MESSAGES permission.
package ws
import (
"context"
"encoding/json"
"errors"
"fmt"
"log/slog"
"github.com/owncord/server/plugin"
"github.com/owncord/server/service"
)
const MsgTypeChatCommand = "chat_command"
// maxCommandArgs is the maximum number of arguments accepted in a
// chat_command payload. This prevents a malicious client from flooding
// the plugin's allocate/dispatch ABI with thousands of strings.
const maxCommandArgs = 64
// handleChatCommandV2 dispatches a slash command to the owning plugin via the
// live plugin registry (wired post-construction). It returns:
// - a ClientError when no plugin registry is wired, the command is unknown,
// or the invoking user may not post the plugin's broadcast;
// - Result.Reply for an ephemeral plugin reply (sender only);
// - Result.Events with a PluginBroadcastEvent for a channel broadcast, gated
// by MessageService.CanPost (same policy as a real message send).
func handleChatCommandV2(ctx context.Context, cmd Command, _ ClientInfo, deps any) Result {
d := deps.(PluginDeps)
cc := cmd.(ChatCommandCmd)
var reg *plugin.Registry
if d.Registry != nil {
reg = d.Registry()
}
if reg == nil {
return Result{Error: ClientError{Code: ErrCodeBadRequest, Message: fmt.Sprintf("unknown command: %s (no plugins loaded)", cc.command)}}
}
result, handled := reg.DispatchCommand(ctx, cc.userID, cc.channelID, cc.command, cc.args)
if !handled {
return Result{Error: ClientError{Code: ErrCodeBadRequest, Message: fmt.Sprintf("unknown command: %s", cc.command)}}
}
if result == nil {
// Plugin acknowledged with no output.
return Result{}
}
var out Result
if result.Reply != "" {
// Ephemeral reply — sent only to the invoking client.
out.Reply = buildCommandReply(cc.reqID, result.Reply)
}
if result.Broadcast != "" && cc.channelID != 0 {
// Verify the invoking client can post to this channel before broadcasting
// (same gate a real send uses: channel role perms + DM membership/blocks).
// ponytail: if the plugin returned both a reply and a broadcast and the
// gate denies, the error wins and the ephemeral reply is dropped (Result
// carries either an error or a reply, not both) — an untested edge; V1
// sent both. Preserve the security signal (denial) over the ack.
if gate := canPluginBroadcast(d.MessageSvc, cc.userID, cc.channelID); gate != nil {
return *gate
}
msg := buildCommandBroadcast(cc.channelID, cc.userID, cc.command, result.Broadcast)
out.Events = append(out.Events, PluginBroadcastEvent{channelID: cc.channelID, payload: msg})
slog.Info("plugin command broadcast", "cmd", cc.command, "channel_id", cc.channelID, "user_id", cc.userID)
}
return out
}
// canPluginBroadcast reports whether userID may post to channelID by delegating
// to the SAME service-layer check a real message send runs (MessageService.CanPost:
// cached channel permissions; DM membership AND DM blocks). Returns nil when
// allowed, or a Result carrying the appropriate ClientError otherwise. A nil
// MessageSvc (bare test hub) fails closed rather than allowing an ungated
// broadcast.
func canPluginBroadcast(messageSvc *service.MessageService, userID, channelID int64) *Result {
if messageSvc == nil {
r := Result{Error: ClientError{Code: ErrCodeForbidden, Message: "broadcast gate unavailable"}}
return &r
}
if err := messageSvc.CanPost(userID, channelID); err != nil {
if errors.Is(err, service.ErrNotFound) {
r := Result{Error: ClientError{Code: ErrCodeNotFound, Message: "channel not found"}}
return &r
}
slog.Warn("ws plugin broadcast permission denied",
"user_id", userID, "channel_id", channelID, "err", err)
r := Result{Error: ClientError{Code: ErrCodeForbidden, Message: "missing permission to post in this channel"}}
return &r
}
return nil
}
// 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
}