nonebot-plugin-grok-bot 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,133 @@
1
+ Metadata-Version: 2.3
2
+ Name: nonebot-plugin-grok-bot
3
+ Version: 0.1.0
4
+ Summary: NoneBot2 plugin bridging chats with Grok Bot via MCP server + webhook
5
+ Author: Yan
6
+ Author-email: Yan <1964649083@qq.com>
7
+ Requires-Dist: anyio>=4.15.1
8
+ Requires-Dist: mcp>=1.9
9
+ Requires-Dist: nonebot-plugin-alconna>=0.57
10
+ Requires-Dist: nonebot2>=2.4.0
11
+ Requires-Dist: nonebot-plugin-savepic>=0.6.7 ; extra == 'savepic'
12
+ Requires-Python: >=3.11
13
+ Provides-Extra: savepic
14
+ Description-Content-Type: text/markdown
15
+
16
+ # nonebot-plugin-grok-bot
17
+
18
+ 把 NoneBot2 接入 [Grok Bot](https://cursor.com/help/grok-bot):
19
+
20
+ - **发送**:插件在 NoneBot 自身的 FastAPI 上挂一个 MCP Server(Streamable HTTP),Grok Bot 通过它发消息,支持图文混排、QQ 内置表情、本地表情包([savepic](https://github.com/Yan-Zero/nonebot-plugin-savepic))。
21
+ - **接收**:收到的消息按会话路由,POST 到对应 Grok Bot Routine 的 Webhook。
22
+
23
+ ## 安装
24
+
25
+ ```bash
26
+ uv add nonebot-plugin-grok-bot # 基础
27
+ uv add "nonebot-plugin-grok-bot[savepic]" # 启用本地表情包
28
+ ```
29
+
30
+ 驱动器需要同时具备 ASGI 和 HTTP 客户端,例如 `DRIVER=~fastapi+~httpx+~aiohttp`。
31
+
32
+ ## 配置
33
+
34
+ | 配置项 | 默认值 | 说明 |
35
+ | --- | --- | --- |
36
+ | `GROK_BOT_MCP_PREFIX` | `/grok_bot` | MCP 端点为 `{prefix}/mcp` |
37
+ | `GROK_BOT_MCP_TOKEN` | 无 | MCP 端点的 Bearer Token,在 Grok Bot 里存为 `Authorization` secret |
38
+ | `GROK_BOT_OWNERS` | `[]` | Owner 的用户 ID |
39
+ | `GROK_BOT_ALLOWED_USERS` | `[]` | 除 Owner 外允许私聊的用户 ID |
40
+ | `GROK_BOT_ALLOWED_GROUPS` | `[]` | 允许的群号(频道为 `<guild>/<channel>`) |
41
+ | `GROK_BOT_ROUTES` | `[]` | 推送路由,见下 |
42
+ | `GROK_BOT_WEBHOOK_TIMEOUT` | `10` | 秒 |
43
+ | `GROK_BOT_WEBHOOK_RETRIES` | `3` | 5xx / 429 / 网络错误时重试 |
44
+ | `GROK_BOT_HISTORY_SIZE` | `100` | 每个会话保留的最近消息数,供 `get_history` 查询 |
45
+
46
+ Grok Bot 只能访问公网或 Tailscale 网络内的地址,`HOST` 需要绑定到对应网卡(例如 Tailscale IP 或 `0.0.0.0`)。
47
+
48
+ ### 白名单
49
+
50
+ - 私聊只接受 Owner 和 `GROK_BOT_ALLOWED_USERS`。
51
+ - 群聊只接受 `GROK_BOT_ALLOWED_GROUPS`。
52
+
53
+ 白名单外的消息不记录、不推送,MCP 也不能向其发送。
54
+
55
+ ### 路由
56
+
57
+ 会话标识为 `group:<群号>`、`private:<QQ号>`、`channel:<guild>/<channel>`。路由按顺序匹配(fnmatch),首个命中的生效;未命中的会话不推送。
58
+
59
+ ```json
60
+ [
61
+ {
62
+ "name": "owner",
63
+ "sessions": ["private:*"],
64
+ "webhook_url": "https://api2.cursor.sh/automations/webhook/<routine-id>",
65
+ "webhook_key": "crsr_xxx",
66
+ "owner_trigger": "always"
67
+ },
68
+ {
69
+ "name": "groups",
70
+ "sessions": ["group:*"],
71
+ "webhook_url": "https://api2.cursor.sh/automations/webhook/<routine-id>",
72
+ "webhook_key": "crsr_xxx",
73
+ "flush_interval": 300,
74
+ "flush_size": 30,
75
+ "max_pending": 100
76
+ }
77
+ ]
78
+ ```
79
+
80
+ 推送时机:
81
+
82
+ - Owner 的消息:按路由的 `owner_trigger` 决定是否立即推送(并带上该会话中尚未推送的消息)。
83
+ - `always`:Owner 任何发言都推送,适合私聊和特殊群。
84
+ - `to_me`(默认):仅 @bot、回复 bot 或私聊时推送,群里普通发言不推送。
85
+ - `never`:Owner 也不触发。
86
+ - 其他人的消息:先积压起来,满 `flush_size` 条,或第一条积压消息之后过了 `flush_interval` 秒时推送;两者都不设置时,只作为上下文等 Owner 触发。
87
+ - bot 自己发出的消息:只记入上下文,不触发推送。
88
+
89
+ ### Webhook Payload
90
+
91
+ ```json
92
+ {
93
+ "route": "groups",
94
+ "trigger": "owner",
95
+ "session": "group:123456",
96
+ "messages": [
97
+ {
98
+ "session": "group:123456",
99
+ "message_id": "1001",
100
+ "time": "2026-10-10T18:00:00+08:00",
101
+ "sender": {"user_id": "10000", "nickname": "Yan", "is_owner": true, "is_self": false},
102
+ "to_me": false,
103
+ "segments": [
104
+ {"type": "text", "text": "看看这个"},
105
+ {"type": "image", "url": "https://multimedia.nt.qq.com.cn/download?appid=1407&fileid=…&rkey=…", "sticker": false},
106
+ {"type": "emoji", "id": "14", "name": "微笑"}
107
+ ]
108
+ }
109
+ ]
110
+ }
111
+ ```
112
+
113
+ `trigger` 为 `owner` / `size` / `interval`。图片直接给原始链接,插件不缓存。QQ 图片链接里的 rkey 会过期,通过 MCP `get_image(url)` 查看或在 `send_message` 中转发时,插件会自动调用 `get_rkey` 重新签发(需要 NapCat / LLOneBot 等支持该扩展 API 的实现)。
114
+
115
+ ## MCP 工具
116
+
117
+ | 工具 | 说明 |
118
+ | --- | --- |
119
+ | `send_message(session, segments, reply_to?, self_id?)` | 发送消息,消息段类型有 `text` / `image`(`url` / `base64`)/ `emoji` / `sticker` / `at` / `at_all` |
120
+ | `get_history(session, limit)` | 会话最近消息,格式同 Webhook |
121
+ | `get_image(url, session?)` | 查看图片,QQ 链接自动续签 rkey |
122
+ | `list_sessions()` | 收到过消息的会话 |
123
+ | `list_emojis()` | QQ 内置表情 ID → 名称 |
124
+ | `search_stickers(query, session, limit)` | 搜索本地表情包(需 savepic) |
125
+ | `view_sticker(name, session)` | 查看表情包图片(需 savepic) |
126
+
127
+ ## 开发
128
+
129
+ ```bash
130
+ uv sync
131
+ uv run pytest
132
+ uv run python bot.py
133
+ ```
@@ -0,0 +1,118 @@
1
+ # nonebot-plugin-grok-bot
2
+
3
+ 把 NoneBot2 接入 [Grok Bot](https://cursor.com/help/grok-bot):
4
+
5
+ - **发送**:插件在 NoneBot 自身的 FastAPI 上挂一个 MCP Server(Streamable HTTP),Grok Bot 通过它发消息,支持图文混排、QQ 内置表情、本地表情包([savepic](https://github.com/Yan-Zero/nonebot-plugin-savepic))。
6
+ - **接收**:收到的消息按会话路由,POST 到对应 Grok Bot Routine 的 Webhook。
7
+
8
+ ## 安装
9
+
10
+ ```bash
11
+ uv add nonebot-plugin-grok-bot # 基础
12
+ uv add "nonebot-plugin-grok-bot[savepic]" # 启用本地表情包
13
+ ```
14
+
15
+ 驱动器需要同时具备 ASGI 和 HTTP 客户端,例如 `DRIVER=~fastapi+~httpx+~aiohttp`。
16
+
17
+ ## 配置
18
+
19
+ | 配置项 | 默认值 | 说明 |
20
+ | --- | --- | --- |
21
+ | `GROK_BOT_MCP_PREFIX` | `/grok_bot` | MCP 端点为 `{prefix}/mcp` |
22
+ | `GROK_BOT_MCP_TOKEN` | 无 | MCP 端点的 Bearer Token,在 Grok Bot 里存为 `Authorization` secret |
23
+ | `GROK_BOT_OWNERS` | `[]` | Owner 的用户 ID |
24
+ | `GROK_BOT_ALLOWED_USERS` | `[]` | 除 Owner 外允许私聊的用户 ID |
25
+ | `GROK_BOT_ALLOWED_GROUPS` | `[]` | 允许的群号(频道为 `<guild>/<channel>`) |
26
+ | `GROK_BOT_ROUTES` | `[]` | 推送路由,见下 |
27
+ | `GROK_BOT_WEBHOOK_TIMEOUT` | `10` | 秒 |
28
+ | `GROK_BOT_WEBHOOK_RETRIES` | `3` | 5xx / 429 / 网络错误时重试 |
29
+ | `GROK_BOT_HISTORY_SIZE` | `100` | 每个会话保留的最近消息数,供 `get_history` 查询 |
30
+
31
+ Grok Bot 只能访问公网或 Tailscale 网络内的地址,`HOST` 需要绑定到对应网卡(例如 Tailscale IP 或 `0.0.0.0`)。
32
+
33
+ ### 白名单
34
+
35
+ - 私聊只接受 Owner 和 `GROK_BOT_ALLOWED_USERS`。
36
+ - 群聊只接受 `GROK_BOT_ALLOWED_GROUPS`。
37
+
38
+ 白名单外的消息不记录、不推送,MCP 也不能向其发送。
39
+
40
+ ### 路由
41
+
42
+ 会话标识为 `group:<群号>`、`private:<QQ号>`、`channel:<guild>/<channel>`。路由按顺序匹配(fnmatch),首个命中的生效;未命中的会话不推送。
43
+
44
+ ```json
45
+ [
46
+ {
47
+ "name": "owner",
48
+ "sessions": ["private:*"],
49
+ "webhook_url": "https://api2.cursor.sh/automations/webhook/<routine-id>",
50
+ "webhook_key": "crsr_xxx",
51
+ "owner_trigger": "always"
52
+ },
53
+ {
54
+ "name": "groups",
55
+ "sessions": ["group:*"],
56
+ "webhook_url": "https://api2.cursor.sh/automations/webhook/<routine-id>",
57
+ "webhook_key": "crsr_xxx",
58
+ "flush_interval": 300,
59
+ "flush_size": 30,
60
+ "max_pending": 100
61
+ }
62
+ ]
63
+ ```
64
+
65
+ 推送时机:
66
+
67
+ - Owner 的消息:按路由的 `owner_trigger` 决定是否立即推送(并带上该会话中尚未推送的消息)。
68
+ - `always`:Owner 任何发言都推送,适合私聊和特殊群。
69
+ - `to_me`(默认):仅 @bot、回复 bot 或私聊时推送,群里普通发言不推送。
70
+ - `never`:Owner 也不触发。
71
+ - 其他人的消息:先积压起来,满 `flush_size` 条,或第一条积压消息之后过了 `flush_interval` 秒时推送;两者都不设置时,只作为上下文等 Owner 触发。
72
+ - bot 自己发出的消息:只记入上下文,不触发推送。
73
+
74
+ ### Webhook Payload
75
+
76
+ ```json
77
+ {
78
+ "route": "groups",
79
+ "trigger": "owner",
80
+ "session": "group:123456",
81
+ "messages": [
82
+ {
83
+ "session": "group:123456",
84
+ "message_id": "1001",
85
+ "time": "2026-10-10T18:00:00+08:00",
86
+ "sender": {"user_id": "10000", "nickname": "Yan", "is_owner": true, "is_self": false},
87
+ "to_me": false,
88
+ "segments": [
89
+ {"type": "text", "text": "看看这个"},
90
+ {"type": "image", "url": "https://multimedia.nt.qq.com.cn/download?appid=1407&fileid=…&rkey=…", "sticker": false},
91
+ {"type": "emoji", "id": "14", "name": "微笑"}
92
+ ]
93
+ }
94
+ ]
95
+ }
96
+ ```
97
+
98
+ `trigger` 为 `owner` / `size` / `interval`。图片直接给原始链接,插件不缓存。QQ 图片链接里的 rkey 会过期,通过 MCP `get_image(url)` 查看或在 `send_message` 中转发时,插件会自动调用 `get_rkey` 重新签发(需要 NapCat / LLOneBot 等支持该扩展 API 的实现)。
99
+
100
+ ## MCP 工具
101
+
102
+ | 工具 | 说明 |
103
+ | --- | --- |
104
+ | `send_message(session, segments, reply_to?, self_id?)` | 发送消息,消息段类型有 `text` / `image`(`url` / `base64`)/ `emoji` / `sticker` / `at` / `at_all` |
105
+ | `get_history(session, limit)` | 会话最近消息,格式同 Webhook |
106
+ | `get_image(url, session?)` | 查看图片,QQ 链接自动续签 rkey |
107
+ | `list_sessions()` | 收到过消息的会话 |
108
+ | `list_emojis()` | QQ 内置表情 ID → 名称 |
109
+ | `search_stickers(query, session, limit)` | 搜索本地表情包(需 savepic) |
110
+ | `view_sticker(name, session)` | 查看表情包图片(需 savepic) |
111
+
112
+ ## 开发
113
+
114
+ ```bash
115
+ uv sync
116
+ uv run pytest
117
+ uv run python bot.py
118
+ ```
@@ -0,0 +1,53 @@
1
+ [project]
2
+ name = "nonebot-plugin-grok-bot"
3
+ version = "0.1.0"
4
+ description = "NoneBot2 plugin bridging chats with Grok Bot via MCP server + webhook"
5
+ readme = "README.md"
6
+ authors = [
7
+ { name = "Yan", email = "1964649083@qq.com" }
8
+ ]
9
+ requires-python = ">=3.11"
10
+ dependencies = [
11
+ "anyio>=4.15.1",
12
+ "mcp>=1.9",
13
+ "nonebot-plugin-alconna>=0.57",
14
+ "nonebot2>=2.4.0",
15
+ ]
16
+
17
+ [project.optional-dependencies]
18
+ savepic = [
19
+ "nonebot-plugin-savepic>=0.6.7",
20
+ ]
21
+
22
+ [build-system]
23
+ requires = ["uv_build>=0.8.17,<0.9.0"]
24
+ build-backend = "uv_build"
25
+
26
+ [dependency-groups]
27
+ dev = [
28
+ "nonebot-adapter-onebot>=2.4.6",
29
+ "nonebot-plugin-savepic",
30
+ "nonebot2[aiohttp,fastapi,httpx]>=2.5.0",
31
+ "nonebug>=0.4.4",
32
+ "pytest>=9.1.1",
33
+ "pytest-asyncio>=1.4.0",
34
+ "ruff>=0.17.0",
35
+ ]
36
+
37
+ [tool.nonebot]
38
+ plugins = ["nonebot_plugin_grok_bot"]
39
+ adapters = [{ name = "OneBot V11", module_name = "nonebot.adapters.onebot.v11" }]
40
+
41
+ [tool.ruff]
42
+ line-length = 120
43
+ target-version = "py311"
44
+
45
+ [tool.ruff.lint]
46
+ select = ["E", "F", "I", "UP", "B", "ASYNC"]
47
+
48
+ [tool.pytest.ini_options]
49
+ asyncio_mode = "auto"
50
+ asyncio_default_fixture_loop_scope = "session"
51
+
52
+ [tool.uv.sources]
53
+ nonebot-plugin-savepic = { git = "https://github.com/Yan-Zero/nonebot-plugin-savepic" }
@@ -0,0 +1,18 @@
1
+ from nonebot import require
2
+ from nonebot.plugin import PluginMetadata, inherit_supported_adapters
3
+
4
+ require("nonebot_plugin_alconna")
5
+
6
+ from . import mcp_server as mcp_server # noqa: E402
7
+ from . import webhook as webhook # noqa: E402
8
+ from .config import Config # noqa: E402
9
+
10
+ __plugin_meta__ = PluginMetadata(
11
+ name="Grok Bot",
12
+ description="通过 MCP Server 让 Grok Bot 发送消息,并将收到的消息推送到 Grok Bot 的 Routine Webhook",
13
+ usage="配置 GROK_BOT_ROUTES / GROK_BOT_OWNERS / GROK_BOT_MCP_TOKEN 后,将 MCP 端点提供给 Grok Bot",
14
+ type="application",
15
+ homepage="https://github.com/Yan-Zero/nonebot-plugin-grok-bot",
16
+ config=Config,
17
+ supported_adapters=inherit_supported_adapters("nonebot_plugin_alconna"),
18
+ )
@@ -0,0 +1,46 @@
1
+ from typing import Literal
2
+
3
+ from pydantic import BaseModel, Field
4
+
5
+
6
+ class Route(BaseModel):
7
+ """一条推送路由:匹配到的会话的消息会推送到对应 Grok Bot 的 Routine Webhook"""
8
+
9
+ name: str = "default"
10
+ sessions: list[str] = Field(default_factory=lambda: ["*"])
11
+ """会话匹配规则(fnmatch),如 `group:123`、`private:*`、`*`,按路由顺序首个匹配生效"""
12
+ webhook_url: str
13
+ webhook_key: str | None = None
14
+ """Routine Webhook 的 key,以 `Authorization: Bearer <key>` 发送"""
15
+ owner_trigger: Literal["always", "to_me", "never"] = "to_me"
16
+ """Owner 的消息何时立即推送:always 任何发言;to_me 仅 @bot / 回复 bot / 私聊;never 不触发"""
17
+ flush_interval: float | None = None
18
+ """非 Owner 消息累积多少秒后推送一次,None 表示不按时间推送"""
19
+ flush_size: int | None = None
20
+ """非 Owner 消息累积多少条后推送一次,None 表示不按数量推送"""
21
+ max_pending: int = 100
22
+ """待推送消息的上限,超出后丢弃最旧的;Owner 触发推送时会一并带上"""
23
+
24
+
25
+ class Config(BaseModel):
26
+ # MCP Server(Grok Bot -> NoneBot,用于发送消息)
27
+ grok_bot_mcp_prefix: str = "/grok_bot"
28
+ """MCP 子应用挂载前缀,最终端点为 `{prefix}/mcp`"""
29
+ grok_bot_mcp_token: str | None = None
30
+ """MCP 端点的 Bearer Token,未设置则不鉴权(仅建议本地调试)"""
31
+
32
+ # Webhook(NoneBot -> Grok Bot,用于推送收到的消息)
33
+ grok_bot_owners: set[str] = Field(default_factory=set)
34
+ """Owner 的用户 ID,私聊默认只接受 Owner;其消息是否立即推送见 Route.owner_trigger"""
35
+ grok_bot_allowed_users: set[str] = Field(default_factory=set)
36
+ """除 Owner 外,额外允许私聊的用户 ID"""
37
+ grok_bot_allowed_groups: set[str] = Field(default_factory=set)
38
+ """允许的群号(频道为 `<guild>/<channel>`),不在其中的群消息会被忽略,也不能向其发送"""
39
+ grok_bot_routes: list[Route] = Field(default_factory=list)
40
+ grok_bot_webhook_timeout: float = 10.0
41
+ grok_bot_webhook_retries: int = 3
42
+
43
+ # 上下文
44
+
45
+ grok_bot_history_size: int = 100
46
+ """每个会话保留的最近消息条数,供 MCP `get_history` 查询"""
@@ -0,0 +1,25 @@
1
+ import functools
2
+ from collections.abc import Awaitable, Callable
3
+ from typing import ParamSpec, TypeVar
4
+
5
+ from mcp.server.mcpserver.exceptions import ToolError
6
+ from nonebot import logger
7
+
8
+ P = ParamSpec("P")
9
+ R = TypeVar("R")
10
+
11
+
12
+ def expose_errors(fn: Callable[P, Awaitable[R]]) -> Callable[P, Awaitable[R]]:
13
+ """mcp 默认隐藏工具内部异常的信息,这里转成 ToolError 让 Grok Bot 能看到原因并自行修正"""
14
+
15
+ @functools.wraps(fn)
16
+ async def wrapper(*args: P.args, **kwargs: P.kwargs) -> R:
17
+ try:
18
+ return await fn(*args, **kwargs)
19
+ except ToolError:
20
+ raise
21
+ except Exception as e:
22
+ logger.opt(exception=e).debug(f"MCP 工具 {fn.__name__} 执行失败")
23
+ raise ToolError(f"{type(e).__name__}: {e}") from e
24
+
25
+ return wrapper
@@ -0,0 +1,5 @@
1
+ import json
2
+ from pathlib import Path
3
+
4
+ # QQ 内置表情(小黄脸)ID -> 名称,来自 nonebot-plugin-chatgpt-vision
5
+ FACES: dict[str, str] = json.loads((Path(__file__).parent / "qface.json").read_text(encoding="utf-8"))
@@ -0,0 +1,205 @@
1
+ import base64
2
+ from contextlib import AsyncExitStack
3
+ from typing import Annotated, Any, Literal
4
+
5
+ from mcp.server.mcpserver import MCPServer
6
+ from mcp.server.mcpserver.utilities.types import Image as MCPImage
7
+ from nonebot import get_app, get_driver, get_plugin_config, logger
8
+ from nonebot.adapters import Bot
9
+ from nonebot.drivers import ASGIMixin
10
+ from nonebot_plugin_alconna.uniseg import At, AtAll, Emoji, Image, Reply, Segment, Text, UniMessage
11
+ from pydantic import BaseModel, Field
12
+ from starlette.types import ASGIApp, Receive, Scope, Send
13
+
14
+ from . import stickers
15
+ from .config import Config
16
+ from .errors import expose_errors
17
+ from .faces import FACES
18
+ from .rkey import with_rkey
19
+ from .session import (
20
+ build_message,
21
+ download,
22
+ dump_segments,
23
+ image_format,
24
+ known_sessions,
25
+ resolve_target,
26
+ )
27
+ from .session import (
28
+ get_history as history_of,
29
+ )
30
+ from .webhook import record_outgoing
31
+
32
+ driver = get_driver()
33
+ config = get_plugin_config(Config)
34
+
35
+ if not isinstance(driver, ASGIMixin):
36
+ raise RuntimeError("nonebot-plugin-grok-bot 需要 ASGI 驱动器(如 ~fastapi)来挂载 MCP Server")
37
+
38
+ mcp = MCPServer(
39
+ name="nonebot-grok-bot",
40
+ instructions=(
41
+ "通过这些工具向聊天平台发送消息。会话用 `group:<群号>` / `private:<QQ号>` 表示,"
42
+ "与 Webhook 推送中的 `session` 字段一致。消息由多个消息段组成,可图文混排,"
43
+ "可使用 QQ 内置表情(list_emojis)和本地表情包(search_stickers)。"
44
+ ),
45
+ )
46
+
47
+
48
+ class TextSegment(BaseModel):
49
+ type: Literal["text"]
50
+ text: str
51
+
52
+
53
+ class ImageSegment(BaseModel):
54
+ type: Literal["image"]
55
+ url: str | None = Field(default=None, description="图片链接,可直接用 Webhook 推送中的链接(会自动续签 rkey)")
56
+ base64: str | None = Field(default=None, description="base64 编码的图片数据")
57
+
58
+
59
+ class EmojiSegment(BaseModel):
60
+ type: Literal["emoji"]
61
+ id: str = Field(description="QQ 内置表情 ID,见 list_emojis")
62
+
63
+
64
+ class StickerSegment(BaseModel):
65
+ type: Literal["sticker"]
66
+ name: str = Field(description="本地表情包名称,见 search_stickers")
67
+
68
+
69
+ class AtSegment(BaseModel):
70
+ type: Literal["at"]
71
+ user_id: str
72
+
73
+
74
+ class AtAllSegment(BaseModel):
75
+ type: Literal["at_all"]
76
+
77
+
78
+ OutSegment = Annotated[
79
+ TextSegment | ImageSegment | EmojiSegment | StickerSegment | AtSegment | AtAllSegment,
80
+ Field(discriminator="type"),
81
+ ]
82
+
83
+
84
+ async def to_segment(seg: OutSegment, session: str, bot: Bot) -> Segment:
85
+ match seg:
86
+ case TextSegment(text=text):
87
+ return Text(text)
88
+ case EmojiSegment(id=id_):
89
+ return Emoji(id_, FACES.get(id_))
90
+ case AtSegment(user_id=user_id):
91
+ return At("user", user_id)
92
+ case AtAllSegment():
93
+ return AtAll()
94
+ case StickerSegment(name=name):
95
+ return await stickers.load(name, session, bot)
96
+ case ImageSegment(url=str(url)):
97
+ return Image(url=await with_rkey(url, bot))
98
+ case ImageSegment(base64=str(b64)):
99
+ return Image(raw=base64.b64decode(b64))
100
+ case ImageSegment():
101
+ raise ValueError("image 消息段需要 url 或 base64 之一")
102
+ raise ValueError(f"不支持的消息段:{seg!r}")
103
+
104
+
105
+ @mcp.tool()
106
+ @expose_errors
107
+ async def send_message(
108
+ session: str,
109
+ segments: list[OutSegment],
110
+ reply_to: str | None = None,
111
+ self_id: str | None = None,
112
+ ) -> dict[str, Any]:
113
+ """向会话发送一条消息,支持文字、图片、QQ 内置表情、本地表情包、@ 混排。
114
+
115
+ Args:
116
+ session: 目标会话,如 `group:123456` 或 `private:123456`
117
+ segments: 消息段列表,按顺序拼接
118
+ reply_to: 要回复的消息 ID(来自 Webhook 推送的 message_id)
119
+ self_id: 指定用哪个 bot 账号发送,默认自动选择
120
+ """
121
+ if not segments:
122
+ raise ValueError("segments 不能为空")
123
+ target = resolve_target(session, self_id)
124
+ bot = await target.select()
125
+ message = UniMessage([await to_segment(seg, session, bot) for seg in segments])
126
+ if reply_to:
127
+ message.insert(0, Reply(reply_to))
128
+ receipt = await target.send(message, bot)
129
+ message_ids = [str(r.id) for r in receipt.get_reply() or []]
130
+ record_outgoing(
131
+ session,
132
+ build_message(
133
+ session, message_ids[0] if message_ids else None, bot.self_id, None, dump_segments(message), is_self=True
134
+ ),
135
+ )
136
+ return {"message_ids": message_ids}
137
+
138
+
139
+ @mcp.tool()
140
+ @expose_errors
141
+ async def list_sessions() -> list[str]:
142
+ """列出自启动以来收到过消息的会话"""
143
+ return known_sessions()
144
+
145
+
146
+ @mcp.tool()
147
+ @expose_errors
148
+ async def get_history(session: str, limit: int = 20) -> list[dict[str, Any]]:
149
+ """获取会话最近的消息(包括 bot 自己发的),格式与 Webhook 推送一致"""
150
+ return history_of(session, limit)
151
+
152
+
153
+ @mcp.tool()
154
+ @expose_errors
155
+ async def get_image(url: str, session: str | None = None) -> MCPImage:
156
+ """查看图片内容,例如 Webhook 推送或历史消息中的图片;QQ 图片链接过期会自动续签
157
+
158
+ Args:
159
+ url: 图片链接
160
+ session: 图片所在会话,用于选择续签 rkey 的 bot 账号,可省略
161
+ """
162
+ bot = await resolve_target(session).select() if session else None
163
+ data = await download(url, bot)
164
+ return MCPImage(data=data, format=image_format(data))
165
+
166
+
167
+ @mcp.tool()
168
+ @expose_errors
169
+ async def list_emojis() -> dict[str, str]:
170
+ """列出常用的 QQ 内置表情,返回 ID -> 名称"""
171
+ return FACES
172
+
173
+
174
+ class _BearerAuth:
175
+ def __init__(self, app: ASGIApp, token: str) -> None:
176
+ self.app = app
177
+ self.expected = f"Bearer {token}".encode()
178
+
179
+ async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
180
+ if scope["type"] == "http" and dict(scope["headers"]).get(b"authorization") != self.expected:
181
+ await send({"type": "http.response.start", "status": 401, "headers": []})
182
+ await send({"type": "http.response.body", "body": b"Unauthorized"})
183
+ return
184
+ await self.app(scope, receive, send)
185
+
186
+
187
+ _mcp_app: ASGIApp = mcp.streamable_http_app(host=str(driver.config.host))
188
+ if config.grok_bot_mcp_token:
189
+ _mcp_app = _BearerAuth(_mcp_app, config.grok_bot_mcp_token)
190
+ get_app().mount(config.grok_bot_mcp_prefix, _mcp_app)
191
+
192
+ # 挂载的子应用不会触发自身 lifespan,需要手动管理 session manager
193
+ _stack = AsyncExitStack()
194
+
195
+
196
+ @driver.on_startup
197
+ async def _() -> None:
198
+ stickers.register_tools(mcp)
199
+ await _stack.enter_async_context(mcp.session_manager.run())
200
+ logger.info(f"Grok Bot MCP Server 已挂载于 {config.grok_bot_mcp_prefix}/mcp")
201
+
202
+
203
+ @driver.on_shutdown
204
+ async def _() -> None:
205
+ await _stack.aclose()
@@ -0,0 +1,280 @@
1
+ {
2
+ "0": "惊讶",
3
+ "1": "撇嘴",
4
+ "2": "色",
5
+ "3": "发呆",
6
+ "4": "得意",
7
+ "5": "流泪",
8
+ "6": "害羞",
9
+ "7": "闭嘴",
10
+ "8": "睡",
11
+ "9": "大哭",
12
+ "10": "尴尬",
13
+ "11": "发怒",
14
+ "12": "调皮",
15
+ "13": "呲牙",
16
+ "14": "微笑",
17
+ "15": "难过",
18
+ "16": "酷",
19
+ "18": "抓狂",
20
+ "19": "吐",
21
+ "20": "偷笑",
22
+ "21": "可爱",
23
+ "22": "白眼",
24
+ "23": "傲慢",
25
+ "24": "饥饿",
26
+ "25": "困",
27
+ "26": "惊恐",
28
+ "27": "流汗",
29
+ "28": "憨笑",
30
+ "29": "悠闲",
31
+ "30": "奋斗",
32
+ "31": "咒骂",
33
+ "32": "疑问",
34
+ "33": "嘘",
35
+ "34": "晕",
36
+ "35": "折磨",
37
+ "36": "衰",
38
+ "37": "骷髅",
39
+ "38": "敲打",
40
+ "39": "再见",
41
+ "41": "发抖",
42
+ "42": "爱情",
43
+ "43": "跳跳",
44
+ "46": "猪头",
45
+ "49": "拥抱",
46
+ "53": "蛋糕",
47
+ "54": "闪电",
48
+ "55": "炸弹",
49
+ "56": "刀",
50
+ "57": "足球",
51
+ "59": "便便",
52
+ "60": "咖啡",
53
+ "61": "饭",
54
+ "63": "玫瑰",
55
+ "64": "凋谢",
56
+ "66": "爱心",
57
+ "67": "心碎",
58
+ "69": "礼物",
59
+ "74": "太阳",
60
+ "75": "月亮",
61
+ "76": "赞",
62
+ "77": "踩",
63
+ "78": "握手",
64
+ "79": "胜利",
65
+ "85": "飞吻",
66
+ "86": "怄火",
67
+ "89": "西瓜",
68
+ "96": "冷汗",
69
+ "97": "擦汗",
70
+ "98": "抠鼻",
71
+ "99": "鼓掌",
72
+ "100": "糗大了",
73
+ "101": "坏笑",
74
+ "102": "左哼哼",
75
+ "103": "右哼哼",
76
+ "104": "哈欠",
77
+ "105": "鄙视",
78
+ "106": "委屈",
79
+ "107": "快哭了",
80
+ "108": "阴险",
81
+ "109": "左亲亲",
82
+ "110": "吓",
83
+ "111": "可怜",
84
+ "112": "菜刀",
85
+ "113": "啤酒",
86
+ "114": "篮球",
87
+ "115": "乒乓",
88
+ "116": "示爱",
89
+ "117": "瓢虫",
90
+ "118": "抱拳",
91
+ "119": "勾引",
92
+ "120": "拳头",
93
+ "121": "差劲",
94
+ "122": "爱你",
95
+ "123": "NO",
96
+ "124": "OK",
97
+ "125": "转圈",
98
+ "126": "磕头",
99
+ "127": "回头",
100
+ "128": "跳绳",
101
+ "129": "挥手",
102
+ "130": "激动",
103
+ "131": "街舞",
104
+ "132": "献吻",
105
+ "133": "左太极",
106
+ "134": "右太极",
107
+ "136": "双喜",
108
+ "137": "鞭炮",
109
+ "138": "灯笼",
110
+ "140": "K歌",
111
+ "144": "喝彩",
112
+ "145": "祈祷",
113
+ "146": "爆筋",
114
+ "147": "棒棒糖",
115
+ "148": "喝奶",
116
+ "151": "飞机",
117
+ "158": "钞票",
118
+ "168": "药",
119
+ "169": "手枪",
120
+ "171": "茶",
121
+ "172": "眨眼睛",
122
+ "173": "泪奔",
123
+ "174": "无奈",
124
+ "175": "卖萌",
125
+ "176": "小纠结",
126
+ "177": "喷血",
127
+ "178": "斜眼笑",
128
+ "179": "doge",
129
+ "180": "惊喜",
130
+ "181": "骚扰",
131
+ "182": "笑哭",
132
+ "183": "我最美",
133
+ "184": "河蟹",
134
+ "185": "羊驼",
135
+ "187": "幽灵",
136
+ "188": "蛋",
137
+ "190": "菊花",
138
+ "192": "红包",
139
+ "193": "大笑",
140
+ "194": "不开心",
141
+ "197": "冷漠",
142
+ "198": "呃",
143
+ "199": "好棒",
144
+ "200": "拜托",
145
+ "201": "点赞",
146
+ "202": "无聊",
147
+ "203": "托脸",
148
+ "204": "吃",
149
+ "205": "送花",
150
+ "206": "害怕",
151
+ "207": "花痴",
152
+ "208": "小样儿",
153
+ "210": "飙泪",
154
+ "211": "我不看",
155
+ "212": "托腮",
156
+ "214": "啵啵",
157
+ "215": "糊脸",
158
+ "216": "拍头",
159
+ "217": "扯一扯",
160
+ "218": "舔一舔",
161
+ "219": "蹭一蹭",
162
+ "220": "拽炸天",
163
+ "221": "顶呱呱",
164
+ "222": "抱抱",
165
+ "223": "暴击",
166
+ "224": "开枪",
167
+ "225": "撩一撩",
168
+ "226": "拍桌",
169
+ "227": "拍手",
170
+ "228": "恭喜",
171
+ "229": "干杯",
172
+ "230": "嘲讽",
173
+ "231": "哼",
174
+ "232": "佛系",
175
+ "233": "掐一掐",
176
+ "234": "惊呆",
177
+ "235": "颤抖",
178
+ "236": "啃头",
179
+ "237": "偷看",
180
+ "238": "扇脸",
181
+ "239": "原谅",
182
+ "240": "喷脸",
183
+ "241": "生日快乐",
184
+ "242": "头撞击",
185
+ "243": "甩头",
186
+ "244": "扔狗",
187
+ "245": "加油必胜",
188
+ "246": "加油抱抱",
189
+ "247": "口罩护体",
190
+ "260": "搬砖中",
191
+ "261": "忙到飞起",
192
+ "262": "脑阔疼",
193
+ "263": "沧桑",
194
+ "264": "捂脸",
195
+ "265": "辣眼睛",
196
+ "266": "哦哟",
197
+ "267": "头秃",
198
+ "268": "问号脸",
199
+ "269": "暗中观察",
200
+ "270": "emm",
201
+ "271": "吃瓜",
202
+ "272": "呵呵哒",
203
+ "273": "我酸了",
204
+ "274": "太南了",
205
+ "276": "辣椒酱",
206
+ "277": "汪汪",
207
+ "278": "汗",
208
+ "279": "打脸",
209
+ "280": "击掌",
210
+ "281": "无眼笑",
211
+ "282": "敬礼",
212
+ "283": "狂笑",
213
+ "284": "面无表情",
214
+ "285": "摸鱼",
215
+ "286": "魔鬼笑",
216
+ "287": "哦",
217
+ "288": "请",
218
+ "289": "睁眼",
219
+ "290": "敲开心",
220
+ "291": "震惊",
221
+ "292": "让我康康",
222
+ "293": "摸锦鲤",
223
+ "294": "期待",
224
+ "295": "拿到红包",
225
+ "296": "真好",
226
+ "297": "拜谢",
227
+ "298": "元宝",
228
+ "299": "牛啊",
229
+ "300": "胖三斤",
230
+ "301": "好闪",
231
+ "302": "左拜年",
232
+ "303": "右拜年",
233
+ "304": "红包包",
234
+ "305": "右亲亲",
235
+ "306": "牛气冲天",
236
+ "307": "喵喵",
237
+ "308": "求红包",
238
+ "309": "谢红包",
239
+ "310": "新年烟花",
240
+ "311": "打call",
241
+ "312": "变形",
242
+ "313": "嗑到了",
243
+ "314": "仔细分析",
244
+ "315": "加油",
245
+ "316": "我没事",
246
+ "317": "菜狗",
247
+ "318": "崇拜",
248
+ "319": "比心",
249
+ "320": "庆祝",
250
+ "321": "老色痞",
251
+ "322": "拒绝",
252
+ "323": "嫌弃",
253
+ "324": "吃糖",
254
+ "325": "惊吓",
255
+ "326": "生气",
256
+ "327": "加一",
257
+ "328": "错号",
258
+ "329": "对号",
259
+ "330": "完成",
260
+ "331": "明白",
261
+ "332": "举牌牌",
262
+ "333": "烟花",
263
+ "334": "虎虎生威",
264
+ "336": "豹富",
265
+ "337": "花朵脸",
266
+ "338": "我想开了",
267
+ "339": "舔屏",
268
+ "340": "热化了",
269
+ "341": "打招呼",
270
+ "342": "酸Q",
271
+ "343": "我方了",
272
+ "344": "大怨种",
273
+ "345": "红包多多",
274
+ "346": "你真棒棒",
275
+ "347": "大展宏兔",
276
+ "348": "福萝卜",
277
+ "351": "敲敲",
278
+ "424": "狂敲按钮",
279
+ "429": "蛇年快乐"
280
+ }
@@ -0,0 +1,59 @@
1
+ """QQ NT 图片链接带有会过期的 rkey,过期后通过 OneBot 扩展 API `get_rkey` 重新签发"""
2
+
3
+ import asyncio
4
+ from datetime import datetime, timedelta
5
+ from urllib.parse import parse_qs, urlencode, urlparse, urlunparse
6
+
7
+ from nonebot import get_bots, logger
8
+ from nonebot.adapters import Bot
9
+
10
+ _rkeys: dict[str, tuple[datetime, str]] = {}
11
+ _lock = asyncio.Lock()
12
+
13
+
14
+ def is_qq_media(url: str) -> bool:
15
+ return urlparse(url).hostname == "multimedia.nt.qq.com.cn"
16
+
17
+
18
+ def _kind(url: str) -> str:
19
+ # appid 1406 为私聊图片,1407 为群聊图片
20
+ return "private" if parse_qs(urlparse(url).query).get("appid") == ["1406"] else "group"
21
+
22
+
23
+ def any_onebot_bot() -> Bot | None:
24
+ return next((bot for bot in get_bots().values() if bot.adapter.get_name() == "OneBot V11"), None)
25
+
26
+
27
+ async def _refresh(bot: Bot, force: bool) -> None:
28
+ async with _lock:
29
+ now = datetime.now()
30
+ if not force and all(_rkeys.get(k, (datetime.min, ""))[0] > now for k in ("group", "private")):
31
+ return
32
+ try:
33
+ ret = await bot.call_api("get_rkey")
34
+ except Exception as e:
35
+ logger.warning(f"get_rkey 调用失败:{e!r}")
36
+ return
37
+ if isinstance(ret, dict):
38
+ ret = ret.get("rkeys", [])
39
+ if not isinstance(ret, list):
40
+ logger.warning(f"get_rkey 返回了奇怪的东西:{ret!r}")
41
+ return
42
+ for item in ret:
43
+ expire = datetime.fromtimestamp(int(item.get("created_at", 0))) + timedelta(
44
+ seconds=int(item.get("ttl", 0)) - 300
45
+ )
46
+ _rkeys[item.get("type")] = (expire, item.get("rkey", "").removeprefix("&rkey="))
47
+
48
+
49
+ async def with_rkey(url: str, bot: Bot | None = None, *, force: bool = False) -> str:
50
+ """给 QQ 图片链接换上当前有效的 rkey,非 QQ 链接或无法获取时原样返回"""
51
+ if not is_qq_media(url) or not (bot := bot or any_onebot_bot()):
52
+ return url
53
+ await _refresh(bot, force)
54
+ if not (rkey := _rkeys.get(_kind(url), (None, ""))[1]):
55
+ return url
56
+ parsed = urlparse(url)
57
+ params = parse_qs(parsed.query)
58
+ params["rkey"] = [rkey]
59
+ return urlunparse(parsed._replace(query=urlencode(params, doseq=True)))
@@ -0,0 +1,154 @@
1
+ from collections import defaultdict, deque
2
+ from datetime import datetime
3
+ from typing import Any
4
+
5
+ from nonebot import get_driver, get_plugin_config, logger
6
+ from nonebot.adapters import Bot
7
+ from nonebot.drivers import HTTPClientMixin, Request
8
+ from nonebot_plugin_alconna.uniseg import (
9
+ At,
10
+ AtAll,
11
+ Emoji,
12
+ Image,
13
+ Reply,
14
+ Target,
15
+ Text,
16
+ UniMessage,
17
+ )
18
+
19
+ from .config import Config
20
+ from .faces import FACES
21
+ from .rkey import is_qq_media, with_rkey
22
+
23
+ config = get_plugin_config(Config)
24
+
25
+
26
+ def session_key(target: Target) -> str:
27
+ if target.private:
28
+ return f"private:{target.id}"
29
+ if target.channel:
30
+ return f"channel:{target.parent_id}/{target.id}"
31
+ return f"group:{target.id}"
32
+
33
+
34
+ def is_allowed(session: str) -> bool:
35
+ kind, _, id_ = session.partition(":")
36
+ if kind == "private":
37
+ return id_ in config.grok_bot_owners or id_ in config.grok_bot_allowed_users
38
+ return id_ in config.grok_bot_allowed_groups
39
+
40
+
41
+ def savepic_scope(session: str) -> str:
42
+ kind, _, id_ = session.partition(":")
43
+ return f"qq_group:{id_}" if kind == "group" else "globe"
44
+
45
+
46
+ _targets: dict[str, Target] = {}
47
+ _history: defaultdict[str, deque[dict[str, Any]]] = defaultdict(lambda: deque(maxlen=config.grok_bot_history_size))
48
+
49
+
50
+ def remember_target(target: Target) -> str:
51
+ key = session_key(target)
52
+ _targets[key] = target
53
+ return key
54
+
55
+
56
+ def resolve_target(session: str, self_id: str | None = None) -> Target:
57
+ kind, _, id_ = session.partition(":")
58
+ if kind not in ("private", "group", "channel"):
59
+ raise ValueError(f"无法解析的会话:{session},应为 group:<id> / private:<id> / channel:<guild>/<channel>")
60
+ if not is_allowed(session):
61
+ raise PermissionError(f"会话 {session} 不在白名单中")
62
+ if target := _targets.get(session):
63
+ return target
64
+ if kind == "private":
65
+ return Target(id_, private=True, self_id=self_id)
66
+ if kind == "channel":
67
+ parent_id, _, channel_id = id_.partition("/")
68
+ return Target(channel_id, parent_id=parent_id, channel=True, self_id=self_id)
69
+ return Target(id_, self_id=self_id)
70
+
71
+
72
+ def known_sessions() -> list[str]:
73
+ return list(_targets)
74
+
75
+
76
+ def record_history(session: str, message: dict[str, Any]) -> None:
77
+ _history[session].append(message)
78
+
79
+
80
+ def get_history(session: str, limit: int) -> list[dict[str, Any]]:
81
+ return list(_history[session])[-limit:]
82
+
83
+
84
+ async def _get(url: str) -> bytes:
85
+ driver = get_driver()
86
+ if not isinstance(driver, HTTPClientMixin):
87
+ raise RuntimeError("下载图片需要 HTTP 客户端驱动器(如 ~httpx 或 ~aiohttp)")
88
+ resp = await driver.request(Request("GET", url, timeout=config.grok_bot_webhook_timeout))
89
+ if resp.status_code != 200 or resp.content is None:
90
+ raise RuntimeError(f"下载图片失败:HTTP {resp.status_code}")
91
+ return resp.content if isinstance(resp.content, bytes) else resp.content.encode()
92
+
93
+
94
+ async def download(url: str, bot: Bot | None = None) -> bytes:
95
+ """下载图片;QQ 图片链接先换上有效的 rkey,失败时强制重新签发后再试一次"""
96
+ if not is_qq_media(url):
97
+ return await _get(url)
98
+ try:
99
+ return await _get(await with_rkey(url, bot))
100
+ except RuntimeError:
101
+ return await _get(await with_rkey(url, bot, force=True))
102
+
103
+
104
+ def image_format(data: bytes) -> str:
105
+ if data.startswith(b"\x89PNG"):
106
+ return "png"
107
+ if data.startswith(b"GIF8"):
108
+ return "gif"
109
+ if data[:4] == b"RIFF" and data[8:12] == b"WEBP":
110
+ return "webp"
111
+ return "jpeg"
112
+
113
+
114
+ def dump_segments(message: UniMessage) -> list[dict[str, Any]]:
115
+ """把 UniMessage 转成给 Grok Bot 看的精简格式,与 MCP send_message 的入参格式对应"""
116
+ ret: list[dict[str, Any]] = []
117
+ for seg in message:
118
+ if isinstance(seg, Text):
119
+ ret.append({"type": "text", "text": seg.text})
120
+ elif isinstance(seg, At):
121
+ ret.append({"type": "at", "user_id": seg.target})
122
+ elif isinstance(seg, AtAll):
123
+ ret.append({"type": "at_all"})
124
+ elif isinstance(seg, Emoji):
125
+ ret.append({"type": "emoji", "id": seg.id, "name": seg.name or FACES.get(seg.id)})
126
+ elif isinstance(seg, Image):
127
+ ret.append({"type": "image", "url": seg.url, "sticker": seg.sticker})
128
+ elif isinstance(seg, Reply):
129
+ ret.append({"type": "reply", "message_id": seg.id})
130
+ else:
131
+ logger.debug(f"未专门处理的消息段:{seg!r}")
132
+ ret.append({"type": seg.type, "text": str(seg)})
133
+ return ret
134
+
135
+
136
+ def build_message(
137
+ session: str,
138
+ message_id: str | None,
139
+ user_id: str,
140
+ nickname: str | None,
141
+ segments: list[dict[str, Any]],
142
+ *,
143
+ is_owner: bool = False,
144
+ is_self: bool = False,
145
+ to_me: bool = False,
146
+ ) -> dict[str, Any]:
147
+ return {
148
+ "session": session,
149
+ "message_id": message_id,
150
+ "time": datetime.now().astimezone().isoformat(timespec="seconds"),
151
+ "sender": {"user_id": user_id, "nickname": nickname, "is_owner": is_owner, "is_self": is_self},
152
+ "to_me": to_me,
153
+ "segments": segments,
154
+ }
@@ -0,0 +1,75 @@
1
+ """本地表情包,基于可选依赖 nonebot-plugin-savepic"""
2
+
3
+ from pathlib import Path
4
+ from typing import TYPE_CHECKING, Any
5
+
6
+ import anyio
7
+ from nonebot import logger
8
+ from nonebot.adapters import Bot
9
+ from nonebot.plugin import get_plugin
10
+ from nonebot_plugin_alconna.uniseg import Image
11
+
12
+ from .errors import expose_errors
13
+ from .rkey import with_rkey
14
+ from .session import download, image_format, savepic_scope
15
+
16
+ if TYPE_CHECKING:
17
+ from mcp.server.mcpserver import MCPServer
18
+
19
+
20
+ def available() -> bool:
21
+ return get_plugin("nonebot_plugin_savepic") is not None
22
+
23
+
24
+ async def _url(name: str, session: str) -> str:
25
+ if not available():
26
+ raise RuntimeError("未加载 nonebot-plugin-savepic,无法使用本地表情包")
27
+ from nonebot_plugin_savepic.core.sql import select_pic
28
+
29
+ if not (url := await select_pic(name, savepic_scope(session))):
30
+ raise ValueError(f"表情包 {name} 不存在,请先用 search_stickers 搜索")
31
+ return url
32
+
33
+
34
+ async def load(name: str, session: str, bot: Bot) -> Image:
35
+ url = await _url(name, session)
36
+ if url.startswith("http"):
37
+ return Image(url=await with_rkey(url, bot), sticker=True)
38
+ return Image(path=Path(url), sticker=True)
39
+
40
+
41
+ def register_tools(mcp: "MCPServer") -> None:
42
+ if not available():
43
+ logger.info("未加载 nonebot-plugin-savepic,跳过表情包工具")
44
+ return
45
+
46
+ from mcp.server.mcpserver.utilities.types import Image as MCPImage
47
+
48
+ try:
49
+ from nonebot_plugin_savepic.core.sql import search_pic
50
+ except ImportError:
51
+ logger.warning("nonebot-plugin-savepic 版本过旧(缺少 search_pic),跳过表情包工具")
52
+ return
53
+
54
+ @mcp.tool()
55
+ @expose_errors
56
+ async def search_stickers(query: str, session: str, limit: int = 10) -> list[dict[str, Any]]:
57
+ """搜索本地表情包,先按名称匹配,不足时按语义相似度补充。
58
+
59
+ Args:
60
+ query: 关键词,可以是名称片段或想表达的情绪/含义
61
+ session: 要发送到的会话,决定可用的表情包范围(群表情包 + 全局表情包)
62
+ limit: 最多返回多少个
63
+ """
64
+ return [
65
+ {"name": pic.name, "global": "globe" in pic.scope, "similarity": similarity}
66
+ for pic, similarity in await search_pic(query, savepic_scope(session), limit)
67
+ ]
68
+
69
+ @mcp.tool()
70
+ @expose_errors
71
+ async def view_sticker(name: str, session: str) -> MCPImage:
72
+ """查看某个本地表情包的图片内容,确认是否合适再发送"""
73
+ url = await _url(name, session)
74
+ data = await download(url) if url.startswith("http") else await anyio.Path(url).read_bytes()
75
+ return MCPImage(data=data, format=image_format(data))
@@ -0,0 +1,163 @@
1
+ import asyncio
2
+ import fnmatch
3
+ from collections import deque
4
+ from typing import Any, Literal
5
+
6
+ from nonebot import get_driver, get_plugin_config, logger, on_message
7
+ from nonebot.adapters import Bot, Event
8
+ from nonebot.drivers import HTTPClientMixin, Request
9
+ from nonebot_plugin_alconna.uniseg import UniMsg, get_message_id, get_target
10
+
11
+ from .config import Config, Route
12
+ from .session import build_message, dump_segments, is_allowed, record_history, remember_target, session_key
13
+
14
+ driver = get_driver()
15
+ config = get_plugin_config(Config)
16
+
17
+ if config.grok_bot_routes and not isinstance(driver, HTTPClientMixin):
18
+ raise RuntimeError("推送 Webhook 需要 HTTP 客户端驱动器(如 ~httpx 或 ~aiohttp)")
19
+
20
+ Trigger = Literal["owner", "interval", "size"]
21
+
22
+
23
+ def match_route(session: str) -> Route | None:
24
+ for route in config.grok_bot_routes:
25
+ if any(fnmatch.fnmatchcase(session, pattern) for pattern in route.sessions):
26
+ return route
27
+ return None
28
+
29
+
30
+ class SessionBuffer:
31
+ def __init__(self, session: str, route: Route) -> None:
32
+ self.session = session
33
+ self.route = route
34
+ self.pending: deque[dict[str, Any]] = deque(maxlen=route.max_pending)
35
+ self.timer: asyncio.Task[None] | None = None
36
+
37
+ def add(self, message: dict[str, Any], *, trigger: bool) -> None:
38
+ self.pending.append(message)
39
+ if not trigger:
40
+ return
41
+ if self._owner_triggers(message):
42
+ self.flush("owner")
43
+ elif self.route.flush_size and len(self.pending) >= self.route.flush_size:
44
+ self.flush("size")
45
+ elif self.route.flush_interval is not None and self.timer is None:
46
+ self.timer = asyncio.create_task(self._wait(self.route.flush_interval))
47
+
48
+ def _owner_triggers(self, message: dict[str, Any]) -> bool:
49
+ if not message["sender"]["is_owner"]:
50
+ return False
51
+ return self.route.owner_trigger == "always" or (self.route.owner_trigger == "to_me" and message["to_me"])
52
+
53
+ async def _wait(self, delay: float) -> None:
54
+ await asyncio.sleep(delay)
55
+ self.timer = None
56
+ self.flush("interval")
57
+
58
+ def flush(self, trigger: Trigger) -> None:
59
+ if self.timer:
60
+ self.timer.cancel()
61
+ self.timer = None
62
+ if not self.pending:
63
+ return
64
+ payload = {
65
+ "route": self.route.name,
66
+ "trigger": trigger,
67
+ "session": self.session,
68
+ "messages": list(self.pending),
69
+ }
70
+ self.pending.clear()
71
+ _background(post(self.route, payload))
72
+
73
+
74
+ _buffers: dict[str, SessionBuffer] = {}
75
+ _tasks: set[asyncio.Task[None]] = set()
76
+
77
+
78
+ def _background(coro) -> None:
79
+ task = asyncio.create_task(coro)
80
+ _tasks.add(task)
81
+ task.add_done_callback(_tasks.discard)
82
+
83
+
84
+ def get_buffer(session: str) -> SessionBuffer | None:
85
+ if session not in _buffers:
86
+ if not (route := match_route(session)):
87
+ return None
88
+ _buffers[session] = SessionBuffer(session, route)
89
+ return _buffers[session]
90
+
91
+
92
+ async def post(route: Route, payload: dict[str, Any]) -> None:
93
+ assert isinstance(driver, HTTPClientMixin)
94
+ headers = {"Authorization": f"Bearer {route.webhook_key}"} if route.webhook_key else {}
95
+ for attempt in range(1, config.grok_bot_webhook_retries + 1):
96
+ try:
97
+ resp = await driver.request(
98
+ Request(
99
+ "POST",
100
+ route.webhook_url,
101
+ headers=headers,
102
+ json=payload,
103
+ timeout=config.grok_bot_webhook_timeout,
104
+ )
105
+ )
106
+ if 200 <= resp.status_code < 300:
107
+ logger.debug(f"已推送 {len(payload['messages'])} 条消息到路由 {route.name}({payload['trigger']})")
108
+ return
109
+ if 400 <= resp.status_code < 500 and resp.status_code != 429:
110
+ logger.error(f"推送到路由 {route.name} 被拒绝:HTTP {resp.status_code} {resp.content!r}")
111
+ return
112
+ logger.warning(f"推送到路由 {route.name} 失败(第 {attempt} 次):HTTP {resp.status_code}")
113
+ except Exception as e:
114
+ logger.warning(f"推送到路由 {route.name} 失败(第 {attempt} 次):{e!r}")
115
+ await asyncio.sleep(2**attempt)
116
+ logger.error(f"推送到路由 {route.name} 最终失败,丢弃 {len(payload['messages'])} 条消息")
117
+
118
+
119
+ def record_outgoing(session: str, message: dict[str, Any]) -> None:
120
+ """bot 自己发出的消息也记入上下文,但不触发推送"""
121
+ record_history(session, message)
122
+ if buffer := get_buffer(session):
123
+ buffer.add(message, trigger=False)
124
+
125
+
126
+ async def _allowed(bot: Bot, event: Event) -> bool:
127
+ try:
128
+ return is_allowed(session_key(get_target(event, bot)))
129
+ except Exception:
130
+ return False
131
+
132
+
133
+ listener = on_message(rule=_allowed, priority=1, block=False)
134
+
135
+
136
+ @listener.handle()
137
+ async def _(bot: Bot, event: Event, msg: UniMsg) -> None:
138
+ session = remember_target(get_target(event, bot))
139
+ user_id = event.get_user_id()
140
+ sender = getattr(event, "sender", None)
141
+ nickname = getattr(sender, "card", None) or getattr(sender, "nickname", None)
142
+ message = build_message(
143
+ session,
144
+ get_message_id(event, bot),
145
+ user_id,
146
+ nickname,
147
+ dump_segments(msg),
148
+ is_owner=user_id in config.grok_bot_owners,
149
+ to_me=event.is_tome(),
150
+ )
151
+ record_history(session, message)
152
+ if buffer := get_buffer(session):
153
+ buffer.add(message, trigger=True)
154
+
155
+
156
+ @driver.on_shutdown
157
+ async def _() -> None:
158
+ # 只有配置了自动推送的路由才在退出前清空积压,否则这些消息只是等待 Owner 触发的上下文
159
+ for buffer in _buffers.values():
160
+ if buffer.route.flush_interval is not None or buffer.route.flush_size:
161
+ buffer.flush("interval")
162
+ if _tasks:
163
+ await asyncio.wait(_tasks, timeout=config.grok_bot_webhook_timeout)