Files
OwnCord/Server/ws/handlers_command.go
T
J3vbandClaude Fable 5 8787b9066d fix: batch of 34 correctness fixes across server and client (#1372)
* fix(client): 3 defect(s) (OC-0037, OC-0063, OC-0116)

Route the tray Status submenu through saveUserStatus() (mapping the legacy
"offline" to "invisible") so notifications, autoIdle, and reconnect presence
restore all agree with the tray's choice; build the connected overlay from
the auth_ok payload instead of a pre-dispatch authStore snapshot; keep the
TOTP overlay open across a rejected verify (totpPending latch) and retain
the partial token for the retry instead of clearing it in finally.

Hand-applied combined cluster preserved from the previous fix run's
overlap-guard block (both clusters edit main.ts).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(voice): 2 defect(s) (OC-0010, OC-0011)

* fix(ws): 1 defect(s) (OC-0050)

* fix(db): 1 defect(s) (OC-0052)

* fix(client): 1 defect(s) (OC-0054)

* fix(client): 1 defect(s) (OC-0059)

* fix(auth): 1 defect(s) (OC-0061)

* fix(ws): 1 defect(s) (OC-0062)

* fix(client): 1 defect(s) (OC-0064)

* fix(service): 1 defect(s) (OC-0070)

* fix(ws): 1 defect(s) (OC-0073)

* fix(service): 2 defect(s) (OC-0075, OC-0120)

* fix(admin): 1 defect(s) (OC-0076)

* fix(voice): 1 defect(s) (OC-0084)

* fix(client): 2 defect(s) (OC-0085, OC-0094)

Scope collapsed-category persistence to the connected host instead of the
server display name, and stop the DM back button from jumping to the first
text channel when DM mode was entered without recording channelBeforeDm.

* fix(service): 1 defect(s) (OC-0087)

* fix(client): 1 defect(s) (OC-0089)

* fix(ws): 1 defect(s) (OC-0091)

* fix(api): 1 defect(s) (OC-0093)

* fix(identity): 1 defect(s) (OC-0118)

* fix(dm): 1 defect(s) (OC-0119)

* fix(voice): 1 defect(s) (OC-0135)

* fix(api): 1 defect(s) (OC-0137)

* fix(client): 1 defect(s) (OC-0142)

* fix(client): 1 defect(s) (OC-0144)

* fix(admin): 1 defect(s) (OC-0145)

* fix(updater): 1 defect(s) (OC-0146)

* fix(client): 1 defect(s) (OC-0150)

* fix(mentions): 1 defect(s) (OC-0131)

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-14 18:48:10 +02:00

158 lines
5.8 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"
"time"
"github.com/owncord/server/auth"
"github.com/owncord/server/plugin"
"github.com/owncord/server/service"
)
// 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
// pluginCommandRateLimit and pluginCommandWindow cap chat_command frames per
// user (OC-0091). Every other V2 handler is throttled; this one drove a WASM
// guest invocation once per frame with no cap at all. Tighter than chat send
// (10/s) because DispatchCommand does real work per call.
const (
pluginCommandRateLimit = 5
pluginCommandWindow = time.Second
)
// 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)
if d.Limiter != nil && !d.Limiter.Allow(auth.Key("plugin_cmd", cc.userID), pluginCommandRateLimit, pluginCommandWindow) {
return Result{Error: ClientError{Code: ErrCodeRateLimited, Message: "too many commands"}}
}
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(ctx, 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(ctx context.Context, 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(ctx, 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: MsgTypeCommandReply,
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: MsgTypePluginBroadcast,
Payload: payload{
ChannelID: channelID,
UserID: userID,
Command: cmd,
Text: text,
},
})
return raw
}