Skip to content
This repository was archived by the owner on Jul 24, 2025. It is now read-only.
Draft

ref #159

Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -143,3 +143,4 @@ run.sh
.env
.vscode
aerich_config.py
.DS_Store
38 changes: 0 additions & 38 deletions U1/message.py

This file was deleted.

2 changes: 1 addition & 1 deletion U1/model.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,5 +16,5 @@ class Channel(Model):
permissions = fields.TextField(null=True, default=None)
createdAt = fields.DatetimeField(null=True, default=None)

class Meta:
class Meta: # type: ignore
unique_together = ("platform", "flag")
35 changes: 35 additions & 0 deletions U1/utils/permission.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
from nonebot.adapters.milky.event import GroupMessageEvent
from nonebot.adapters.milky.model.common import Member
from nonebot.permission import Permission


async def _group_admin(event: GroupMessageEvent) -> bool:
if isinstance(event.data.sender, Member):
return event.data.sender.role == "admin"
raise TypeError(
f"Expected Member, got {type(event.data.sender)}: {event.data.sender}"
)


async def _group_owner(event: GroupMessageEvent) -> bool:
if isinstance(event.data.sender, Member):
return event.data.sender.role == "owner"
raise TypeError(
f"Expected Member, got {type(event.data.sender)}: {event.data.sender}"
)


async def _group_member(event: GroupMessageEvent) -> bool:
if isinstance(event.data.sender, Member):
return event.data.sender.role == "member"
raise TypeError(
f"Expected Member, got {type(event.data.sender)}: {event.data.sender}"
)


GROUP_MEMBER: Permission = Permission(_group_member)
"""匹配任意群员群聊消息类型事件"""
GROUP_ADMIN: Permission = Permission(_group_admin)
"""匹配任意群管理员群聊消息类型事件"""
GROUP_OWNER: Permission = Permission(_group_owner)
"""匹配任意群主群聊消息类型事件"""
86 changes: 86 additions & 0 deletions U1/utils/token_bucket.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
from asyncio import get_running_loop
from collections import defaultdict
from enum import IntEnum, auto

from nonebot.adapters.milky.event import MessageEvent
from nonebot.matcher import Matcher
from nonebot.params import Depends


class CooldownIsolateLevel(IntEnum):
"""命令冷却的隔离级别"""

GLOBAL = auto()
"""全局使用同一个冷却计时"""
GROUP = auto()
"""群组内使用同一个冷却计时"""
USER = auto()
"""按用户使用同一个冷却计时"""
GROUP_USER = auto()
"""群组内每个用户使用同一个冷却计时"""


def Cooldown(
cooldown: float = 5,
*,
prompt: str | None = None,
isolate_level: CooldownIsolateLevel = CooldownIsolateLevel.USER,
parallel: int = 1,
) -> None:
"""依赖注入形式的事件冷却

用法:
```python
@matcher.handle(parameterless=[Cooldown(cooldown=11.4514, ...)])
async def handle_command(matcher: Matcher, message: Message):
...
```

参数:
cooldown: 冷却间隔
prompt: 当触发冷却时发送给用户的提示消息
isolate_level: 事件冷却的隔离级别, 参考 `CooldownIsolateLevel`
parallel: 并行执行的命令数量
"""
if not isinstance(isolate_level, CooldownIsolateLevel):
raise ValueError(
f"invalid isolate level: {isolate_level!r}, "
"isolate level must use provided enumerate value."
)
running: defaultdict[str, int] = defaultdict(lambda: parallel)

def increase(key: str, value: int = 1):
running[key] += value
if running[key] >= parallel:
del running[key]
return

async def dependency(matcher: Matcher, event: MessageEvent):
loop = get_running_loop()


if isolate_level is CooldownIsolateLevel.GROUP:
if event.data.message_scene == "group":
key = str(event.data.peer_id)
else:
raise ValueError(
"isolate_level is set to GROUP, but event is not a group message."
)
elif isolate_level is CooldownIsolateLevel.USER:
key = event.get_user_id()
elif isolate_level is CooldownIsolateLevel.GROUP_USER:
key = event.get_session_id()
else:
key = CooldownIsolateLevel.GLOBAL.name

if not key:
return

if running[key] <= 0:
await matcher.finish(prompt)
else:
running[key] -= 1
loop.call_later(cooldown, lambda: increase(key))
return

return Depends(dependency)
16 changes: 16 additions & 0 deletions U1/utils/utils.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
from nonebot.adapters.milky import Message

def extract_image_urls(message: Message) -> list[str]:
"""提取消息中的图片链接

参数:
message: 消息对象

返回:
图片链接列表
"""
return [
segment.data["url"]
for segment in message
if (segment.type == "image") and ("url" in segment.data)
]
12 changes: 6 additions & 6 deletions bot.py
Original file line number Diff line number Diff line change
Expand Up @@ -45,14 +45,14 @@ def default_filter(record: "Record"):
)


from nonebot.adapters.onebot.v11 import Adapter as ONEBOT_V11Adapter
from nonebot.adapters.onebot.v11 import Bot, GroupMessageEvent
from nonebot.adapters.milky import Adapter as MilkyAdapter
from nonebot.adapters.milky import Bot
from nonebot.adapters.milky.event import GroupMessageEvent
from nonebot.exception import IgnoredException

