mirror of
https://github.com/zhamao-robot/zhamao-framework.git
synced 2026-03-18 05:04:51 +08:00
refactor bot action sender, add BotMap to mark bot
This commit is contained in:
parent
8bb4421a70
commit
fb17efdc52
@ -279,12 +279,14 @@ function config(array|string $key = null, mixed $default = null)
|
||||
return $config->get($key, $default);
|
||||
}
|
||||
|
||||
function bot(): ZM\Context\BotContext
|
||||
function bot(string $bot_id = '', string $platform = ''): ZM\Context\BotContext
|
||||
{
|
||||
if (container()->has(ZM\Context\BotContext::class)) {
|
||||
return container()->get(ZM\Context\BotContext::class);
|
||||
}
|
||||
return new \ZM\Context\BotContext('', '');
|
||||
return BotMap::getBotContext($bot_id, $platform);
|
||||
}
|
||||
|
||||
function bot_connect(int $flag, int $fd)
|
||||
{
|
||||
return BotMap::getConnectContext($flag, $fd);
|
||||
}
|
||||
|
||||
/**
|
||||
@ -297,7 +299,7 @@ function kv(string $name = ''): Psr\SimpleCache\CacheInterface
|
||||
{
|
||||
global $kv_class;
|
||||
if (!$kv_class) {
|
||||
$kv_class = config('global.kv.use', \LightCache::class);
|
||||
$kv_class = config('global.kv.use', LightCache::class);
|
||||
}
|
||||
/* @phpstan-ignore-next-line */
|
||||
return is_a($kv_class, KVInterface::class, true) ? $kv_class::open($name) : new $kv_class($name);
|
||||
|
||||
@ -27,6 +27,9 @@ class ContainerRegistrant
|
||||
'bot.event' => DI\get(OneBotEvent::class),
|
||||
]);
|
||||
|
||||
// 不用依赖注入可能会更好一点,而且方便其他开发者排查问题(因为挺多开发者在非机器人事件里面用 bot() 的,会让依赖注入报错,而且他们自己也看不懂
|
||||
// 而且我想让 BotContext 对象成为无状态无数据的对象,一切东西都从 container 和 BotMap 获取,它就是用作调用方法而已
|
||||
/*
|
||||
if (isset($event->self['platform'])) {
|
||||
self::addServices([
|
||||
BotContext::class => DI\autowire($bot_context)->constructor(
|
||||
@ -35,6 +38,7 @@ class ContainerRegistrant
|
||||
),
|
||||
]);
|
||||
}
|
||||
*/
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
31
src/ZM/Context/BotConnectContext.php
Normal file
31
src/ZM/Context/BotConnectContext.php
Normal file
@ -0,0 +1,31 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace ZM\Context;
|
||||
|
||||
use ZM\Context\Trait\BotActionTrait;
|
||||
|
||||
/**
|
||||
* 机器人裸连接的上下文
|
||||
*/
|
||||
class BotConnectContext
|
||||
{
|
||||
use BotActionTrait;
|
||||
|
||||
private ?array $self = null;
|
||||
|
||||
public function __construct(private int $flag, private int $fd)
|
||||
{
|
||||
}
|
||||
|
||||
public function getFd(): int
|
||||
{
|
||||
return $this->fd;
|
||||
}
|
||||
|
||||
public function getFlag(): int
|
||||
{
|
||||
return $this->flag;
|
||||
}
|
||||
}
|
||||
@ -7,15 +7,13 @@ namespace ZM\Context;
|
||||
use DI\DependencyException;
|
||||
use DI\NotFoundException;
|
||||
use OneBot\Driver\Coroutine\Adaptive;
|
||||
use OneBot\Driver\Event\Http\HttpRequestEvent;
|
||||
use OneBot\Driver\Event\WebSocket\WebSocketMessageEvent;
|
||||
use OneBot\V12\Object\ActionResponse;
|
||||
use OneBot\V12\Object\MessageSegment;
|
||||
use OneBot\V12\Object\OneBotEvent;
|
||||
use ZM\Context\Trait\BotActionTrait;
|
||||
use ZM\Exception\OneBot12Exception;
|
||||
use ZM\Exception\WaitTimeoutException;
|
||||
use ZM\Plugin\OneBot12Adapter;
|
||||
use ZM\Plugin\OneBot\OneBot12Adapter;
|
||||
use ZM\Schedule\Timer;
|
||||
use ZM\Utils\MessageUtil;
|
||||
|
||||
@ -26,7 +24,7 @@ class BotContext implements ContextInterface
|
||||
/** @var array<string, array<string, BotContext>> 记录机器人的上下文列表 */
|
||||
private static array $bots = [];
|
||||
|
||||
/** @var string[] 记录当前上下文绑定的机器人 */
|
||||
/** @var null|string[] 记录当前上下文绑定的机器人 */
|
||||
private array $self;
|
||||
|
||||
/** @var array 如果是 BotCommand 匹配的上下文,这里会存放匹配到的参数 */
|
||||
@ -35,11 +33,9 @@ class BotContext implements ContextInterface
|
||||
/** @var bool 用于标记当前上下文会话是否已经调用过 reply() 方法 */
|
||||
private bool $replied = false;
|
||||
|
||||
public function __construct(string $bot_id, string $platform, null|WebSocketMessageEvent|HttpRequestEvent $event = null)
|
||||
public function __construct(string $bot_id, string $platform)
|
||||
{
|
||||
$this->self = ['user_id' => $bot_id, 'platform' => $platform];
|
||||
self::$bots[$bot_id][$platform] = $this;
|
||||
$this->base_event = $event;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@ -4,36 +4,28 @@ declare(strict_types=1);
|
||||
|
||||
namespace ZM\Context\Trait;
|
||||
|
||||
use Choir\Http\HttpFactory;
|
||||
use OneBot\Driver\Coroutine\Adaptive;
|
||||
use OneBot\Driver\Event\Http\HttpRequestEvent;
|
||||
use OneBot\Driver\Event\WebSocket\WebSocketMessageEvent;
|
||||
use OneBot\Util\Utils;
|
||||
use OneBot\V12\Object\Action;
|
||||
use OneBot\V12\Object\ActionResponse;
|
||||
use OneBot\V12\Object\MessageSegment;
|
||||
use ZM\Annotation\AnnotationHandler;
|
||||
use ZM\Annotation\OneBot\BotAction;
|
||||
use ZM\Context\BotConnectContext;
|
||||
use ZM\Exception\OneBot12Exception;
|
||||
use ZM\Plugin\OneBot\BotMap;
|
||||
use ZM\Utils\MessageUtil;
|
||||
|
||||
trait BotActionTrait
|
||||
{
|
||||
/**
|
||||
* @var array<string, int> 一个记录 echo 对应协程 ID 的列表,用于恢复协程
|
||||
*/
|
||||
protected static array $coroutine_list = [];
|
||||
|
||||
protected null|WebSocketMessageEvent|HttpRequestEvent $base_event;
|
||||
|
||||
/**
|
||||
* @internal 只允许内部调用
|
||||
* @param ActionResponse $response 尝试调用看看有没有协程等待的
|
||||
*/
|
||||
public static function tryResume(ActionResponse $response): void
|
||||
{
|
||||
if (($co = Adaptive::getCoroutine()) !== null && isset(static::$coroutine_list[$response->echo ?? ''])) {
|
||||
$co->resume(static::$coroutine_list[$response->echo ?? ''], $response);
|
||||
if (($co = Adaptive::getCoroutine()) !== null && isset(BotMap::$bot_coroutines[$response->echo ?? ''])) {
|
||||
$co->resume(BotMap::$bot_coroutines[$response->echo ?? ''], $response);
|
||||
}
|
||||
}
|
||||
|
||||
@ -48,7 +40,7 @@ trait BotActionTrait
|
||||
$message = MessageUtil::convertToArr($message);
|
||||
$params['message'] = $message;
|
||||
$params['detail_type'] = $detail_type;
|
||||
return $this->sendAction(Utils::camelToSeparator(__FUNCTION__), $params, $this->self);
|
||||
return $this->sendAction(Utils::camelToSeparator(__FUNCTION__), $params, $this->getSelf());
|
||||
}
|
||||
|
||||
/**
|
||||
@ -58,6 +50,9 @@ trait BotActionTrait
|
||||
*/
|
||||
public function sendAction(string $action, array $params = [], ?array $self = null): bool|ActionResponse
|
||||
{
|
||||
if ($self === null && $this->self !== null) {
|
||||
$self = $this->self;
|
||||
}
|
||||
// 声明 Action 对象
|
||||
$a = new Action($action, $params, ob_uuidgen(), $self);
|
||||
// 调用事件在回复之前的回调
|
||||
@ -70,29 +65,29 @@ trait BotActionTrait
|
||||
return false;
|
||||
}
|
||||
|
||||
// 调用机器人连接发送 Action,首先试试看是不是 WebSocket
|
||||
if ($this->base_event instanceof WebSocketMessageEvent) {
|
||||
logger()->debug('使用传入的 base_event 发送消息');
|
||||
$result = $this->base_event->send(json_encode($a->jsonSerialize()));
|
||||
}
|
||||
if (!isset($result) && container()->has('ws.message.event')) {
|
||||
logger()->debug('使用容器的 Event 发送消息');
|
||||
$result = container()->get('ws.message.event')->send(json_encode($a->jsonSerialize()));
|
||||
}
|
||||
// 如果是 HTTP WebHook 的形式,那么直接调用 Response
|
||||
if (!isset($result) && $this->base_event instanceof HttpRequestEvent) {
|
||||
$response = HttpFactory::createResponse(headers: ['Content-Type' => 'application/json'], body: json_encode([$a->jsonSerialize()]));
|
||||
$this->base_event->withResponse($response);
|
||||
$result = true;
|
||||
}
|
||||
if (!isset($result) && container()->has('http.request.event')) {
|
||||
$response = HttpFactory::createResponse(headers: ['Content-Type' => 'application/json'], body: json_encode([$a->jsonSerialize()]));
|
||||
container()->get('http.request.event')->withResponse($response);
|
||||
$result = true;
|
||||
// 获取机器人的 BotMap 对应连接(前提是当前上下文有 self)
|
||||
if ($self !== null) {
|
||||
$fd_map = BotMap::getBotFd($self['user_id'], $self['platform']);
|
||||
if ($fd_map === null) {
|
||||
logger()->error("机器人 [{$self['platform']}:{$self['user_id']}] 没有连接或未就绪,无法发送数据");
|
||||
return false;
|
||||
}
|
||||
$result = ws_socket($fd_map[0])->send(json_encode($a->jsonSerialize()), $fd_map[1]);
|
||||
} elseif ($this instanceof BotConnectContext) {
|
||||
// self 为空,说明可能是发送的元动作,需要通过 fd 来查找对应的 connect 连接
|
||||
$flag = $this->getFlag();
|
||||
$fd = $this->getFd();
|
||||
$result = ws_socket($flag)->send(json_encode($a->jsonSerialize()), $fd);
|
||||
} elseif (method_exists($this, 'emitSendAction')) {
|
||||
$result = $this->emitSendAction($a);
|
||||
} else {
|
||||
logger()->error('未匹配到任何机器人连接');
|
||||
return false;
|
||||
}
|
||||
|
||||
// 如果开启了协程,并且成功发送,那就进入协程等待,挂起等待结果返回一个 ActionResponse 对象
|
||||
if (($result ?? false) === true && ($co = Adaptive::getCoroutine()) !== null) {
|
||||
static::$coroutine_list[$a->echo] = $co->getCid();
|
||||
BotMap::$bot_coroutines[$a->echo] = $co->getCid();
|
||||
$response = $co->suspend();
|
||||
if ($response instanceof ActionResponse) {
|
||||
return $response;
|
||||
|
||||
133
src/ZM/Plugin/OneBot/BotMap.php
Normal file
133
src/ZM/Plugin/OneBot/BotMap.php
Normal file
@ -0,0 +1,133 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace ZM\Plugin\OneBot;
|
||||
|
||||
use OneBot\V12\Object\OneBotEvent;
|
||||
use ZM\Context\BotConnectContext;
|
||||
use ZM\Context\BotContext;
|
||||
use ZM\Exception\OneBot12Exception;
|
||||
|
||||
/**
|
||||
* 用于记录多个机器人对应的 fd、flag、状态等的全局关系表(基于反向 WS 类型连接才可用)
|
||||
*/
|
||||
class BotMap
|
||||
{
|
||||
/**
|
||||
* @internal 仅允许框架内部使用
|
||||
* @var array 存储动作 echo 的协程 ID 对应表
|
||||
*/
|
||||
public static array $bot_coroutines = [];
|
||||
|
||||
/**
|
||||
* @var array<string, array<string, bool>> 机器人上下文对象列表
|
||||
*/
|
||||
private static array $bot_status = [];
|
||||
|
||||
/**
|
||||
* @var array<string, array<string, BotContext>> 机器人上下文缓存对象,避免重复创建
|
||||
*/
|
||||
private static array $bot_ctx_cache = [];
|
||||
|
||||
/**
|
||||
* 机器人对应连接 fd
|
||||
* 例如:{ "qq": { "123456": [1,2] } }
|
||||
*
|
||||
* @var array<string, array<string, array>> 机器人对应连接 fd
|
||||
*/
|
||||
private static array $bot_fds = [];
|
||||
|
||||
public static function getConnectContext(int $flag, int $fd): BotConnectContext
|
||||
{
|
||||
return new BotConnectContext($flag, $fd);
|
||||
}
|
||||
|
||||
/**
|
||||
* 注册机器人
|
||||
*
|
||||
* @param int|string $bot_id 机器人 ID
|
||||
* @param string $platform 机器人平台
|
||||
* @param bool $status 机器人状态
|
||||
* @param int $fd 绑定的反向 ws 连接的客户端对应 fd
|
||||
* @param int $flag fd 所在 server 监听端口
|
||||
*/
|
||||
public static function registerBotWithFd(string|int $bot_id, string $platform, bool $status, int $fd, int $flag): bool
|
||||
{
|
||||
logger()->debug('正在注册机器人:' . "{$platform}:{$bot_id}, fd:{$fd}, flag:{$flag}");
|
||||
self::$bot_fds[$platform][strval($bot_id)] = [$flag, $fd];
|
||||
self::$bot_status[$platform][strval($bot_id)] = $status;
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取所有机器人对应的 fd
|
||||
*
|
||||
* @return array<string, array<string, array>>
|
||||
*/
|
||||
public static function getBotFds(): array
|
||||
{
|
||||
return self::$bot_fds;
|
||||
}
|
||||
|
||||
public static function getBotFd(string|int $bot_id, string $platform): ?array
|
||||
{
|
||||
return self::$bot_fds[$platform][$bot_id] ?? null;
|
||||
}
|
||||
|
||||
public static function unregisterBot(string|int $bot_id, string $platform): void
|
||||
{
|
||||
logger()->debug('取消注册 bot: ' . $bot_id);
|
||||
unset(self::$bot_fds[$platform][$bot_id], self::$bot_status[$platform][$bot_id], self::$bot_ctx_cache[$platform][$bot_id]);
|
||||
}
|
||||
|
||||
public static function unregisterBotByFd(int $flag, int $fd): void
|
||||
{
|
||||
$unreg_list = [];
|
||||
foreach (self::$bot_fds as $platform => $bots) {
|
||||
foreach ($bots as $bot_id => $bot_fd) {
|
||||
if ($bot_fd[0] === $flag && $bot_fd[1] = $fd) {
|
||||
$unreg_list[] = [$platform, $bot_id];
|
||||
}
|
||||
}
|
||||
}
|
||||
foreach ($unreg_list as $item) {
|
||||
self::unregisterBot($item[1], $item[0]);
|
||||
}
|
||||
}
|
||||
|
||||
public static function getBotContext(string|int $bot_id = '', string $platform = ''): BotContext
|
||||
{
|
||||
if (isset(self::$bot_ctx_cache[$platform][$bot_id])) {
|
||||
return self::$bot_ctx_cache[$platform][$bot_id];
|
||||
}
|
||||
// 如果传入的是空,说明需要通过 cid 来获取事件绑定的机器人,并且机器人没有
|
||||
if ($bot_id === '' && $platform === '') {
|
||||
if (!container()->has(OneBotEvent::class)) {
|
||||
throw new OneBot12Exception('无法在不指定机器人平台、机器人 ID 的情况下在非机器人事件回调内获取机器人上下文');
|
||||
}
|
||||
$event = container()->get(OneBotEvent::class);
|
||||
if (($event->self['platform'] ?? null) === null) {
|
||||
throw new OneBot12Exception('无法在不包含机器人 ID 的事件回调内获取机器人上下文');
|
||||
}
|
||||
// 有,那就通过事件本身的 self 字段来获取一下
|
||||
$self = $event->self;
|
||||
return self::$bot_ctx_cache[$self['platform']][$self['user_id']] = new BotContext($self['user_id'], $self['platform']);
|
||||
}
|
||||
// 传入的 platform 为空,但 ID 不为空,那么就模糊搜索一个平台的 ID 下的机器人 ID 返回
|
||||
if ($platform === '') {
|
||||
foreach (self::$bot_fds as $platform => $bot_ids) {
|
||||
foreach ($bot_ids as $id => $fd_map) {
|
||||
if ($id === $bot_id) {
|
||||
return self::$bot_ctx_cache[$platform][$id] = new BotContext($id, $platform);
|
||||
}
|
||||
}
|
||||
}
|
||||
throw new OneBot12Exception('未找到 ID 为 ' . $bot_id . ' 的机器人');
|
||||
}
|
||||
if (!isset(self::$bot_fds[$platform][$bot_id])) {
|
||||
throw new OneBot12Exception('未找到 ' . $platform . ' 平台下 ID 为 ' . $bot_id . ' 的机器人');
|
||||
}
|
||||
return self::$bot_ctx_cache[$platform][$bot_id] = new BotContext($bot_id, $platform);
|
||||
}
|
||||
}
|
||||
@ -2,11 +2,12 @@
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace ZM\Plugin;
|
||||
namespace ZM\Plugin\OneBot;
|
||||
|
||||
use Choir\Http\HttpFactory;
|
||||
use OneBot\Driver\Coroutine\Adaptive;
|
||||
use OneBot\Driver\Event\StopException;
|
||||
use OneBot\Driver\Event\WebSocket\WebSocketCloseEvent;
|
||||
use OneBot\Driver\Event\WebSocket\WebSocketMessageEvent;
|
||||
use OneBot\Driver\Event\WebSocket\WebSocketOpenEvent;
|
||||
use OneBot\V12\Exception\OneBotException;
|
||||
@ -28,6 +29,7 @@ use ZM\Context\BotContext;
|
||||
use ZM\Exception\InterruptException;
|
||||
use ZM\Exception\OneBot12Exception;
|
||||
use ZM\Exception\WaitTimeoutException;
|
||||
use ZM\Plugin\ZMPlugin;
|
||||
use ZM\Utils\ConnectionUtil;
|
||||
use ZM\Utils\MessageUtil;
|
||||
|
||||
@ -49,19 +51,20 @@ class OneBot12Adapter extends ZMPlugin
|
||||
*/
|
||||
private static array $context_prompt_queue = [];
|
||||
|
||||
public function __construct(string $submodule = '', ?AnnotationParser $parser = null)
|
||||
public function __construct(string $submodule = 'onebot12', ?AnnotationParser $parser = null)
|
||||
{
|
||||
switch ($submodule) {
|
||||
case '':
|
||||
case 'onebot12':
|
||||
// 处理所有 OneBot 12 的反向 WS 握手事件
|
||||
$this->addEvent(WebSocketOpenEvent::class, [$this, 'handleWSReverseOpen']);
|
||||
$this->addEvent(WebSocketMessageEvent::class, [$this, 'handleWSReverseMessage']);
|
||||
$this->addEvent(WebSocketCloseEvent::class, [$this, 'handleWSReverseClose']);
|
||||
// 在 BotEvent 内处理 BotCommand
|
||||
$this->addBotEvent(BotEvent::make(type: 'message', level: 15)->on([$this, 'handleBotCommand']));
|
||||
// 在 BotEvent 内处理需要等待回复的 CommandArgument
|
||||
$this->addBotEvent(BotEvent::make(type: 'message', level: 49)->on([$this, 'handleCommandArgument']));
|
||||
$this->addBotEvent(BotEvent::make(type: 'message', level: 50)->on([$this, 'handleContextPrompt']));
|
||||
$this->addBotEvent(BotEvent::make(type: 'meta', detail_type: 'status_update', level: 50)->on([$this, 'handleStatusUpdate']));
|
||||
// 处理和声明所有 BotCommand 下的 CommandArgument
|
||||
$parser->addSpecialParser(BotCommand::class, [$this, 'parseBotCommand']);
|
||||
// 不需要给列表写入 CommandArgument
|
||||
@ -165,6 +168,44 @@ class OneBot12Adapter extends ZMPlugin
|
||||
$this->callBotCommand($ctx, $command);
|
||||
}
|
||||
|
||||
/**
|
||||
* [CALLBACK] 处理 status_update 事件,更新 BotMap
|
||||
*
|
||||
* @param OneBotEvent $event 机器人事件
|
||||
*/
|
||||
public function handleStatusUpdate(OneBotEvent $event, WebSocketMessageEvent $message_event): void
|
||||
{
|
||||
$status = $event->get('status');
|
||||
$old = BotMap::getBotFds();
|
||||
if (($status['good'] ?? false) === true) {
|
||||
foreach (($status['bots'] ?? []) as $bot) {
|
||||
BotMap::registerBotWithFd(
|
||||
bot_id: $bot['self']['user_id'],
|
||||
platform: $bot['self']['platform'],
|
||||
status: $bot['good'] ?? false,
|
||||
fd: $message_event->getFd(),
|
||||
flag: $message_event->getSocketFlag()
|
||||
);
|
||||
if (isset($old[$bot['self']['platform']][$bot['self']['user_id']])) {
|
||||
unset($old[$bot['self']['platform']][$bot['self']['user_id']]);
|
||||
}
|
||||
logger()->error("[{$bot['self']['platform']}.{$bot['self']['user_id']}] 已接入,状态:" . (($bot['good'] ?? false) ? 'OK' : 'Not OK'));
|
||||
}
|
||||
} else {
|
||||
logger()->debug('该实现状态目前不是正常的,不处理 bots 列表');
|
||||
$old = [];
|
||||
}
|
||||
foreach ($old as $platform => $bot_ids) {
|
||||
if (empty($bot_ids)) {
|
||||
continue;
|
||||
}
|
||||
foreach ($bot_ids as $id => $flag_fd) {
|
||||
logger()->debug("[{$platform}.{$id}] 已断开!");
|
||||
BotMap::unregisterBot($id, $platform);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* [CALLBACK] 处理需要等待回复的 CommandArgument
|
||||
*
|
||||
@ -339,6 +380,14 @@ class OneBot12Adapter extends ZMPlugin
|
||||
|
||||
// 绑定容器
|
||||
ContainerRegistrant::registerOBEventServices($obj);
|
||||
if ($obj->getSelf() !== null) {
|
||||
$bot_id = $obj->self['user_id'];
|
||||
$platform = $obj->self['platform'];
|
||||
if (BotMap::getBotFd($bot_id, $platform) === null) {
|
||||
BotMap::registerBotWithFd($bot_id, $platform, true, $event->getFd(), $event->getSocketFlag());
|
||||
}
|
||||
container()->set(BotContext::class, bot($obj->self['user_id'], $obj->self['platform']));
|
||||
}
|
||||
|
||||
// 调用 BotEvent 事件
|
||||
$handler = new AnnotationHandler(BotEvent::class);
|
||||
@ -387,6 +436,17 @@ class OneBot12Adapter extends ZMPlugin
|
||||
}
|
||||
}
|
||||
|
||||
public function handleWSReverseClose(WebSocketCloseEvent $event)
|
||||
{
|
||||
// 忽略非 OneBot 12 的消息
|
||||
$impl = ConnectionUtil::getConnection($event->getFd())['impl'] ?? null;
|
||||
if ($impl === null) {
|
||||
return;
|
||||
}
|
||||
// 在关闭连接的时候
|
||||
BotMap::unregisterBotByFd($event->getSocketFlag(), $event->getFd());
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据事件匹配规则
|
||||
*
|
||||
Loading…
x
Reference in New Issue
Block a user