Add Cloth Config plugin management screen
This commit is contained in:
@@ -0,0 +1,61 @@
|
||||
package sdk
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
)
|
||||
|
||||
var callSequence atomic.Uint64
|
||||
|
||||
type Context struct {
|
||||
actions []ActionRequest
|
||||
systemCalls []SystemCallRequest
|
||||
logs []LogEntry
|
||||
snapshot *SnapshotSubscription
|
||||
}
|
||||
|
||||
func (context *Context) Broadcast(message string) {
|
||||
context.actions = append(context.actions, ActionRequest{
|
||||
Type: "minecraft:chat.broadcast",
|
||||
Payload: map[string]any{
|
||||
"message": message,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func (context *Context) SendMessage(playerUUID, message string) {
|
||||
context.actions = append(context.actions, ActionRequest{
|
||||
Type: "minecraft:chat.player",
|
||||
Payload: map[string]any{
|
||||
"playerUuid": playerUUID,
|
||||
"message": message,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func (context *Context) SystemCall(name string, payload any) string {
|
||||
id := fmt.Sprintf("call-%d", callSequence.Add(1))
|
||||
context.systemCalls = append(context.systemCalls, SystemCallRequest{
|
||||
ID: id,
|
||||
Name: name,
|
||||
Payload: payload,
|
||||
})
|
||||
return id
|
||||
}
|
||||
|
||||
func (context *Context) SubscribeSnapshot(entities bool, blocks ...BlockReference) {
|
||||
context.snapshot = &SnapshotSubscription{
|
||||
Entities: entities,
|
||||
Blocks: append([]BlockReference(nil), blocks...),
|
||||
}
|
||||
}
|
||||
|
||||
func (context *Context) Log(level, message string) {
|
||||
context.logs = append(context.logs, LogEntry{
|
||||
Stream: "sdk",
|
||||
Level: level,
|
||||
Message: message,
|
||||
TimestampUnixMilli: time.Now().UnixMilli(),
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
module github.com/yawaflua/GoMinecraftBridge/sdk
|
||||
|
||||
go 1.24.0
|
||||
@@ -0,0 +1,63 @@
|
||||
package sdk
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func BenchmarkJSONSnapshot1000Entities(b *testing.B) {
|
||||
snapshot := benchmarkSnapshot(1000)
|
||||
encoded, err := json.Marshal(snapshot)
|
||||
if err != nil {
|
||||
b.Fatal(err)
|
||||
}
|
||||
b.Run("encode", func(b *testing.B) {
|
||||
b.ReportAllocs()
|
||||
for b.Loop() {
|
||||
if _, err := json.Marshal(snapshot); err != nil {
|
||||
b.Fatal(err)
|
||||
}
|
||||
}
|
||||
b.ReportMetric(float64(len(encoded)), "bytes/snapshot")
|
||||
})
|
||||
|
||||
b.Run("decode", func(b *testing.B) {
|
||||
b.ReportAllocs()
|
||||
for b.Loop() {
|
||||
var decoded ServerSnapshot
|
||||
if err := json.Unmarshal(encoded, &decoded); err != nil {
|
||||
b.Fatal(err)
|
||||
}
|
||||
}
|
||||
b.ReportMetric(float64(len(encoded)), "bytes/snapshot")
|
||||
})
|
||||
}
|
||||
|
||||
func benchmarkSnapshot(entityCount int) ServerSnapshot {
|
||||
entities := make([]EntitySnapshot, entityCount)
|
||||
for index := range entities {
|
||||
health := float32(20)
|
||||
entities[index] = EntitySnapshot{
|
||||
RuntimeID: index,
|
||||
UUID: fmt.Sprintf("00000000-0000-0000-0000-%012d", index),
|
||||
Type: "minecraft:zombie",
|
||||
Name: "Zombie",
|
||||
Dimension: "minecraft:overworld",
|
||||
X: float64(index),
|
||||
Y: 64,
|
||||
Z: float64(-index),
|
||||
Alive: true,
|
||||
Health: &health,
|
||||
MaxHealth: &health,
|
||||
}
|
||||
}
|
||||
return ServerSnapshot{
|
||||
Tick: 1,
|
||||
TimestampUnixMilli: 1,
|
||||
Levels: []LevelSnapshot{{
|
||||
Dimension: "minecraft:overworld",
|
||||
}},
|
||||
Entities: entities,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
//go:build cgo && !wasm
|
||||
|
||||
package sdk
|
||||
|
||||
/*
|
||||
#include <stdint.h>
|
||||
#include <stdlib.h>
|
||||
*/
|
||||
import "C"
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"unsafe"
|
||||
)
|
||||
|
||||
//export gmb_abi_version
|
||||
func gmb_abi_version() C.int32_t {
|
||||
return C.int32_t(ABIVersion)
|
||||
}
|
||||
|
||||
//export gmb_call
|
||||
func gmb_call(
|
||||
operation C.int32_t,
|
||||
input *C.uint8_t,
|
||||
inputLength C.uint64_t,
|
||||
output **C.uint8_t,
|
||||
outputLength *C.uint64_t,
|
||||
) (status C.int32_t) {
|
||||
*output = nil
|
||||
*outputLength = 0
|
||||
|
||||
defer func() {
|
||||
if recovered := recover(); recovered != nil {
|
||||
fallback := []byte(fmt.Sprintf(`{"status":"panic","error":%q}`, fmt.Sprint(recovered)))
|
||||
writeNativeOutput(fallback, output, outputLength)
|
||||
status = 0
|
||||
}
|
||||
}()
|
||||
|
||||
if inputLength > 64*1024*1024 {
|
||||
return 2
|
||||
}
|
||||
|
||||
var payload []byte
|
||||
if inputLength > 0 {
|
||||
if input == nil {
|
||||
return 3
|
||||
}
|
||||
payload = C.GoBytes(unsafe.Pointer(input), C.int(inputLength))
|
||||
}
|
||||
|
||||
writeNativeOutput(Dispatch(int(operation), payload), output, outputLength)
|
||||
return 0
|
||||
}
|
||||
|
||||
//export gmb_free
|
||||
func gmb_free(pointer unsafe.Pointer) {
|
||||
C.free(pointer)
|
||||
}
|
||||
|
||||
func writeNativeOutput(payload []byte, output **C.uint8_t, outputLength *C.uint64_t) {
|
||||
if len(payload) == 0 {
|
||||
return
|
||||
}
|
||||
pointer := C.CBytes(payload)
|
||||
*output = (*C.uint8_t)(pointer)
|
||||
*outputLength = C.uint64_t(len(payload))
|
||||
}
|
||||
+115
@@ -0,0 +1,115 @@
|
||||
package sdk
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"os"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
var captured = struct {
|
||||
sync.Mutex
|
||||
logs []LogEntry
|
||||
}{}
|
||||
|
||||
var outputCaptureOnce sync.Once
|
||||
|
||||
type streamCapture struct {
|
||||
writer *os.File
|
||||
marker string
|
||||
ack chan struct{}
|
||||
mu sync.Mutex
|
||||
}
|
||||
|
||||
var outputStreams []*streamCapture
|
||||
|
||||
func enableOutputCapture() {
|
||||
outputCaptureOnce.Do(func() {
|
||||
outputStreams = append(outputStreams, captureStream("stdout", &os.Stdout))
|
||||
outputStreams = append(outputStreams, captureStream("stderr", &os.Stderr))
|
||||
log.SetOutput(os.Stderr)
|
||||
})
|
||||
}
|
||||
|
||||
func captureStream(name string, destination **os.File) *streamCapture {
|
||||
reader, writer, err := os.Pipe()
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
stream := &streamCapture{
|
||||
writer: writer,
|
||||
marker: fmt.Sprintf("\x00gmb-flush-%s-%d\x00", name, time.Now().UnixNano()),
|
||||
ack: make(chan struct{}),
|
||||
}
|
||||
*destination = writer
|
||||
go func() {
|
||||
buffered := bufio.NewReader(reader)
|
||||
for {
|
||||
line, readErr := buffered.ReadString('\n')
|
||||
line = strings.TrimSuffix(strings.TrimSuffix(line, "\n"), "\r")
|
||||
if markerIndex := strings.Index(line, stream.marker); markerIndex >= 0 {
|
||||
captureLine(name, line[:markerIndex])
|
||||
captureLine(name, line[markerIndex+len(stream.marker):])
|
||||
stream.ack <- struct{}{}
|
||||
} else {
|
||||
captureLine(name, line)
|
||||
}
|
||||
if readErr != nil {
|
||||
if readErr != io.EOF {
|
||||
captureLine("stderr", "stdout capture failed: "+readErr.Error())
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
return stream
|
||||
}
|
||||
|
||||
func captureLine(stream, message string) {
|
||||
if message == "" {
|
||||
return
|
||||
}
|
||||
captured.Lock()
|
||||
captured.logs = append(captured.logs, LogEntry{
|
||||
Stream: stream,
|
||||
Level: streamLevel(stream),
|
||||
Message: message,
|
||||
TimestampUnixMilli: time.Now().UnixMilli(),
|
||||
})
|
||||
captured.Unlock()
|
||||
}
|
||||
|
||||
func drainCapturedLogs() []LogEntry {
|
||||
for _, stream := range outputStreams {
|
||||
if stream != nil {
|
||||
stream.flush()
|
||||
}
|
||||
}
|
||||
|
||||
captured.Lock()
|
||||
defer captured.Unlock()
|
||||
logs := append([]LogEntry(nil), captured.logs...)
|
||||
captured.logs = captured.logs[:0]
|
||||
return logs
|
||||
}
|
||||
|
||||
func (stream *streamCapture) flush() {
|
||||
stream.mu.Lock()
|
||||
defer stream.mu.Unlock()
|
||||
if _, err := stream.writer.WriteString(stream.marker + "\n"); err != nil {
|
||||
return
|
||||
}
|
||||
<-stream.ack
|
||||
}
|
||||
|
||||
func streamLevel(stream string) string {
|
||||
if stream == "stderr" {
|
||||
return "error"
|
||||
}
|
||||
return "info"
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
package sdk
|
||||
|
||||
type Plugin interface {
|
||||
Metadata() Metadata
|
||||
}
|
||||
|
||||
type Initializer interface {
|
||||
Init(context *Context, event InitEvent) error
|
||||
}
|
||||
|
||||
type TickHandler interface {
|
||||
Tick(context *Context, snapshot ServerSnapshot) error
|
||||
}
|
||||
|
||||
type ChatHandler interface {
|
||||
Chat(context *Context, event ChatEvent) error
|
||||
}
|
||||
|
||||
type DeathHandler interface {
|
||||
Death(context *Context, event DeathEvent) error
|
||||
}
|
||||
|
||||
type SystemCallResultHandler interface {
|
||||
SystemCallResult(context *Context, result SystemCallResult) error
|
||||
}
|
||||
|
||||
type Deinitializer interface {
|
||||
Deinit(context *Context, event DeinitEvent) error
|
||||
}
|
||||
+141
@@ -0,0 +1,141 @@
|
||||
package sdk
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"runtime/debug"
|
||||
"sync"
|
||||
)
|
||||
|
||||
var (
|
||||
pluginMu sync.RWMutex
|
||||
registeredPlugin Plugin
|
||||
)
|
||||
|
||||
func Register(plugin Plugin) {
|
||||
if plugin == nil {
|
||||
panic("sdk: cannot register a nil plugin")
|
||||
}
|
||||
|
||||
pluginMu.Lock()
|
||||
defer pluginMu.Unlock()
|
||||
if registeredPlugin != nil {
|
||||
panic("sdk: a plugin is already registered")
|
||||
}
|
||||
registeredPlugin = plugin
|
||||
enableOutputCapture()
|
||||
}
|
||||
|
||||
func Dispatch(operation int, input []byte) (output []byte) {
|
||||
context := &Context{}
|
||||
result := response{Status: "ok"}
|
||||
|
||||
defer func() {
|
||||
if recovered := recover(); recovered != nil {
|
||||
result.Status = "panic"
|
||||
result.Error = fmt.Sprint(recovered)
|
||||
result.Stack = string(debug.Stack())
|
||||
}
|
||||
|
||||
result.Logs = append(result.Logs, context.logs...)
|
||||
result.Logs = append(result.Logs, drainCapturedLogs()...)
|
||||
result.Actions = context.actions
|
||||
result.SystemCalls = context.systemCalls
|
||||
result.Snapshot = context.snapshot
|
||||
|
||||
encoded, err := json.Marshal(result)
|
||||
if err != nil {
|
||||
output = []byte(`{"status":"panic","error":"cannot encode plugin response"}`)
|
||||
return
|
||||
}
|
||||
output = encoded
|
||||
}()
|
||||
|
||||
plugin := currentPlugin()
|
||||
var err error
|
||||
|
||||
switch operation {
|
||||
case OperationMetadata:
|
||||
metadata := plugin.Metadata()
|
||||
if metadata.APIVersion == 0 {
|
||||
metadata.APIVersion = ABIVersion
|
||||
}
|
||||
result.Data = metadata
|
||||
case OperationInit:
|
||||
var event InitEvent
|
||||
err = decode(input, &event)
|
||||
if err == nil {
|
||||
if handler, ok := plugin.(Initializer); ok {
|
||||
err = handler.Init(context, event)
|
||||
}
|
||||
}
|
||||
case OperationTick:
|
||||
var snapshot ServerSnapshot
|
||||
err = decode(input, &snapshot)
|
||||
if err == nil {
|
||||
if handler, ok := plugin.(TickHandler); ok {
|
||||
err = handler.Tick(context, snapshot)
|
||||
}
|
||||
}
|
||||
case OperationChat:
|
||||
var event ChatEvent
|
||||
err = decode(input, &event)
|
||||
if err == nil {
|
||||
if handler, ok := plugin.(ChatHandler); ok {
|
||||
err = handler.Chat(context, event)
|
||||
}
|
||||
}
|
||||
case OperationDeath:
|
||||
var event DeathEvent
|
||||
err = decode(input, &event)
|
||||
if err == nil {
|
||||
if handler, ok := plugin.(DeathHandler); ok {
|
||||
err = handler.Death(context, event)
|
||||
}
|
||||
}
|
||||
case OperationSystemCallResult:
|
||||
var event SystemCallResult
|
||||
err = decode(input, &event)
|
||||
if err == nil {
|
||||
if handler, ok := plugin.(SystemCallResultHandler); ok {
|
||||
err = handler.SystemCallResult(context, event)
|
||||
}
|
||||
}
|
||||
case OperationDeinit:
|
||||
var event DeinitEvent
|
||||
err = decode(input, &event)
|
||||
if err == nil {
|
||||
if handler, ok := plugin.(Deinitializer); ok {
|
||||
err = handler.Deinit(context, event)
|
||||
}
|
||||
}
|
||||
default:
|
||||
err = fmt.Errorf("sdk: unknown operation %d", operation)
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
result.Status = "error"
|
||||
result.Error = err.Error()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func currentPlugin() Plugin {
|
||||
pluginMu.RLock()
|
||||
defer pluginMu.RUnlock()
|
||||
if registeredPlugin == nil {
|
||||
panic("sdk: plugin was not registered; call sdk.Register from init")
|
||||
}
|
||||
return registeredPlugin
|
||||
}
|
||||
|
||||
func decode(input []byte, target any) error {
|
||||
if len(input) == 0 {
|
||||
return errors.New("sdk: operation requires an input payload")
|
||||
}
|
||||
if err := json.Unmarshal(input, target); err != nil {
|
||||
return fmt.Errorf("sdk: decode input: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
package sdk
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"testing"
|
||||
)
|
||||
|
||||
type testPlugin struct{}
|
||||
|
||||
func (testPlugin) Metadata() Metadata {
|
||||
return Metadata{ID: "test_plugin", Name: "Test", Version: "1.0.0"}
|
||||
}
|
||||
|
||||
func (testPlugin) Chat(context *Context, event ChatEvent) error {
|
||||
if event.Message == "panic" {
|
||||
panic("expected panic")
|
||||
}
|
||||
if event.Message == "error" {
|
||||
return errors.New("expected error")
|
||||
}
|
||||
context.SendMessage(event.PlayerUUID, "received")
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestDispatch(t *testing.T) {
|
||||
pluginMu.Lock()
|
||||
registeredPlugin = testPlugin{}
|
||||
pluginMu.Unlock()
|
||||
|
||||
input, _ := json.Marshal(ChatEvent{PlayerUUID: "player", Message: "hello"})
|
||||
var got response
|
||||
if err := json.Unmarshal(Dispatch(OperationChat, input), &got); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got.Status != "ok" || len(got.Actions) != 1 {
|
||||
t.Fatalf("unexpected response: %#v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDispatchRecoversPanic(t *testing.T) {
|
||||
pluginMu.Lock()
|
||||
registeredPlugin = testPlugin{}
|
||||
pluginMu.Unlock()
|
||||
|
||||
input, _ := json.Marshal(ChatEvent{Message: "panic"})
|
||||
var got response
|
||||
if err := json.Unmarshal(Dispatch(OperationChat, input), &got); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got.Status != "panic" || got.Stack == "" {
|
||||
t.Fatalf("panic was not captured: %#v", got)
|
||||
}
|
||||
}
|
||||
+144
@@ -0,0 +1,144 @@
|
||||
package sdk
|
||||
|
||||
import "encoding/json"
|
||||
|
||||
const ABIVersion = 1
|
||||
|
||||
const (
|
||||
OperationMetadata = 1
|
||||
OperationInit = 2
|
||||
OperationTick = 3
|
||||
OperationChat = 4
|
||||
OperationDeath = 5
|
||||
OperationSystemCallResult = 6
|
||||
OperationDeinit = 7
|
||||
)
|
||||
|
||||
type Metadata struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Version string `json:"version"`
|
||||
Description string `json:"description,omitempty"`
|
||||
Authors []string `json:"authors,omitempty"`
|
||||
Website string `json:"website,omitempty"`
|
||||
APIVersion int `json:"apiVersion"`
|
||||
ConfigSchema map[string]any `json:"configSchema,omitempty"`
|
||||
}
|
||||
|
||||
type InitEvent struct {
|
||||
MinecraftVersion string `json:"minecraftVersion"`
|
||||
Dedicated bool `json:"dedicated"`
|
||||
DataDirectory string `json:"dataDirectory"`
|
||||
}
|
||||
|
||||
type ServerSnapshot struct {
|
||||
Tick int64 `json:"tick"`
|
||||
TimestampUnixMilli int64 `json:"timestampUnixMilli"`
|
||||
Levels []LevelSnapshot `json:"levels"`
|
||||
Entities []EntitySnapshot `json:"entities"`
|
||||
Blocks []BlockSnapshot `json:"blocks"`
|
||||
}
|
||||
|
||||
type LevelSnapshot struct {
|
||||
Dimension string `json:"dimension"`
|
||||
GameTime int64 `json:"gameTime"`
|
||||
DayTime int64 `json:"dayTime"`
|
||||
Raining bool `json:"raining"`
|
||||
Thundering bool `json:"thundering"`
|
||||
}
|
||||
|
||||
type EntitySnapshot struct {
|
||||
RuntimeID int `json:"runtimeId"`
|
||||
UUID string `json:"uuid"`
|
||||
Type string `json:"type"`
|
||||
Name string `json:"name"`
|
||||
Dimension string `json:"dimension"`
|
||||
X float64 `json:"x"`
|
||||
Y float64 `json:"y"`
|
||||
Z float64 `json:"z"`
|
||||
Yaw float32 `json:"yaw"`
|
||||
Pitch float32 `json:"pitch"`
|
||||
VelocityX float64 `json:"velocityX"`
|
||||
VelocityY float64 `json:"velocityY"`
|
||||
VelocityZ float64 `json:"velocityZ"`
|
||||
Alive bool `json:"alive"`
|
||||
Player bool `json:"player"`
|
||||
Health *float32 `json:"health"`
|
||||
MaxHealth *float32 `json:"maxHealth"`
|
||||
}
|
||||
|
||||
type BlockSnapshot struct {
|
||||
Dimension string `json:"dimension"`
|
||||
X int `json:"x"`
|
||||
Y int `json:"y"`
|
||||
Z int `json:"z"`
|
||||
Block string `json:"block"`
|
||||
Properties map[string]string `json:"properties"`
|
||||
}
|
||||
|
||||
type BlockReference struct {
|
||||
Dimension string `json:"dimension"`
|
||||
X int `json:"x"`
|
||||
Y int `json:"y"`
|
||||
Z int `json:"z"`
|
||||
}
|
||||
|
||||
type ChatEvent struct {
|
||||
PlayerUUID string `json:"playerUuid"`
|
||||
PlayerName string `json:"playerName"`
|
||||
Message string `json:"message"`
|
||||
TimestampUnixMilli int64 `json:"timestampUnixMilli"`
|
||||
}
|
||||
|
||||
type DeathEvent struct {
|
||||
Entity EntitySnapshot `json:"entity"`
|
||||
DamageType string `json:"damageType"`
|
||||
AttackerUUID *string `json:"attackerUuid"`
|
||||
TimestampUnixMilli int64 `json:"timestampUnixMilli"`
|
||||
}
|
||||
|
||||
type SystemCallResult struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Success bool `json:"success"`
|
||||
Data json.RawMessage `json:"data"`
|
||||
Error string `json:"error"`
|
||||
}
|
||||
|
||||
type DeinitEvent struct {
|
||||
Reason string `json:"reason"`
|
||||
}
|
||||
|
||||
type ActionRequest struct {
|
||||
Type string `json:"type"`
|
||||
Payload any `json:"payload"`
|
||||
}
|
||||
|
||||
type SystemCallRequest struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Payload any `json:"payload"`
|
||||
}
|
||||
|
||||
type SnapshotSubscription struct {
|
||||
Entities bool `json:"entities"`
|
||||
Blocks []BlockReference `json:"blocks"`
|
||||
}
|
||||
|
||||
type LogEntry struct {
|
||||
Stream string `json:"stream"`
|
||||
Level string `json:"level"`
|
||||
Message string `json:"message"`
|
||||
TimestampUnixMilli int64 `json:"timestampUnixMilli"`
|
||||
}
|
||||
|
||||
type response struct {
|
||||
Status string `json:"status"`
|
||||
Error string `json:"error,omitempty"`
|
||||
Stack string `json:"stack,omitempty"`
|
||||
Data any `json:"data,omitempty"`
|
||||
Logs []LogEntry `json:"logs,omitempty"`
|
||||
Actions []ActionRequest `json:"actions,omitempty"`
|
||||
SystemCalls []SystemCallRequest `json:"systemCalls,omitempty"`
|
||||
Snapshot *SnapshotSubscription `json:"snapshot,omitempty"`
|
||||
}
|
||||
Reference in New Issue
Block a user