nonebot.init()
app = nonebot.get_asgi()
driver = nonebot.get_driver()
driver.register_adapter(ONEBOT_V11Adapter)
driver.register_adapter(MilkyAdapter)


@driver.on_startup
Expand Down Expand Up @@ -86,10 +86,10 @@ async def _(bot: Bot, event: GroupMessageEvent):
if event.to_me:
return

channel = await get_channel(str(event.group_id))
channel = await get_channel(str(event.data.peer_id))
if channel is None:
for _ in range(3):
channel = await get_channel(str(event.group_id))
channel = await get_channel(str(event.data.peer_id))
if channel is not None:
break # 重试直到找到频道
await asyncio.sleep(0.5)
Expand Down
100 changes: 100 additions & 0 deletions migrations/versions/adc8daa3ce44_migrate_cq_codes_to_img_base64.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
"""migrate_cq_codes_to_img_base64

迁移 ID: adc8daa3ce44
父迁移: 782cb0785d08
创建时间: 2025-07-11 01:05:56.515358

"""

from __future__ import annotations

import json
import re
from collections.abc import Sequence

import sqlalchemy as sa
from alembic import op

revision: str = "adc8daa3ce44"
down_revision: str | Sequence[str] | None = "782cb0785d08"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None


def extract_base64_from_cq_codes(text: str) -> list[str]:
"""
从文本中提取所有 [CQ:image,file=base64://...] 中的 base64 数据。
"""
base64_images = []
# 匹配 [CQ:image,file=base64://xxxxx] 格式
cq_pattern = r"\[CQ:image,file=base64://([^]]+)\]"
matches = re.findall(cq_pattern, text)
base64_images.extend(matches)
return base64_images


def remove_cq_codes(text: str) -> str:
"""
移除文本中的所有 CQ 码。
"""
# 移除所有 [CQ:...] 格式的代码
return re.sub(r"\[CQ:[^\]]+\]", "", text).strip()


def upgrade(name: str = "") -> None:
if name:
return

# 检查 img_base64 列是否存在
connection = op.get_bind()
inspector = sa.inspect(connection)
columns = [col["name"] for col in inspector.get_columns("cave_models")]

# 如果字段不存在,则添加它
if "img_base64" not in columns:
with op.batch_alter_table("cave_models", schema=None) as batch_op:
batch_op.add_column(sa.Column("img_base64", sa.JSON(), nullable=False))

# 数据迁移:处理现有的 CQ 码数据
# 查询所有包含 CQ 码的记录
result = connection.execute(
sa.text("SELECT id, details FROM cave_models WHERE details LIKE '%[CQ:image%'")
)

for row in result:
record_id, details = row

# 提取 base64 数据
base64_images = extract_base64_from_cq_codes(details)

# 移除 CQ 码
clean_details = remove_cq_codes(details)

# 更新记录
connection.execute(
sa.text(
"UPDATE cave_models SET details = :details, img_base64 = :img_base64 WHERE id = :id"
),
{
"details": clean_details,
"img_base64": json.dumps(base64_images),
"id": record_id,
},
)

# 为没有图片的记录设置空数组
connection.execute(
sa.text("UPDATE cave_models SET img_base64 = '[]' WHERE img_base64 IS NULL OR img_base64 = ''")
)

connection.commit()


def downgrade(name: str = "") -> None:
if name:
return
# ### commands auto generated by Alembic - please adjust! ###
with op.batch_alter_table("cave_models", schema=None) as batch_op:
batch_op.drop_column("img_base64")

# ### end Alembic commands ###
12 changes: 5 additions & 7 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,6 @@ plugins = [
"nonebot_plugin_orm",
]
plugin_dirs = ["src/plugins"]
builtin_plugins = []
[[tool.nonebot.adapters]]
name = "OneBot V11"
module_name = "nonebot.adapters.onebot.v11"

[tool.ruff]
line-length = 88
Expand All @@ -22,16 +18,14 @@ select = ["F", "W", "E", "UP", "ASYNC", "C4", "T10", "PYI", "PT", "Q", "RUF"]
ignore = ["E402", "E501", "UP037", "RUF001", "RUF002", "RUF003"]

[tool.pyright]
typeCheckingMode = "basic"
typeCheckingMode = "standard"

[project]
requires-python = "<4.0,>=3.10"
dependencies = [
"nonebot2[fastapi,aiohttp,websockets]>=2.4.1",
"nonebot-plugin-orm[mysql]>=0.8.1",
"nonebot-plugin-apscheduler>=0.5.0",
"nonebot-plugin-htmlrender==0.4.0",
"nonebot-adapter-onebot>=2.4.6",
"nonebot-plugin-userinfo>=0.2.6",
"aiohttp>=3.10.0",
"ujson>=5.10.0",
Expand All @@ -50,6 +44,10 @@ dependencies = [
"openai>=1.76.0",
"tomlkit>=0.13.2",
"nb-cli>=1.4.2",
"nonebot-adapter-milky>=0.4.1",
"pysilk>=0.0.1",
"qrcode>=8.2",
"nonebot2[httpx,websockets]>=2.4.2",
]
name = "u1bot"
version = "0.1.0"
Expand Down
Loading