From baee59b0252859cca14050bd4dbe6ab875bce2fe Mon Sep 17 00:00:00 2001 From: crazywhalecc Date: Fri, 13 Jan 2023 15:55:52 +0800 Subject: [PATCH] initial commit --- .gitignore | 4 + README.md | 74 +++++++++++ composer.json | 16 +++ main.php | 12 ++ src/GoBotContext.php | 58 +++++++++ src/GocqActionConverter.php | 202 ++++++++++++++++++++++++++++++ src/GocqAdapter.php | 156 +++++++++++++++++++++++ src/GocqEventConverter.php | 236 +++++++++++++++++++++++++++++++++++ src/GocqRetcodeConverter.php | 15 +++ src/GocqSegmentConverter.php | 172 +++++++++++++++++++++++++ zmplugin.json | 5 + 11 files changed, 950 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 composer.json create mode 100644 main.php create mode 100644 src/GoBotContext.php create mode 100644 src/GocqActionConverter.php create mode 100644 src/GocqAdapter.php create mode 100644 src/GocqEventConverter.php create mode 100644 src/GocqRetcodeConverter.php create mode 100644 src/GocqSegmentConverter.php create mode 100644 zmplugin.json diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..700c3aa --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +/vendor/ +composer.lock +/.idea/ +/zm_data/ \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..2226b2f --- /dev/null +++ b/README.md @@ -0,0 +1,74 @@ +# go-cqhttp-adapter-plugin + +炸毛框架用于接入 go-cqhttp(OneBot 11)的适配器插件。 + +## 功能 + +该插件将 gocq 的反向 WebSocket 接入信息全部转换为 OneBot 12 标准,安装该插件后几乎无需修改任何代码即可接入。 + +## 安装 + +```bash +# Composer 安装稳定版 +composer require zhamao/go-cqhttp-adapter-plugin + +# GitHub 安装 Nightly 版 +./zhamao plugin:install https://github.com/zhamao-robot/go-cqhttp-adapter-plugin.git +``` + +## 转换注意事项 + +由于 OneBot 11 和 OneBot 12 有较多差异,而这些差异也导致两者不能无损相互转换。 +例如 OneBot 11 中未规定要求文件分片上传和下载的动作,那么在使用本插件时也无法使用这些动作。 + +由于 OneBot 11 的实现较多,而且和 OneBot 11 本身相差较大,该插件也时重点针对 go-cqhttp 进行适配转换,这也是插件不叫 onebot-11-adapter 的原因。 + +## 事件转换规则(11 转 12) + +下面是 `post_type` 转换规则: + +- 字段名 `post_type` 转换为 `type`。 +- 如果 `post_type` 值为 `meta_event`,转换为 `meta`。 +- 如果 `post_type` 值为 `message_sent`,转换为 `message`。 + +下面是 `XXX_type` 转换规则: + +- 如果 `post_type` 为 `message` 或 `message_sent`,将字段名 `message_type` 转换为 `detail_type`。 +- 如果 `post_type` 为 `meta_event`,将字段名 `meta_event_type` 转换为 `detail_type`。 +- 如果 `post_type` 为 `notice`,将字段名 `notice_type` 转换为 `detail_type`。 +- 如果 `post_type` 为 `request`,将字段名 `request_type` 转换为 `detail_type`。 + +下面是 `self_id` 转换规则: + +- 如果存在 `self_id` 字段,将该字段删除,替换为 `"self" => ["user_id" => $user_id, "platform" => "qq"]`,其中 `$user_id` 的值等于 `self_id` 的字符串值。 +- 如果不存在 `self_id` 字段,将该字段删除,替换为和上方一样的格式,`$user_id` 的值等于该连接请求握手时 `X-Self-ID` 的值。 + +下面是 `user_id`、`group_id`、`guild_id`、`channel_id`、`message_id` 转换规则: + +- 以上列举的值都取字符串值,即转换为字符串。 + +下面是其他字段的一些转换规则: + +- 如果 `post_type` 为 `message` 或 `message_sent`,则将 `raw_message` 转换为 `alt_message`。 +- go-cqhttp 的消息事件中默认带有 `sender`,将其字段名转换为 `qq.sender`,内部参数不变。 + +下面是通知事件(`notice`)的一些转换规则: + +| go-cqhttp 的 `notice_type` | OneBot 12 的 `detail_type` | 描述 | +|---------------------------|---------------------------|-----------| +| `friend_recall` | `private_message_delete` | 撤回一条私聊消息 | +| `friend_add` | `friend_increase` | 添加好友的通知事件 | +| `group_increase` | `group_member_increase` | 群成员增加 | +| `group_decrease` | `group_member_decrease` | 群成员减少 | +| `group_recall` | `group_message_delete` | 群消息撤回 | +| 除上述外的其他通知事件 | `qq.` 前缀加上原名称 | | + +- `friend_recall` 转换后,仅保留 `message_id` 和 `user_id` 字段。 +- `friend_add` 转换后,仅保留 `user_id` 字段。 +- `group_increase` 转换后,如果 `sub_type` 值为 `approve` 或空,则转换为 `join`;如果为 `invite` 则不变,如果是其他,则加上前缀 `qq.`。 +- `group_increase` 转换后,仅保留 `sub_type`、`group_id`、`user_id``operator_id` 且转换为字符串值。 +- `group_decrease` 转换后,如果 `sub_type` 值为 `kick` 或 `kick_me`,则转换为 `kick`;如果为 `leave` 则不变,如果是其他,则加上前缀 `qq.`。如果想判断是否为 `kick_me`,可以使用判断 `user_id` 和 `operator_id` 是否相同。 +- `group_decrease` 转换后,仅保留 `sub_type`、`group_id`、`user_id``operator_id` 且转换为字符串值。 +- `group_recall` 转换后,如果 `user_id` 与 `operator_id` 相同,则 `sub_type` 值设定为 `recall`,否则为 `delete`(分别代表自己撤回和被撤回)。 +- `group_recall` 转换后,仅保留 `message_id`、`group_id`、`user_id`、`operator_id` 且转换为字符串值。 +- 其他通知类事件加前缀,其他字段除 `post_type`、`notice_type`、`sub_type`、`request_type`、`meta_event_type`、`time` 和一系列 `xxx_id` 外,均保留,并加上 `qq.` 前缀,值不变。 \ No newline at end of file diff --git a/composer.json b/composer.json new file mode 100644 index 0000000..ce35c4a --- /dev/null +++ b/composer.json @@ -0,0 +1,16 @@ +{ + "name": "zhamao/go-cqhttp-adapter-plugin", + "license": "Apache-2.0", + "autoload": { + "psr-4": { + "GocqAdapter\\": "src/" + } + }, + "require": { + "php": "~8.0 || ~8.1" + }, + "require-dev": { + "zhamao/framework": "dev-main" + }, + "minimum-stability": "dev" +} \ No newline at end of file diff --git a/main.php b/main.php new file mode 100644 index 0000000..5f4a445 --- /dev/null +++ b/main.php @@ -0,0 +1,12 @@ +onPluginLoad(function (AnnotationParser $parser) { + +}); + +return $zm; diff --git a/src/GoBotContext.php b/src/GoBotContext.php new file mode 100644 index 0000000..c1ed482 --- /dev/null +++ b/src/GoBotContext.php @@ -0,0 +1,58 @@ +set(Action::class, $a); + $handler->setRuleCallback(fn (BotAction $act) => $act->action === '' || $act->action === $action && !$act->need_response); + $handler->handleAll($a); + // 被阻断时候,就不发送了 + if ($handler->getStatus() === AnnotationHandler::STATUS_INTERRUPTED) { + return false; + } + + // 从这里开始,gocq 需要做一个 12 -> 11 的转换 + $action_array = GocqActionConverter::getInstance()->convertAction12To11($a); + // 将这个 action 提取出来需要记忆的 echo + GocqAdapter::$action_hold_list[$a->echo] = $action_array; + + // 调用机器人连接发送 Action + if ($this->base_event instanceof WebSocketMessageEvent) { + $result = $this->base_event->send(json_encode($action_array)); + } + if (!isset($result) && container()->has('ws.message.event')) { + $result = container()->get('ws.message.event')->send(json_encode($action_array)); + } + // 如果开启了协程,并且成功发送,那就进入协程等待,挂起等待结果返回一个 ActionResponse 对象 + if (($result ?? false) === true && ($co = Adaptive::getCoroutine()) !== null) { + static::$coroutine_list[$a->echo] = $co->getCid(); + $response = $co->suspend(); + if ($response instanceof ActionResponse) { + return $response; + } + return false; + } + if (isset($result)) { + return $result; + } + // 到这里表明你调用时候不在 WS 或 HTTP 上下文 + throw new OneBot12Exception('No bot connection found.'); + } +} diff --git a/src/GocqActionConverter.php b/src/GocqActionConverter.php new file mode 100644 index 0000000..757fcd3 --- /dev/null +++ b/src/GocqActionConverter.php @@ -0,0 +1,202 @@ +echo; + switch ($action->action) { + case 'send_message': + switch ($action->params['detail_type'] ?? '') { + case 'group': + $act = 'send_group_msg'; + $params['group_id'] = $action->params['group_id']; + $params['message'] = $this->parseSegments12To11($action->params['message']); + break; + case 'private': + $act = 'send_private_msg'; + $params['user_id'] = $action->params['user_id']; + if (isset($action->params['group_id'])) { + $params['group_id'] = $action->params['group_id']; + } + $params['message'] = $this->parseSegments12To11($action->params['message']); + break; + default: + throw new OneBot12Exception('Unsupported detail_type for gocq action'); + } + break; + case 'delete_message': + $act = 'delete_msg'; + $params['message_id'] = $action->params['message_id']; + break; + case 'get_self_info': + $act = 'get_login_info'; + break; + case 'get_user_info': + $act = 'get_stranger_info'; + $params['user_id'] = $action->params['user_id']; + break; + case 'get_friend_list': + case 'get_group_info': + case 'get_group_list': + case 'get_group_member_info': + case 'get_group_member_list': + case 'set_group_name': + $act = $action->action; + $params = $action->params; + break; + case 'leave_group': + $act = 'set_group_leave'; + $params = $action->params; + break; + case 'upload_file': + // OneBot 11 / go-cqhttp 只支持 url 方式上传文件 + if ($action->params['type'] !== 'url') { + throw new OneBot12Exception('OneBot 11 / go-cqhttp only support url uploader'); + } + $act = 'download_file'; + $params = [ + 'url' => $action->params['url'], + 'headers' => $action->params['headers'] ?? [], + 'thread_count' => $action->params['thread_count'] ?? 1, + ]; + break; + default: + // qq. 开头的动作,一律当作 gocq 的其他事件,这时候 params 原封不动发出 + if (str_starts_with($action->action, 'qq.')) { + $act = substr($action->action, 3); + $params = $action->params; + } else { + throw new OneBot12Exception('Current action cannot send to gocq'); + } + break; + } + return ['action' => $act, 'params' => $params, 'echo' => $echo]; + } + + public function convertActionResponse11To12(array $response, array $action): ActionResponse + { + $response_obj = new ActionResponse(); + $response_obj->status = $response['status']; + $response_obj->retcode = GocqRetcodeConverter::getInstance()->convertRetCode11To12($response['retcode']); + $response_obj->message = $response['msg'] ?? ''; + $response_obj->echo = $response['echo'] ?? null; + // 接下来判断 action + if ($response_obj->retcode !== 0) { + return $response_obj; + } + switch ($action['action']) { + case 'send_group_msg': + case 'send_private_msg': + $response_obj->data = [ + 'message_id' => $response['data']['message_id'], + ]; + break; + case 'get_stranger_info': + $response_obj->data = [ + 'user_id' => $response['data']['user_id'], + 'user_name' => $response['data']['nickname'], + 'user_displayname' => $response['data']['nickname'], + 'user_remark' => '', + ]; + break; + case 'get_login_info': + $response_obj->data = [ + 'user_id' => $response['data']['user_id'], + 'user_name' => $response['data']['nickname'], + 'user_displayname' => $response['data']['nickname'], + ]; + break; + case 'get_friend_list': + $response_obj->data = []; + foreach ($response['data'] as $friend) { + $response_obj->data[] = [ + 'user_id' => $friend['user_id'], + 'user_name' => $friend['nickname'], + 'user_displayname' => $friend['nickname'], + 'user_remark' => $friend['remark'], + ]; + } + break; + case 'get_group_info': + case 'get_group_list': + $response_obj->data = $response['data']; + break; + case 'get_group_member_info': + $response_obj->data = [ + 'user_id' => $response['data']['user_id'], + 'user_name' => $response['data']['nickname'], + 'user_displayname' => $response['data']['card'] ?? $response['data']['nickname'], + ]; + foreach ($response['data'] as $kss => $vss) { + if (in_array($kss, ['user_id', 'group_id'])) { + continue; + } + $response_obj->data['qq.' . $kss] = $vss; + } + break; + case 'get_group_member_list': + foreach ($response['data'] as $member) { + $dt = [ + 'user_id' => $member['user_id'], + 'user_name' => $member['nickname'], + 'user_displayname' => $member['card'] ?? $member['nickname'], + ]; + foreach ($member as $kss => $vss) { + if (in_array($kss, ['user_id', 'group_id'])) { + continue; + } + $dt['qq.' . $kss] = $vss; + } + $response_obj->data[] = $dt; + } + break; + + case 'download_file': + $response_obj->data = [ + 'file_id' => $response['data']['file'], + ]; + break; + case 'set_group_name': + case 'set_group_leave': + default: + $response_obj->data = $response['data'] ?? []; + break; + } + return $response_obj; + } + + /** + * @param MessageSegment[] $message 消息段 + * @return array OneBot 11 的消息段 + */ + private function parseSegments12To11(array $message): array + { + $msgs = []; + foreach ($message as $v) { + $msgs[] = GocqSegmentConverter::getInstance()->parseSegment12To11($v); + } + return $msgs; + } +} diff --git a/src/GocqAdapter.php b/src/GocqAdapter.php new file mode 100644 index 0000000..d3777da --- /dev/null +++ b/src/GocqAdapter.php @@ -0,0 +1,156 @@ + + * @internal + */ + public static array $action_hold_list = []; + + /** @var GocqEventConverter[] */ + private static array $converters = []; + + #[Init] + public function init(): void + { + logger()->info('go-cqhttp 转换器已加载!'); + } + + /** + * [CALLBACK] 接入和认证 go-cqhttp 的反向 WebSocket 连接 + * @throws \JsonException + */ + #[BindEvent(WebSocketOpenEvent::class)] + public function handleWSReverseOpen(WebSocketOpenEvent $event): void + { + logger()->info('连接到 ob11'); + $request = $event->getRequest(); + ob_dump($request); + // 判断是不是 Gocq 或 OneBot 11 标准的连接。OB11 标准必须带有 X-Client-Role 和 X-Self-ID 两个头。 + if ($request->getHeaderLine('X-Client-Role') === 'Universal' && $request->getHeaderLine('X-Self-ID') !== '') { + logger()->info('检测到 OneBot 11 反向 WS 连接 ' . $request->getHeaderLine('User-Agent')); + $info = ['gocq_impl' => 'go-cqhttp', 'self_id' => $request->getHeaderLine('X-Self-ID')]; + // TODO: 验证 Token + ConnectionUtil::setConnection($event->getFd(), $info); + logger()->info('已接入 go-cqhttp 的反向 WS 连接,连接 ID 为 ' . $event->getFd()); + } + } + + /** + * @throws OneBotException + */ + #[BindEvent(WebSocketMessageEvent::class)] + public function handleWSReverseMessage(WebSocketMessageEvent $event): void + { + // 忽略非 gocq 的消息 + $impl = ConnectionUtil::getConnection($event->getFd())['gocq_impl'] ?? null; + if ($impl === null) { + return; + } + + // 解析 Frame 到 UTF-8 JSON + $body = $event->getFrame()->getData(); + $body = json_decode($body, true); + if ($body === null) { + logger()->warning('收到非 JSON 格式的消息,已忽略'); + return; + } + + if (isset($body['post_type'], $body['self_id'])) { + $ob12 = self::getConverter($event->getFd(), strval($body['self_id']))->convertEvent($body); + if ($ob12 === null) { + logger()->debug('收到了不支持的 Event,丢弃此事件'); + logger()->debug('事件详情对象:' . json_encode($ob12, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE)); + return; + } + try { + $obj = new OneBotEvent($ob12); + } catch (OneBotException $e) { + logger()->debug('收到非 OneBot 12(由11转换而来)标准的消息,已忽略'); + logger()->debug($e->getMessage()); + return; + } + + // 绑定容器 + ContainerRegistrant::registerOBEventServices($obj, GoBotContext::class); + + // 调用 BotEvent 事件 + $handler = new AnnotationHandler(BotEvent::class); + $handler->setRuleCallback(function (BotEvent $event) use ($obj) { + return ($event->type === null || $event->type === $obj->type) + && ($event->sub_type === null || $event->sub_type === $obj->sub_type) + && ($event->detail_type === null || $event->detail_type === $obj->detail_type); + }); + try { + $handler->handleAll($obj); + } catch (WaitTimeoutException $e) { + // 这里是处理 prompt() 下超时的情况的 + if ($e->getTimeoutPrompt() === null) { + return; + } + if (($e->getPromptOption() & ZM_PROMPT_TIMEOUT_MENTION_USER) === ZM_PROMPT_TIMEOUT_MENTION_USER && ($ev = $e->getUserEvent()) !== null) { + $prompt = [MessageSegment::mention($ev->getUserId()), ...$e->getTimeoutPrompt()]; + } + if (($e->getPromptOption() & ZM_PROMPT_TIMEOUT_QUOTE_SELF) === ZM_PROMPT_TIMEOUT_QUOTE_SELF && ($rsp = $e->getPromptResponse()) !== null && ($ev = $e->getUserEvent()) !== null) { + $prompt = [MessageSegment::reply($rsp->data['message_id'], $ev->self['user_id']), ...$e->getTimeoutPrompt()]; + } elseif (($e->getPromptOption() & ZM_PROMPT_TIMEOUT_QUOTE_USER) === ZM_PROMPT_TIMEOUT_QUOTE_USER && ($ev = $e->getUserEvent()) !== null) { + $prompt = [MessageSegment::reply($ev->getMessageId(), $ev->getUserId()), ...$e->getTimeoutPrompt()]; + } + bot()->reply($prompt ?? $e->getTimeoutPrompt()); + } + } elseif (isset($body['status'], $body['retcode'], $body['echo'])) { + if (isset(self::$action_hold_list[$body['echo']])) { + $origin_action = self::$action_hold_list[$body['echo']]; + unset(self::$action_hold_list[$body['echo']]); + $resp = GocqActionConverter::getInstance()->convertActionResponse11To12($body, $origin_action); + ContainerRegistrant::registerOBActionResponseServices($resp); + + // 调用 BotActionResponse 事件 + $handler = new AnnotationHandler(BotActionResponse::class); + $handler->setRuleCallback(function (BotActionResponse $event) use ($resp) { + return $event->retcode === null || $event->retcode === $resp->retcode; + }); + $handler->handleAll($resp); + + // 如果有协程,并且该 echo 记录在案的话,就恢复协程 + BotContext::tryResume($resp); + } + } + } + + #[BindEvent(WebSocketCloseEvent::class)] + public function handleWSReverseClose(WebSocketCloseEvent $event): void + { + unset(self::$converters[$event->getFd()]); + } + + public static function getConverter(int $fd, ?string $self_id = null): GocqEventConverter + { + if (!isset(self::$converters[$fd])) { + self::$converters[$fd] = new GocqEventConverter($self_id, 'unknown'); + } + return self::$converters[$fd]; + } +} diff --git a/src/GocqEventConverter.php b/src/GocqEventConverter.php new file mode 100644 index 0000000..daf1698 --- /dev/null +++ b/src/GocqEventConverter.php @@ -0,0 +1,236 @@ + strval($event['self_id']), + 'platform' => 'qq', + ]; + } elseif ($event['post_type'] !== 'meta_event') { + $ob12['self'] = [ + 'user_id' => $this->self_id, + 'platform' => 'qq', + ]; + } + // time 原封不动 + $ob12['time'] = $event['time']; + // 生成一个事件 ID + $ob12['id'] = ob_uuidgen(); + switch ($ob12['type']) { + case 'message': + case 'message_sent': + // message_type 转换为 detail_type + $ob12['detail_type'] = $event['message_type']; + // TODO:目前只适配了 gocq 的 private 和 group 类型,以后再适配 guild,因为 gocq 的 guild 太特殊了 + if (!in_array($ob12['detail_type'], ['private', 'group'])) { + return null; + } + // sub_type 原封不动 + $ob12['sub_type'] = $event['sub_type']; + // message_id 需要 strval 后 + $ob12['message_id'] = strval($event['message_id']); + // user_id 需要 strval + $ob12['user_id'] = strval($event['user_id']); + // 转换下消息 + $ob12['message'] = $this->convertMessageSegment($event['message']); + // raw_message 转换为 alt_message + $ob12['alt_message'] = $event['raw_message']; + // sender 转换为 qq.sender + $ob12['qq.sender'] = $event['sender']; + // font 转换为 qq.font + $ob12['qq.font'] = $event['font']; + // message_type 为 group 时,需要转换 group_id + if ($ob12['detail_type'] === 'group') { + $ob12['group_id'] = strval($event['group_id']); + } + break; + case 'notice': + $ob12 = $this->convertNoticeEvent($ob12, $event); + break; + case 'request': + $ob12 = $this->convertRequestEvent($ob12, $event); + break; + case 'meta_event': + $ob12 = $this->convertMetaEvent($ob12, $event); + break; + } + // 有的事件没有 sub_type,补上 + if (!isset($ob12['sub_type'])) { + $ob12['sub_type'] = ''; + } + return $ob12; + } + + /** + * 将 OneBot 11 的消息转换为 OneBot 12 的消息段 + */ + public function convertMessageSegment(string|array $message): array + { + // 如果是 string,先读 CQ 码 + if (is_string($message)) { + $message = GocqSegmentConverter::getInstance()->strToSegments($message); + } else { + foreach ($message as $k => $v) { + [$type, $data] = GocqSegmentConverter::getInstance()->parseSegment11To12($v['type'], $v['data'] ?? []); + $message[$k] = ['type' => $type, 'data' => $data]; + } + } + return $message; + } + + /** + * 转换通知事件 + * + * @param array $ob12 OneBot 12 事件数组 + * @param array $event OneBot 11 / go-cqhttp 事件数组 + */ + public function convertNoticeEvent(array $ob12, array $event): array + { + // 标准对照表,将一些特定 OneBot 12 中规定了的事件转换到标准的,剩下的都加上前缀 + switch ($event['notice_type']) { + case 'friend_recall': // 消息撤回,转换为 private_message_delete + $ob12['detail_type'] = 'private_message_delete'; + $ob12['message_id'] = strval($event['message_id']); + $ob12['user_id'] = strval($event['user_id']); + break; + case 'friend_add': // 好友添加,转换为 friend_increase + $ob12['detail_type'] = 'friend_increase'; + $ob12['user_id'] = strval($event['user_id']); + break; + case 'group_increase': // 群成员增加,转换为 group_member_increase + $ob12['detail_type'] = 'group_member_increase'; + $ob12['sub_type'] = match ($event['sub_type']) { + 'approve', '' => 'join', + 'invite' => 'invite', + // no break + default => 'qq.' . $event['sub_type'], + }; + $ob12['group_id'] = strval($event['group_id']); + $ob12['user_id'] = strval($event['user_id']); + $ob12['operator_id'] = strval($event['operator_id']); + break; + case 'group_decrease': // 群成员减少,转换为 group_member_decrease + $ob12['detail_type'] = 'group_member_decrease'; + $ob12['sub_type'] = match ($event['sub_type']) { + 'leave' => 'leave', + 'kick', 'kick_me' => 'kick', + // no break + default => ('qq.' . $event['sub_type']), + }; + $ob12['group_id'] = strval($event['group_id']); + $ob12['user_id'] = strval($event['user_id']); + $ob12['operator_id'] = strval($event['operator_id']); + break; + case 'group_recall': // 群消息撤回,转换为 group_message_delete + $ob12['detail_type'] = 'group_message_delete'; + $ob12['sub_type'] = $event['user_id'] == $event['operator_id'] ? 'recall' : 'delete'; + $ob12['group_id'] = strval($event['group_id']); + $ob12['message_id'] = strval($event['message_id']); + $ob12['user_id'] = strval($event['user_id']); + $ob12['operator_id'] = strval($event['operator_id']); + break; + default: // 其他的 notice 事件,统一加上前缀 + $ob12['detail_type'] = 'qq.' . $event['notice_type']; + $ob12 = $this->parseExtendedKeys($ob12, $event); + break; + } + return $ob12; + } + + /** + * 转换请求事件 + * + * @param array $ob12 OneBot 12 事件数组 + * @param array $event OneBot 11 / go-cqhttp 事件数组 + */ + public function convertRequestEvent(array $ob12, array $event): array + { + // OneBot 12 标准中没有规定任何 request,所以任何 request 都加前缀 + $ob12['detail_type'] = 'qq.' . $event['request_type']; + return $this->parseExtendedKeys($ob12, $event); + } + + public function convertMetaEvent(array $ob12, array $event): array + { + $ob12['type'] = 'meta'; + switch ($event['meta_event_type']) { + case 'lifecycle': + if ($event['sub_type'] == 'connect') { + $ob12['detail_type'] = 'connect'; + $ob12['version'] = [ + 'impl' => 'go-cqhttp', + 'version' => $this->version, + 'onebot_version' => '12', + ]; + } + break; + case 'heartbeat': + $ob12['detail_type'] = 'heartbeat'; + $ob12['interval'] = $event['interval']; + break; + default: + $ob12['detail_type'] = $event['meta_event_type']; + $ob12 = $this->parseExtendedKeys($ob12, $event); + } + $ob12['sub_type'] = $ob12['detail_type'] === 'connect' ? '' : ($event['sub_type'] ?? ''); + return $ob12; + } + + /** + * 将 OneBot 11 / go-cqhttp 事件数组中的其他字段进行前缀化 + * + * @param array $ob12 OneBot 12 事件数组 + * @param array $event OneBot 11 / go-cqhttp 事件数组 + */ + public function parseExtendedKeys(array $ob12, array $event): array + { + foreach ($event as $k => $v) { + /* + 其他事件转换规则: + 1. 'post_type', 'notice_type', 'request_type', 'meta_event_type', 'time', 'self_id' 这几个字段忽略 + 2. 现有 ID 类,'user_id', 'group_id', 'channel_id', 'guild_id', 'operator_id', 'message_id' 这几个 id 类的需要取字符串值 + 3. 'sub_type' 如果值不为空,则直接加上前缀,否则保持空着 + 4. 其他字段,统一加上前缀,值不变 + */ + $result = match ($k) { + 'post_type', 'notice_type', 'request_type', 'meta_event_type', 'time', 'self_id' => null, + 'user_id', 'group_id', 'channel_id', 'guild_id', 'operator_id', 'message_id' => [$k, strval($v)], + 'sub_type' => $v === '' ? [$k, $v] : ($event['post_type'] === 'meta_event' ? [$k, $v] : [$k, 'qq.' . $v]), + default => ['qq.' . $k, $v], + }; + if ($result !== null) { + $ob12[$result[0]] = $result[1]; + } + } + return $ob12; + } + +} diff --git a/src/GocqRetcodeConverter.php b/src/GocqRetcodeConverter.php new file mode 100644 index 0000000..622dec3 --- /dev/null +++ b/src/GocqRetcodeConverter.php @@ -0,0 +1,15 @@ + 12 进行兼容性转换 + * + * @param string $msg 带 CQ 码的字符串 + */ + public function strToSegments(string $msg): array + { + $segments = []; + // 循环找 CQ 码 + while ($msg !== '') { + $before = mb_strstr($msg, '[CQ:', true); + $after = mb_strstr($msg, '[CQ:'); + // 找不到 CQ 码,直接返回原文本 + if ($before === false) { + $segments[] = MessageSegment::text($msg); + break; + } + // 找下 ],没找到的话返回消息 + if (($close = mb_strpos($after, ']')) === false) { + $segments[] = MessageSegment::text($msg); + break; + } + // 这里拿到右括号了,我们读取左括号和右括号之间的内容进行解析 + $cq = mb_substr($after, 4, $close - 4); + // 读取到的 CQ 码 type + $cqs = explode(',', $cq); + $type = array_shift($cqs); + // 剩下的都是参数,我们用等于号再次分割可以获得kv + $params = []; + foreach ($cqs as $v) { + $kv = explode('=', $v); + $key = array_shift($kv); + $params[$key] = $this->cqDecode(implode('=', $kv)); + } + // 将 11 的消息段转换为 12 的消息段 + [$type, $params] = $this->parseSegment11To12($type, $params); + // CQ 码前面有文本,当作一个消息段 + if ($before !== '') { + $segments[] = MessageSegment::text($before); + } + $segments[] = new MessageSegment($type, $params); + $msg = mb_substr($after, $close + 1); + } + return $segments; + } + + /** + * 反转义 CQ 码特殊字符(仅参数内容) + * + * @param string $content 内容 + */ + public function cqDecode(string $content): string + { + return str_replace(['&', '[', ']', ','], ['&', '[', ']', ','], $content); + } + + /** + * 将 OneBot 11 消息段转换为 OneBot 12 消息段 + * + * @param string $type 类型 + * @param array $data 参数 + */ + public function parseSegment11To12(string $type, array $data): array + { + switch ($type) { + case 'at': + $qq = $data['qq']; + unset($data['qq']); + if ($qq === 'all') { + $type = 'mention_all'; + } else { + $type = 'mention'; + $data['user_id'] = $qq; + } + break; + case 'video': + case 'image': + // 使用 file 字段当作 file_id + $file = $data['file']; + unset($data['file']); + $data['file_id'] = $file; + break; + case 'record': + $type = 'voice'; + // 使用 file 字段当作 file_id + $file = $data['file']; + unset($data['file']); + $data['file_id'] = $file; + break; + case 'location': + $data_old = $data; + $data = [ + 'latitude' => floatval($data_old['lat']), + 'longitude' => floatval($data_old['lon']), + 'title' => $data_old['title'] ?? '', + 'content' => $data_old['content'] ?? '', + ]; + break; + case 'reply': + $id = $data['id']; + unset($data['id']); + $data['message_id'] = $id; + if (isset($data['qq'])) { + $qq = $data['qq']; + unset($data['qq']); + $data['user_id'] = strval($qq); + } + break; + default: + $type = 'qq.' . $type; + break; + } + return [$type, $data]; + } + + /** + * 将 OneBot 消息段由 12 -> 11 + * @param MessageSegment $segment OneBot 12 的消息段 + * @return array OneBot 11 的消息段 + */ + public function parseSegment12To11(MessageSegment $segment): array + { + switch ($segment->type) { + case 'text': + return ['type' => 'text', 'data' => ['text' => $segment->data['text']]]; + case 'mention': + return ['type' => 'at', 'data' => ['qq' => $segment->data['user_id']]]; + case 'mention_all': + return ['type' => 'at', 'data' => ['qq' => 'all']]; + case 'image': + return ['type' => 'image', 'data' => ['file' => $segment->data['file_id'], 'url' => $segment->data['url'] ?? '']]; + case 'voice': + return ['type' => 'record', 'data' => ['file' => $segment->data['file_id'], 'url' => $segment->data['url'] ?? '']]; + case 'video': + return ['type' => 'video', 'data' => ['file' => $segment->data['file_id'], 'url' => $segment->data['url'] ?? '']]; + case 'location': + return ['type' => 'location', 'data' => [ + 'lat' => $segment->data['latitude'], + 'lon' => $segment->data['longitude'], + 'title' => $segment->data['title'], + 'content' => $segment->data['content'], + ]]; + case 'reply': + $data = ['id' => $segment->data['message_id']]; + if (isset($segment->data['user_id'])) { + $data['qq'] = $segment->data['user_id']; + } + return ['type' => 'reply', 'data' => $data]; + default: + if (str_starts_with($segment->type, 'qq.')) { + $type = substr($segment->type, 3); + } else { + $type = $segment->type; + } + $data = $segment->data; + return ['type' => $type, 'data' => $data]; + } + } +} diff --git a/zmplugin.json b/zmplugin.json new file mode 100644 index 0000000..af9a0cb --- /dev/null +++ b/zmplugin.json @@ -0,0 +1,5 @@ +{ + "name": "go-cqhttp-adapter-plugin", + "version": "1.0.0", + "main": "main.php" +}