2026-04-06 22:48:59 +02:00
|
|
|
// 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).
|
2026-04-07 05:28:53 +00:00
|
|
|
// If the plugin returns a Broadcast string, it is broadcast to the channel
|
|
|
|
|
// only after verifying the invoking client holds SEND_MESSAGES permission.
|
2026-07-18 12:55:22 +02:00
|
|
|
|
2026-04-06 22:48:59 +02:00
|
|
|
package ws
|
|
|
|
|
|
|
|
|
|
import (
|
|
|
|
|
"context"
|
|
|
|
|
"encoding/json"
|
2026-07-19 09:03:03 +02:00
|
|
|
"errors"
|
2026-04-06 22:48:59 +02:00
|
|
|
"fmt"
|
|
|
|
|
"log/slog"
|
|
|
|
|
"strings"
|
2026-04-07 05:28:53 +00:00
|
|
|
|
2026-07-19 09:03:03 +02:00
|
|
|
"github.com/owncord/server/service"
|
2026-04-06 22:48:59 +02:00
|
|
|
)
|
|
|
|
|
|
|
|
|
|
const MsgTypeChatCommand = "chat_command"
|
|
|
|
|
|
2026-04-07 05:28:53 +00:00
|
|
|
// 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
|
|
|
|
|
|
2026-04-06 22:48:59 +02:00
|
|
|
// 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,
|
2026-04-07 05:28:53 +00:00
|
|
|
// - too many arguments are supplied,
|
2026-04-06 22:48:59 +02:00
|
|
|
// - 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
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-07 05:28:53 +00:00
|
|
|
if len(p.Args) > maxCommandArgs {
|
|
|
|
|
c.sendMsg(buildErrorMsg(ErrCodeBadRequest, fmt.Sprintf("too many command arguments (max %d)", maxCommandArgs)))
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-06 22:48:59 +02:00
|
|
|
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 {
|
2026-07-17 21:08:54 +02:00
|
|
|
// Verify the invoking client can post to this channel before broadcasting
|
|
|
|
|
// the plugin result to all channel members. Mirrors the normal send path:
|
|
|
|
|
// non-DM channels require READ_MESSAGES|SEND_MESSAGES (so a user cannot
|
|
|
|
|
// post into a channel they cannot read), and DM channels are validated by
|
|
|
|
|
// participant membership rather than role permissions.
|
|
|
|
|
if !h.requireChannelBroadcastAccess(c, p.ChannelID) {
|
2026-04-07 05:28:53 +00:00
|
|
|
return
|
|
|
|
|
}
|
2026-04-06 22:48:59 +02:00
|
|
|
// 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)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-17 21:08:54 +02:00
|
|
|
// requireChannelBroadcastAccess reports whether the client may post to
|
2026-07-19 09:03:03 +02:00
|
|
|
// channelID, by delegating to the SAME service-layer check a real message
|
|
|
|
|
// send runs (MessageService.CanPost: cached channel permissions; DM
|
|
|
|
|
// membership AND DM blocks). The previous RequireChannelAccess route skipped
|
|
|
|
|
// the block check in its DM branch — a blocked user's plugin broadcast could
|
|
|
|
|
// reach the person who blocked them — and issued a raw GetRoleByID per
|
|
|
|
|
// broadcast, bypassing the permission cache. On failure it sends an error to
|
|
|
|
|
// the client and returns false.
|
2026-07-17 21:08:54 +02:00
|
|
|
func (h *Hub) requireChannelBroadcastAccess(c *Client, channelID int64) bool {
|
|
|
|
|
if c.user == nil {
|
|
|
|
|
c.sendMsg(buildErrorMsg(ErrCodeForbidden, "not authenticated"))
|
|
|
|
|
return false
|
|
|
|
|
}
|
2026-07-19 09:03:03 +02:00
|
|
|
if h.messageSvc == nil {
|
|
|
|
|
// No service wired (bare test hub) — fail closed rather than allow
|
|
|
|
|
// an ungated broadcast.
|
|
|
|
|
c.sendMsg(buildErrorMsg(ErrCodeForbidden, "broadcast gate unavailable"))
|
2026-07-17 21:08:54 +02:00
|
|
|
return false
|
|
|
|
|
}
|
2026-07-19 09:03:03 +02:00
|
|
|
if err := h.messageSvc.CanPost(c.userID, channelID); err != nil {
|
|
|
|
|
if errors.Is(err, service.ErrNotFound) {
|
|
|
|
|
c.sendMsg(buildErrorMsg(ErrCodeNotFound, "channel not found"))
|
|
|
|
|
return false
|
|
|
|
|
}
|
2026-07-17 21:08:54 +02:00
|
|
|
slog.Warn("ws plugin broadcast permission denied",
|
2026-07-19 09:03:03 +02:00
|
|
|
"user_id", c.userID, "channel_id", channelID, "err", err)
|
2026-07-17 21:08:54 +02:00
|
|
|
c.sendMsg(buildErrorMsg(ErrCodeForbidden, "missing permission to post in this channel"))
|
|
|
|
|
return false
|
|
|
|
|
}
|
|
|
|
|
return true
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-06 22:48:59 +02:00
|
|
|
// 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
|
|
|
|
|
}
|