mirror of
https://github.com/J3vb/OwnCord.git
synced 2026-09-03 03:50:00 +03:00
* 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>
158 lines
5.8 KiB
Go
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
|
|
}
|