feat: organize tools and enable qwen realtime calls
This commit is contained in:
@@ -0,0 +1,605 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import unittest
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock
|
||||
|
||||
from fastapi import HTTPException
|
||||
from models import AssistantConfig, RuntimeTool, RuntimeToolBinding
|
||||
from pipecat.frames.frames import (
|
||||
BotStartedSpeakingFrame,
|
||||
BotStoppedSpeakingFrame,
|
||||
OutputTransportMessageUrgentFrame,
|
||||
)
|
||||
from services.brains.base import RealtimeBrainRuntime
|
||||
from services.brains.prompt_realtime_tools import PromptRealtimeToolController
|
||||
from services.pipecat.call_lifecycle import (
|
||||
CallEndCoordinator,
|
||||
FixedSpeechPlaybackMarkerFrame,
|
||||
)
|
||||
from services.pipecat.realtime_tools import (
|
||||
RealtimeToolResult,
|
||||
RealtimeToolSession,
|
||||
)
|
||||
from services.realtime.launcher import validate_runtime_requirements
|
||||
from services.runtime_variables import DynamicVariableStore
|
||||
from services.system_tools import RESERVED_TOOL_NAMES
|
||||
from routes.assistants import _validate_prompt_realtime_tool_capabilities
|
||||
from routes.model_registry import _ensure_realtime_prompt_tools_remain_supported
|
||||
from schemas import AssistantUpsert
|
||||
|
||||
|
||||
class RealtimeToolSessionTests(unittest.IsolatedAsyncioTestCase):
|
||||
async def test_follow_up_priority_and_one_response_create(self):
|
||||
events: list[dict] = []
|
||||
suppressed = AsyncMock()
|
||||
|
||||
async def send_event(event: dict) -> None:
|
||||
events.append(event)
|
||||
|
||||
session = RealtimeToolSession(send_event, suppress_response=suppressed)
|
||||
|
||||
async def dispatch(name, _arguments, _call_id):
|
||||
return RealtimeToolResult(
|
||||
{"name": name},
|
||||
follow_up={"first": "none", "second": "suppress"}[name],
|
||||
)
|
||||
|
||||
session.set_dispatcher(dispatch)
|
||||
session.submit_call(
|
||||
response_id="resp",
|
||||
name="second",
|
||||
call_id="call_2",
|
||||
arguments={},
|
||||
output_index=1,
|
||||
)
|
||||
session.submit_call(
|
||||
response_id="resp",
|
||||
name="first",
|
||||
call_id="call_1",
|
||||
arguments={},
|
||||
output_index=0,
|
||||
)
|
||||
session.finish_response("resp")
|
||||
await session.wait_pending()
|
||||
|
||||
self.assertEqual(
|
||||
[event["item"]["call_id"] for event in events],
|
||||
["call_1", "call_2"],
|
||||
)
|
||||
self.assertFalse(any(event["type"] == "response.create" for event in events))
|
||||
suppressed.assert_awaited_once()
|
||||
|
||||
async def test_new_input_stops_unstarted_side_effect_and_follow_up(self):
|
||||
events: list[dict] = []
|
||||
first_started = asyncio.Event()
|
||||
release_first = asyncio.Event()
|
||||
dispatched: list[str] = []
|
||||
|
||||
async def send_event(event: dict) -> None:
|
||||
events.append(event)
|
||||
|
||||
session = RealtimeToolSession(send_event)
|
||||
|
||||
async def dispatch(name, _arguments, _call_id):
|
||||
dispatched.append(name)
|
||||
if name == "first":
|
||||
first_started.set()
|
||||
await release_first.wait()
|
||||
return RealtimeToolResult({"status": "ok"})
|
||||
|
||||
session.set_dispatcher(dispatch)
|
||||
for index, name in enumerate(("first", "dangerous_second")):
|
||||
session.submit_call(
|
||||
response_id="resp",
|
||||
name=name,
|
||||
call_id=f"call_{index}",
|
||||
arguments={},
|
||||
output_index=index,
|
||||
)
|
||||
session.finish_response("resp")
|
||||
await first_started.wait()
|
||||
session.invalidate_pending_follow_ups()
|
||||
release_first.set()
|
||||
await session.wait_pending()
|
||||
|
||||
self.assertEqual(dispatched, ["first"])
|
||||
self.assertFalse(any(event["type"] == "response.create" for event in events))
|
||||
cancelled = json.loads(events[-1]["item"]["output"])
|
||||
self.assertEqual(cancelled["status"], "cancelled")
|
||||
|
||||
async def test_close_cancels_active_background_call(self):
|
||||
started = asyncio.Event()
|
||||
cancelled = asyncio.Event()
|
||||
session = RealtimeToolSession(AsyncMock())
|
||||
|
||||
async def dispatch(_name, _arguments, _call_id):
|
||||
started.set()
|
||||
try:
|
||||
await asyncio.Future()
|
||||
except asyncio.CancelledError:
|
||||
cancelled.set()
|
||||
raise
|
||||
|
||||
session.set_dispatcher(dispatch)
|
||||
session.submit_call(
|
||||
response_id="resp",
|
||||
name="slow",
|
||||
call_id="call_slow",
|
||||
arguments={},
|
||||
)
|
||||
session.finish_response("resp")
|
||||
await started.wait()
|
||||
await session.shutdown()
|
||||
|
||||
self.assertTrue(cancelled.is_set())
|
||||
|
||||
|
||||
class _FakeRealtime:
|
||||
def __init__(self) -> None:
|
||||
self.dispatcher = None
|
||||
self.updated_sessions: list[tuple[str, list]] = []
|
||||
self.instructions: list[str] = []
|
||||
self.spoken: list[str] = []
|
||||
|
||||
def set_tool_dispatcher(self, dispatcher) -> None:
|
||||
self.dispatcher = dispatcher
|
||||
|
||||
async def update_session(self, prompt: str, tools: list) -> None:
|
||||
self.updated_sessions.append((prompt, tools))
|
||||
|
||||
async def update_instructions(self, prompt: str) -> None:
|
||||
self.instructions.append(prompt)
|
||||
|
||||
async def speak_fixed(self, content: str, **_kwargs):
|
||||
self.spoken.append(content)
|
||||
future = asyncio.get_running_loop().create_future()
|
||||
future.set_result(None)
|
||||
return future
|
||||
|
||||
|
||||
class _FakeExecutor:
|
||||
def __init__(self, result: dict) -> None:
|
||||
self.result = result
|
||||
self.calls: list[tuple[str, dict]] = []
|
||||
|
||||
@staticmethod
|
||||
def schema_parts(_tool, _binding):
|
||||
return {"order_id": {"type": "string"}}, ["order_id"]
|
||||
|
||||
async def execute(self, tool, arguments, *, binding):
|
||||
self.calls.append((tool.function_name, dict(arguments)))
|
||||
return dict(self.result)
|
||||
|
||||
|
||||
class PromptRealtimeToolControllerTests(unittest.IsolatedAsyncioTestCase):
|
||||
def setUp(self) -> None:
|
||||
self.frames: list = []
|
||||
self.ended: list[str] = []
|
||||
self.inputs: list[bool] = []
|
||||
self.realtime = _FakeRealtime()
|
||||
|
||||
async def queue_frame(self, frame) -> None:
|
||||
self.frames.append(frame)
|
||||
if isinstance(frame, FixedSpeechPlaybackMarkerFrame):
|
||||
await frame.completion.mark_played()
|
||||
|
||||
async def queue_end(self, reason: str) -> None:
|
||||
self.ended.append(reason)
|
||||
|
||||
def runtime(self, call_end=None, client_tools=None) -> RealtimeBrainRuntime:
|
||||
return RealtimeBrainRuntime(
|
||||
realtime=self.realtime,
|
||||
queue_frame=self.queue_frame,
|
||||
call_end=call_end or CallEndCoordinator(self.queue_end),
|
||||
session_id="conv_test",
|
||||
client_tools=client_tools,
|
||||
set_input_enabled=self.inputs.append,
|
||||
)
|
||||
|
||||
async def controller(self, cfg: AssistantConfig, **runtime_kwargs):
|
||||
store = DynamicVariableStore.from_config(cfg)
|
||||
controller = PromptRealtimeToolController(
|
||||
cfg=cfg,
|
||||
store=store,
|
||||
runtime=self.runtime(**runtime_kwargs),
|
||||
)
|
||||
await controller.start()
|
||||
return controller, store
|
||||
|
||||
async def test_update_state_is_atomic_and_refreshes_prompt_after_event(self):
|
||||
cfg = AssistantConfig(
|
||||
type="prompt",
|
||||
runtimeMode="realtime",
|
||||
realtime_interface_type="qwen-audio-realtime",
|
||||
prompt="城市 {{city}} / 确认 {{confirmed}}",
|
||||
dynamic_variable_definitions={
|
||||
"city": {"type": "string"},
|
||||
"confirmed": {"type": "boolean"},
|
||||
},
|
||||
dynamic_variables={"city": "北京", "confirmed": False},
|
||||
built_in_tools={
|
||||
"updateState": {
|
||||
"updates": [
|
||||
{
|
||||
"variableName": "city",
|
||||
"expression": {
|
||||
"type": "llm",
|
||||
"parameterName": "new_city",
|
||||
"valueType": "string",
|
||||
"description": "新城市",
|
||||
},
|
||||
},
|
||||
{
|
||||
"variableName": "confirmed",
|
||||
"expression": {
|
||||
"type": "llm",
|
||||
"parameterName": "confirmed",
|
||||
"valueType": "boolean",
|
||||
"description": "是否确认",
|
||||
},
|
||||
},
|
||||
]
|
||||
}
|
||||
},
|
||||
)
|
||||
controller, store = await self.controller(cfg)
|
||||
|
||||
failed = await controller.dispatch_tool(
|
||||
"update_state",
|
||||
{"new_city": "上海", "confirmed": "yes"},
|
||||
"call_1",
|
||||
)
|
||||
self.assertEqual(failed.output["status"], "error")
|
||||
self.assertEqual(store.public_values(), {"city": "北京", "confirmed": False})
|
||||
|
||||
succeeded = await controller.dispatch_tool(
|
||||
"update_state",
|
||||
{"new_city": "上海", "confirmed": True},
|
||||
"call_2",
|
||||
)
|
||||
self.assertEqual(succeeded.output["status"], "success")
|
||||
self.assertEqual(store.public_values(), {"city": "上海", "confirmed": True})
|
||||
variable_event = next(
|
||||
frame.message
|
||||
for frame in self.frames
|
||||
if isinstance(frame, OutputTransportMessageUrgentFrame)
|
||||
and frame.message.get("type") == "session-variables"
|
||||
)
|
||||
self.assertEqual(variable_event["changed"], ["city", "confirmed"])
|
||||
self.assertEqual(self.realtime.instructions[-1], "城市 上海 / 确认 true")
|
||||
|
||||
async def test_external_tool_uses_executor_mapping_policy_and_input_gate(self):
|
||||
cfg = AssistantConfig(
|
||||
type="prompt",
|
||||
runtimeMode="realtime",
|
||||
realtime_interface_type="qwen-audio-realtime",
|
||||
prompt="状态 {{order_status}}",
|
||||
dynamic_variable_definitions={"order_status": {"type": "string"}},
|
||||
dynamic_variables={"order_status": "unknown"},
|
||||
tools=[
|
||||
RuntimeTool(
|
||||
id="tool_order",
|
||||
name="订单查询",
|
||||
function_name="lookup_order",
|
||||
type="http",
|
||||
definition={
|
||||
"type": "http",
|
||||
"config": {
|
||||
"allow_interruptions": False,
|
||||
"execution_mode": "immediate",
|
||||
},
|
||||
},
|
||||
)
|
||||
],
|
||||
tool_bindings=[
|
||||
RuntimeToolBinding(
|
||||
tool_id="tool_order",
|
||||
argument_bindings={"order_id": {"source": "model"}},
|
||||
)
|
||||
],
|
||||
)
|
||||
controller, store = await self.controller(cfg)
|
||||
store.values["order_status"] = "paid"
|
||||
controller._executor = _FakeExecutor(
|
||||
{"status": "ok", "data": {"status": "paid"}, "updated_variables": ["order_status"]}
|
||||
)
|
||||
|
||||
result = await controller.dispatch_tool(
|
||||
"lookup_order", {"order_id": "A-1"}, "call_1"
|
||||
)
|
||||
|
||||
self.assertEqual(result.follow_up, "continue")
|
||||
self.assertEqual(controller._executor.calls, [("lookup_order", {"order_id": "A-1"})])
|
||||
self.assertEqual(self.inputs, [False, True])
|
||||
self.assertEqual(self.realtime.instructions[-1], "状态 paid")
|
||||
trace_events = [
|
||||
frame.message.get("event")
|
||||
for frame in self.frames
|
||||
if isinstance(frame, OutputTransportMessageUrgentFrame)
|
||||
and frame.message.get("type") == "workflow-event"
|
||||
]
|
||||
self.assertIn("tool_started", trace_events)
|
||||
self.assertIn("tool_completed", trace_events)
|
||||
|
||||
async def test_missing_client_capability_emits_tool_skipped(self):
|
||||
cfg = AssistantConfig(
|
||||
type="prompt",
|
||||
runtimeMode="realtime",
|
||||
realtime_interface_type="qwen-audio-realtime",
|
||||
tools=[
|
||||
RuntimeTool(
|
||||
id="tool_photo",
|
||||
name="显示拍照按钮",
|
||||
function_name="set_photo_button_visible",
|
||||
type="client",
|
||||
definition={"type": "client", "config": {"parameters": []}},
|
||||
)
|
||||
],
|
||||
tool_bindings=[RuntimeToolBinding(tool_id="tool_photo")],
|
||||
)
|
||||
client_tools = SimpleNamespace(supports=lambda _name: False)
|
||||
await self.controller(cfg, client_tools=client_tools)
|
||||
|
||||
self.assertEqual(self.realtime.updated_sessions[-1][1], [])
|
||||
skipped = next(
|
||||
frame.message
|
||||
for frame in self.frames
|
||||
if isinstance(frame, OutputTransportMessageUrgentFrame)
|
||||
and frame.message.get("event") == "tool_skipped"
|
||||
)
|
||||
self.assertEqual(skipped["reason"], "client_capability_missing")
|
||||
|
||||
async def test_handoff_is_idempotent_and_updates_system_state(self):
|
||||
cfg = AssistantConfig(
|
||||
type="prompt",
|
||||
runtimeMode="realtime",
|
||||
realtime_interface_type="qwen-audio-realtime",
|
||||
built_in_tools={"requestHumanHandoff": {}},
|
||||
)
|
||||
controller, store = await self.controller(cfg)
|
||||
|
||||
await controller.dispatch_tool("request_human_handoff", {"reason": "投诉"}, "1")
|
||||
await controller.dispatch_tool("request_human_handoff", {"reason": "再次"}, "2")
|
||||
|
||||
handoff_events = [
|
||||
frame.message
|
||||
for frame in self.frames
|
||||
if isinstance(frame, OutputTransportMessageUrgentFrame)
|
||||
and frame.message.get("type") == "handoff-requested"
|
||||
]
|
||||
self.assertEqual(len(handoff_events), 1)
|
||||
self.assertEqual(store.values["system__handoff_status"], "requested")
|
||||
|
||||
async def test_custom_end_waits_for_playback_marker_before_hangup(self):
|
||||
call_end = CallEndCoordinator(self.queue_end)
|
||||
cfg = AssistantConfig(
|
||||
type="prompt",
|
||||
runtimeMode="realtime",
|
||||
realtime_interface_type="qwen-audio-realtime",
|
||||
built_in_tools={
|
||||
"endConversation": {
|
||||
"messageType": "custom",
|
||||
"customMessage": "感谢来电,再见。",
|
||||
"captureReason": True,
|
||||
"allowEndInterruption": False,
|
||||
}
|
||||
},
|
||||
)
|
||||
controller, _store = await self.controller(cfg, call_end=call_end)
|
||||
|
||||
result = await controller.dispatch_tool(
|
||||
"end_conversation", {"reason": "completed"}, "call_end"
|
||||
)
|
||||
self.assertEqual(result.follow_up, "suppress")
|
||||
self.assertEqual(self.ended, [])
|
||||
await result.after_output()
|
||||
|
||||
self.assertEqual(self.realtime.spoken, ["感谢来电,再见。"])
|
||||
self.assertEqual(self.ended, ["completed"])
|
||||
self.assertIn(False, self.inputs)
|
||||
|
||||
async def test_generated_end_is_interruptible_and_idempotent(self):
|
||||
call_end = CallEndCoordinator(self.queue_end)
|
||||
cfg = AssistantConfig(
|
||||
type="prompt",
|
||||
runtimeMode="realtime",
|
||||
realtime_interface_type="qwen-audio-realtime",
|
||||
built_in_tools={
|
||||
"endConversation": {
|
||||
"messageType": "none",
|
||||
"allowEndInterruption": True,
|
||||
}
|
||||
},
|
||||
)
|
||||
controller, _store = await self.controller(cfg, call_end=call_end)
|
||||
|
||||
first = await controller.dispatch_tool(
|
||||
"end_conversation", {"reason": "done"}, "call_1"
|
||||
)
|
||||
second = await controller.dispatch_tool(
|
||||
"end_conversation", {"reason": "done"}, "call_2"
|
||||
)
|
||||
self.assertEqual(first.follow_up, "continue")
|
||||
self.assertEqual(second.follow_up, "suppress")
|
||||
await controller.on_user_speech_started()
|
||||
|
||||
self.assertFalse(call_end.ending)
|
||||
self.assertEqual(self.ended, [])
|
||||
|
||||
async def test_generated_end_waits_for_its_own_transport_speech(self):
|
||||
call_end = CallEndCoordinator(self.queue_end)
|
||||
# Simulate completed speech from an older response. The new response
|
||||
# must reset this state before deciding whether it can hang up.
|
||||
await call_end.observe(BotStartedSpeakingFrame())
|
||||
await call_end.observe(BotStoppedSpeakingFrame())
|
||||
cfg = AssistantConfig(
|
||||
type="prompt",
|
||||
runtimeMode="realtime",
|
||||
realtime_interface_type="qwen-audio-realtime",
|
||||
built_in_tools={"endConversation": {"messageType": "none"}},
|
||||
)
|
||||
controller, _store = await self.controller(cfg, call_end=call_end)
|
||||
await controller.dispatch_tool(
|
||||
"end_conversation", {"reason": "done"}, "call_1"
|
||||
)
|
||||
|
||||
controller.on_assistant_text_start()
|
||||
await controller.on_response_done("再见。", False)
|
||||
self.assertEqual(self.ended, [])
|
||||
|
||||
await call_end.observe(BotStartedSpeakingFrame())
|
||||
self.assertEqual(self.ended, [])
|
||||
await call_end.observe(BotStoppedSpeakingFrame())
|
||||
self.assertEqual(self.ended, ["done"])
|
||||
|
||||
|
||||
class PromptRealtimeCapabilityTests(unittest.TestCase):
|
||||
@staticmethod
|
||||
def config(interface_type: str, **updates) -> AssistantConfig:
|
||||
values = {
|
||||
"type": "prompt",
|
||||
"runtimeMode": "realtime",
|
||||
"realtime_interface_type": interface_type,
|
||||
"realtimeModel": "model",
|
||||
"realtime_base_url": "wss://example.test/realtime",
|
||||
"realtime_api_key": "secret",
|
||||
}
|
||||
values.update(updates)
|
||||
return AssistantConfig(**values)
|
||||
|
||||
def test_qwen_accepts_supported_tools_and_rejects_skip_turn(self):
|
||||
validate_runtime_requirements(
|
||||
self.config(
|
||||
"qwen-audio-realtime",
|
||||
built_in_tools={"updateState": {"updates": []}},
|
||||
)
|
||||
)
|
||||
with self.assertRaisesRegex(ValueError, "skipTurn"):
|
||||
validate_runtime_requirements(
|
||||
self.config(
|
||||
"qwen-audio-realtime",
|
||||
built_in_tools={"skipTurn": {}},
|
||||
)
|
||||
)
|
||||
|
||||
def test_stepfun_rejects_prompt_realtime_tools(self):
|
||||
with self.assertRaisesRegex(ValueError, "endConversation"):
|
||||
validate_runtime_requirements(
|
||||
self.config(
|
||||
"stepfun-realtime",
|
||||
built_in_tools={"endConversation": {}},
|
||||
)
|
||||
)
|
||||
|
||||
def test_runtime_rejects_reserved_external_names(self):
|
||||
self.assertIn("end_conversation", RESERVED_TOOL_NAMES)
|
||||
with self.assertRaisesRegex(ValueError, "平台保留名"):
|
||||
validate_runtime_requirements(
|
||||
self.config(
|
||||
"qwen-audio-realtime",
|
||||
tools=[
|
||||
RuntimeTool(
|
||||
id="legacy",
|
||||
name="旧外部工具",
|
||||
function_name="end_conversation",
|
||||
type="http",
|
||||
)
|
||||
],
|
||||
tool_bindings=[RuntimeToolBinding(tool_id="legacy")],
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
class _ScalarRows:
|
||||
def __init__(self, rows) -> None:
|
||||
self._rows = rows
|
||||
|
||||
def scalars(self):
|
||||
return self
|
||||
|
||||
def all(self):
|
||||
return list(self._rows)
|
||||
|
||||
|
||||
class PromptRealtimeApiGateTests(unittest.IsolatedAsyncioTestCase):
|
||||
async def test_save_accepts_qwen_features_and_rejects_stepfun_or_skip_turn(self):
|
||||
resource = SimpleNamespace(
|
||||
enabled=True,
|
||||
capability="Realtime",
|
||||
interface_type="qwen-audio-realtime",
|
||||
)
|
||||
qwen = SimpleNamespace(
|
||||
enabled=True,
|
||||
field_schema={
|
||||
"features": {
|
||||
"realtimePromptTools": {
|
||||
"externalTypes": ["http", "mcp", "client"],
|
||||
"builtIns": [
|
||||
"endConversation",
|
||||
"updateState",
|
||||
"requestHumanHandoff",
|
||||
],
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
session = AsyncMock()
|
||||
|
||||
async def get(_model, key):
|
||||
return resource if key == "model_qwen" else qwen
|
||||
|
||||
session.get.side_effect = get
|
||||
supported = AssistantUpsert(
|
||||
name="Qwen",
|
||||
type="prompt",
|
||||
runtimeMode="realtime",
|
||||
modelResourceIds={"Realtime": "model_qwen"},
|
||||
builtInTools={"updateState": {"updates": []}},
|
||||
)
|
||||
await _validate_prompt_realtime_tool_capabilities(session, supported)
|
||||
|
||||
skipped = AssistantUpsert(
|
||||
name="Qwen",
|
||||
type="prompt",
|
||||
runtimeMode="realtime",
|
||||
modelResourceIds={"Realtime": "model_qwen"},
|
||||
builtInTools={"skipTurn": {"description": "silent"}},
|
||||
)
|
||||
with self.assertRaisesRegex(HTTPException, "skipTurn"):
|
||||
await _validate_prompt_realtime_tool_capabilities(session, skipped)
|
||||
|
||||
resource.interface_type = "stepfun-realtime"
|
||||
stepfun = SimpleNamespace(enabled=True, field_schema={"fields": []})
|
||||
session.get.side_effect = lambda _model, key: (
|
||||
resource if key == "model_qwen" else stepfun
|
||||
)
|
||||
with self.assertRaisesRegex(HTTPException, "updateState"):
|
||||
await _validate_prompt_realtime_tool_capabilities(session, supported)
|
||||
|
||||
async def test_model_resource_cannot_drop_features_while_tools_are_bound(self):
|
||||
assistant = SimpleNamespace(
|
||||
id="asst_qwen",
|
||||
name="Realtime 客服",
|
||||
built_in_tools={"end_conversation": {"capture_reason": True}},
|
||||
)
|
||||
session = AsyncMock()
|
||||
session.execute.return_value = _ScalarRows([assistant])
|
||||
resource = SimpleNamespace(id="model_qwen", capability="Realtime")
|
||||
unsupported_definition = SimpleNamespace(
|
||||
capability="Realtime",
|
||||
field_schema={"fields": []},
|
||||
)
|
||||
|
||||
with self.assertRaisesRegex(HTTPException, "新接口不支持其内建工具"):
|
||||
await _ensure_realtime_prompt_tools_remain_supported(
|
||||
session,
|
||||
resource,
|
||||
unsupported_definition,
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user