mirror of
https://github.com/AvengeMedia/DankMaterialShell.git
synced 2026-01-28 07:22:50 -05:00
core/server: add generic dbus service
- Add QML client with subscribe/introspect/getprop/setprop/call - Add CLI helper `dms notify` that allows async calls with action handlers.
This commit is contained in:
237
core/internal/server/dbus/handlers.go
Normal file
237
core/internal/server/dbus/handlers.go
Normal file
@@ -0,0 +1,237 @@
|
||||
package dbus
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net"
|
||||
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/models"
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/params"
|
||||
)
|
||||
|
||||
type objectParams struct {
|
||||
bus string
|
||||
dest string
|
||||
path string
|
||||
iface string
|
||||
}
|
||||
|
||||
func extractObjectParams(p map[string]any, requirePath bool) (objectParams, error) {
|
||||
bus, err := params.String(p, "bus")
|
||||
if err != nil {
|
||||
return objectParams{}, err
|
||||
}
|
||||
dest, err := params.String(p, "dest")
|
||||
if err != nil {
|
||||
return objectParams{}, err
|
||||
}
|
||||
|
||||
var path string
|
||||
if requirePath {
|
||||
path, err = params.String(p, "path")
|
||||
if err != nil {
|
||||
return objectParams{}, err
|
||||
}
|
||||
} else {
|
||||
path = params.StringOpt(p, "path", "/")
|
||||
}
|
||||
|
||||
iface, err := params.String(p, "interface")
|
||||
if err != nil {
|
||||
return objectParams{}, err
|
||||
}
|
||||
|
||||
return objectParams{bus: bus, dest: dest, path: path, iface: iface}, nil
|
||||
}
|
||||
|
||||
func HandleRequest(conn net.Conn, req models.Request, m *Manager, clientID string) {
|
||||
switch req.Method {
|
||||
case "dbus.call":
|
||||
handleCall(conn, req, m)
|
||||
case "dbus.getProperty":
|
||||
handleGetProperty(conn, req, m)
|
||||
case "dbus.setProperty":
|
||||
handleSetProperty(conn, req, m)
|
||||
case "dbus.getAllProperties":
|
||||
handleGetAllProperties(conn, req, m)
|
||||
case "dbus.introspect":
|
||||
handleIntrospect(conn, req, m)
|
||||
case "dbus.listNames":
|
||||
handleListNames(conn, req, m)
|
||||
case "dbus.subscribe":
|
||||
handleSubscribe(conn, req, m, clientID)
|
||||
case "dbus.unsubscribe":
|
||||
handleUnsubscribe(conn, req, m)
|
||||
default:
|
||||
models.RespondError(conn, req.ID, fmt.Sprintf("unknown method: %s", req.Method))
|
||||
}
|
||||
}
|
||||
|
||||
func handleCall(conn net.Conn, req models.Request, m *Manager) {
|
||||
op, err := extractObjectParams(req.Params, true)
|
||||
if err != nil {
|
||||
models.RespondError(conn, req.ID, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
method, err := params.String(req.Params, "method")
|
||||
if err != nil {
|
||||
models.RespondError(conn, req.ID, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
var args []any
|
||||
if argsRaw, ok := params.Any(req.Params, "args"); ok {
|
||||
if argsSlice, ok := argsRaw.([]any); ok {
|
||||
args = argsSlice
|
||||
}
|
||||
}
|
||||
|
||||
result, err := m.Call(op.bus, op.dest, op.path, op.iface, method, args)
|
||||
if err != nil {
|
||||
models.RespondError(conn, req.ID, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
models.Respond(conn, req.ID, result)
|
||||
}
|
||||
|
||||
func handleGetProperty(conn net.Conn, req models.Request, m *Manager) {
|
||||
op, err := extractObjectParams(req.Params, true)
|
||||
if err != nil {
|
||||
models.RespondError(conn, req.ID, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
property, err := params.String(req.Params, "property")
|
||||
if err != nil {
|
||||
models.RespondError(conn, req.ID, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
result, err := m.GetProperty(op.bus, op.dest, op.path, op.iface, property)
|
||||
if err != nil {
|
||||
models.RespondError(conn, req.ID, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
models.Respond(conn, req.ID, result)
|
||||
}
|
||||
|
||||
func handleSetProperty(conn net.Conn, req models.Request, m *Manager) {
|
||||
op, err := extractObjectParams(req.Params, true)
|
||||
if err != nil {
|
||||
models.RespondError(conn, req.ID, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
property, err := params.String(req.Params, "property")
|
||||
if err != nil {
|
||||
models.RespondError(conn, req.ID, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
value, ok := params.Any(req.Params, "value")
|
||||
if !ok {
|
||||
models.RespondError(conn, req.ID, "missing 'value' parameter")
|
||||
return
|
||||
}
|
||||
|
||||
if err := m.SetProperty(op.bus, op.dest, op.path, op.iface, property, value); err != nil {
|
||||
models.RespondError(conn, req.ID, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
models.Respond(conn, req.ID, models.SuccessResult{Success: true})
|
||||
}
|
||||
|
||||
func handleGetAllProperties(conn net.Conn, req models.Request, m *Manager) {
|
||||
op, err := extractObjectParams(req.Params, true)
|
||||
if err != nil {
|
||||
models.RespondError(conn, req.ID, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
result, err := m.GetAllProperties(op.bus, op.dest, op.path, op.iface)
|
||||
if err != nil {
|
||||
models.RespondError(conn, req.ID, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
models.Respond(conn, req.ID, result)
|
||||
}
|
||||
|
||||
func handleIntrospect(conn net.Conn, req models.Request, m *Manager) {
|
||||
bus, err := params.String(req.Params, "bus")
|
||||
if err != nil {
|
||||
models.RespondError(conn, req.ID, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
dest, err := params.String(req.Params, "dest")
|
||||
if err != nil {
|
||||
models.RespondError(conn, req.ID, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
path := params.StringOpt(req.Params, "path", "/")
|
||||
|
||||
result, err := m.Introspect(bus, dest, path)
|
||||
if err != nil {
|
||||
models.RespondError(conn, req.ID, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
models.Respond(conn, req.ID, result)
|
||||
}
|
||||
|
||||
func handleListNames(conn net.Conn, req models.Request, m *Manager) {
|
||||
bus, err := params.String(req.Params, "bus")
|
||||
if err != nil {
|
||||
models.RespondError(conn, req.ID, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
result, err := m.ListNames(bus)
|
||||
if err != nil {
|
||||
models.RespondError(conn, req.ID, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
models.Respond(conn, req.ID, result)
|
||||
}
|
||||
|
||||
func handleSubscribe(conn net.Conn, req models.Request, m *Manager, clientID string) {
|
||||
bus, err := params.String(req.Params, "bus")
|
||||
if err != nil {
|
||||
models.RespondError(conn, req.ID, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
sender := params.StringOpt(req.Params, "sender", "")
|
||||
path := params.StringOpt(req.Params, "path", "")
|
||||
iface := params.StringOpt(req.Params, "interface", "")
|
||||
member := params.StringOpt(req.Params, "member", "")
|
||||
|
||||
result, err := m.Subscribe(clientID, bus, sender, path, iface, member)
|
||||
if err != nil {
|
||||
models.RespondError(conn, req.ID, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
models.Respond(conn, req.ID, result)
|
||||
}
|
||||
|
||||
func handleUnsubscribe(conn net.Conn, req models.Request, m *Manager) {
|
||||
subID, err := params.String(req.Params, "subscriptionId")
|
||||
if err != nil {
|
||||
models.RespondError(conn, req.ID, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
if err := m.Unsubscribe(subID); err != nil {
|
||||
models.RespondError(conn, req.ID, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
models.Respond(conn, req.ID, models.SuccessResult{Success: true})
|
||||
}
|
||||
362
core/internal/server/dbus/manager.go
Normal file
362
core/internal/server/dbus/manager.go
Normal file
@@ -0,0 +1,362 @@
|
||||
package dbus
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/log"
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/pkg/dbusutil"
|
||||
"github.com/godbus/dbus/v5"
|
||||
)
|
||||
|
||||
func NewManager() (*Manager, error) {
|
||||
systemConn, err := dbus.ConnectSystemBus()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to connect to system bus: %w", err)
|
||||
}
|
||||
|
||||
sessionConn, err := dbus.ConnectSessionBus()
|
||||
if err != nil {
|
||||
systemConn.Close()
|
||||
return nil, fmt.Errorf("failed to connect to session bus: %w", err)
|
||||
}
|
||||
|
||||
m := &Manager{
|
||||
systemConn: systemConn,
|
||||
sessionConn: sessionConn,
|
||||
}
|
||||
|
||||
go m.processSystemSignals()
|
||||
go m.processSessionSignals()
|
||||
|
||||
return m, nil
|
||||
}
|
||||
|
||||
func (m *Manager) getConn(bus string) (*dbus.Conn, error) {
|
||||
switch bus {
|
||||
case "system":
|
||||
if m.systemConn == nil {
|
||||
return nil, fmt.Errorf("system bus not connected")
|
||||
}
|
||||
return m.systemConn, nil
|
||||
case "session":
|
||||
if m.sessionConn == nil {
|
||||
return nil, fmt.Errorf("session bus not connected")
|
||||
}
|
||||
return m.sessionConn, nil
|
||||
default:
|
||||
return nil, fmt.Errorf("invalid bus: %s (must be 'system' or 'session')", bus)
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Manager) Call(bus, dest, path, iface, method string, args []any) (*CallResult, error) {
|
||||
conn, err := m.getConn(bus)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
obj := conn.Object(dest, dbus.ObjectPath(path))
|
||||
fullMethod := iface + "." + method
|
||||
|
||||
call := obj.Call(fullMethod, 0, args...)
|
||||
if call.Err != nil {
|
||||
return nil, fmt.Errorf("dbus call failed: %w", call.Err)
|
||||
}
|
||||
|
||||
return &CallResult{Values: call.Body}, nil
|
||||
}
|
||||
|
||||
func (m *Manager) GetProperty(bus, dest, path, iface, property string) (*PropertyResult, error) {
|
||||
conn, err := m.getConn(bus)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
obj := conn.Object(dest, dbus.ObjectPath(path))
|
||||
|
||||
var variant dbus.Variant
|
||||
err = obj.Call("org.freedesktop.DBus.Properties.Get", 0, iface, property).Store(&variant)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get property: %w", err)
|
||||
}
|
||||
|
||||
return &PropertyResult{Value: dbusutil.Normalize(variant.Value())}, nil
|
||||
}
|
||||
|
||||
func (m *Manager) SetProperty(bus, dest, path, iface, property string, value any) error {
|
||||
conn, err := m.getConn(bus)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
obj := conn.Object(dest, dbus.ObjectPath(path))
|
||||
|
||||
call := obj.Call("org.freedesktop.DBus.Properties.Set", 0, iface, property, dbus.MakeVariant(value))
|
||||
if call.Err != nil {
|
||||
return fmt.Errorf("failed to set property: %w", call.Err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *Manager) GetAllProperties(bus, dest, path, iface string) (map[string]any, error) {
|
||||
conn, err := m.getConn(bus)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
obj := conn.Object(dest, dbus.ObjectPath(path))
|
||||
|
||||
var props map[string]dbus.Variant
|
||||
err = obj.Call("org.freedesktop.DBus.Properties.GetAll", 0, iface).Store(&props)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get properties: %w", err)
|
||||
}
|
||||
|
||||
result := make(map[string]any)
|
||||
for k, v := range props {
|
||||
result[k] = dbusutil.Normalize(v.Value())
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (m *Manager) Introspect(bus, dest, path string) (*IntrospectResult, error) {
|
||||
conn, err := m.getConn(bus)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
obj := conn.Object(dest, dbus.ObjectPath(path))
|
||||
|
||||
var xml string
|
||||
err = obj.Call("org.freedesktop.DBus.Introspectable.Introspect", 0).Store(&xml)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to introspect: %w", err)
|
||||
}
|
||||
|
||||
return &IntrospectResult{XML: xml}, nil
|
||||
}
|
||||
|
||||
func (m *Manager) ListNames(bus string) (*ListNamesResult, error) {
|
||||
conn, err := m.getConn(bus)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var names []string
|
||||
err = conn.BusObject().Call("org.freedesktop.DBus.ListNames", 0).Store(&names)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to list names: %w", err)
|
||||
}
|
||||
|
||||
return &ListNamesResult{Names: names}, nil
|
||||
}
|
||||
|
||||
func (m *Manager) Subscribe(clientID, bus, sender, path, iface, member string) (*SubscribeResult, error) {
|
||||
conn, err := m.getConn(bus)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
subID := generateSubscriptionID()
|
||||
|
||||
parts := []string{"type='signal'"}
|
||||
if sender != "" {
|
||||
parts = append(parts, fmt.Sprintf("sender='%s'", sender))
|
||||
}
|
||||
if path != "" {
|
||||
parts = append(parts, fmt.Sprintf("path='%s'", path))
|
||||
}
|
||||
if iface != "" {
|
||||
parts = append(parts, fmt.Sprintf("interface='%s'", iface))
|
||||
}
|
||||
if member != "" {
|
||||
parts = append(parts, fmt.Sprintf("member='%s'", member))
|
||||
}
|
||||
matchRule := strings.Join(parts, ",")
|
||||
|
||||
call := conn.BusObject().Call("org.freedesktop.DBus.AddMatch", 0, matchRule)
|
||||
if call.Err != nil {
|
||||
return nil, fmt.Errorf("failed to add match rule: %w", call.Err)
|
||||
}
|
||||
|
||||
sub := &signalSubscription{
|
||||
Bus: bus,
|
||||
Sender: sender,
|
||||
Path: path,
|
||||
Interface: iface,
|
||||
Member: member,
|
||||
ClientID: clientID,
|
||||
}
|
||||
m.subscriptions.Store(subID, sub)
|
||||
|
||||
log.Debugf("dbus: subscribed %s to %s", subID, matchRule)
|
||||
|
||||
return &SubscribeResult{SubscriptionID: subID}, nil
|
||||
}
|
||||
|
||||
func (m *Manager) Unsubscribe(subID string) error {
|
||||
sub, ok := m.subscriptions.LoadAndDelete(subID)
|
||||
if !ok {
|
||||
return fmt.Errorf("subscription not found: %s", subID)
|
||||
}
|
||||
|
||||
conn, err := m.getConn(sub.Bus)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
parts := []string{"type='signal'"}
|
||||
if sub.Sender != "" {
|
||||
parts = append(parts, fmt.Sprintf("sender='%s'", sub.Sender))
|
||||
}
|
||||
if sub.Path != "" {
|
||||
parts = append(parts, fmt.Sprintf("path='%s'", sub.Path))
|
||||
}
|
||||
if sub.Interface != "" {
|
||||
parts = append(parts, fmt.Sprintf("interface='%s'", sub.Interface))
|
||||
}
|
||||
if sub.Member != "" {
|
||||
parts = append(parts, fmt.Sprintf("member='%s'", sub.Member))
|
||||
}
|
||||
matchRule := strings.Join(parts, ",")
|
||||
|
||||
call := conn.BusObject().Call("org.freedesktop.DBus.RemoveMatch", 0, matchRule)
|
||||
if call.Err != nil {
|
||||
log.Warnf("dbus: failed to remove match rule: %v", call.Err)
|
||||
}
|
||||
|
||||
log.Debugf("dbus: unsubscribed %s", subID)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *Manager) UnsubscribeClient(clientID string) {
|
||||
var toDelete []string
|
||||
m.subscriptions.Range(func(subID string, sub *signalSubscription) bool {
|
||||
if sub.ClientID == clientID {
|
||||
toDelete = append(toDelete, subID)
|
||||
}
|
||||
return true
|
||||
})
|
||||
|
||||
for _, subID := range toDelete {
|
||||
if err := m.Unsubscribe(subID); err != nil {
|
||||
log.Warnf("dbus: failed to unsubscribe %s: %v", subID, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Manager) SubscribeSignals(clientID string) chan SignalEvent {
|
||||
ch := make(chan SignalEvent, 64)
|
||||
existing, loaded := m.signalSubscribers.LoadOrStore(clientID, ch)
|
||||
if loaded {
|
||||
return existing
|
||||
}
|
||||
return ch
|
||||
}
|
||||
|
||||
func (m *Manager) UnsubscribeSignals(clientID string) {
|
||||
if ch, ok := m.signalSubscribers.LoadAndDelete(clientID); ok {
|
||||
close(ch)
|
||||
}
|
||||
m.UnsubscribeClient(clientID)
|
||||
}
|
||||
|
||||
func (m *Manager) processSystemSignals() {
|
||||
if m.systemConn == nil {
|
||||
return
|
||||
}
|
||||
ch := make(chan *dbus.Signal, 256)
|
||||
m.systemConn.Signal(ch)
|
||||
|
||||
for sig := range ch {
|
||||
m.dispatchSignal("system", sig)
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Manager) processSessionSignals() {
|
||||
if m.sessionConn == nil {
|
||||
return
|
||||
}
|
||||
ch := make(chan *dbus.Signal, 256)
|
||||
m.sessionConn.Signal(ch)
|
||||
|
||||
for sig := range ch {
|
||||
m.dispatchSignal("session", sig)
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Manager) dispatchSignal(bus string, sig *dbus.Signal) {
|
||||
path := string(sig.Path)
|
||||
iface := ""
|
||||
member := sig.Name
|
||||
|
||||
if idx := strings.LastIndex(sig.Name, "."); idx != -1 {
|
||||
iface = sig.Name[:idx]
|
||||
member = sig.Name[idx+1:]
|
||||
}
|
||||
|
||||
m.subscriptions.Range(func(subID string, sub *signalSubscription) bool {
|
||||
if sub.Bus != bus {
|
||||
return true
|
||||
}
|
||||
if sub.Path != "" && sub.Path != path && !strings.HasPrefix(path, sub.Path) {
|
||||
return true
|
||||
}
|
||||
if sub.Interface != "" && sub.Interface != iface {
|
||||
return true
|
||||
}
|
||||
if sub.Member != "" && sub.Member != member {
|
||||
return true
|
||||
}
|
||||
|
||||
event := SignalEvent{
|
||||
SubscriptionID: subID,
|
||||
Sender: sig.Sender,
|
||||
Path: path,
|
||||
Interface: iface,
|
||||
Member: member,
|
||||
Body: dbusutil.NormalizeSlice(sig.Body),
|
||||
}
|
||||
|
||||
ch, ok := m.signalSubscribers.Load(sub.ClientID)
|
||||
if !ok {
|
||||
return true
|
||||
}
|
||||
|
||||
select {
|
||||
case ch <- event:
|
||||
default:
|
||||
log.Warnf("dbus: channel full for %s, dropping signal", subID)
|
||||
}
|
||||
|
||||
return true
|
||||
})
|
||||
}
|
||||
|
||||
func (m *Manager) Close() {
|
||||
m.signalSubscribers.Range(func(clientID string, ch chan SignalEvent) bool {
|
||||
close(ch)
|
||||
m.signalSubscribers.Delete(clientID)
|
||||
return true
|
||||
})
|
||||
|
||||
if m.systemConn != nil {
|
||||
m.systemConn.Close()
|
||||
}
|
||||
if m.sessionConn != nil {
|
||||
m.sessionConn.Close()
|
||||
}
|
||||
}
|
||||
|
||||
func generateSubscriptionID() string {
|
||||
b := make([]byte, 8)
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
log.Warnf("dbus: failed to generate random subscription ID: %v", err)
|
||||
}
|
||||
return hex.EncodeToString(b)
|
||||
}
|
||||
52
core/internal/server/dbus/types.go
Normal file
52
core/internal/server/dbus/types.go
Normal file
@@ -0,0 +1,52 @@
|
||||
package dbus
|
||||
|
||||
import (
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/pkg/syncmap"
|
||||
"github.com/godbus/dbus/v5"
|
||||
)
|
||||
|
||||
type Manager struct {
|
||||
systemConn *dbus.Conn
|
||||
sessionConn *dbus.Conn
|
||||
|
||||
subscriptions syncmap.Map[string, *signalSubscription]
|
||||
signalSubscribers syncmap.Map[string, chan SignalEvent]
|
||||
}
|
||||
|
||||
type signalSubscription struct {
|
||||
Bus string
|
||||
Sender string
|
||||
Path string
|
||||
Interface string
|
||||
Member string
|
||||
ClientID string
|
||||
}
|
||||
|
||||
type SignalEvent struct {
|
||||
SubscriptionID string `json:"subscriptionId"`
|
||||
Sender string `json:"sender"`
|
||||
Path string `json:"path"`
|
||||
Interface string `json:"interface"`
|
||||
Member string `json:"member"`
|
||||
Body []any `json:"body"`
|
||||
}
|
||||
|
||||
type CallResult struct {
|
||||
Values []any `json:"values"`
|
||||
}
|
||||
|
||||
type PropertyResult struct {
|
||||
Value any `json:"value"`
|
||||
}
|
||||
|
||||
type IntrospectResult struct {
|
||||
XML string `json:"xml"`
|
||||
}
|
||||
|
||||
type ListNamesResult struct {
|
||||
Names []string `json:"names"`
|
||||
}
|
||||
|
||||
type SubscribeResult struct {
|
||||
SubscriptionID string `json:"subscriptionId"`
|
||||
}
|
||||
Reference in New Issue
Block a user