├── tests ├── __init__.py ├── test_koishi_command.yml ├── test_mc.py ├── test_koishi_command1.yml ├── test_fleep_vendor.py ├── test_tg.py ├── test_funcommand.py ├── test_use_origin.py ├── test_filehost_apply.py ├── test_context.py ├── test_saa_patch.py ├── test_typings.py ├── test_unmatch.py ├── test_kook.py ├── conftest.py ├── test_aliases.py ├── test_ref.py ├── test_gotpath.py ├── test_onebot.py ├── test_koishi_command.py ├── test_i18n.py └── test_satori.py ├── src └── nonebot_plugin_alconna │ ├── py.typed │ ├── uniseg │ ├── utils │ │ ├── __init__.py │ │ ├── filehost.py │ │ ├── matcher.py │ │ └── fleep.py │ ├── i18n │ │ ├── .config.json │ │ ├── __init__.py │ │ ├── .template.json │ │ ├── zh-CN.json │ │ ├── en-US.json │ │ ├── .template.schema.json │ │ └── .lang.schema.json │ ├── adapters │ │ ├── ding │ │ │ ├── __init__.py │ │ │ ├── builder.py │ │ │ └── exporter.py │ │ ├── mail │ │ │ ├── __init__.py │ │ │ └── builder.py │ │ ├── wxmp │ │ │ ├── __init__.py │ │ │ ├── builder.py │ │ │ └── exporter.py │ │ ├── efchat │ │ │ ├── __init__.py │ │ │ ├── builder.py │ │ │ └── exporter.py │ │ ├── github │ │ │ ├── __init__.py │ │ │ ├── builder.py │ │ │ └── exporter.py │ │ ├── heybox │ │ │ ├── __init__.py │ │ │ ├── builder.py │ │ │ └── exporter.py │ │ ├── nonebug │ │ │ ├── builder.py │ │ │ ├── __init__.py │ │ │ └── exporter.py │ │ ├── ntchat │ │ │ ├── __init__.py │ │ │ ├── builder.py │ │ │ └── exporter.py │ │ ├── console │ │ │ ├── __init__.py │ │ │ └── builder.py │ │ ├── bililive │ │ │ ├── __init__.py │ │ │ ├── builder.py │ │ │ └── exporter.py │ │ ├── telegram │ │ │ └── __init__.py │ │ ├── minecraft │ │ │ ├── __init__.py │ │ │ └── builder.py │ │ ├── qq │ │ │ ├── __init__.py │ │ │ └── target.py │ │ ├── red │ │ │ ├── __init__.py │ │ │ ├── target.py │ │ │ └── builder.py │ │ ├── dodo │ │ │ ├── __init__.py │ │ │ ├── target.py │ │ │ └── builder.py │ │ ├── kook │ │ │ ├── __init__.py │ │ │ └── target.py │ │ ├── milky │ │ │ ├── __init__.py │ │ │ └── target.py │ │ ├── mirai │ │ │ ├── __init__.py │ │ │ └── target.py │ │ ├── feishu │ │ │ ├── __init__.py │ │ │ ├── builder.py │ │ │ └── target.py │ │ ├── kritor │ │ │ ├── __init__.py │ │ │ └── target.py │ │ ├── discord │ │ │ ├── __init__.py │ │ │ └── target.py │ │ ├── onebot11 │ │ │ ├── __init__.py │ │ │ ├── target.py │ │ │ └── builder.py │ │ ├── onebot12 │ │ │ ├── __init__.py │ │ │ ├── builder.py │ │ │ └── target.py │ │ ├── tailchat │ │ │ ├── __init__.py │ │ │ ├── target.py │ │ │ └── builder.py │ │ └── satori │ │ │ ├── __init__.py │ │ │ └── target.py │ ├── loader.py │ ├── fallback.py │ ├── rule.py │ ├── builder.py │ └── params.py │ ├── builtins │ ├── plugins │ │ ├── __init__.py │ │ ├── echo │ │ │ ├── config.py │ │ │ └── __init__.py │ │ ├── with │ │ │ ├── config.py │ │ │ ├── extension.py │ │ │ └── __init__.py │ │ ├── help │ │ │ └── config.py │ │ ├── switch │ │ │ └── config.py │ │ └── lang.py │ ├── uniseg │ │ ├── __init__.py │ │ └── markdown.py │ └── extensions │ │ ├── __init__.py │ │ ├── shortcut.py │ │ ├── onebot11.py │ │ ├── permission.py │ │ ├── telegram.py │ │ └── markdown.py │ ├── i18n │ ├── .config.json │ ├── __init__.py │ ├── .template.schema.json │ ├── zh-CN.json │ ├── .template.json │ ├── en-US.json │ └── model.py │ ├── consts.py │ └── config.py ├── .github ├── workflows │ ├── ruff.yml │ ├── auto-merge.yml │ ├── test.yml │ └── release.yml ├── ISSUE_TEMPLATE │ ├── feature.md │ └── bug.md ├── actions │ └── setup-python │ │ └── action.yml └── dependabot.yml ├── example ├── pyproject.toml ├── .env.prod └── bot.py ├── .pre-commit-config.yaml ├── LICENSE ├── .devcontainer └── devcontainer.json └── intro.md /tests/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/nonebot_plugin_alconna/py.typed: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /src/nonebot_plugin_alconna/uniseg/utils/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/nonebot_plugin_alconna/builtins/plugins/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/nonebot_plugin_alconna/builtins/uniseg/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/nonebot_plugin_alconna/builtins/extensions/__init__.py: -------------------------------------------------------------------------------- 1 | from .markdown import MarkdownOutputExtension as MarkdownOutputExtension 2 | from .reply import ReplyRecordExtension as ReplyRecordExtension 3 | -------------------------------------------------------------------------------- /src/nonebot_plugin_alconna/i18n/.config.json: -------------------------------------------------------------------------------- 1 | { 2 | "default": "zh-CN", 3 | "frozen": [ 4 | "nbp-alc:completion" 5 | ], 6 | "require": [ 7 | "nbp-alc" 8 | ], 9 | "name": "nonebot_plugin_alconna" 10 | } -------------------------------------------------------------------------------- /src/nonebot_plugin_alconna/builtins/plugins/echo/config.py: -------------------------------------------------------------------------------- 1 | from pydantic import BaseModel, Field 2 | 3 | 4 | class Config(BaseModel): 5 | """Plugin Config Here""" 6 | 7 | nbp_alc_echo_tome: bool = Field(default=False) 8 | -------------------------------------------------------------------------------- /src/nonebot_plugin_alconna/uniseg/i18n/.config.json: -------------------------------------------------------------------------------- 1 | { 2 | "default": "zh-CN", 3 | "frozen": [ 4 | "nbp-uniseg" 5 | ], 6 | "require": [ 7 | "nbp-uniseg" 8 | ], 9 | "name": "nonebot_plugin_uniseg" 10 | } -------------------------------------------------------------------------------- /src/nonebot_plugin_alconna/uniseg/i18n/__init__.py: -------------------------------------------------------------------------------- 1 | # This file is @generated by tarina.lang CLI tool 2 | # It is not intended for manual editing. 3 | 4 | from pathlib import Path 5 | 6 | from tarina.lang import lang 7 | 8 | lang.load(Path(__file__).parent) 9 | -------------------------------------------------------------------------------- /src/nonebot_plugin_alconna/builtins/plugins/with/config.py: -------------------------------------------------------------------------------- 1 | from pydantic import BaseModel, Field 2 | 3 | 4 | class Config(BaseModel): 5 | """Plugin Config Here""" 6 | 7 | nbp_alc_with_text: str = Field(default="with") 8 | nbp_alc_with_alias: set[str] = Field(default={"局部前缀"}) 9 | -------------------------------------------------------------------------------- /src/nonebot_plugin_alconna/i18n/__init__.py: -------------------------------------------------------------------------------- 1 | # This file is @generated by tarina.lang CLI tool 2 | # It is not intended for manual editing. 3 | 4 | from pathlib import Path 5 | 6 | from tarina.lang import lang 7 | 8 | lang.load(Path(__file__).parent) 9 | 10 | from .model import Lang as Lang 11 | -------------------------------------------------------------------------------- /.github/workflows/ruff.yml: -------------------------------------------------------------------------------- 1 | name: Ruff Lint 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | pull_request: 8 | 9 | jobs: 10 | ruff: 11 | name: Ruff Lint 12 | runs-on: ubuntu-latest 13 | steps: 14 | - uses: actions/checkout@v6 15 | 16 | - name: Run Ruff Lint 17 | uses: chartboost/ruff-action@v1 18 | -------------------------------------------------------------------------------- /src/nonebot_plugin_alconna/uniseg/i18n/.template.json: -------------------------------------------------------------------------------- 1 | 2 | { 3 | "$schema": "./.template.schema.json", 4 | "scopes": [ 5 | { 6 | "scope": "nbp-uniseg", 7 | "types": [ 8 | "invalid_segment", 9 | "failed_segment", 10 | "failed", 11 | "bot_missing", 12 | "event_missing", 13 | "unsupported" 14 | ] 15 | } 16 | ] 17 | } 18 | -------------------------------------------------------------------------------- /example/pyproject.toml: -------------------------------------------------------------------------------- 1 | [project] 2 | name = "nonebot_plugin_test" 3 | version = "0.1.0" 4 | description = "nonebot_plugin_test" 5 | readme = "README.md" 6 | requires-python = ">=3.8, <4.0" 7 | 8 | [tool.nonebot] 9 | adapters = [ 10 | { name = "OneBot V12", module_name = "nonebot.adapters.onebot.v12" } 11 | ] 12 | plugins = ["nonebot_plugin_alconna"] 13 | plugin_dirs = [] 14 | builtin_plugins = [] 15 | -------------------------------------------------------------------------------- /tests/test_koishi_command.yml: -------------------------------------------------------------------------------- 1 | command: book1 2 | help: 测试 3 | options: 4 | - name: writer 5 | opt: "-w " 6 | - name: writer 7 | opt: "--anonymous" 8 | default: 9 | id: 1 10 | usage: book [-w | --anonymous] 11 | shortcuts: 12 | - key: 测试 13 | args: ["--anonymous"] 14 | actions: 15 | - 16 | params: ["options"] 17 | code: | 18 | return str(options) -------------------------------------------------------------------------------- /src/nonebot_plugin_alconna/uniseg/i18n/zh-CN.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "./.lang.schema.json", 3 | "nbp-uniseg": { 4 | "invalid_segment": "无效的 {type} 类型消息段: {seg!r}", 5 | "failed_segment": "无法将 {seg!r} 序列化为适配器 {adapter} 下的 {target} 消息段", 6 | "failed": "无法将 {target!r} 序列化为适配器 {adapter} 下的消息", 7 | "bot_missing": "在没有机器人实例的情况下无法将通用消息转为对应的适配器消息", 8 | "event_missing": "在没有事件实例的情况下无法将通用消息转为对应的适配器消息", 9 | "unsupported": "未找到适配器 {adapter} 下的消息转换器" 10 | } 11 | } -------------------------------------------------------------------------------- /src/nonebot_plugin_alconna/builtins/plugins/help/config.py: -------------------------------------------------------------------------------- 1 | from typing import Optional 2 | 3 | from pydantic import BaseModel, Field 4 | 5 | 6 | class Config(BaseModel): 7 | """Plugin Config Here""" 8 | 9 | nbp_alc_help_text: str = Field(default="help") 10 | nbp_alc_help_alias: set[str] = Field(default={"帮助", "命令帮助"}) 11 | nbp_alc_help_all_alias: set[str] = Field(default={"所有帮助", "所有命令帮助"}) 12 | nbp_alc_page_size: Optional[int] = Field(ge=2, default=None) 13 | -------------------------------------------------------------------------------- /example/.env.prod: -------------------------------------------------------------------------------- 1 | DRIVER=~fastapi+~httpx+~websockets 2 | HOST=127.0.0.1 3 | PORT=9555 4 | ALCONNA_AUTO_SEND_OUTPUT=true 5 | ALCONNA_USE_COMMAND_START=true 6 | ALCONNA_GLOBAL_COMPLETION=' 7 | { 8 | "tab": "True" 9 | } 10 | ' 11 | NBP_ALC_PAGE_SIZE=6 12 | LOG_LEVEL=DEBUG 13 | COMMAND_START=["."] 14 | SATORI_CLIENTS=' 15 | [ 16 | { 17 | "port": 7788, 18 | "token": "77545054e8012518" 19 | } 20 | ] 21 | ' 22 | SUPERUSERS=["3165388245"] 23 | ALCONNA_CONFLICT_RESOLVER="default" -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature 特性请求 3 | about: 为 Nonebot-Plugin-Alconna 加份菜 4 | title: "[Feature] " 5 | labels: enhancement, triage 6 | assignees: "" 7 | --- 8 | 9 | ## 请确认: 10 | 11 | * [ ] 新特性的目的明确 12 | * [ ] 我已经阅读了[相关文档](https://nonebot.dev/docs/next/best-practice/alconna/) 并且找不到类似特性 13 | 14 | 15 | ## Feature 16 | ### 概要 17 | 18 | 19 | 20 | ### 是否已有相关实现 21 | 22 | 暂无 23 | 24 | 25 | ### 其他内容 26 | 27 | 暂无 28 | -------------------------------------------------------------------------------- /.github/actions/setup-python/action.yml: -------------------------------------------------------------------------------- 1 | name: Setup Python 2 | description: Setup Python 3 | 4 | inputs: 5 | python-version: 6 | description: Python version 7 | required: false 8 | default: "3.11" 9 | 10 | runs: 11 | using: "composite" 12 | steps: 13 | - uses: pdm-project/setup-pdm@v3 14 | name: Setup PDM 15 | with: 16 | python-version: ${{ inputs.python-version }} 17 | architecture: "x64" 18 | cache: true 19 | 20 | - run: pdm sync -G default -v 21 | shell: bash 22 | -------------------------------------------------------------------------------- /src/nonebot_plugin_alconna/consts.py: -------------------------------------------------------------------------------- 1 | from typing import Literal 2 | 3 | from nonebot.utils import logger_wrapper 4 | 5 | from .i18n import lang as lang # noqa: F401 6 | 7 | ALCONNA_RESULT: Literal["_alc_result"] = "_alc_result" 8 | ALCONNA_EXEC_RESULT: Literal["_alc_exec_result"] = "_alc_exec_result" 9 | ALCONNA_ARG_KEY: Literal["_alc_arg_{key}"] = "_alc_arg_{key}" 10 | ALCONNA_ARG_KEYS: Literal["_alc_arg_keys"] = "_alc_arg_keys" 11 | ALCONNA_EXTENSION: Literal["_alc_extension"] = "_alc_extension" 12 | 13 | log = logger_wrapper("Plugin-Alconna") 14 | -------------------------------------------------------------------------------- /src/nonebot_plugin_alconna/builtins/plugins/switch/config.py: -------------------------------------------------------------------------------- 1 | from typing import Optional 2 | 3 | from pydantic import BaseModel, Field 4 | 5 | 6 | class Config(BaseModel): 7 | """Plugin Config Here""" 8 | 9 | nbp_alc_switch_enable: str = Field(default="enable") 10 | nbp_alc_switch_enable_alias: set[str] = Field(default={"启用", "启用指令"}) 11 | nbp_alc_switch_disable: str = Field(default="disable") 12 | nbp_alc_switch_disable_alias: set[str] = Field(default={"禁用", "禁用指令"}) 13 | nbp_alc_page_size: Optional[int] = Field(ge=2, default=None) 14 | -------------------------------------------------------------------------------- /src/nonebot_plugin_alconna/uniseg/i18n/en-US.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "./.lang.schema.json", 3 | "nbp-uniseg": { 4 | "invalid_segment": "Invalid {type} segment: {seg!r}", 5 | "failed_segment": "Cannot serialize {seg!r} to {target} segment of adatper {adapter}", 6 | "failed": "Cannot serialize {target!r} to message of adapter {adapter}", 7 | "bot_missing": "Can not export message without bot instance", 8 | "event_missing": "Can not export message without event instance", 9 | "unsupported": "Message Exporter not found under adapter {adapter}" 10 | } 11 | } -------------------------------------------------------------------------------- /src/nonebot_plugin_alconna/uniseg/adapters/ding/__init__.py: -------------------------------------------------------------------------------- 1 | from nonebot_plugin_alconna.uniseg.constraint import SupportAdapter 2 | from nonebot_plugin_alconna.uniseg.loader import BaseLoader 3 | 4 | 5 | class Loader(BaseLoader): 6 | def get_adapter(self) -> SupportAdapter: 7 | return SupportAdapter.ding 8 | 9 | def get_builder(self): 10 | from .builder import DingMessageBuilder 11 | 12 | return DingMessageBuilder() 13 | 14 | def get_exporter(self): 15 | from .exporter import DingMessageExporter 16 | 17 | return DingMessageExporter() 18 | -------------------------------------------------------------------------------- /src/nonebot_plugin_alconna/uniseg/adapters/mail/__init__.py: -------------------------------------------------------------------------------- 1 | from nonebot_plugin_alconna.uniseg.constraint import SupportAdapter 2 | from nonebot_plugin_alconna.uniseg.loader import BaseLoader 3 | 4 | 5 | class Loader(BaseLoader): 6 | def get_adapter(self) -> SupportAdapter: 7 | return SupportAdapter.mail 8 | 9 | def get_builder(self): 10 | from .builder import MailMessageBuilder 11 | 12 | return MailMessageBuilder() 13 | 14 | def get_exporter(self): 15 | from .exporter import MailMessageExporter 16 | 17 | return MailMessageExporter() 18 | -------------------------------------------------------------------------------- /src/nonebot_plugin_alconna/uniseg/adapters/wxmp/__init__.py: -------------------------------------------------------------------------------- 1 | from nonebot_plugin_alconna.uniseg.constraint import SupportAdapter 2 | from nonebot_plugin_alconna.uniseg.loader import BaseLoader 3 | 4 | 5 | class Loader(BaseLoader): 6 | def get_adapter(self) -> SupportAdapter: 7 | return SupportAdapter.wxmp 8 | 9 | def get_builder(self): 10 | from .builder import WXMPMessageBuilder 11 | 12 | return WXMPMessageBuilder() 13 | 14 | def get_exporter(self): 15 | from .exporter import WXMPMessageExporter 16 | 17 | return WXMPMessageExporter() 18 | -------------------------------------------------------------------------------- /src/nonebot_plugin_alconna/uniseg/adapters/efchat/__init__.py: -------------------------------------------------------------------------------- 1 | from nonebot_plugin_alconna.uniseg.constraint import SupportAdapter 2 | from nonebot_plugin_alconna.uniseg.loader import BaseLoader 3 | 4 | 5 | class Loader(BaseLoader): 6 | def get_adapter(self) -> SupportAdapter: 7 | return SupportAdapter.efchat 8 | 9 | def get_builder(self): 10 | from .builder import EFChatMessageBuilder 11 | 12 | return EFChatMessageBuilder() 13 | 14 | def get_exporter(self): 15 | from .exporter import EFChatMessageExporter 16 | 17 | return EFChatMessageExporter() 18 | -------------------------------------------------------------------------------- /src/nonebot_plugin_alconna/uniseg/adapters/github/__init__.py: -------------------------------------------------------------------------------- 1 | from nonebot_plugin_alconna.uniseg.constraint import SupportAdapter 2 | from nonebot_plugin_alconna.uniseg.loader import BaseLoader 3 | 4 | 5 | class Loader(BaseLoader): 6 | def get_adapter(self) -> SupportAdapter: 7 | return SupportAdapter.github 8 | 9 | def get_builder(self): 10 | from .builder import GithubMessageBuilder 11 | 12 | return GithubMessageBuilder() 13 | 14 | def get_exporter(self): 15 | from .exporter import GithubMessageExporter 16 | 17 | return GithubMessageExporter() 18 | -------------------------------------------------------------------------------- /src/nonebot_plugin_alconna/uniseg/adapters/heybox/__init__.py: -------------------------------------------------------------------------------- 1 | from nonebot_plugin_alconna.uniseg.constraint import SupportAdapter 2 | from nonebot_plugin_alconna.uniseg.loader import BaseLoader 3 | 4 | 5 | class Loader(BaseLoader): 6 | def get_adapter(self) -> SupportAdapter: 7 | return SupportAdapter.heybox 8 | 9 | def get_builder(self): 10 | from .builder import HeyboxMessageBuilder 11 | 12 | return HeyboxMessageBuilder() 13 | 14 | def get_exporter(self): 15 | from .exporter import HeyboxMessageExporter 16 | 17 | return HeyboxMessageExporter() 18 | -------------------------------------------------------------------------------- /src/nonebot_plugin_alconna/uniseg/adapters/nonebug/builder.py: -------------------------------------------------------------------------------- 1 | from nonebot.adapters import MessageSegment 2 | 3 | from nonebot_plugin_alconna.uniseg.builder import MessageBuilder, build 4 | from nonebot_plugin_alconna.uniseg.constraint import SupportAdapter 5 | from nonebot_plugin_alconna.uniseg.segment import Text 6 | 7 | 8 | class NonebugMessageBuilder(MessageBuilder): 9 | @classmethod 10 | def get_adapter(cls) -> SupportAdapter: 11 | return SupportAdapter.nonebug 12 | 13 | @build("text") 14 | def text(self, seg: MessageSegment): 15 | return Text(seg.data["text"]) 16 | -------------------------------------------------------------------------------- /src/nonebot_plugin_alconna/uniseg/adapters/ntchat/__init__.py: -------------------------------------------------------------------------------- 1 | from nonebot_plugin_alconna.uniseg.constraint import SupportAdapter 2 | from nonebot_plugin_alconna.uniseg.loader import BaseLoader 3 | 4 | 5 | class Loader(BaseLoader): 6 | def get_adapter(self) -> SupportAdapter: 7 | return SupportAdapter.ntchat 8 | 9 | def get_builder(self): 10 | from .builder import NTChatMessageBuilder 11 | 12 | return NTChatMessageBuilder() 13 | 14 | def get_exporter(self): 15 | from .exporter import NTChatMessageExporter 16 | 17 | return NTChatMessageExporter() 18 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug 报告 3 | about: 有关 bug 的报告 4 | title: "[Bug]" 5 | labels: bug, triage 6 | assignees: "" 7 | --- 8 | 9 | ## 请确认: 10 | 11 | * [ ] 问题的标题明确 12 | * [ ] 我翻阅过其他的issue并且找不到类似的问题 13 | * [ ] 我已经阅读了[相关文档](https://nonebot.dev/docs/next/best-practice/alconna/) 并仍然认为这是一个Bug 14 | 15 | # Bug 16 | 17 | ## 问题 18 | 19 | 20 | ## 如何复现 21 | 22 | 23 | ## 预期行为 24 | 25 | 26 | ## 使用环境: 27 | - Python 版本: 28 | - Nonebot2 版本: 29 | - Alconna 版本: 30 | 31 | ## 日志/截图 32 | 33 | -------------------------------------------------------------------------------- /src/nonebot_plugin_alconna/uniseg/adapters/console/__init__.py: -------------------------------------------------------------------------------- 1 | from nonebot_plugin_alconna.uniseg.constraint import SupportAdapter 2 | from nonebot_plugin_alconna.uniseg.loader import BaseLoader 3 | 4 | 5 | class Loader(BaseLoader): 6 | def get_adapter(self) -> SupportAdapter: 7 | return SupportAdapter.console 8 | 9 | def get_builder(self): 10 | from .builder import ConsoleMessageBuilder 11 | 12 | return ConsoleMessageBuilder() 13 | 14 | def get_exporter(self): 15 | from .exporter import ConsoleMessageExporter 16 | 17 | return ConsoleMessageExporter() 18 | -------------------------------------------------------------------------------- /src/nonebot_plugin_alconna/uniseg/adapters/nonebug/__init__.py: -------------------------------------------------------------------------------- 1 | from nonebot_plugin_alconna.uniseg.constraint import SupportAdapter 2 | from nonebot_plugin_alconna.uniseg.loader import BaseLoader 3 | 4 | 5 | class Loader(BaseLoader): 6 | def get_adapter(self) -> SupportAdapter: 7 | return SupportAdapter.nonebug 8 | 9 | def get_builder(self): 10 | from .builder import NonebugMessageBuilder 11 | 12 | return NonebugMessageBuilder() 13 | 14 | def get_exporter(self): 15 | from .exporter import NonebugMessageExporter 16 | 17 | return NonebugMessageExporter() 18 | -------------------------------------------------------------------------------- /src/nonebot_plugin_alconna/uniseg/adapters/bililive/__init__.py: -------------------------------------------------------------------------------- 1 | from nonebot_plugin_alconna.uniseg.constraint import SupportAdapter 2 | from nonebot_plugin_alconna.uniseg.loader import BaseLoader 3 | 4 | 5 | class Loader(BaseLoader): 6 | def get_adapter(self) -> SupportAdapter: 7 | return SupportAdapter.bililive 8 | 9 | def get_builder(self): 10 | from .builder import BiliLiveMessageBuilder 11 | 12 | return BiliLiveMessageBuilder() 13 | 14 | def get_exporter(self): 15 | from .exporter import BiliLiveMessageExporter 16 | 17 | return BiliLiveMessageExporter() 18 | -------------------------------------------------------------------------------- /src/nonebot_plugin_alconna/uniseg/adapters/telegram/__init__.py: -------------------------------------------------------------------------------- 1 | from nonebot_plugin_alconna.uniseg.constraint import SupportAdapter 2 | from nonebot_plugin_alconna.uniseg.loader import BaseLoader 3 | 4 | 5 | class Loader(BaseLoader): 6 | def get_adapter(self) -> SupportAdapter: 7 | return SupportAdapter.telegram 8 | 9 | def get_builder(self): 10 | from .builder import TelegramMessageBuilder 11 | 12 | return TelegramMessageBuilder() 13 | 14 | def get_exporter(self): 15 | from .exporter import TelegramMessageExporter 16 | 17 | return TelegramMessageExporter() 18 | -------------------------------------------------------------------------------- /src/nonebot_plugin_alconna/uniseg/adapters/minecraft/__init__.py: -------------------------------------------------------------------------------- 1 | from nonebot_plugin_alconna.uniseg.constraint import SupportAdapter 2 | from nonebot_plugin_alconna.uniseg.loader import BaseLoader 3 | 4 | 5 | class Loader(BaseLoader): 6 | def get_adapter(self) -> SupportAdapter: 7 | return SupportAdapter.minecraft 8 | 9 | def get_builder(self): 10 | from .builder import MinecraftMessageBuilder 11 | 12 | return MinecraftMessageBuilder() 13 | 14 | def get_exporter(self): 15 | from .exporter import MinecraftMessageExporter 16 | 17 | return MinecraftMessageExporter() 18 | -------------------------------------------------------------------------------- /src/nonebot_plugin_alconna/uniseg/adapters/heybox/builder.py: -------------------------------------------------------------------------------- 1 | from nonebot.adapters.heybox.message import MessageSegment # type: ignore 2 | 3 | from nonebot_plugin_alconna.uniseg.builder import MessageBuilder, build 4 | from nonebot_plugin_alconna.uniseg.constraint import SupportAdapter 5 | from nonebot_plugin_alconna.uniseg.segment import Text 6 | 7 | 8 | class HeyboxMessageBuilder(MessageBuilder): 9 | @classmethod 10 | def get_adapter(cls) -> SupportAdapter: 11 | return SupportAdapter.heybox 12 | 13 | @build("text") 14 | def text(self, seg: MessageSegment): 15 | return Text(seg.data["text"]) 16 | -------------------------------------------------------------------------------- /src/nonebot_plugin_alconna/uniseg/adapters/github/builder.py: -------------------------------------------------------------------------------- 1 | from nonebot.adapters.github.message import MessageSegment # type: ignore 2 | 3 | from nonebot_plugin_alconna.uniseg.builder import MessageBuilder, build 4 | from nonebot_plugin_alconna.uniseg.constraint import SupportAdapter 5 | from nonebot_plugin_alconna.uniseg.segment import Text 6 | 7 | 8 | class GithubMessageBuilder(MessageBuilder): 9 | @classmethod 10 | def get_adapter(cls) -> SupportAdapter: 11 | return SupportAdapter.github 12 | 13 | @build("markdown") 14 | def text(self, seg: MessageSegment): 15 | return Text(seg.data["text"]) 16 | -------------------------------------------------------------------------------- /src/nonebot_plugin_alconna/uniseg/loader.py: -------------------------------------------------------------------------------- 1 | from abc import ABCMeta, abstractmethod 2 | 3 | from .builder import MessageBuilder 4 | from .constraint import SupportAdapter 5 | from .exporter import MessageExporter 6 | from .target import TargetFetcher 7 | 8 | 9 | class BaseLoader(metaclass=ABCMeta): 10 | @abstractmethod 11 | def get_adapter(self) -> SupportAdapter: ... 12 | 13 | @abstractmethod 14 | def get_builder(self) -> MessageBuilder: ... 15 | 16 | @abstractmethod 17 | def get_exporter(self) -> MessageExporter: ... 18 | 19 | def get_fetcher(self) -> TargetFetcher: 20 | raise NotImplementedError 21 | -------------------------------------------------------------------------------- /tests/test_mc.py: -------------------------------------------------------------------------------- 1 | def test_mc_style_text(): 2 | from nonebot.adapters.minecraft.message import Message, MessageSegment 3 | from nonebot.adapters.minecraft.model import TextColor 4 | 5 | from nonebot_plugin_alconna import Text, UniMessage 6 | 7 | msg = UniMessage([Text("1234").color("red", 0, 2).color("yellow"), Text("456").color("blue")]) 8 | 9 | assert msg.export_sync(adapter="Minecraft") == Message( 10 | [ 11 | MessageSegment.text("12", color=TextColor.RED), 12 | MessageSegment.text("34", color=TextColor.YELLOW), 13 | MessageSegment.text("456", color=TextColor.BLUE), 14 | ] 15 | ) 16 | -------------------------------------------------------------------------------- /tests/test_koishi_command1.yml: -------------------------------------------------------------------------------- 1 | book2: 2 | command: book2 3 | help: 测试 4 | options: 5 | - name: writer 6 | opt: "-w " 7 | - name: writer 8 | opt: "--anonymous" 9 | default: 10 | id: 2 11 | usage: book [-w | --anonymous] 12 | shortcuts: 13 | - key: 测试 14 | args: ["--anonymous"] 15 | 16 | book3: 17 | command: book3 18 | help: 测试 19 | options: 20 | - name: writer 21 | opt: "-w " 22 | - name: writer 23 | opt: "--anonymous" 24 | default: 25 | id: 3 26 | usage: book [-w | --anonymous] 27 | shortcuts: 28 | - key: 测试 29 | args: ["--anonymous"] 30 | -------------------------------------------------------------------------------- /src/nonebot_plugin_alconna/uniseg/adapters/qq/__init__.py: -------------------------------------------------------------------------------- 1 | from nonebot_plugin_alconna.uniseg.constraint import SupportAdapter 2 | from nonebot_plugin_alconna.uniseg.loader import BaseLoader 3 | 4 | 5 | class Loader(BaseLoader): 6 | def get_adapter(self) -> SupportAdapter: 7 | return SupportAdapter.qq 8 | 9 | def get_builder(self): 10 | from .builder import QQMessageBuilder 11 | 12 | return QQMessageBuilder() 13 | 14 | def get_exporter(self): 15 | from .exporter import QQMessageExporter 16 | 17 | return QQMessageExporter() 18 | 19 | def get_fetcher(self): 20 | from .target import QQTargetFetcher 21 | 22 | return QQTargetFetcher() 23 | -------------------------------------------------------------------------------- /src/nonebot_plugin_alconna/uniseg/adapters/red/__init__.py: -------------------------------------------------------------------------------- 1 | from nonebot_plugin_alconna.uniseg.constraint import SupportAdapter 2 | from nonebot_plugin_alconna.uniseg.loader import BaseLoader 3 | 4 | 5 | class Loader(BaseLoader): 6 | def get_adapter(self) -> SupportAdapter: 7 | return SupportAdapter.red 8 | 9 | def get_builder(self): 10 | from .builder import RedMessageBuilder 11 | 12 | return RedMessageBuilder() 13 | 14 | def get_exporter(self): 15 | from .exporter import RedMessageExporter 16 | 17 | return RedMessageExporter() 18 | 19 | def get_fetcher(self): 20 | from .target import RedTargetFetcher 21 | 22 | return RedTargetFetcher() 23 | -------------------------------------------------------------------------------- /.github/workflows/auto-merge.yml: -------------------------------------------------------------------------------- 1 | name: Dependabot auto-merge 2 | on: pull_request_target 3 | 4 | permissions: 5 | pull-requests: write 6 | contents: write 7 | 8 | jobs: 9 | dependabot: 10 | runs-on: ubuntu-latest 11 | if: ${{ github.actor == 'dependabot[bot]' }} 12 | steps: 13 | - name: Dependabot metadata 14 | id: metadata 15 | uses: dependabot/fetch-metadata@v2.4.0 16 | with: 17 | github-token: "${{ secrets.GITHUB_TOKEN }}" 18 | - name: Enable auto-merge for Dependabot PRs 19 | run: gh pr merge --auto --squash "$PR_URL" 20 | env: 21 | PR_URL: ${{github.event.pull_request.html_url}} 22 | GITHUB_TOKEN: ${{secrets.GITHUB_TOKEN}} -------------------------------------------------------------------------------- /src/nonebot_plugin_alconna/uniseg/adapters/dodo/__init__.py: -------------------------------------------------------------------------------- 1 | from nonebot_plugin_alconna.uniseg.constraint import SupportAdapter 2 | from nonebot_plugin_alconna.uniseg.loader import BaseLoader 3 | 4 | 5 | class Loader(BaseLoader): 6 | def get_adapter(self) -> SupportAdapter: 7 | return SupportAdapter.dodo 8 | 9 | def get_builder(self): 10 | from .builder import DodoMessageBuilder 11 | 12 | return DodoMessageBuilder() 13 | 14 | def get_exporter(self): 15 | from .exporter import DoDoMessageExporter 16 | 17 | return DoDoMessageExporter() 18 | 19 | def get_fetcher(self): 20 | from .target import DodoTargetFetcher 21 | 22 | return DodoTargetFetcher() 23 | -------------------------------------------------------------------------------- /src/nonebot_plugin_alconna/uniseg/adapters/kook/__init__.py: -------------------------------------------------------------------------------- 1 | from nonebot_plugin_alconna.uniseg.constraint import SupportAdapter 2 | from nonebot_plugin_alconna.uniseg.loader import BaseLoader 3 | 4 | 5 | class Loader(BaseLoader): 6 | def get_adapter(self) -> SupportAdapter: 7 | return SupportAdapter.kook 8 | 9 | def get_builder(self): 10 | from .builder import KookMessageBuilder 11 | 12 | return KookMessageBuilder() 13 | 14 | def get_exporter(self): 15 | from .exporter import KookMessageExporter 16 | 17 | return KookMessageExporter() 18 | 19 | def get_fetcher(self): 20 | from .target import KookTargetFetcher 21 | 22 | return KookTargetFetcher() 23 | -------------------------------------------------------------------------------- /src/nonebot_plugin_alconna/uniseg/adapters/milky/__init__.py: -------------------------------------------------------------------------------- 1 | from nonebot_plugin_alconna.uniseg.constraint import SupportAdapter 2 | from nonebot_plugin_alconna.uniseg.loader import BaseLoader 3 | 4 | 5 | class Loader(BaseLoader): 6 | def get_adapter(self) -> SupportAdapter: 7 | return SupportAdapter.milky 8 | 9 | def get_builder(self): 10 | from .builder import MilkyMessageBuilder 11 | 12 | return MilkyMessageBuilder() 13 | 14 | def get_exporter(self): 15 | from .exporter import MilkyMessageExporter 16 | 17 | return MilkyMessageExporter() 18 | 19 | def get_fetcher(self): 20 | from .target import MilkyTargetFetcher 21 | 22 | return MilkyTargetFetcher() 23 | -------------------------------------------------------------------------------- /src/nonebot_plugin_alconna/uniseg/adapters/mirai/__init__.py: -------------------------------------------------------------------------------- 1 | from nonebot_plugin_alconna.uniseg.constraint import SupportAdapter 2 | from nonebot_plugin_alconna.uniseg.loader import BaseLoader 3 | 4 | 5 | class Loader(BaseLoader): 6 | def get_adapter(self) -> SupportAdapter: 7 | return SupportAdapter.mirai 8 | 9 | def get_builder(self): 10 | from .builder import MiraiMessageBuilder 11 | 12 | return MiraiMessageBuilder() 13 | 14 | def get_exporter(self): 15 | from .exporter import MiraiMessageExporter 16 | 17 | return MiraiMessageExporter() 18 | 19 | def get_fetcher(self): 20 | from .target import MiraiTargetFetcher 21 | 22 | return MiraiTargetFetcher() 23 | -------------------------------------------------------------------------------- /src/nonebot_plugin_alconna/uniseg/adapters/feishu/__init__.py: -------------------------------------------------------------------------------- 1 | from nonebot_plugin_alconna.uniseg.constraint import SupportAdapter 2 | from nonebot_plugin_alconna.uniseg.loader import BaseLoader 3 | 4 | 5 | class Loader(BaseLoader): 6 | def get_adapter(self) -> SupportAdapter: 7 | return SupportAdapter.feishu 8 | 9 | def get_builder(self): 10 | from .builder import FeishuMessageBuilder 11 | 12 | return FeishuMessageBuilder() 13 | 14 | def get_exporter(self): 15 | from .exporter import FeishuMessageExporter 16 | 17 | return FeishuMessageExporter() 18 | 19 | def get_fetcher(self): 20 | from .target import FeishuTargetFetcher 21 | 22 | return FeishuTargetFetcher() 23 | -------------------------------------------------------------------------------- /src/nonebot_plugin_alconna/uniseg/adapters/kritor/__init__.py: -------------------------------------------------------------------------------- 1 | from nonebot_plugin_alconna.uniseg.constraint import SupportAdapter 2 | from nonebot_plugin_alconna.uniseg.loader import BaseLoader 3 | 4 | 5 | class Loader(BaseLoader): 6 | def get_adapter(self) -> SupportAdapter: 7 | return SupportAdapter.kritor 8 | 9 | def get_builder(self): 10 | from .builder import KritorMessageBuilder 11 | 12 | return KritorMessageBuilder() 13 | 14 | def get_exporter(self): 15 | from .exporter import KritorMessageExporter 16 | 17 | return KritorMessageExporter() 18 | 19 | def get_fetcher(self): 20 | from .target import KritorTargetFetcher 21 | 22 | return KritorTargetFetcher() 23 | -------------------------------------------------------------------------------- /src/nonebot_plugin_alconna/uniseg/adapters/discord/__init__.py: -------------------------------------------------------------------------------- 1 | from nonebot_plugin_alconna.uniseg.constraint import SupportAdapter 2 | from nonebot_plugin_alconna.uniseg.loader import BaseLoader 3 | 4 | 5 | class Loader(BaseLoader): 6 | def get_adapter(self) -> SupportAdapter: 7 | return SupportAdapter.discord 8 | 9 | def get_builder(self): 10 | from .builder import DiscordMessageBuilder 11 | 12 | return DiscordMessageBuilder() 13 | 14 | def get_exporter(self): 15 | from .exporter import DiscordMessageExporter 16 | 17 | return DiscordMessageExporter() 18 | 19 | def get_fetcher(self): 20 | from .target import DiscordTargetFetcher 21 | 22 | return DiscordTargetFetcher() 23 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | # To get started with Dependabot version updates, you'll need to specify which 2 | # package ecosystems to update and where the package manifests are located. 3 | # Please see the documentation for all configuration options: 4 | # https://docs.github.com/github/administering-a-repository/configuration-options-for-dependency-updates 5 | 6 | version: 2 7 | updates: 8 | - package-ecosystem: "pip" # See documentation for possible values 9 | directory: "/" # Location of package manifests 10 | schedule: 11 | interval: "weekly" 12 | 13 | - package-ecosystem: "github-actions" # See documentation for possible values 14 | directory: "/" # Location of package manifests 15 | schedule: 16 | interval: "weekly" 17 | -------------------------------------------------------------------------------- /src/nonebot_plugin_alconna/uniseg/adapters/onebot11/__init__.py: -------------------------------------------------------------------------------- 1 | from nonebot_plugin_alconna.uniseg.constraint import SupportAdapter 2 | from nonebot_plugin_alconna.uniseg.loader import BaseLoader 3 | 4 | 5 | class Loader(BaseLoader): 6 | def get_adapter(self) -> SupportAdapter: 7 | return SupportAdapter.onebot11 8 | 9 | def get_builder(self): 10 | from .builder import Onebot11MessageBuilder 11 | 12 | return Onebot11MessageBuilder() 13 | 14 | def get_exporter(self): 15 | from .exporter import Onebot11MessageExporter 16 | 17 | return Onebot11MessageExporter() 18 | 19 | def get_fetcher(self): 20 | from .target import Onebot11TargetFetcher 21 | 22 | return Onebot11TargetFetcher() 23 | -------------------------------------------------------------------------------- /src/nonebot_plugin_alconna/uniseg/adapters/onebot12/__init__.py: -------------------------------------------------------------------------------- 1 | from nonebot_plugin_alconna.uniseg.constraint import SupportAdapter 2 | from nonebot_plugin_alconna.uniseg.loader import BaseLoader 3 | 4 | 5 | class Loader(BaseLoader): 6 | def get_adapter(self) -> SupportAdapter: 7 | return SupportAdapter.onebot12 8 | 9 | def get_builder(self): 10 | from .builder import Onebot12MessageBuilder 11 | 12 | return Onebot12MessageBuilder() 13 | 14 | def get_exporter(self): 15 | from .exporter import Onebot12MessageExporter 16 | 17 | return Onebot12MessageExporter() 18 | 19 | def get_fetcher(self): 20 | from .target import Onebot12TargetFetcher 21 | 22 | return Onebot12TargetFetcher() 23 | -------------------------------------------------------------------------------- /src/nonebot_plugin_alconna/uniseg/adapters/tailchat/__init__.py: -------------------------------------------------------------------------------- 1 | from nonebot_plugin_alconna.uniseg.constraint import SupportAdapter 2 | from nonebot_plugin_alconna.uniseg.loader import BaseLoader 3 | 4 | 5 | class Loader(BaseLoader): 6 | def get_adapter(self) -> SupportAdapter: 7 | return SupportAdapter.tail_chat 8 | 9 | def get_builder(self): 10 | from .builder import TailChatMessageBuilder 11 | 12 | return TailChatMessageBuilder() 13 | 14 | def get_exporter(self): 15 | from .exporter import TailChatMessageExporter 16 | 17 | return TailChatMessageExporter() 18 | 19 | def get_fetcher(self): 20 | from .target import TailChatTargetFetcher 21 | 22 | return TailChatTargetFetcher() 23 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | default_install_hook_types: [pre-commit, prepare-commit-msg] 2 | ci: 3 | autofix_commit_msg: ":rotating_light: auto fix by pre-commit hooks" 4 | autofix_prs: true 5 | autoupdate_branch: master 6 | autoupdate_schedule: monthly 7 | autoupdate_commit_msg: ":arrow_up: auto update by pre-commit hooks" 8 | repos: 9 | - repo: https://github.com/pycqa/isort 10 | rev: 5.13.2 11 | hooks: 12 | - id: isort 13 | stages: [pre-commit] 14 | 15 | - repo: https://github.com/psf/black 16 | rev: 25.1.0 17 | hooks: 18 | - id: black 19 | stages: [pre-commit] 20 | 21 | - repo: https://github.com/astral-sh/ruff-pre-commit 22 | rev: v0.12.7 23 | hooks: 24 | - id: ruff 25 | args: [--fix, --exit-non-zero-on-fix] 26 | stages: [pre-commit] 27 | -------------------------------------------------------------------------------- /example/bot.py: -------------------------------------------------------------------------------- 1 | import nonebot 2 | 3 | # from nonebot.adapters.console import Adapter as ConsoleAdapter 4 | from nonebot.adapters.onebot.v11 import Adapter as ONEBOT_V11Adapter 5 | 6 | # from nonebot.adapters.satori import Adapter as SatoriAdapter 7 | 8 | 9 | nonebot.init() 10 | 11 | driver = nonebot.get_driver() 12 | driver.register_adapter(ONEBOT_V11Adapter) 13 | # driver.register_adapter(SatoriAdapter) 14 | 15 | # nonebot.require("nonebot_plugin_alconna") 16 | # nonebot.load_plugins("plugins") 17 | nonebot.load_plugin("plugins.demo1") 18 | 19 | 20 | async def _(): 21 | from nonebot_plugin_alconna import SupportScope, Target, UniMessage 22 | 23 | await Target.group("123456789", SupportScope.qq_client).send(UniMessage.image(path="test.png")) 24 | 25 | 26 | if __name__ == "__main__": 27 | nonebot.run() 28 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Test & Track 2 | on: 3 | push: 4 | branches: 5 | - master 6 | paths-ignore: 7 | - 'README.md' 8 | - 'docs.md' 9 | - 'example/**' 10 | - 'intro.md' 11 | pull_request: 12 | 13 | jobs: 14 | test: 15 | strategy: 16 | matrix: 17 | py_ver: ['3.10', '3.11', '3.12', '3.13'] 18 | runs-on: ubuntu-latest 19 | steps: 20 | - uses: actions/checkout@v6 21 | - uses: actions/setup-python@v6 22 | name: Ensure Python Runtime 23 | with: 24 | python-version: ${{matrix.py_ver}} 25 | architecture: 'x64' 26 | - name: Ensure PDM & twine 27 | run: | 28 | python3 -m pip install pdm 29 | - name: Install Package 30 | run: | 31 | pdm sync -G test -v 32 | - name: Test & Report 33 | run: | 34 | pdm run test 35 | -------------------------------------------------------------------------------- /src/nonebot_plugin_alconna/uniseg/utils/filehost.py: -------------------------------------------------------------------------------- 1 | from io import BytesIO 2 | from pathlib import Path 3 | from typing import Union 4 | 5 | from nonebot import require 6 | 7 | from nonebot_plugin_alconna.uniseg.segment import Media 8 | 9 | try: 10 | require("nonebot_plugin_filehost") 11 | from nonebot_plugin_filehost import FileHost 12 | except ImportError: 13 | raise ImportError("You need to install nonebot_plugin_filehost to use this module.") from None 14 | 15 | 16 | async def to_url(data: Union[str, Path, bytes, BytesIO], bot: ..., name: Union[str, None] = None) -> str: 17 | if isinstance(data, str): 18 | data = Path(data) 19 | return await FileHost(data, filename=name).to_url() 20 | 21 | 22 | _OLD_METHOD = Media.to_url 23 | 24 | 25 | def apply(): 26 | 27 | Media.to_url = to_url 28 | 29 | def dispose(): 30 | Media.to_url = _OLD_METHOD # type: ignore 31 | 32 | return dispose 33 | -------------------------------------------------------------------------------- /src/nonebot_plugin_alconna/uniseg/adapters/bililive/builder.py: -------------------------------------------------------------------------------- 1 | from nonebot.adapters.bilibili_live.message import AtSegment, EmoticonSegment, TextSegment 2 | 3 | from nonebot_plugin_alconna.uniseg.builder import MessageBuilder, build 4 | from nonebot_plugin_alconna.uniseg.constraint import SupportAdapter 5 | from nonebot_plugin_alconna.uniseg.segment import At, Emoji, Text 6 | 7 | 8 | class BiliLiveMessageBuilder(MessageBuilder): 9 | @classmethod 10 | def get_adapter(cls) -> SupportAdapter: 11 | return SupportAdapter.bililive 12 | 13 | @build("text") 14 | def text(self, seg: TextSegment): 15 | return Text(seg.data["text"]) 16 | 17 | @build("at") 18 | def at(self, seg: AtSegment): 19 | return At("user", str(seg.user_id), seg.data.get("name")) 20 | 21 | @build("emoticon") 22 | def emoticon(self, seg: EmoticonSegment): 23 | return Emoji(seg.data["emoji"], seg.data.get("descript")) 24 | -------------------------------------------------------------------------------- /src/nonebot_plugin_alconna/uniseg/adapters/console/builder.py: -------------------------------------------------------------------------------- 1 | from nonebot.adapters.console.message import MessageSegment 2 | 3 | from nonebot_plugin_alconna.uniseg.builder import MessageBuilder, build 4 | from nonebot_plugin_alconna.uniseg.constraint import SupportAdapter 5 | from nonebot_plugin_alconna.uniseg.segment import Emoji, Text 6 | 7 | 8 | class ConsoleMessageBuilder(MessageBuilder): 9 | @classmethod 10 | def get_adapter(cls) -> SupportAdapter: 11 | return SupportAdapter.console 12 | 13 | @build("markup") 14 | def markup(self, seg: MessageSegment): 15 | return Text(seg.data["markup"]).mark(0, len(seg.data["markup"]), "markup", seg.data["style"]) 16 | 17 | @build("markdown") 18 | def markdown(self, seg: MessageSegment): 19 | return Text(seg.data["markup"]).mark(0, len(seg.data["markup"]), "markdown", seg.data["code_theme"]) 20 | 21 | @build("emoji") 22 | def emoji(self, seg: MessageSegment): 23 | return Emoji(seg.data["name"]) 24 | -------------------------------------------------------------------------------- /src/nonebot_plugin_alconna/uniseg/adapters/ding/builder.py: -------------------------------------------------------------------------------- 1 | from nonebot.adapters.ding.message import MessageSegment 2 | 3 | from nonebot_plugin_alconna.uniseg.builder import MessageBuilder, build 4 | from nonebot_plugin_alconna.uniseg.constraint import SupportAdapter 5 | from nonebot_plugin_alconna.uniseg.segment import At, AtAll, Image, Text 6 | 7 | 8 | class DingMessageBuilder(MessageBuilder): 9 | @classmethod 10 | def get_adapter(cls) -> SupportAdapter: 11 | return SupportAdapter.ding 12 | 13 | @build("text") 14 | def text(self, seg: MessageSegment): 15 | return Text(seg.data["content"]) 16 | 17 | @build("image") 18 | def image(self, seg: MessageSegment): 19 | return Image(url=seg.data["picURL"]) 20 | 21 | @build("at") 22 | def at(self, seg: MessageSegment): 23 | if seg.data.get("isAtAll"): 24 | return AtAll() 25 | if "atDingtalkIds" in seg.data: 26 | return At("user", seg.data["atDingtalkIds"][0]) 27 | return None 28 | -------------------------------------------------------------------------------- /src/nonebot_plugin_alconna/uniseg/adapters/efchat/builder.py: -------------------------------------------------------------------------------- 1 | from nonebot.adapters.efchat.message import At as AtSegment 2 | from nonebot.adapters.efchat.message import Image as ImageSegment 3 | from nonebot.adapters.efchat.message import Voice as VoiceSegment 4 | 5 | from nonebot_plugin_alconna.uniseg.builder import MessageBuilder, build 6 | from nonebot_plugin_alconna.uniseg.constraint import SupportAdapter 7 | from nonebot_plugin_alconna.uniseg.segment import At, Image, Voice 8 | 9 | 10 | class EFChatMessageBuilder(MessageBuilder): 11 | @classmethod 12 | def get_adapter(cls) -> SupportAdapter: 13 | return SupportAdapter.efchat 14 | 15 | @build("at") 16 | def at(self, seg: AtSegment): 17 | return At("user", seg.data["target"]) 18 | 19 | @build("image") 20 | def image(self, seg: ImageSegment): 21 | return Image(url=seg.data["url"]) 22 | 23 | @build("voice") 24 | def voice(self, seg: VoiceSegment): 25 | return Voice(url=seg.data["url"], id=seg.data.get("src")) 26 | -------------------------------------------------------------------------------- /src/nonebot_plugin_alconna/uniseg/i18n/.template.schema.json: -------------------------------------------------------------------------------- 1 | 2 | { 3 | "title": "Template", 4 | "description": "Template for lang items to generate schema for lang files", 5 | "type": "object", 6 | "properties": { 7 | "scopes": { 8 | "title": "Scopes", 9 | "description": "All scopes of lang items", 10 | "type": "array", 11 | "uniqueItems": true, 12 | "items": { 13 | "title": "Scope", 14 | "description": "First level of all lang items", 15 | "type": "object", 16 | "properties": { 17 | "scope": { 18 | "type": "string", 19 | "description": "Scope name" 20 | }, 21 | "types": { 22 | "type": "array", 23 | "description": "All types of lang items", 24 | "uniqueItems": true, 25 | "items": { 26 | "type": "string", 27 | "description": "Value of lang item" 28 | } 29 | } 30 | } 31 | } 32 | } 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /tests/test_fleep_vendor.py: -------------------------------------------------------------------------------- 1 | def test_fleep(): 2 | from nonebot_plugin_alconna.uniseg.utils.fleep import get 3 | 4 | raw_png = b"\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR\x00\x00\x00\x01\x00\x00\x00\x01\x08\x02\x00\x00\x00\x90wS\xde\x00\x00\x00\x0cIDAT\x08\x99c```\x00\x00\x00\x04\x00\x01\x1d\x00\x00\x00\x00IEND\xaeB`\x82" # noqa: E501 5 | raw_jpeg = b"\xff\xd8\xff\xe0\x00\x10JFIF\x00\x01\x01\x01\x00`\x00`\x00\x00\xff\xdb\x00C\x00\x02\x01\x01\x02\x01\x01\x02\x02\x02\x02\x02\x02\x02\x02\x03\x05\x03\x03\x03\x03\x03\x06\x04\x04\x03\x05\x07\x06\x07\x07\x07\x06\x07\x07\x08\t\x0b\t\x08\x08\n\x08\x07\x07\t\x0c\x0f\x0c\x0c\x0b\x0f\x0b\x07\x08\x0e\x0f\x0d\x0e\x0c\x0d\x0d\x0d\x0e\xff\xdb\x00C\xff\xd9" # noqa: E501 6 | raw_gif = b"GIF89a\x01\x00\x01\x00\x80\x01\x00\x00\x00\x00ccc\x00\x00\x00\x00\x00\x00!\xf9\x04\x01\x00\x00\x00\x00,\x00\x00\x00\x00\x01\x00\x01\x00\x00\x02\x02D\x01\x00;" # noqa: E501 7 | 8 | assert get(raw_png).mimes == ["image/png"] 9 | assert get(raw_jpeg).mimes == ["image/jpeg"] 10 | assert get(raw_gif).mimes == ["image/gif"] 11 | -------------------------------------------------------------------------------- /tests/test_tg.py: -------------------------------------------------------------------------------- 1 | from arclet.alconna import Alconna, Args 2 | 3 | 4 | def test_tg(): 5 | from nonebot.adapters.telegram.message import Entity 6 | 7 | from nonebot_plugin_alconna import Bold, Text, Underline 8 | 9 | msg = "/com" + Entity.bold("mand some_arg") + " " + Entity.underline("some_arg ") + "some_arg" 10 | 11 | alc = Alconna("/command", Args["some_arg", Bold]["some_arg1", Underline]["some_arg2", str]) 12 | 13 | res = alc.parse(msg, {"$adapter.name": "Telegram"}) 14 | assert res.matched 15 | assert isinstance(res.some_arg, Text) 16 | assert str(res.some_arg) == "some_arg" 17 | assert isinstance(res.some_arg1, Text) 18 | assert isinstance(res.some_arg2, str) 19 | 20 | msg1 = "/command " + Entity.bold("foo bar baz") 21 | 22 | alc1 = Alconna("/command", Args["foo", str]["bar", Bold]["baz", Bold]) 23 | 24 | res1 = alc1.parse(msg1, {"$adapter.name": "Telegram"}) 25 | assert res1.matched 26 | assert isinstance(res1.foo, str) 27 | assert isinstance(res1.bar, Text) 28 | assert res1["baz"].text == "baz" 29 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | 3 | on: 4 | push: 5 | tags: 6 | - v* 7 | 8 | jobs: 9 | release: 10 | runs-on: ubuntu-latest 11 | permissions: 12 | id-token: write 13 | contents: write 14 | steps: 15 | - uses: actions/checkout@v6 16 | 17 | - name: Setup Python environment 18 | uses: ./.github/actions/setup-python 19 | 20 | - name: Get Version 21 | id: version 22 | run: | 23 | echo "VERSION=$(pdm show --version)" >> $GITHUB_OUTPUT 24 | echo "TAG_VERSION=${GITHUB_REF#refs/tags/v}" >> $GITHUB_OUTPUT 25 | echo "TAG_NAME=${GITHUB_REF#refs/tags/}" >> $GITHUB_OUTPUT 26 | 27 | - name: Check Version 28 | if: steps.version.outputs.VERSION != steps.version.outputs.TAG_VERSION 29 | run: exit 1 30 | 31 | - name: Publish Package 32 | run: | 33 | pdm publish 34 | gh release upload --clobber ${{ steps.version.outputs.TAG_NAME }} dist/*.tar.gz dist/*.whl 35 | env: 36 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 37 | -------------------------------------------------------------------------------- /src/nonebot_plugin_alconna/uniseg/adapters/nonebug/exporter.py: -------------------------------------------------------------------------------- 1 | from typing import Union 2 | 3 | from nonebot.adapters import Bot, Event 4 | 5 | from nonebot_plugin_alconna.uniseg.exporter import MessageExporter, SupportAdapter, Target, export 6 | from nonebot_plugin_alconna.uniseg.fallback import FallbackMessage, FallbackSegment 7 | from nonebot_plugin_alconna.uniseg.segment import Text 8 | 9 | 10 | class NonebugMessageExporter(MessageExporter[FallbackMessage]): 11 | def get_message_type(self): 12 | return FallbackMessage 13 | 14 | def get_message_id(self, event: Event) -> str: 15 | return event.get_session_id() 16 | 17 | @classmethod 18 | def get_adapter(cls) -> SupportAdapter: 19 | return SupportAdapter.nonebug 20 | 21 | @export 22 | async def text(self, seg: Text, bot: Union[Bot, None]) -> "FallbackSegment": 23 | return FallbackSegment.text(seg.text) 24 | 25 | async def send_to(self, target: Union[Target, Event], bot: Bot, message: FallbackMessage, **kwargs): 26 | return await bot.send(target, message, **kwargs) # type: ignore 27 | -------------------------------------------------------------------------------- /tests/test_funcommand.py: -------------------------------------------------------------------------------- 1 | from typing import Literal 2 | 3 | from nonebot import get_adapter 4 | from nonebot.adapters.onebot.v11 import Adapter, Bot, Message 5 | from nonebug import App 6 | import pytest 7 | 8 | from tests.fake import fake_group_message_event_v11 9 | 10 | 11 | @pytest.mark.asyncio() 12 | async def test_funcommand(app: App): 13 | from nonebot_plugin_alconna import funcommand 14 | 15 | table = { 16 | "add": float.__add__, 17 | "sub": float.__sub__, 18 | "mul": float.__mul__, 19 | "div": float.__truediv__, 20 | } 21 | 22 | @funcommand() 23 | async def calc(op: Literal["add", "sub", "mul", "div"], a: float, b: float): 24 | """加法测试""" 25 | return f"{a} {op} {b} = {table[op](a, b)}" 26 | 27 | async with app.test_matcher(calc) as ctx: # type: ignore 28 | adapter = get_adapter(Adapter) 29 | bot = ctx.create_bot(base=Bot, adapter=adapter) 30 | event = fake_group_message_event_v11(message=Message("calc add 1.3 2.4"), user_id=123) 31 | ctx.receive_event(bot, event) 32 | ctx.should_call_send(event, "1.3 add 2.4 = 3.7") 33 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 nonebot 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /tests/test_use_origin.py: -------------------------------------------------------------------------------- 1 | from arclet.alconna import Alconna 2 | from nonebot import get_adapter 3 | from nonebot.adapters.satori import Adapter, Bot, Message 4 | from nonebug import App 5 | import pytest 6 | 7 | from tests.fake import fake_message_event_satori, fake_satori_bot_params 8 | 9 | 10 | @pytest.mark.asyncio() 11 | async def test_use_origin(app: App): 12 | from nonebot_plugin_alconna import on_alconna 13 | 14 | test_cmd = on_alconna(Alconna("log"), use_origin=False) 15 | test_cmd1 = on_alconna(Alconna("ALClog"), use_origin=True) 16 | 17 | @test_cmd.handle() 18 | async def _(): 19 | await test_cmd.send("ok") 20 | 21 | @test_cmd1.handle() 22 | async def _(): 23 | await test_cmd1.send("ok1") 24 | 25 | async with app.test_matcher([test_cmd, test_cmd1]) as ctx: 26 | adapter = get_adapter(Adapter) 27 | bot = ctx.create_bot(base=Bot, adapter=adapter, **fake_satori_bot_params()) 28 | event = fake_message_event_satori(message=Message("log"), original_message=Message("ALClog"), id=123) 29 | ctx.receive_event(bot, event) 30 | ctx.should_call_send(event, "ok") 31 | ctx.should_call_send(event, "ok1") 32 | -------------------------------------------------------------------------------- /tests/test_filehost_apply.py: -------------------------------------------------------------------------------- 1 | from arclet.alconna import Alconna 2 | from nonebot import get_adapter 3 | from nonebot.adapters.satori import Adapter, Bot, Message, MessageSegment 4 | from nonebug import App 5 | import pytest 6 | 7 | from tests.fake import fake_message_event_satori, fake_satori_bot_params 8 | 9 | 10 | @pytest.mark.asyncio() 11 | async def test_patch(app: App): 12 | from nonebot_plugin_alconna import Image, apply_filehost, on_alconna 13 | 14 | test_cmd = on_alconna(Alconna("test")) 15 | 16 | @test_cmd.handle() 17 | async def tt_h(): 18 | await test_cmd.send(Image(raw=b"PNG123", name="test.png")) 19 | 20 | dispose = apply_filehost() 21 | async with app.test_matcher(test_cmd) as ctx: 22 | adapter = get_adapter(Adapter) 23 | bot = ctx.create_bot(base=Bot, adapter=adapter, **fake_satori_bot_params()) 24 | msg = Message("test") 25 | event = fake_message_event_satori(message=msg, id=123) 26 | ctx.receive_event(bot, event) 27 | ctx.should_call_send( 28 | event, 29 | Message(MessageSegment.image("http://filehost.example.com/filehost/test.png", name="test.png")), 30 | ) 31 | 32 | dispose() 33 | -------------------------------------------------------------------------------- /src/nonebot_plugin_alconna/builtins/extensions/shortcut.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from arclet.alconna import Alconna 4 | from nonebot.internal.adapter import Bot, Event 5 | from nonebot.permission import SuperUser 6 | 7 | from nonebot_plugin_alconna import Extension, UniMessage 8 | 9 | 10 | class SuperUserShortcutExtension(Extension): 11 | """ 12 | 用于设置仅超级用户可使用内置选项 `--shortcut` 的扩展。 13 | 14 | Example: 15 | >>> from nonebot_plugin_alconna.builtins.extensions.shortcut import SuperUserShortcutExtension 16 | >>> 17 | >>> matcher = on_alconna("...", extensions=[SuperUserShortcutExtension()]) 18 | """ 19 | 20 | @property 21 | def priority(self) -> int: 22 | return 20 23 | 24 | @property 25 | def id(self) -> str: 26 | return "builtins.extensions.shortcut:SuperUserShortcutExtension" 27 | 28 | async def receive_wrapper(self, bot: Bot, event: Event, command: Alconna, receive: UniMessage) -> UniMessage: 29 | su = SuperUser() 30 | if await su(bot, event): 31 | command.namespace_config.disable_builtin_options.discard("shortcut") 32 | else: 33 | command.namespace_config.disable_builtin_options.add("shortcut") 34 | return receive 35 | 36 | 37 | __extension__ = SuperUserShortcutExtension 38 | -------------------------------------------------------------------------------- /src/nonebot_plugin_alconna/builtins/plugins/echo/__init__.py: -------------------------------------------------------------------------------- 1 | from arclet.alconna import namespace 2 | from nonebot import get_plugin_config 3 | from nonebot.plugin import PluginMetadata 4 | from nonebot.rule import to_me 5 | 6 | from nonebot_plugin_alconna import Command, __supported_adapters__ 7 | from nonebot_plugin_alconna.builtins.extensions.reply import ReplyMergeExtension 8 | 9 | from .config import Config 10 | 11 | plugin_config = get_plugin_config(Config) 12 | 13 | __plugin_meta__ = PluginMetadata( 14 | name="echo", 15 | description="重复你说的话", 16 | usage="/echo [text]", 17 | type="application", 18 | homepage="https://github.com/nonebot/plugin-alconna/blob/master/src/nonebot_plugin_alconna/builtins/plugins/echo", 19 | config=Config, 20 | supported_adapters=__supported_adapters__, 21 | ) 22 | 23 | with namespace("builtin/echo") as ns: 24 | ns.disable_builtin_options = {"shortcut", "completion"} 25 | 26 | echo = ( 27 | Command("echo <...content>", "echo 指令") 28 | .config(compact=True) 29 | .usage("重复你说的话") 30 | .action(lambda content: content) 31 | .build( 32 | use_cmd_start=True, 33 | extensions=[ReplyMergeExtension()], 34 | rule=to_me() if plugin_config.nbp_alc_echo_tome else None, 35 | ) 36 | ) 37 | -------------------------------------------------------------------------------- /tests/test_context.py: -------------------------------------------------------------------------------- 1 | from arclet.alconna import Alconna, Args, CommandMeta 2 | from nonebot import get_adapter 3 | from nonebot.adapters.satori import Adapter, Bot, Message 4 | from nonebot.adapters.satori.models import User 5 | from nonebug import App 6 | import pytest 7 | 8 | from tests.fake import fake_message_event_satori, fake_satori_bot_params 9 | 10 | 11 | @pytest.mark.asyncio() 12 | async def test_ctx(app: App): 13 | from nonebot_plugin_alconna import on_alconna 14 | 15 | test_cmd = on_alconna( 16 | Alconna("test", Args["userid", str]["selfid", str], meta=CommandMeta(context_style="parentheses")) 17 | ) 18 | 19 | @test_cmd.handle() 20 | async def tt_h(userid: str, selfid: str, ctx: dict): 21 | assert ctx["event"].get_user_id() == userid 22 | assert ctx["bot.self_id"] == selfid 23 | await test_cmd.send(f"ok\n{userid}") 24 | 25 | async with app.test_matcher(test_cmd) as ctx: 26 | adapter = get_adapter(Adapter) 27 | bot = ctx.create_bot(base=Bot, adapter=adapter, **fake_satori_bot_params()) 28 | msg = Message("test $(event.get_user_id()) $(bot.self_id)") 29 | event = fake_message_event_satori(message=msg, id=123, user=User(id="456", name="test")) 30 | ctx.receive_event(bot, event) 31 | ctx.should_call_send(event, "ok\n456") 32 | -------------------------------------------------------------------------------- /src/nonebot_plugin_alconna/uniseg/adapters/qq/target.py: -------------------------------------------------------------------------------- 1 | from typing import TYPE_CHECKING, Union 2 | 3 | from nonebot.adapters import Bot 4 | from nonebot.adapters.qq.bot import Bot as QQBot 5 | 6 | from nonebot_plugin_alconna.uniseg.constraint import SupportAdapter 7 | from nonebot_plugin_alconna.uniseg.target import Target, TargetFetcher 8 | 9 | 10 | class QQTargetFetcher(TargetFetcher): 11 | @classmethod 12 | def get_adapter(cls) -> SupportAdapter: 13 | return SupportAdapter.qq 14 | 15 | async def fetch(self, bot: Bot, target: Union[Target, None] = None): 16 | if TYPE_CHECKING: 17 | assert isinstance(bot, QQBot) 18 | if target and not target.channel: 19 | return 20 | if target and target.parent_id: 21 | guilds = [await bot.get_guild(guild_id=target.parent_id)] 22 | else: 23 | guilds = await bot.guilds() 24 | for guild in guilds: 25 | channels = await bot.get_channels(guild_id=guild.id) 26 | for channel in channels: 27 | yield Target( 28 | str(channel.id), 29 | str(guild.id), 30 | channel=True, 31 | adapter=self.get_adapter(), 32 | self_id=bot.self_id, 33 | extra={"channel_type": channel.type}, 34 | ) 35 | -------------------------------------------------------------------------------- /tests/test_saa_patch.py: -------------------------------------------------------------------------------- 1 | from arclet.alconna import Alconna, Args 2 | from nonebot import get_adapter, require 3 | from nonebot.adapters.satori import Adapter, Bot, MessageSegment 4 | from nonebug import App 5 | import pytest 6 | 7 | from tests.fake import fake_message_event_satori, fake_satori_bot_params 8 | 9 | 10 | @pytest.mark.asyncio() 11 | async def test_patch(app: App): 12 | require("nonebot_plugin_saa") 13 | from nonebot_plugin_saa import Mention, MessageFactory, Text 14 | 15 | from nonebot_plugin_alconna import At, on_alconna, patch_saa 16 | 17 | test_cmd = on_alconna(Alconna("test", Args["target", At])) 18 | 19 | @test_cmd.handle() 20 | async def tt_h(target: At): 21 | await MessageFactory( 22 | [ 23 | Text("ok\n"), 24 | Mention(target.target), 25 | ] 26 | ).send() 27 | 28 | dispose = patch_saa() 29 | 30 | async with app.test_matcher(test_cmd) as ctx: 31 | adapter = get_adapter(Adapter) 32 | bot = ctx.create_bot(base=Bot, adapter=adapter, **fake_satori_bot_params()) 33 | msg = "test" + MessageSegment.at("234") 34 | event = fake_message_event_satori(message=msg, id=123) 35 | ctx.receive_event(bot, event) 36 | ctx.should_call_send(event, MessageSegment.text("ok\n") + MessageSegment.at("234")) # type: ignore 37 | 38 | dispose() 39 | -------------------------------------------------------------------------------- /src/nonebot_plugin_alconna/uniseg/adapters/mirai/target.py: -------------------------------------------------------------------------------- 1 | from typing import TYPE_CHECKING, Union 2 | 3 | from nonebot.adapters import Bot 4 | from nonebot.adapters.mirai.bot import Bot as MiraiBot 5 | 6 | from nonebot_plugin_alconna.uniseg.constraint import SupportAdapter 7 | from nonebot_plugin_alconna.uniseg.target import Target, TargetFetcher 8 | 9 | 10 | class MiraiTargetFetcher(TargetFetcher): 11 | @classmethod 12 | def get_adapter(cls) -> SupportAdapter: 13 | return SupportAdapter.mirai 14 | 15 | async def fetch(self, bot: Bot, target: Union[Target, None] = None): 16 | if TYPE_CHECKING: 17 | assert isinstance(bot, MiraiBot) 18 | if target and target.channel: 19 | return 20 | if not target or not target.private: 21 | groups = await bot.get_group_list() 22 | for group in groups: 23 | yield Target( 24 | str(group.id), 25 | adapter=self.get_adapter(), 26 | self_id=bot.self_id, 27 | ) 28 | if not target or target.private: 29 | friends = await bot.get_friend_list() 30 | for friend in friends: 31 | yield Target( 32 | str(friend.id), 33 | private=True, 34 | adapter=self.get_adapter(), 35 | self_id=bot.self_id, 36 | ) 37 | -------------------------------------------------------------------------------- /src/nonebot_plugin_alconna/uniseg/adapters/red/target.py: -------------------------------------------------------------------------------- 1 | from typing import TYPE_CHECKING, Union 2 | 3 | from nonebot.adapters import Bot 4 | from nonebot.adapters.red.bot import Bot as RedBot 5 | 6 | from nonebot_plugin_alconna.uniseg.constraint import SupportAdapter 7 | from nonebot_plugin_alconna.uniseg.target import Target, TargetFetcher 8 | 9 | 10 | class RedTargetFetcher(TargetFetcher): 11 | @classmethod 12 | def get_adapter(cls) -> SupportAdapter: 13 | return SupportAdapter.red 14 | 15 | async def fetch(self, bot: Bot, target: Union[Target, None] = None): 16 | if TYPE_CHECKING: 17 | assert isinstance(bot, RedBot) 18 | if target and target.channel: 19 | return 20 | if not target or not target.private: 21 | groups = await bot.get_groups() 22 | for group in groups: 23 | yield Target( 24 | str(group.groupCode), 25 | adapter=self.get_adapter(), 26 | self_id=bot.self_id, 27 | ) 28 | if not target or target.private: 29 | friends = await bot.get_friends() 30 | for friend in friends: 31 | yield Target( 32 | str(friend.uin or friend.uid), 33 | private=True, 34 | adapter=self.get_adapter(), 35 | self_id=bot.self_id, 36 | ) 37 | -------------------------------------------------------------------------------- /src/nonebot_plugin_alconna/uniseg/adapters/milky/target.py: -------------------------------------------------------------------------------- 1 | from typing import TYPE_CHECKING, Union 2 | 3 | from nonebot.adapters import Bot 4 | from nonebot.adapters.milky.bot import Bot as MilkyBot 5 | 6 | from nonebot_plugin_alconna.uniseg.constraint import SupportAdapter 7 | from nonebot_plugin_alconna.uniseg.target import Target, TargetFetcher 8 | 9 | 10 | class MilkyTargetFetcher(TargetFetcher): 11 | @classmethod 12 | def get_adapter(cls) -> SupportAdapter: 13 | return SupportAdapter.milky 14 | 15 | async def fetch(self, bot: Bot, target: Union[Target, None] = None): 16 | if TYPE_CHECKING: 17 | assert isinstance(bot, MilkyBot) 18 | if target and target.channel: 19 | return 20 | if not target or not target.private: 21 | groups = await bot.get_group_list() 22 | for group in groups: 23 | yield Target( 24 | str(group.group_id), 25 | adapter=self.get_adapter(), 26 | self_id=bot.self_id, 27 | ) 28 | if not target or target.private: 29 | friends = await bot.get_friend_list() 30 | for friend in friends: 31 | yield Target( 32 | str(friend.user_id), 33 | private=True, 34 | adapter=self.get_adapter(), 35 | self_id=bot.self_id, 36 | ) 37 | -------------------------------------------------------------------------------- /tests/test_typings.py: -------------------------------------------------------------------------------- 1 | from arclet.alconna import Alconna, Args 2 | 3 | 4 | def test_v11(): 5 | from nonebot.adapters.onebot.v11 import Message, MessageSegment 6 | 7 | from nonebot_plugin_alconna import AtID 8 | 9 | msg = Message("Hello!11") + MessageSegment.at(123) 10 | msg1 = Message("Hello!11 @123") 11 | msg2 = Message("Hello!11 @abcd") 12 | 13 | ctx = {"$adapter.name": "OneBot V11"} 14 | alc = Alconna("Hello!11", Args["target", AtID]) 15 | assert alc.parse(msg, ctx).matched 16 | assert alc.parse(msg1, ctx).matched 17 | assert alc.parse(msg2, ctx).matched 18 | assert not alc.parse(Message("Hello!11 123"), ctx).matched 19 | assert not alc.parse(Message("Hello!11") + MessageSegment.face(123), ctx).matched 20 | 21 | 22 | def test_v12(): 23 | from nonebot.adapters.onebot.v12 import Message, MessageSegment 24 | 25 | from nonebot_plugin_alconna import AtID 26 | 27 | msg = Message("Hello!12") + MessageSegment.mention("123") 28 | msg1 = Message("Hello!12 @123") 29 | msg2 = Message("Hello!12 @abcd") 30 | 31 | ctx = {"$adapter.name": "OneBot V12"} 32 | alc = Alconna("Hello!12", Args["target", AtID]) 33 | assert alc.parse(msg, ctx).matched 34 | assert alc.parse(msg1, ctx).matched 35 | assert alc.parse(msg2, ctx).matched 36 | assert not alc.parse(Message("Hello!12 123"), ctx).matched 37 | assert not alc.parse(Message("Hello!12") + MessageSegment.image("1.png"), ctx).matched 38 | -------------------------------------------------------------------------------- /src/nonebot_plugin_alconna/uniseg/adapters/dodo/target.py: -------------------------------------------------------------------------------- 1 | from typing import TYPE_CHECKING, Union 2 | 3 | from nonebot.adapters import Bot 4 | from nonebot.adapters.dodo.bot import Bot as DodoBot 5 | 6 | from nonebot_plugin_alconna.uniseg.constraint import SupportAdapter 7 | from nonebot_plugin_alconna.uniseg.target import Target, TargetFetcher 8 | 9 | 10 | class DodoTargetFetcher(TargetFetcher): 11 | @classmethod 12 | def get_adapter(cls) -> SupportAdapter: 13 | return SupportAdapter.dodo 14 | 15 | async def fetch(self, bot: Bot, target: Union[Target, None] = None): 16 | if TYPE_CHECKING: 17 | assert isinstance(bot, DodoBot) 18 | if target and not target.channel: 19 | return 20 | if target and target.parent_id: 21 | islands = [await bot.get_island_info(island_source_id=target.parent_id)] 22 | else: 23 | islands = await bot.get_island_list() 24 | for island in islands: 25 | channels = await bot.get_channel_list(island_source_id=island.island_source_id) 26 | for channel in channels: 27 | yield Target( 28 | channel.channel_id, 29 | island.island_source_id, 30 | channel=True, 31 | adapter=self.get_adapter(), 32 | self_id=bot.self_id, 33 | extra={"channel_type": channel.channel_type}, 34 | ) 35 | -------------------------------------------------------------------------------- /src/nonebot_plugin_alconna/uniseg/adapters/onebot11/target.py: -------------------------------------------------------------------------------- 1 | from typing import TYPE_CHECKING, Union 2 | 3 | from nonebot.adapters import Bot 4 | from nonebot.adapters.onebot.v11.bot import Bot as Onebot11Bot 5 | 6 | from nonebot_plugin_alconna.uniseg.constraint import SupportAdapter 7 | from nonebot_plugin_alconna.uniseg.target import Target, TargetFetcher 8 | 9 | 10 | class Onebot11TargetFetcher(TargetFetcher): 11 | @classmethod 12 | def get_adapter(cls) -> SupportAdapter: 13 | return SupportAdapter.onebot11 14 | 15 | async def fetch(self, bot: Bot, target: Union[Target, None] = None): 16 | if TYPE_CHECKING: 17 | assert isinstance(bot, Onebot11Bot) 18 | if target and target.channel: 19 | return 20 | if not target or not target.private: 21 | groups = await bot.get_group_list() 22 | for group in groups: 23 | yield Target( 24 | str(group["group_id"]), 25 | adapter=self.get_adapter(), 26 | self_id=bot.self_id, 27 | ) 28 | if not target or target.private: 29 | friends = await bot.get_friend_list() 30 | for friend in friends: 31 | yield Target( 32 | str(friend["user_id"]), 33 | private=True, 34 | adapter=self.get_adapter(), 35 | self_id=bot.self_id, 36 | ) 37 | -------------------------------------------------------------------------------- /tests/test_unmatch.py: -------------------------------------------------------------------------------- 1 | from arclet.alconna import Alconna, Args 2 | from nonebot import get_adapter 3 | from nonebot.adapters.onebot.v11 import Adapter, Bot, Message 4 | from nonebug import App 5 | import pytest 6 | 7 | from tests.fake import fake_group_message_event_v11 8 | 9 | 10 | @pytest.mark.asyncio() 11 | async def test_unmatch(app: App): 12 | from nonebot_plugin_alconna import Match, UniMessage, on_alconna 13 | 14 | test_cmd = on_alconna(Alconna("test", Args["target", int]), skip_for_unmatch=False, auto_send_output=True) 15 | 16 | @test_cmd.handle() 17 | async def tt_h(target: Match[int]): 18 | await test_cmd.send(UniMessage(["ok\n", str(target.result)])) 19 | 20 | async with app.test_matcher(test_cmd) as ctx: 21 | adapter = get_adapter(Adapter) 22 | bot = ctx.create_bot(base=Bot, adapter=adapter) 23 | 24 | event = fake_group_message_event_v11(message=Message("tes 1234"), user_id=123) 25 | ctx.receive_event(bot, event) 26 | ctx.should_not_pass_rule() 27 | 28 | event = fake_group_message_event_v11(message=Message("test 1234"), user_id=123) 29 | ctx.receive_event(bot, event) 30 | ctx.should_call_send(event, Message("ok\n1234")) 31 | 32 | event = fake_group_message_event_v11(message=Message("test abcd"), user_id=123) 33 | ctx.receive_event(bot, event) 34 | ctx.should_not_pass_rule() 35 | ctx.should_call_send(event, "参数 'abcd' 不正确, 其应该符合 'int'", bot=bot) 36 | -------------------------------------------------------------------------------- /tests/test_kook.py: -------------------------------------------------------------------------------- 1 | from arclet.alconna import Alconna, Args 2 | 3 | 4 | def test_kook(): 5 | from nonebot.adapters.kaiheila.message import Message, MessageSegment 6 | 7 | from nonebot_plugin_alconna import At, Text 8 | 9 | msg = ( 10 | Message() 11 | + MessageSegment.text("/command ") 12 | + MessageSegment.mention("123456") 13 | + MessageSegment.KMarkdown("12345678") 14 | ) 15 | 16 | alc = Alconna("/command", Args["some_arg", At]["some_arg1", Text]) 17 | ctx = {"$adapter.name": "Kaiheila"} 18 | res = alc.parse(msg, ctx) 19 | assert res.matched 20 | assert res["some_arg"].origin.type == "mention" 21 | assert res["some_arg"].origin.data["user_id"] == "123456" 22 | 23 | msg1 = Message([MessageSegment.text("/command1 "), MessageSegment.KMarkdown("[(met)123456(met)](42345) 12345678")]) 24 | 25 | alc1 = Alconna("/command1", Args["some_arg", str]["some_arg1", str]) 26 | 27 | res1 = alc1.parse(msg1, ctx) 28 | assert res1.matched 29 | assert res1.some_arg == "[(met)123456(met)](42345)" 30 | assert res1.some_arg1 == "12345678" 31 | 32 | msg2 = Message(MessageSegment.KMarkdown("/foo 1:2:3")) 33 | alc2 = Alconna("/foo", Args["some_arg", str]) 34 | res2 = alc2.parse(msg2, ctx) 35 | assert res2.matched 36 | assert res2.some_arg == "1:2:3" 37 | assert alc2.parse(Message(MessageSegment.text("/foo :aaa:")), ctx).matched 38 | assert alc2.parse(Message([MessageSegment.text("/foo "), MessageSegment.KMarkdown(":aaa:")]), ctx).matched 39 | -------------------------------------------------------------------------------- /src/nonebot_plugin_alconna/uniseg/adapters/tailchat/target.py: -------------------------------------------------------------------------------- 1 | from typing import TYPE_CHECKING, Union 2 | 3 | from nonebot.adapters import Bot 4 | from nonebot_adapter_tailchat.bot import Bot as TailChatBot 5 | 6 | from nonebot_plugin_alconna.uniseg.constraint import SupportAdapter 7 | from nonebot_plugin_alconna.uniseg.target import Target, TargetFetcher 8 | 9 | 10 | class TailChatTargetFetcher(TargetFetcher): 11 | @classmethod 12 | def get_adapter(cls) -> SupportAdapter: 13 | return SupportAdapter.tail_chat 14 | 15 | async def fetch(self, bot: Bot, target: Union[Target, None] = None): 16 | if TYPE_CHECKING: 17 | assert isinstance(bot, TailChatBot) 18 | if not target or target.private: 19 | friends = await bot.getAllConverse() 20 | for friend in friends: 21 | yield Target( 22 | friend, 23 | private=True, 24 | channel=True, 25 | adapter=self.get_adapter(), 26 | self_id=bot.self_id, 27 | ) 28 | if not target or not target.private: 29 | groups = await bot.getUserGroups() 30 | for group in groups: 31 | for panel in group.panels: 32 | yield Target( 33 | panel.id, 34 | group.id, 35 | channel=True, 36 | adapter=self.get_adapter(), 37 | self_id=bot.self_id, 38 | ) 39 | -------------------------------------------------------------------------------- /src/nonebot_plugin_alconna/uniseg/adapters/discord/target.py: -------------------------------------------------------------------------------- 1 | from typing import TYPE_CHECKING, Union 2 | 3 | from nonebot.adapters import Bot 4 | from nonebot.adapters.discord.api.types import ChannelType 5 | from nonebot.adapters.discord.bot import Bot as DiscordBot 6 | 7 | from nonebot_plugin_alconna.uniseg.constraint import SupportAdapter 8 | from nonebot_plugin_alconna.uniseg.target import Target, TargetFetcher 9 | 10 | 11 | class DiscordTargetFetcher(TargetFetcher): 12 | @classmethod 13 | def get_adapter(cls) -> SupportAdapter: 14 | return SupportAdapter.discord 15 | 16 | async def fetch(self, bot: Bot, target: Union[Target, None] = None): 17 | if TYPE_CHECKING: 18 | assert isinstance(bot, DiscordBot) 19 | if target and not target.channel: 20 | return 21 | if target and target.parent_id: 22 | guilds = [await bot.get_guild(guild_id=int(target.parent_id))] 23 | else: 24 | guilds = await bot.get_current_user_guilds() 25 | for guild in guilds: 26 | channels = await bot.get_guild_channels(guild_id=guild.id) 27 | for channel in channels: 28 | yield Target( 29 | str(channel.id), 30 | str(guild.id), 31 | channel=True, 32 | private=channel.type == ChannelType.DM, 33 | adapter=self.get_adapter(), 34 | self_id=bot.self_id, 35 | extra={"channel_type": channel.type}, 36 | ) 37 | -------------------------------------------------------------------------------- /src/nonebot_plugin_alconna/uniseg/i18n/.lang.schema.json: -------------------------------------------------------------------------------- 1 | { 2 | "title": "Lang Schema", 3 | "description": "Schema for lang file", 4 | "type": "object", 5 | "minProperties": 2, 6 | "maxProperties": 2, 7 | "properties": { 8 | "nbp-uniseg": { 9 | "title": "Nbp-uniseg", 10 | "description": "Scope 'nbp-uniseg' of lang item", 11 | "type": "object", 12 | "additionalProperties": false, 13 | "properties": { 14 | "invalid_segment": { 15 | "title": "invalid_segment", 16 | "description": "value of lang item type 'invalid_segment'", 17 | "type": "string" 18 | }, 19 | "failed_segment": { 20 | "title": "failed_segment", 21 | "description": "value of lang item type 'failed_segment'", 22 | "type": "string" 23 | }, 24 | "failed": { 25 | "title": "failed", 26 | "description": "value of lang item type 'failed'", 27 | "type": "string" 28 | }, 29 | "bot_missing": { 30 | "title": "bot_missing", 31 | "description": "value of lang item type 'bot_missing'", 32 | "type": "string" 33 | }, 34 | "event_missing": { 35 | "title": "event_missing", 36 | "description": "value of lang item type 'event_missing'", 37 | "type": "string" 38 | }, 39 | "unsupported": { 40 | "title": "unsupported", 41 | "description": "value of lang item type 'unsupported'", 42 | "type": "string" 43 | } 44 | } 45 | } 46 | } 47 | } -------------------------------------------------------------------------------- /src/nonebot_plugin_alconna/builtins/plugins/with/extension.py: -------------------------------------------------------------------------------- 1 | import random 2 | from typing import Any, Callable, ClassVar, Optional 3 | 4 | from arclet.alconna import Alconna 5 | from nonebot.internal.adapter import Bot, Event 6 | from tarina import LRU 7 | 8 | from nonebot_plugin_alconna import Extension, Target, UniMessage, get_message_id, get_target 9 | 10 | 11 | class PrefixAppendExtension(Extension): 12 | """用于自动为传入消息增加一个指定前缀""" 13 | 14 | @property 15 | def priority(self) -> int: 16 | return 12 17 | 18 | @property 19 | def id(self) -> str: 20 | return "builtins.plugins.with.extension:PrefixAppendExtension" 21 | 22 | supplier: ClassVar[Callable[[Any, Target], Optional[str]]] 23 | prefixes: list[str] 24 | command: str 25 | sep: str 26 | cache: "LRU[str, UniMessage]" = LRU(20) 27 | 28 | def post_init(self, alc: Alconna) -> None: 29 | self.prefixes = [pf for pf in alc.prefixes if isinstance(pf, str)] 30 | self.command = alc.header_display 31 | self.sep = alc.separators[0] 32 | 33 | async def receive_wrapper(self, bot: Bot, event: Event, command: Alconna, receive: UniMessage) -> UniMessage: 34 | msg_id = get_message_id(event, bot) 35 | if msg_id in self.cache: 36 | return self.cache[msg_id] 37 | target = get_target(event, bot) 38 | prefix = self.supplier(target) 39 | if not prefix or not command.header_display.endswith(prefix): 40 | return receive 41 | res = UniMessage.text(random.choice(self.prefixes) + prefix + self.sep) + receive 42 | self.cache[msg_id] = res 43 | return res 44 | -------------------------------------------------------------------------------- /src/nonebot_plugin_alconna/uniseg/utils/matcher.py: -------------------------------------------------------------------------------- 1 | from typing import Any, Union 2 | 3 | from nonebot.adapters import Message, MessageSegment, MessageTemplate 4 | from nonebot.internal.matcher import current_bot, current_event, current_matcher 5 | from nonebot.matcher import Matcher 6 | 7 | from nonebot_plugin_alconna.uniseg.fallback import FallbackStrategy 8 | from nonebot_plugin_alconna.uniseg.message import UniMessage 9 | 10 | 11 | async def send( 12 | cls, 13 | message: Union[str, Message, MessageSegment, MessageTemplate], 14 | **kwargs: Any, 15 | ): 16 | """发送一条消息给当前交互用户 17 | 18 | 参数: 19 | message: 消息内容 20 | kwargs: {ref}`nonebot.adapters.Bot.send` 的参数, 21 | 请参考对应 adapter 的 bot 对象 api 22 | """ 23 | bot = current_bot.get() 24 | event = current_event.get() 25 | state = current_matcher.get().state 26 | if isinstance(message, MessageTemplate): 27 | _message = message.format(**state) 28 | else: 29 | _message = message 30 | if isinstance(_message, Message): 31 | _unimsg = UniMessage.of(_message, bot=bot) 32 | elif isinstance(_message, MessageSegment): 33 | _unimsg = UniMessage.of(_message.get_message_class()(_message), bot=bot) 34 | else: 35 | _unimsg = UniMessage.text(_message) 36 | _send = await _unimsg.export(bot=bot, fallback=FallbackStrategy.to_text) 37 | return await bot.send(event=event, message=_send, **kwargs) 38 | 39 | 40 | _OLD_SEND = Matcher.send 41 | 42 | 43 | def patch(): 44 | 45 | Matcher.send = classmethod(send) # type: ignore 46 | 47 | def dispose(): 48 | Matcher.send = classmethod(_OLD_SEND) # type: ignore 49 | 50 | return dispose 51 | -------------------------------------------------------------------------------- /src/nonebot_plugin_alconna/config.py: -------------------------------------------------------------------------------- 1 | from typing import Literal, Optional 2 | 3 | from pydantic import BaseModel, Field 4 | 5 | from .model import CompConfig 6 | 7 | 8 | class Config(BaseModel): 9 | """Plugin Config Here""" 10 | 11 | alconna_auto_send_output: Optional[bool] = None 12 | """是否全局启用输出信息自动发送""" 13 | 14 | alconna_use_command_start: bool = False 15 | """是否将 COMMAND_START 作为全局命令前缀""" 16 | 17 | alconna_global_completion: Optional[CompConfig] = Field(None, strict=True) 18 | """全局的补全会话配置 (不代表全局启用补全会话)""" 19 | 20 | alconna_use_origin: bool = False 21 | """是否全局使用原始消息 (即未经过 to_me 等处理的)""" 22 | 23 | alconna_use_command_sep: bool = False 24 | """是否将 COMMAND_SEP 作为全局命令分隔符""" 25 | 26 | alconna_global_extensions: list[str] = Field(default_factory=list) 27 | """全局加载的扩展, 路径以 . 分隔, 如 foo.bar.baz:DemoExtension""" 28 | 29 | alconna_context_style: Optional[Literal["bracket", "parentheses"]] = Field(default=None) 30 | """全局命令上下文插值的风格,None 为关闭,bracket 为 {...},parentheses 为 $(...)""" 31 | 32 | alconna_enable_saa_patch: bool = False 33 | """是否启用 SAA 补丁""" 34 | 35 | alconna_apply_filehost: bool = False 36 | """是否启用文件托管""" 37 | 38 | alconna_apply_fetch_targets: bool = False 39 | """是否启动时拉取一次发送对象列表""" 40 | 41 | alconna_builtin_plugins: set[str] = Field(default_factory=set) 42 | """需要加载的alc内置插件集合""" 43 | 44 | alconna_conflict_resolver: Literal["raise", "default", "ignore", "replace"] = Field(default="default") 45 | """命令冲突解决策略,default 为保留两个命令,raise 为抛出异常,ignore 为忽略新命令,replace 为替换旧命令""" 46 | 47 | alconna_response_self: bool = False 48 | """是否响应自身发送的消息""" 49 | 50 | alconna_cache_message: bool = True 51 | """是否缓存已解析的消息""" 52 | -------------------------------------------------------------------------------- /src/nonebot_plugin_alconna/uniseg/fallback.py: -------------------------------------------------------------------------------- 1 | from enum import Enum 2 | from typing import Final 3 | 4 | from nonebot.adapters import Message, MessageSegment 5 | 6 | 7 | class FallbackStrategy(str, Enum): 8 | ignore = "ignore" 9 | """将丢弃未转换元素""" 10 | 11 | to_text = "to_text" 12 | """将未转换元素作为文本元素""" 13 | 14 | rollback = "rollback" 15 | """从未转换元素的子元素中提取可能的可发送元素""" 16 | 17 | forbid = "forbid" 18 | """禁止未转换元素""" 19 | 20 | auto = "auto" 21 | """自动选择策略""" 22 | 23 | @classmethod 24 | def _missing_(cls, value): 25 | if value == "text": 26 | return cls.to_text 27 | return cls.auto 28 | 29 | 30 | class FallbackSegment(MessageSegment["FallbackMessage"]): 31 | @classmethod 32 | def get_message_class(cls): 33 | return FallbackMessage 34 | 35 | def __str__(self) -> str: 36 | if self.type == "text": 37 | return self.data["text"] 38 | return f"[{self.type}][{self.data}]" 39 | 40 | def is_text(self) -> bool: 41 | return self.type == "text" 42 | 43 | @staticmethod 44 | def text(text: str) -> "FallbackSegment": 45 | return FallbackSegment("text", {"text": text}) 46 | 47 | 48 | class FallbackMessage(Message[FallbackSegment]): 49 | @classmethod 50 | def get_segment_class(cls): 51 | return FallbackSegment 52 | 53 | @staticmethod 54 | def _construct(msg: str): 55 | yield FallbackSegment.text(msg) 56 | 57 | 58 | IGNORE: Final[FallbackStrategy] = FallbackStrategy.ignore 59 | TO_TEXT: Final[FallbackStrategy] = FallbackStrategy.to_text 60 | ROLLBACK: Final[FallbackStrategy] = FallbackStrategy.rollback 61 | FORBID: Final[FallbackStrategy] = FallbackStrategy.forbid 62 | AUTO: Final[FallbackStrategy] = FallbackStrategy.auto 63 | -------------------------------------------------------------------------------- /src/nonebot_plugin_alconna/i18n/.template.schema.json: -------------------------------------------------------------------------------- 1 | { 2 | "title": "Template", 3 | "description": "Template for lang items to generate schema for lang files", 4 | "type": "object", 5 | "properties": { 6 | "scopes": { 7 | "title": "Scopes", 8 | "description": "All scopes of lang items", 9 | "type": "array", 10 | "uniqueItems": true, 11 | "items": { 12 | "title": "Scope", 13 | "description": "First level of all lang items", 14 | "type": "object", 15 | "properties": { 16 | "scope": { 17 | "type": "string", 18 | "description": "Scope name" 19 | }, 20 | "types": { 21 | "type": "array", 22 | "description": "All types of lang items", 23 | "uniqueItems": true, 24 | "items": { 25 | "oneOf": [ 26 | { 27 | "type": "string", 28 | "description": "Value of lang item" 29 | }, 30 | { 31 | "type": "object", 32 | "properties": { 33 | "subtype": { 34 | "type": "string", 35 | "description": "Subtype name of lang item" 36 | }, 37 | "types": { 38 | "type": "array", 39 | "description": "All subtypes of lang items", 40 | "uniqueItems": true, 41 | "items": { 42 | "$ref": "#/properties/scopes/items/properties/types/items" 43 | } 44 | } 45 | } 46 | } 47 | ] 48 | } 49 | } 50 | } 51 | } 52 | } 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /src/nonebot_plugin_alconna/uniseg/adapters/kritor/target.py: -------------------------------------------------------------------------------- 1 | from typing import TYPE_CHECKING, Union 2 | 3 | from nonebot.adapters import Bot 4 | from nonebot.adapters.kritor.bot import Bot as KritorBot 5 | 6 | from nonebot_plugin_alconna.uniseg.constraint import SupportAdapter 7 | from nonebot_plugin_alconna.uniseg.target import Target, TargetFetcher 8 | 9 | 10 | class KritorTargetFetcher(TargetFetcher): 11 | @classmethod 12 | def get_adapter(cls) -> SupportAdapter: 13 | return SupportAdapter.kritor 14 | 15 | async def fetch(self, bot: Bot, target: Union[Target, None] = None): 16 | if TYPE_CHECKING: 17 | assert isinstance(bot, KritorBot) 18 | if not target or not target.private: 19 | groups = await bot.get_group_list() 20 | for group in groups: 21 | yield Target( 22 | str(group.group_id), 23 | adapter=self.get_adapter(), 24 | self_id=bot.self_id, 25 | ) 26 | if not target or target.private: 27 | friends = await bot.get_friend_list(refresh=True) 28 | for friend in friends: 29 | yield Target( 30 | str(friend.uin or friend.uid), 31 | private=True, 32 | adapter=self.get_adapter(), 33 | self_id=bot.self_id, 34 | ) 35 | if target and target.channel: 36 | channels = await bot.get_guild_channel_list(guild_id=str(target.parent_id), refresh=True) 37 | for channel in channels: 38 | yield Target( 39 | str(channel.channel_id), 40 | parent_id=str(channel.guild_id), 41 | channel=True, 42 | adapter=self.get_adapter(), 43 | self_id=bot.self_id, 44 | ) 45 | -------------------------------------------------------------------------------- /.devcontainer/devcontainer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Default Linux Universal", 3 | "image": "mcr.microsoft.com/devcontainers/universal:2-linux", 4 | "features": { 5 | "ghcr.io/devcontainers-extra/features/pdm:2": {} 6 | }, 7 | "postCreateCommand": "pdm config venv.in_project true && pdm sync -G:all && pdm run pre-commit install", 8 | "customizations": { 9 | "vscode": { 10 | "settings": { 11 | "python.analysis.diagnosticMode": "workspace", 12 | "python.analysis.typeCheckingMode": "basic", 13 | "ruff.organizeImports": false, 14 | "[python]": { 15 | "editor.defaultFormatter": "ms-python.black-formatter", 16 | "editor.codeActionsOnSave": { 17 | "source.fixAll.ruff": true, 18 | "source.organizeImports": true 19 | } 20 | }, 21 | "[javascript]": { 22 | "editor.defaultFormatter": "esbenp.prettier-vscode" 23 | }, 24 | "[html]": { 25 | "editor.defaultFormatter": "esbenp.prettier-vscode" 26 | }, 27 | "[typescript]": { 28 | "editor.defaultFormatter": "esbenp.prettier-vscode" 29 | }, 30 | "[javascriptreact]": { 31 | "editor.defaultFormatter": "esbenp.prettier-vscode" 32 | }, 33 | "[typescriptreact]": { 34 | "editor.defaultFormatter": "esbenp.prettier-vscode" 35 | }, 36 | "files.exclude": { 37 | "**/__pycache__": true 38 | }, 39 | "files.watcherExclude": { 40 | "**/target/**": true, 41 | "**/__pycache__": true 42 | } 43 | }, 44 | "extensions": [ 45 | "ms-python.python", 46 | "ms-python.vscode-pylance", 47 | "ms-python.isort", 48 | "ms-python.black-formatter", 49 | "charliermarsh.ruff", 50 | "EditorConfig.EditorConfig", 51 | "esbenp.prettier-vscode" 52 | ] 53 | } 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /src/nonebot_plugin_alconna/uniseg/adapters/minecraft/builder.py: -------------------------------------------------------------------------------- 1 | from nonebot.adapters.minecraft.message import MessageSegment 2 | from nonebot.adapters.minecraft.model import TextColor 3 | 4 | from nonebot_plugin_alconna.uniseg.builder import MessageBuilder, build 5 | from nonebot_plugin_alconna.uniseg.constraint import SupportAdapter 6 | from nonebot_plugin_alconna.uniseg.segment import Text 7 | 8 | 9 | class MinecraftMessageBuilder(MessageBuilder): 10 | @classmethod 11 | def get_adapter(cls) -> SupportAdapter: 12 | return SupportAdapter.minecraft 13 | 14 | def get_styles(self, data: dict): 15 | styles = [] 16 | if "color" in data and data["color"] and data["color"] != TextColor.WHITE: 17 | styles.append(data["color"]) 18 | if data.get("bold"): 19 | styles.append("bold") 20 | if data.get("italic"): 21 | styles.append("italic") 22 | if data.get("underlined"): 23 | styles.append("underlined") 24 | if data.get("strikethrough"): 25 | styles.append("strikethrough") 26 | if data.get("obfuscated"): 27 | styles.append("obfuscated") 28 | return styles 29 | 30 | @build("text") 31 | def text(self, seg: MessageSegment): 32 | text = Text(seg.data["text"]) 33 | _len = len(seg.data["text"]) 34 | text.mark(0, _len, *self.get_styles(seg.data)) 35 | return text 36 | 37 | @build("title") 38 | def title(self, seg: MessageSegment): 39 | text = Text(seg.data["title"]["text"]) 40 | _len = len(seg.data["title"]["text"]) 41 | return text.mark(0, _len, "title", *self.get_styles(seg.data["title"])) 42 | 43 | @build("actionbar") 44 | def actionbar(self, seg: MessageSegment): 45 | text = Text(seg.data["text"]) 46 | _len = len(seg.data["text"]) 47 | text.mark(0, _len, "actionbar", *self.get_styles(seg.data)) 48 | return text 49 | -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | import os 2 | from typing import Any 3 | 4 | import nonebot 5 | from nonebot.adapters.discord import Adapter as DiscordAdapter 6 | from nonebot.adapters.onebot.v11 import Adapter as Onebot11Adapter 7 | from nonebot.adapters.onebot.v12 import Adapter as Onebot12Adapter 8 | 9 | # 导入适配器 10 | from nonebot.adapters.qq import Adapter as QQAdapter 11 | from nonebot.adapters.satori import Adapter as SatoriAdapter 12 | from nonebug import NONEBOT_INIT_KWARGS 13 | import pytest 14 | from pytest_asyncio import is_async_test 15 | 16 | 17 | def pytest_configure(config: pytest.Config): 18 | config.stash[NONEBOT_INIT_KWARGS] = { 19 | "driver": "~fastapi+~httpx+~websockets", 20 | "log_level": "DEBUG", 21 | "host": "127.0.0.1", 22 | "port": "9555", 23 | "filehost_host_override": "http://filehost.example.com", 24 | } 25 | os.environ["PLUGIN_ALCONNA_TESTENV"] = "1" 26 | 27 | 28 | def pytest_collection_modifyitems(items: Any) -> None: 29 | """ 30 | Make all tests run on the same event loop. 31 | 32 | See: https://pytest-asyncio.readthedocs.io/en/latest/how-to-guides/run_session_tests_in_same_loop.html 33 | """ 34 | pytest_asyncio_tests = (item for item in items if is_async_test(item)) 35 | session_scope_marker = pytest.mark.asyncio(loop_scope="session") 36 | for async_test in pytest_asyncio_tests: 37 | async_test.add_marker(session_scope_marker, append=False) 38 | 39 | 40 | @pytest.fixture(scope="session", autouse=True) 41 | async def after_nonebot_init(after_nonebot_init: None): 42 | # 加载适配器 43 | driver = nonebot.get_driver() 44 | driver.register_adapter(QQAdapter) 45 | driver.register_adapter(DiscordAdapter) 46 | driver.register_adapter(Onebot11Adapter) 47 | driver.register_adapter(Onebot12Adapter) 48 | driver.register_adapter(SatoriAdapter) 49 | 50 | nonebot.require("nonebot_plugin_alconna") 51 | nonebot.require("nonebot_plugin_filehost") 52 | -------------------------------------------------------------------------------- /src/nonebot_plugin_alconna/uniseg/adapters/onebot12/builder.py: -------------------------------------------------------------------------------- 1 | from typing import TYPE_CHECKING 2 | 3 | from nonebot.adapters import Bot, Event 4 | from nonebot.adapters.onebot.v12.event import MessageEvent 5 | from nonebot.adapters.onebot.v12.message import MessageSegment 6 | 7 | from nonebot_plugin_alconna.uniseg.builder import MessageBuilder, build 8 | from nonebot_plugin_alconna.uniseg.constraint import SupportAdapter 9 | from nonebot_plugin_alconna.uniseg.segment import At, AtAll, Audio, File, Image, Reply, Video, Voice 10 | 11 | 12 | class Onebot12MessageBuilder(MessageBuilder): 13 | @classmethod 14 | def get_adapter(cls) -> SupportAdapter: 15 | return SupportAdapter.onebot12 16 | 17 | @build("mention") 18 | def mention(self, seg: MessageSegment): 19 | return At("user", seg.data["user_id"]) 20 | 21 | @build("mention_all") 22 | def mention_all(self, seg: MessageSegment): 23 | return AtAll() 24 | 25 | @build("image") 26 | def image(self, seg: MessageSegment): 27 | return Image(id=seg.data["file_id"]) 28 | 29 | @build("video") 30 | def video(self, seg: MessageSegment): 31 | return Video(id=seg.data["file_id"]) 32 | 33 | @build("audio") 34 | def audio(self, seg: MessageSegment): 35 | return Audio(id=seg.data["file_id"]) 36 | 37 | @build("voice") 38 | def voice(self, seg: MessageSegment): 39 | return Voice(id=seg.data["file_id"]) 40 | 41 | @build("file") 42 | def file(self, seg: MessageSegment): 43 | return File(id=seg.data["file_id"]) 44 | 45 | @build("reply") 46 | def reply(self, seg: MessageSegment): 47 | return Reply(seg.data["message_id"], origin=seg) 48 | 49 | async def extract_reply(self, event: Event, bot: Bot): 50 | if TYPE_CHECKING: 51 | assert isinstance(event, MessageEvent) 52 | if _reply := event.reply: 53 | return Reply(str(_reply.message_id), None, _reply) 54 | return None 55 | -------------------------------------------------------------------------------- /tests/test_aliases.py: -------------------------------------------------------------------------------- 1 | from nonebot import get_adapter 2 | from nonebot.adapters.onebot.v11 import Adapter, Bot, Message 3 | from nonebug import App 4 | import pytest 5 | 6 | from tests.fake import fake_group_message_event_v11 7 | 8 | 9 | @pytest.mark.asyncio() 10 | async def test_command(app: App): 11 | from nonebot_plugin_alconna import Alconna, Args, CommandMeta, on_alconna 12 | 13 | alc = Alconna("weather", Args["city#城市名称", str]) 14 | matcher = on_alconna(alc, aliases={"天气"}) 15 | 16 | @matcher.handle() 17 | async def _(city: str): 18 | await matcher.send(city) 19 | 20 | async with app.test_matcher(matcher) as ctx: # type: ignore 21 | adapter = get_adapter(Adapter) 22 | bot = ctx.create_bot(base=Bot, adapter=adapter) 23 | event = fake_group_message_event_v11(message=Message("weather abcd"), user_id=123) 24 | ctx.receive_event(bot, event) 25 | ctx.should_call_send(event, "abcd") 26 | event1 = fake_group_message_event_v11(message=Message("天气 abcd"), user_id=123) 27 | ctx.receive_event(bot, event1) 28 | ctx.should_call_send(event1, "abcd") 29 | event2 = fake_group_message_event_v11(message=Message("天气abcd"), user_id=123) 30 | ctx.receive_event(bot, event2) 31 | ctx.should_not_pass_rule() 32 | 33 | matcher.clean() 34 | 35 | alc = Alconna( 36 | "weather", 37 | Args["city#城市名称", str], 38 | meta=CommandMeta(compact=True), 39 | ) 40 | matcher = on_alconna(alc, aliases={"天气"}) 41 | 42 | @matcher.handle() 43 | async def _(city: str): 44 | await matcher.send(city) 45 | 46 | async with app.test_matcher(matcher) as ctx: 47 | adapter = get_adapter(Adapter) 48 | bot = ctx.create_bot(base=Bot, adapter=adapter) 49 | event2 = fake_group_message_event_v11(message=Message("天气abcd"), user_id=123) 50 | ctx.receive_event(bot, event2) 51 | ctx.should_call_send(event2, "abcd") 52 | 53 | matcher.clean() 54 | -------------------------------------------------------------------------------- /src/nonebot_plugin_alconna/uniseg/adapters/wxmp/builder.py: -------------------------------------------------------------------------------- 1 | from nonebot.adapters.wxmp.message import Emjoy as EmojiSegment 2 | from nonebot.adapters.wxmp.message import Image as ImageSegment 3 | from nonebot.adapters.wxmp.message import Location as LocationSegment 4 | from nonebot.adapters.wxmp.message import MessageSegment 5 | from nonebot.adapters.wxmp.message import Miniprogrampage as MiniProgramSegment 6 | from nonebot.adapters.wxmp.message import Text as TextSegment 7 | from nonebot.adapters.wxmp.message import Video as VideoSegment 8 | from nonebot.adapters.wxmp.message import Voice as VoiceSegment 9 | 10 | from nonebot_plugin_alconna.uniseg.builder import MessageBuilder, build 11 | from nonebot_plugin_alconna.uniseg.constraint import SupportAdapter 12 | from nonebot_plugin_alconna.uniseg.segment import Audio, Emoji, Hyper, Image, Other, Text, Video 13 | 14 | 15 | class WXMPMessageBuilder(MessageBuilder[MessageSegment]): 16 | @classmethod 17 | def get_adapter(cls) -> SupportAdapter: 18 | return SupportAdapter.wxmp 19 | 20 | @build("text") 21 | def build_text(self, seg: TextSegment): 22 | return Text(seg.data["text"]) 23 | 24 | @build("image") 25 | def build_image(self, seg: ImageSegment): 26 | return Image(id=seg.data["media_id"], url=str(seg.data["file_url"])) 27 | 28 | @build("miniprogrampage") 29 | def build_miniprogrampage(self, seg: MiniProgramSegment): 30 | return Hyper("json", content={**seg.data}) 31 | 32 | @build("video") 33 | def build_video(self, seg: VideoSegment): 34 | return Video(id=seg.data["media_id"]) 35 | 36 | @build("voice") 37 | def build_voice(self, seg: VoiceSegment): 38 | return Audio(id=seg.data["media_id"]) 39 | 40 | @build("location") 41 | def build_location(self, seg: LocationSegment): 42 | return Other(seg) 43 | 44 | @build("emjoy") 45 | def build_emoji(self, seg: EmojiSegment): 46 | t = seg.data["emjoy"] 47 | return Emoji(t.value, t.name) 48 | -------------------------------------------------------------------------------- /tests/test_ref.py: -------------------------------------------------------------------------------- 1 | from arclet.alconna import Arparma 2 | from nonebot import get_adapter 3 | from nonebot.adapters.onebot.v11 import Adapter, Bot, Message 4 | from nonebug import App 5 | import pytest 6 | 7 | from tests.fake import fake_group_message_event_v11 8 | 9 | 10 | @pytest.mark.asyncio() 11 | async def test_ref(app: App): 12 | from nonebot_plugin_alconna import Command, referent 13 | 14 | book = ( 15 | Command("book", "测试") 16 | .option("writer", "-w ") 17 | .option("writer", "--anonymous", {"id": 0}) 18 | .usage("book [-w | --anonymous]") 19 | .shortcut("测试", {"args": ["--anonymous"]}) 20 | .build() 21 | ) 22 | 23 | @book.handle() 24 | async def _(arp: Arparma): 25 | await book.send(f"0: {(arp.options)}") 26 | 27 | async with app.test_matcher(book) as ctx: # type: ignore 28 | adapter: Adapter = get_adapter(Adapter) 29 | bot = ctx.create_bot(base=Bot, adapter=adapter) 30 | event = fake_group_message_event_v11(message=Message("book --anonymous"), user_id=123) 31 | ctx.receive_event(bot, event) 32 | ctx.should_call_send(event, "0: {'writer': (value=Ellipsis args={'id': 0})}") 33 | 34 | book1 = referent(book.command()) 35 | assert book1 36 | assert id(book1) == id(book) 37 | 38 | @book1.handle(override=("replace", 0)) 39 | async def _(arp: Arparma): 40 | await book1.send(f"1: {(arp.options)}") 41 | 42 | @book1.handle(override=("insert", 0)) 43 | async def _(arp: Arparma): 44 | await book1.send(f"2: {(arp.options)}") 45 | 46 | async with app.test_matcher(book) as ctx: # type: ignore 47 | adapter: Adapter = get_adapter(Adapter) 48 | bot = ctx.create_bot(base=Bot, adapter=adapter) 49 | event = fake_group_message_event_v11(message=Message("book --anonymous"), user_id=123) 50 | ctx.receive_event(bot, event) 51 | ctx.should_call_send(event, "2: {'writer': (value=Ellipsis args={'id': 0})}") 52 | ctx.should_call_send(event, "1: {'writer': (value=Ellipsis args={'id': 0})}") 53 | -------------------------------------------------------------------------------- /src/nonebot_plugin_alconna/uniseg/adapters/mail/builder.py: -------------------------------------------------------------------------------- 1 | from typing import TYPE_CHECKING 2 | 3 | from nonebot.adapters import Bot, Event 4 | from nonebot.adapters.mail.event import NewMailMessageEvent 5 | from nonebot.adapters.mail.message import Attachment, Html, MessageSegment 6 | 7 | from nonebot_plugin_alconna.uniseg.builder import MessageBuilder, build 8 | from nonebot_plugin_alconna.uniseg.constraint import SupportAdapter 9 | from nonebot_plugin_alconna.uniseg.segment import Audio, File, Image, Reply, Text, Video 10 | 11 | 12 | class MailMessageBuilder(MessageBuilder[MessageSegment]): 13 | @classmethod 14 | def get_adapter(cls) -> SupportAdapter: 15 | return SupportAdapter.mail 16 | 17 | @build("html") 18 | def html(self, seg: Html): 19 | return Text(seg.data["html"]).mark(0, len(seg.data["html"]), "html") 20 | 21 | @build("attachment") 22 | def attachment(self, seg: Attachment): 23 | mtype = seg.data["content_type"] 24 | if mtype and mtype.startswith("image"): 25 | return Image( 26 | raw=seg.data["data"], 27 | mimetype=mtype, 28 | name=seg.data["name"], 29 | ) 30 | if mtype and mtype.startswith("audio"): 31 | return Audio( 32 | raw=seg.data["data"], 33 | mimetype=mtype, 34 | name=seg.data["name"], 35 | ) 36 | if mtype and mtype.startswith("video"): 37 | return Video( 38 | raw=seg.data["data"], 39 | mimetype=mtype, 40 | name=seg.data["name"], 41 | ) 42 | return File( 43 | raw=seg.data["data"], 44 | mimetype=seg.data["content_type"], 45 | name=seg.data["name"], 46 | ) 47 | 48 | async def extract_reply(self, event: Event, bot: Bot): 49 | if TYPE_CHECKING: 50 | assert isinstance(event, NewMailMessageEvent) 51 | 52 | if event.reply: 53 | return Reply(event.reply.id, msg=event.reply.message, origin=event.reply) 54 | return None 55 | -------------------------------------------------------------------------------- /src/nonebot_plugin_alconna/builtins/extensions/onebot11.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from nonebot.adapters.onebot.v11 import Event as OneBot11Event 4 | from nonebot.adapters.onebot.v11 import Message 5 | from nonebot.internal.adapter import Bot, Event 6 | from nonebot.typing import T_State 7 | from tarina import LRU 8 | 9 | from nonebot_plugin_alconna import Extension, UniMessage, get_message_id 10 | from nonebot_plugin_alconna.extension import cache_msg 11 | 12 | 13 | class MessageSentExtension(Extension): 14 | """ 15 | 用于提取自身上报消息事件中的消息内容 16 | 17 | 推荐配合 `add_global_extension` 使用 18 | 19 | 注意: 该扩展仅提供消息内容,无法配置 Alconna 响应器响应自身消息。 20 | 21 | Example: 22 | >>> from nonebot_plugin_alconna import add_global_extension 23 | >>> from nonebot_plugin_alconna.builtins.extensions.onebot11 import MessageSentExtension 24 | >>> 25 | >>> add_global_extension(MessageSentExtension()) 26 | """ 27 | 28 | cache: LRU[str, UniMessage] = LRU(20) 29 | 30 | @property 31 | def priority(self) -> int: 32 | return 8 33 | 34 | @property 35 | def id(self) -> str: 36 | return "builtins.extensions.onebot11:MessageSentExtension" 37 | 38 | def validate(self, bot: Bot, event: Event) -> bool: 39 | return isinstance(event, OneBot11Event) and event.get_type() == "message_sent" 40 | 41 | async def message_provider( 42 | self, event: Event, state: T_State, bot: Bot, use_origin: bool = False 43 | ) -> UniMessage | None: 44 | if event.get_type() == "message_sent" and hasattr(event, "message"): 45 | msg_id = get_message_id(event, bot) 46 | if use_origin and cache_msg and (uni_msg := self.cache.get(msg_id)) is not None: 47 | return uni_msg 48 | if cache_msg and (uni_msg := self.cache.get(msg_id)) is not None: 49 | return uni_msg 50 | msg = Message._validate(event.message) # type: ignore 51 | uni_msg = UniMessage.of(message=msg, bot=bot) 52 | self.cache[msg_id] = uni_msg 53 | return uni_msg 54 | return None 55 | -------------------------------------------------------------------------------- /src/nonebot_plugin_alconna/uniseg/adapters/onebot12/target.py: -------------------------------------------------------------------------------- 1 | from typing import TYPE_CHECKING, Union 2 | 3 | from nonebot.adapters import Bot 4 | from nonebot.adapters.onebot.v12.bot import Bot as Onebot12Bot 5 | 6 | from nonebot_plugin_alconna.uniseg.constraint import SupportAdapter 7 | from nonebot_plugin_alconna.uniseg.target import Target, TargetFetcher 8 | 9 | 10 | class Onebot12TargetFetcher(TargetFetcher): 11 | @classmethod 12 | def get_adapter(cls) -> SupportAdapter: 13 | return SupportAdapter.onebot12 14 | 15 | async def fetch(self, bot: Bot, target: Union[Target, None] = None): 16 | if TYPE_CHECKING: 17 | assert isinstance(bot, Onebot12Bot) 18 | if not target or (not target.private and not target.channel): 19 | groups = await bot.get_group_list() 20 | for group in groups: 21 | yield Target( 22 | str(group["group_id"]), 23 | adapter=self.get_adapter(), 24 | platform=bot.platform, 25 | self_id=bot.self_id, 26 | ) 27 | if not target or target.private: 28 | friends = await bot.get_friend_list() 29 | for friend in friends: 30 | yield Target( 31 | str(friend["user_id"]), 32 | private=True, 33 | adapter=self.get_adapter(), 34 | platform=bot.platform, 35 | self_id=bot.self_id, 36 | ) 37 | if not target or target.channel: 38 | guilds = await bot.get_guild_list() 39 | for guild in guilds: 40 | channels = await bot.get_channel_list(guild_id=guild["guild_id"]) 41 | for channel in channels: 42 | yield Target( 43 | str(channel["channel_id"]), 44 | str(guild["guild_id"]), 45 | channel=True, 46 | adapter=self.get_adapter(), 47 | platform=bot.platform, 48 | self_id=bot.self_id, 49 | ) 50 | -------------------------------------------------------------------------------- /tests/test_gotpath.py: -------------------------------------------------------------------------------- 1 | from typing import Union 2 | 3 | from arclet.alconna import Alconna, Args 4 | from nonebot import get_adapter 5 | from nonebot.adapters.onebot.v11 import Adapter, Bot, Message, MessageSegment 6 | from nonebug import App 7 | import pytest 8 | 9 | from tests.fake import fake_group_message_event_v11 10 | 11 | 12 | @pytest.mark.asyncio() 13 | async def test_got_path(app: App): 14 | from nonebot_plugin_alconna import At, Match, UniMessage, on_alconna 15 | 16 | test_cmd = on_alconna(Alconna("test_got", Args["target?", Union[str, At]])) 17 | 18 | @test_cmd.handle() 19 | async def tt_h(target: Match[Union[str, At]]): 20 | if target.available: 21 | test_cmd.set_path_arg("target", target.result) 22 | 23 | @test_cmd.got_path("target", prompt="请输入目标") 24 | async def tt(target: Union[str, At]): 25 | await test_cmd.send(UniMessage(["ok\n", target])) 26 | 27 | async with app.test_matcher(test_cmd) as ctx: 28 | adapter = get_adapter(Adapter) 29 | bot = ctx.create_bot(base=Bot, adapter=adapter) 30 | event = fake_group_message_event_v11(message=Message("test_got"), user_id=123) 31 | ctx.receive_event(bot, event) 32 | ctx.should_call_send(event, "请输入目标", result=None) 33 | ctx.should_rejected(test_cmd) 34 | event = fake_group_message_event_v11(message=Message("1234"), user_id=123) 35 | ctx.receive_event(bot, event) 36 | ctx.should_call_send(event, Message("ok\n1234")) 37 | 38 | event = fake_group_message_event_v11(message=Message("test_got"), user_id=123) 39 | ctx.receive_event(bot, event) 40 | ctx.should_call_send(event, "请输入目标", result=None) 41 | ctx.should_rejected(test_cmd) 42 | event = fake_group_message_event_v11(message=Message(MessageSegment.at(1234)), user_id=123) 43 | ctx.receive_event(bot, event) 44 | ctx.should_call_send(event, Message([MessageSegment.text("ok\n"), MessageSegment.at(1234)])) 45 | 46 | event = fake_group_message_event_v11(message=Message("test_got 1234"), user_id=123) 47 | ctx.receive_event(bot, event) 48 | ctx.should_call_send(event, Message("ok\n1234")) 49 | -------------------------------------------------------------------------------- /src/nonebot_plugin_alconna/uniseg/adapters/ntchat/builder.py: -------------------------------------------------------------------------------- 1 | from typing import TYPE_CHECKING 2 | 3 | from nonebot.adapters import Bot, Event 4 | 5 | from nonebot_plugin_alconna.uniseg.builder import MessageBuilder, build 6 | from nonebot_plugin_alconna.uniseg.constraint import SupportAdapter 7 | from nonebot_plugin_alconna.uniseg.segment import File, Hyper, Image, Reply, Text, Video, Voice 8 | 9 | if TYPE_CHECKING: 10 | from nonebot.adapters.ntchat.message import MessageSegment # type: ignore 11 | 12 | 13 | class NTChatMessageBuilder(MessageBuilder): 14 | @classmethod 15 | def get_adapter(cls) -> SupportAdapter: 16 | return SupportAdapter.ntchat 17 | 18 | @build("text") 19 | def text(self, seg: "MessageSegment"): 20 | return Text(seg.data["content"]) 21 | 22 | @build("image") 23 | def image(self, seg: "MessageSegment"): 24 | return Image(id=seg.data["file_path"], path=seg.data["file_path"]) 25 | 26 | @build("video") 27 | def video(self, seg: "MessageSegment"): 28 | return Video(id=seg.data["file_path"], path=seg.data["file_path"]) 29 | 30 | @build("voice") 31 | def voice(self, seg: "MessageSegment"): 32 | return Voice(id=seg.data["file_path"], path=seg.data["file_path"]) 33 | 34 | @build("audio") 35 | def audio(self, seg: "MessageSegment"): 36 | return Voice(id=seg.data["file_path"], path=seg.data["file_path"]) 37 | 38 | @build("file") 39 | def file(self, seg: "MessageSegment"): 40 | return File(id=seg.data["file_path"]) 41 | 42 | @build("card") 43 | def card(self, seg: "MessageSegment"): 44 | return Hyper("json", content={"card_wxid": seg.data["card_wxid"]}) 45 | 46 | @build("xml") 47 | def xml(self, seg: "MessageSegment"): 48 | return Hyper("xml", seg.data["xml"]) 49 | 50 | async def extract_reply(self, event: Event, bot: Bot): 51 | from nonebot.adapters.ntchat.event import QuoteMessageEvent # type: ignore 52 | 53 | if TYPE_CHECKING: 54 | assert isinstance(event, QuoteMessageEvent) 55 | if event.type == 11061: # type: ignore 56 | return Reply(event.quote_message_id, origin=event) # type: ignore 57 | return None 58 | -------------------------------------------------------------------------------- /src/nonebot_plugin_alconna/i18n/zh-CN.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": ".lang.schema.json", 3 | "nbp-alc": { 4 | "completion": { 5 | "tab": "发送 {cmd} 切换提示", 6 | "enter": "发送 {cmd} 以使用提示内容作为输入", 7 | "exit": "发送 {cmd} 退出补全会话", 8 | "other": "以上选项之外的输入将作为参数输入", 9 | "timeout": "补全会话超时, 已自动退出", 10 | "exited": "补全会话已退出" 11 | }, 12 | "log": { 13 | "load_global_extensions": "正在从 {path} 加载全局扩展 。。。", 14 | "got_path": { 15 | "ms": "got_path({path}) 下使用的消息段是 {ms!r}", 16 | "validate": "got_path({path}) 下的验证结果 是 {validate!r}" 17 | }, 18 | "discord": { 19 | "ambiguous_command": "同时具有 Args 和 OptionSubcommand 的 {cmd} 在将其转换为 Discord 斜杠命令时会造成意料之外的后果", 20 | "ambiguous_subcommand": "同时具有 Args 和 OptionSubcommand 的子命令 {name} 在将其转换为 Discord 斜杠命令时会造成意料之外的后果" 21 | }, 22 | "parse": "{cmd} 对 \"{msg}\" 的解析结果是 ({arp})" 23 | }, 24 | "error": { 25 | "discord_prefix": "Alconna 命令对象在用于转换为 Discord 斜杠命令时必须具有 '/' 前缀", 26 | "existed_command": "Alconna 命令对象 {cmd} 已经存在", 27 | "extension": { 28 | "forbid_exclude": "不能排除 id 以“!”开头的扩展", 29 | "path_load": "{path} 的值不是扩展的子类", 30 | "path_invalid": "{path} 不是有效的扩展路径" 31 | }, 32 | "matcher_got_path": "在 Alconna {cmd} 中找不到路径 {path}" 33 | }, 34 | "test": { 35 | "command_unusable": "{cmd} 无法使用", 36 | "parse_failed": "测试失败: \"{msg}\" 无法被 {cmd} 解析", 37 | "check_failed": "测试失败: {cmd} 的参数 {arg} 期望值为 {expected!r}, 但得到了 {got!r}", 38 | "passed": "{cmd} 通过了测试" 39 | } 40 | }, 41 | "nbp-alc/builtin": { 42 | "lang": { 43 | "help": { 44 | "list": "查看支持的语言列表", 45 | "switch": "切换语言", 46 | "main": "国际化配置相关功能" 47 | }, 48 | "list": "支持的语言列表:", 49 | "switch": "切换语言成功: '{locale}'。", 50 | "locale_missing": "{:At(user, $event.get_user_id())}缺少语言参数,请输入:", 51 | "locale_timeout": "{:At(user, $event.get_user_id())}等待语言参数输入超时。", 52 | "config_name_error": "{:At(user, $event.get_user_id())} 未能找到 {name} 所属的 i18n 目录" 53 | }, 54 | "help": { 55 | "plugin_name_unknown": "未知插件", 56 | "plugin_id": "插件标识", 57 | "plugin_name": "插件名称", 58 | "plugin_version": "插件版本", 59 | "plugin_module": "插件模块", 60 | "plugin_path": "插件路径" 61 | } 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /src/nonebot_plugin_alconna/uniseg/adapters/dodo/builder.py: -------------------------------------------------------------------------------- 1 | from typing import TYPE_CHECKING 2 | 3 | from nonebot.adapters import Bot, Event 4 | from nonebot.adapters.dodo import MessageEvent 5 | from nonebot.adapters.dodo.message import ( 6 | AtAllSegment, 7 | AtRoleSegment, 8 | AtUserSegment, 9 | ChannelLinkSegment, 10 | FileSegment, 11 | PictureSegment, 12 | ReferenceSegment, 13 | ShareSegment, 14 | VideoSegment, 15 | ) 16 | 17 | from nonebot_plugin_alconna.uniseg.builder import MessageBuilder, build 18 | from nonebot_plugin_alconna.uniseg.constraint import SupportAdapter 19 | from nonebot_plugin_alconna.uniseg.segment import At, AtAll, File, Image, Reply, Text, Video 20 | 21 | 22 | class DodoMessageBuilder(MessageBuilder): 23 | @classmethod 24 | def get_adapter(cls) -> SupportAdapter: 25 | return SupportAdapter.dodo 26 | 27 | @build("share") 28 | def share(self, seg: ShareSegment): 29 | return Text(seg.data["share"].jump_url).link() 30 | 31 | @build("at_user") 32 | def at_user(self, seg: AtUserSegment): 33 | return At("user", seg.data["dodo_id"]) 34 | 35 | @build("at_role") 36 | def at_role(self, seg: AtRoleSegment): 37 | return At("role", seg.data["role_id"]) 38 | 39 | @build("channel_link") 40 | def channel_link(self, seg: ChannelLinkSegment): 41 | return At("channel", seg.data["channel_id"]) 42 | 43 | @build("at_all") 44 | def mention_everyone(self, seg: AtAllSegment): 45 | return AtAll() 46 | 47 | @build("picture") 48 | def picture(self, seg: PictureSegment): 49 | return Image(url=seg.data["picture"].url) 50 | 51 | @build("video") 52 | def video(self, seg: VideoSegment): 53 | return Video(url=seg.data["video"].url) 54 | 55 | @build("file") 56 | def file(self, seg: FileSegment): 57 | return File(url=seg.data["file"].url) 58 | 59 | @build("reference") 60 | def reference(self, seg: ReferenceSegment): 61 | return Reply(seg.data["message_id"], origin=seg) 62 | 63 | async def extract_reply(self, event: Event, bot: Bot): 64 | if TYPE_CHECKING: 65 | assert isinstance(event, MessageEvent) 66 | 67 | if reply := event.reply: 68 | return Reply(reply.message_id, origin=reply) 69 | return None 70 | -------------------------------------------------------------------------------- /src/nonebot_plugin_alconna/builtins/uniseg/markdown.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass 2 | from typing import Optional, cast 3 | 4 | from nepattern import BasePattern, UnionPattern, local_patterns 5 | from nonebot.adapters import Bot 6 | from nonebot.adapters import MessageSegment as BaseMessageSegment 7 | 8 | from nonebot_plugin_alconna.typings import Style 9 | from nonebot_plugin_alconna.uniseg import Segment, Text, custom_handler, custom_register 10 | from nonebot_plugin_alconna.uniseg.builder import MessageBuilder 11 | from nonebot_plugin_alconna.uniseg.constraint import SupportAdapter 12 | from nonebot_plugin_alconna.uniseg.exporter import MessageExporter 13 | 14 | 15 | @dataclass 16 | class Markdown(Segment): 17 | content: Optional[str] = None 18 | template_id: Optional[str] = None 19 | params: Optional[dict[str, list[str]]] = None 20 | 21 | 22 | @custom_register(Markdown, "markdown") 23 | def mdbuild(builder: MessageBuilder, seg: BaseMessageSegment): 24 | if builder.get_adapter() is SupportAdapter.qq: 25 | from nonebot.adapters.qq.message import Markdown as MarkdownSegment 26 | 27 | seg = cast(MarkdownSegment, seg) 28 | return Markdown( 29 | template_id=seg.data["markdown"].custom_template_id, 30 | params=( 31 | {param.key: param.values for param in seg.data["markdown"].params} # type: ignore 32 | if seg.data["markdown"].params 33 | else None 34 | ), 35 | ) 36 | return None 37 | 38 | 39 | @custom_handler(Markdown) 40 | async def music_export(exporter: MessageExporter, seg: Markdown, bot: Optional[Bot], fallback): 41 | if exporter.get_adapter() is SupportAdapter.qq and seg.template_id: 42 | from nonebot.adapters.qq.message import MessageSegment 43 | from nonebot.adapters.qq.models import MessageMarkdown, MessageMarkdownParams 44 | 45 | if seg.params: 46 | params = [MessageMarkdownParams(key=k, values=v) for k, v in seg.params.items()] 47 | else: 48 | params = None 49 | md = MessageMarkdown(custom_template_id=seg.template_id, params=params) 50 | return MessageSegment.markdown(md) 51 | 52 | if seg.content: 53 | return (await exporter.export([Text(seg.content).markdown()], bot, fallback))[0] 54 | return None 55 | 56 | 57 | local_patterns()[Markdown] = UnionPattern([Style("markdown"), BasePattern.of(Markdown)]) 58 | -------------------------------------------------------------------------------- /src/nonebot_plugin_alconna/uniseg/adapters/satori/__init__.py: -------------------------------------------------------------------------------- 1 | from importlib_metadata import PackageNotFoundError, distribution 2 | from nonebot.adapters import MessageSegment as BaseMessageSegment 3 | 4 | from nonebot_plugin_alconna.uniseg.builder import MessageBuilder 5 | from nonebot_plugin_alconna.uniseg.constraint import SupportAdapter 6 | from nonebot_plugin_alconna.uniseg.exporter import MessageExporter 7 | from nonebot_plugin_alconna.uniseg.loader import BaseLoader 8 | from nonebot_plugin_alconna.uniseg.segment import Emoji, custom_handler, custom_register 9 | 10 | 11 | def get_satori_version(): 12 | try: 13 | satori = distribution("nonebot-adapter-satori") 14 | except PackageNotFoundError: 15 | return None 16 | else: 17 | return satori.version 18 | 19 | 20 | class Loader(BaseLoader): 21 | def __init__(self): 22 | if (version := get_satori_version()) and tuple(map(int, version.split(".")[:2])) < (0, 12): 23 | raise ImportError("nonebot-adapter-satori>=0.12 is required.") 24 | 25 | def get_adapter(self) -> SupportAdapter: 26 | return SupportAdapter.satori 27 | 28 | def get_builder(self): 29 | from nonebot.adapters.satori.message import Custom 30 | 31 | from .builder import SatoriMessageBuilder 32 | 33 | @custom_register(Emoji, "chronocat:face") 34 | def fbuild(builder: MessageBuilder, seg: BaseMessageSegment): 35 | if not isinstance(seg, Custom): 36 | raise ValueError("Emoji can only be built from Satori Message") 37 | return Emoji(seg.data["id"], seg.data.get("name"))(*builder.generate(seg.children)) 38 | 39 | return SatoriMessageBuilder() 40 | 41 | def get_exporter(self): 42 | from nonebot.adapters.satori import Message, MessageSegment 43 | 44 | from .exporter import SatoriMessageExporter 45 | 46 | @custom_handler(Emoji) 47 | async def fexport(exporter: MessageExporter, seg: Emoji, bot, fallback): 48 | if exporter.get_message_type() is Message: 49 | return MessageSegment("chronocat:face", seg.data)( 50 | await exporter.export(seg.children, bot, fallback) # type: ignore 51 | ) 52 | return None 53 | 54 | return SatoriMessageExporter() 55 | 56 | def get_fetcher(self): 57 | from .target import SatoriTargetFetcher 58 | 59 | return SatoriTargetFetcher() 60 | -------------------------------------------------------------------------------- /src/nonebot_plugin_alconna/builtins/plugins/lang.py: -------------------------------------------------------------------------------- 1 | from arclet.alconna import Alconna, Args, CommandMeta, Field, Option, namespace 2 | from nonebot.plugin import PluginMetadata 3 | 4 | from nonebot_plugin_alconna import Match, UniMessage, __supported_adapters__, on_alconna 5 | from nonebot_plugin_alconna.i18n import Lang, lang 6 | 7 | __plugin_meta__ = PluginMetadata( 8 | name="lang", 9 | description=Lang.nbp_alc_builtin.lang.help.main.cast(), 10 | usage="/lang list/switch [lang]", 11 | type="application", 12 | homepage="https://github.com/nonebot/plugin-alconna/blob/master/src/nonebot_plugin_alconna/builtins/plugins/lang.py", 13 | config=None, 14 | supported_adapters=__supported_adapters__, 15 | ) 16 | 17 | with namespace("builtin/lang") as ns: 18 | ns.disable_builtin_options = {"shortcut", "completion"} 19 | 20 | cmd = on_alconna( 21 | Alconna( 22 | "lang", 23 | Option("list", Args["name?", str], help_text=Lang.nbp_alc_builtin.lang.help.list.cast()), 24 | Option( 25 | "switch", 26 | Args["locale?", str, Field(completion=lambda: list(lang.locales))], 27 | help_text=Lang.nbp_alc_builtin.lang.help.switch.cast(), 28 | ), 29 | meta=CommandMeta(Lang.nbp_alc_builtin.lang.help.main.cast(), compact=True), 30 | ), 31 | use_cmd_start=True, 32 | ) 33 | 34 | 35 | @cmd.assign("list") 36 | async def _(name: Match[str]): 37 | try: 38 | locales = lang.locales_in(name.result) if name.available else lang.locales 39 | except KeyError: 40 | await cmd.finish(UniMessage.i18n(Lang.nbp_alc_builtin.lang.config_name_error, name=name.result)) 41 | else: 42 | await cmd.finish(Lang.nbp_alc_builtin.lang.list() + "\n" + "\n".join(f" * {locale}" for locale in locales)) 43 | 44 | 45 | @cmd.assign("switch") 46 | async def _(locale: Match[str]): 47 | if not locale.available: 48 | resp = await cmd.prompt(UniMessage.i18n(Lang.nbp_alc_builtin.lang.locale_missing), timeout=30) 49 | if resp is None: 50 | await UniMessage.i18n(Lang.nbp_alc_builtin.lang.locale_timeout).finish() 51 | _locale = str(resp) 52 | else: 53 | _locale = locale.result 54 | try: 55 | lang.select(_locale) 56 | except ValueError as e: 57 | await cmd.finish(str(e)) 58 | else: 59 | await UniMessage.i18n(Lang.nbp_alc_builtin.lang.switch, locale=_locale).finish() 60 | -------------------------------------------------------------------------------- /src/nonebot_plugin_alconna/builtins/extensions/permission.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import asyncio 4 | from typing import Protocol 5 | 6 | from arclet.alconna import Arparma, CompSession, SubcommandResult 7 | from nonebot.internal.adapter import Bot, Event 8 | 9 | from nonebot_plugin_alconna import Extension 10 | 11 | 12 | class Checker(Protocol): 13 | async def __call__(self, bot: Bot, event: Event, permission: str) -> bool: ... 14 | 15 | 16 | class SubcommandPermExtension(Extension): 17 | """ 18 | 用于简易检查调用者是否有命令权限的拓展。 19 | 20 | Example: 21 | >>> from nonebot_plugin_alconna.builtins.extensions.permission import SubcommandPermExtension 22 | >>> 23 | >>> matcher = on_alconna("...", extensions=[SubcommandPermExtension(...)]) 24 | """ 25 | 26 | @property 27 | def priority(self) -> int: 28 | return 20 29 | 30 | def __init__(self, checker: Checker, include_options: bool = False) -> None: 31 | """ 32 | Args: 33 | checker: 权限检查函数,接受 bot、event、permission 三个参数,返回是否有权限的布尔值 34 | include_options: 是否需要选项的权限检查 35 | """ 36 | self.checker = checker 37 | self.include_options = include_options 38 | 39 | @property 40 | def id(self) -> str: 41 | return "builtins.extensions.permission:SubcommandPermExtension" 42 | 43 | async def permission_check(self, bot: Bot, event: Event, medium: Arparma | CompSession) -> bool: 44 | if isinstance(medium, CompSession): 45 | return True 46 | base = [f"command.{medium.source.name}"] 47 | if self.include_options: 48 | base.extend(f"command.{medium.source.name}.$options.{opt}" for opt in medium.options) 49 | 50 | def gen_permissions(subcommands: dict[str, SubcommandResult], prefix: str): 51 | for name, result in subcommands.items(): 52 | current_perm = f"{prefix}.{name}" 53 | yield current_perm 54 | if self.include_options and result.options: 55 | for opt in result.options: 56 | yield f"{current_perm}.$options.{opt}" 57 | if result.subcommands: 58 | yield from gen_permissions(result.subcommands, current_perm) 59 | 60 | base.extend(gen_permissions(medium.subcommands, f"command.{medium.source.name}")) 61 | tasks = [self.checker(bot, event, perm) for perm in base] 62 | results = await asyncio.gather(*tasks) 63 | return all(results) 64 | 65 | 66 | __extension__ = SubcommandPermExtension 67 | -------------------------------------------------------------------------------- /src/nonebot_plugin_alconna/uniseg/rule.py: -------------------------------------------------------------------------------- 1 | from nonebot.adapters import Bot, Event, Message 2 | from nonebot.internal.rule import Rule 3 | from nonebot.params import Depends 4 | 5 | from .constraint import SupportScope 6 | from .functions import get_target 7 | from .message import UniMessage 8 | from .segment import At, Reply, Text 9 | 10 | 11 | async def _get_message(event: Event, bot: Bot): 12 | if event.get_type() != "message": 13 | return None 14 | try: 15 | msg: Message = event.get_message() 16 | except (NotImplementedError, ValueError): 17 | return None 18 | try: 19 | msg: Message = getattr(event, "original_message", msg) # type: ignore 20 | except (NotImplementedError, ValueError): 21 | pass 22 | return UniMessage.of(message=msg, bot=bot) 23 | 24 | 25 | class AtInRule: 26 | 27 | def __init__(self, *target: str): 28 | self.targets = set(target) 29 | 30 | async def __call__(self, msg: UniMessage = Depends(_get_message)): 31 | if not msg: 32 | return False 33 | if isinstance(msg[0], Reply): 34 | msg.pop(0) 35 | if not msg or not isinstance(msg[0], At): 36 | return False 37 | at = msg[At, 0] 38 | if at.flag != "user": 39 | return False 40 | return at.target in self.targets 41 | 42 | 43 | class AtMeRule: 44 | 45 | def __init__(self, only_at: bool = False): 46 | self.only = only_at 47 | 48 | async def __call__(self, event: Event, bot: Bot, msg: UniMessage = Depends(_get_message)): 49 | if not msg: 50 | return False 51 | if isinstance(msg[0], Reply): 52 | msg.pop(0) 53 | if not msg or not isinstance(msg[0], At): 54 | target = get_target(event=event, bot=bot) 55 | if target.scope is SupportScope.qq_api and not target.channel: # QQ API 群聊下会吞 At 56 | msg.insert(0, At("user", bot.self_id)) 57 | else: 58 | return False 59 | at = msg[At, 0] 60 | if at.flag != "user": 61 | return False 62 | ans = bot.self_id == at.target 63 | if self.only and len(msg) > 1: 64 | if not isinstance(msg[1], Text): 65 | return False 66 | text: Text = msg[Text, 0] 67 | if text.text.strip("\xa0").strip(): 68 | return False 69 | return ans 70 | 71 | 72 | def at_in(*target: str) -> Rule: 73 | return Rule(AtInRule(*target)) 74 | 75 | 76 | def at_me(only_at: bool = False) -> Rule: 77 | return Rule(AtMeRule(only_at)) 78 | -------------------------------------------------------------------------------- /tests/test_onebot.py: -------------------------------------------------------------------------------- 1 | from nonebot import get_adapter 2 | from nonebot.adapters.onebot.v11 import Adapter, Bot, Message, MessageSegment 3 | from nonebug import App 4 | import pytest 5 | 6 | from tests.fake import fake_group_message_event_v11, fake_self_message_event_v11 7 | 8 | 9 | @pytest.mark.asyncio() 10 | async def test_command(app: App): 11 | from nonebot_plugin_alconna import Alconna, Args, on_alconna 12 | 13 | alc = Alconna("天气", Args["city#城市名称", str]) 14 | matcher = on_alconna(alc) 15 | matcher.shortcut("^(?P.+)天气$", {"args": ["{city}"]}) 16 | 17 | @matcher.handle() 18 | async def _(city: str): 19 | await matcher.send(city) 20 | 21 | async with app.test_matcher(matcher) as ctx: # type: ignore 22 | adapter = get_adapter(Adapter) 23 | bot = ctx.create_bot(base=Bot, adapter=adapter) 24 | event = fake_group_message_event_v11(message=Message("天气 abcd"), user_id=123) 25 | ctx.receive_event(bot, event) 26 | ctx.should_call_send(event, "abcd") 27 | event1 = fake_group_message_event_v11(message=Message("abcd天气"), user_id=123) 28 | ctx.receive_event(bot, event1) 29 | ctx.should_call_send(event1, "abcd") 30 | ev2 = fake_group_message_event_v11(message=Message([MessageSegment.face(i) for i in range(50)]), user_id=123) 31 | ctx.receive_event(bot, ev2) 32 | ctx.should_not_pass_rule(matcher) 33 | 34 | 35 | @pytest.mark.asyncio() 36 | async def test_sent(app: App): 37 | from nonebot_plugin_alconna import Alconna, AlconnaMatcher, add_global_extension, on_alconna 38 | from nonebot_plugin_alconna.builtins.extensions.onebot11 import MessageSentExtension 39 | 40 | add_global_extension(MessageSentExtension()) 41 | 42 | mat1 = on_alconna("sent1", response_self=True) 43 | mat2 = on_alconna("sent2", response_self=False) 44 | 45 | @mat1.handle() 46 | @mat2.handle() 47 | async def _(cmd: Alconna, mat: AlconnaMatcher): 48 | await mat.send(cmd.name) 49 | 50 | async with app.test_matcher(mat1) as ctx: 51 | adapter = get_adapter(Adapter) 52 | bot = ctx.create_bot(base=Bot, adapter=adapter, self_id="123") 53 | event = fake_self_message_event_v11(message=Message("sent1"), user_id=123, self_id=123) 54 | ctx.receive_event(bot, event) 55 | ctx.should_call_send(event, "sent1") 56 | 57 | async with app.test_matcher(mat2) as ctx: 58 | adapter = get_adapter(Adapter) 59 | bot = ctx.create_bot(base=Bot, adapter=adapter, self_id="123") 60 | event = fake_self_message_event_v11(message=Message("sent2"), user_id=123, self_id=123) 61 | ctx.receive_event(bot, event) 62 | ctx.should_not_pass_rule(mat2) 63 | -------------------------------------------------------------------------------- /src/nonebot_plugin_alconna/i18n/.template.json: -------------------------------------------------------------------------------- 1 | 2 | { 3 | "$schema": "./.template.schema.json", 4 | "scopes": [ 5 | { 6 | "scope": "nbp-alc", 7 | "types": [ 8 | { 9 | "subtype": "completion", 10 | "types": [ 11 | "tab", 12 | "enter", 13 | "exit", 14 | "other", 15 | "timeout", 16 | "exited" 17 | ] 18 | }, 19 | { 20 | "subtype": "log", 21 | "types": [ 22 | "load_global_extensions", 23 | { 24 | "subtype": "got_path", 25 | "types": [ 26 | "ms", 27 | "validate" 28 | ] 29 | }, 30 | { 31 | "subtype": "discord", 32 | "types": [ 33 | "ambiguous_command", 34 | "ambiguous_subcommand" 35 | ] 36 | }, 37 | "parse" 38 | ] 39 | }, 40 | { 41 | "subtype": "error", 42 | "types": [ 43 | "discord_prefix", 44 | "existed_command", 45 | { 46 | "subtype": "extension", 47 | "types": [ 48 | "forbid_exclude", 49 | "path_load", 50 | "path_invalid" 51 | ] 52 | }, 53 | "matcher_got_path" 54 | ] 55 | }, 56 | { 57 | "subtype": "test", 58 | "types": [ 59 | "command_unusable", 60 | "parse_failed", 61 | "check_failed", 62 | "passed" 63 | ] 64 | } 65 | ] 66 | }, 67 | { 68 | "scope": "nbp-alc/builtin", 69 | "types": [ 70 | { 71 | "subtype": "lang", 72 | "types": [ 73 | { 74 | "subtype": "help", 75 | "types": [ 76 | "list", 77 | "switch", 78 | "main" 79 | ] 80 | }, 81 | "list", 82 | "switch", 83 | "locale_missing", 84 | "locale_timeout", 85 | "config_name_error" 86 | ] 87 | }, 88 | { 89 | "subtype": "help", 90 | "types": [ 91 | "plugin_name_unknown", 92 | "plugin_name", 93 | "plugin_id", 94 | "plugin_path", 95 | "plugin_module", 96 | "plugin_version" 97 | ] 98 | } 99 | ] 100 | } 101 | ] 102 | } 103 | -------------------------------------------------------------------------------- /tests/test_koishi_command.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | 3 | from arclet.alconna import Arparma 4 | from nonebot import get_adapter 5 | from nonebot.adapters.onebot.v11 import Adapter, Bot, Message 6 | from nonebug import App 7 | import pytest 8 | 9 | from tests.fake import fake_group_message_event_v11 10 | 11 | FILE = Path(__file__).parent / "test_koishi_command.yml" 12 | FILE1 = Path(__file__).parent / "test_koishi_command1.yml" 13 | 14 | 15 | @pytest.mark.asyncio() 16 | async def test_command(app: App): 17 | from nonebot.matcher import Matcher 18 | 19 | from nonebot_plugin_alconna import Command, command_from_yaml, commands_from_yaml 20 | 21 | book = ( 22 | Command("book", "测试") 23 | .option("writer", "-w ") 24 | .option("writer", "--anonymous", {"id": 0}) 25 | .usage("book [-w | --anonymous]") 26 | .shortcut("测试", {"args": ["--anonymous"]}) 27 | .build() 28 | ) 29 | 30 | @book.handle() 31 | async def _(arp: Arparma): 32 | await book.send(str(arp.options)) 33 | 34 | async with app.test_matcher(book) as ctx: # type: ignore 35 | adapter = get_adapter(Adapter) 36 | bot = ctx.create_bot(base=Bot, adapter=adapter) 37 | event = fake_group_message_event_v11(message=Message("book --anonymous"), user_id=123) 38 | ctx.receive_event(bot, event) 39 | ctx.should_call_send(event, "{'writer': (value=Ellipsis args={'id': 0})}") 40 | 41 | book1 = command_from_yaml(FILE).build() 42 | 43 | async with app.test_matcher(book1) as ctx: # type: ignore 44 | adapter = get_adapter(Adapter) 45 | bot = ctx.create_bot(base=Bot, adapter=adapter) 46 | event = fake_group_message_event_v11(message=Message("book1 --anonymous"), user_id=123) 47 | ctx.receive_event(bot, event) 48 | ctx.should_call_send(event, "{'writer': (value=Ellipsis args={'id': 1})}") 49 | 50 | books = [cmd.build() for cmd in commands_from_yaml(FILE1).values()] 51 | for matcher in books: 52 | 53 | @matcher.handle() 54 | async def _(arp: Arparma, m: Matcher): 55 | await m.send(str(arp.options)) 56 | 57 | async with app.test_matcher(books) as ctx: # type: ignore 58 | adapter = get_adapter(Adapter) 59 | bot = ctx.create_bot(base=Bot, adapter=adapter) 60 | event = fake_group_message_event_v11(message=Message("book2 --anonymous"), user_id=123) 61 | ctx.receive_event(bot, event) 62 | ctx.should_call_send(event, "{'writer': (value=Ellipsis args={'id': 2})}") 63 | event1 = fake_group_message_event_v11(message=Message("book3 --anonymous"), user_id=123) 64 | ctx.receive_event(bot, event1) 65 | ctx.should_call_send(event1, "{'writer': (value=Ellipsis args={'id': 3})}") 66 | -------------------------------------------------------------------------------- /src/nonebot_plugin_alconna/uniseg/adapters/onebot11/builder.py: -------------------------------------------------------------------------------- 1 | from typing import TYPE_CHECKING 2 | 3 | from nonebot.adapters import Bot, Event 4 | from nonebot.adapters.onebot.v11.event import MessageEvent 5 | from nonebot.adapters.onebot.v11.message import MessageSegment 6 | 7 | from nonebot_plugin_alconna.uniseg.builder import MessageBuilder, build 8 | from nonebot_plugin_alconna.uniseg.constraint import SupportAdapter 9 | from nonebot_plugin_alconna.uniseg.segment import At, AtAll, Emoji, File, Hyper, Image, Reference, Reply, Video, Voice 10 | 11 | 12 | class Onebot11MessageBuilder(MessageBuilder): 13 | @classmethod 14 | def get_adapter(cls) -> SupportAdapter: 15 | return SupportAdapter.onebot11 16 | 17 | @build("at") 18 | def at(self, seg: MessageSegment): 19 | if seg.data["qq"] == "all": 20 | return AtAll() 21 | if int(seg.data["qq"]) == 0: 22 | return AtAll() 23 | return At("user", str(seg.data["qq"])) 24 | 25 | @build("face") 26 | def face(self, seg: MessageSegment): 27 | return Emoji(str(seg.data["id"])) 28 | 29 | @build("image") 30 | def image(self, seg: MessageSegment): 31 | is_sticker = seg.data.get("subType") == 1 or seg.data.get("sub_type") == 1 32 | return Image(url=seg.data.get("url") or seg.data.get("file"), id=seg.data["file"], sticker=is_sticker) 33 | 34 | @build("video") 35 | def video(self, seg: MessageSegment): 36 | return Video(url=seg.data.get("url") or seg.data.get("file"), id=seg.data["file"]) 37 | 38 | @build("record") 39 | def record(self, seg: MessageSegment): 40 | return Voice(url=seg.data.get("url") or seg.data.get("file"), id=seg.data["file"]) 41 | 42 | @build("reply") 43 | def reply(self, seg: MessageSegment): 44 | return Reply(str(seg.data["id"]), origin=seg) 45 | 46 | @build("forward") 47 | def forward(self, seg: MessageSegment): 48 | return Reference(seg.data["id"]) 49 | 50 | @build("json") 51 | def json(self, seg: MessageSegment): 52 | return Hyper("json", seg.data["data"]) 53 | 54 | @build("xml") 55 | def xml(self, seg: MessageSegment): 56 | return Hyper("xml", seg.data["data"]) 57 | 58 | @build("file") 59 | def file(self, seg: MessageSegment): 60 | url = seg.data.get("url") or seg.data.get("file") 61 | name = seg.data.get("file_name") or seg.data.get("file") 62 | return File(id=seg.data["file_id"], name=name or "file.bin", url=url) 63 | 64 | async def extract_reply(self, event: Event, bot: Bot): 65 | if TYPE_CHECKING: 66 | assert isinstance(event, MessageEvent) 67 | if _reply := event.reply: 68 | return Reply(str(_reply.message_id), _reply.message, _reply) 69 | return None 70 | -------------------------------------------------------------------------------- /src/nonebot_plugin_alconna/builtins/extensions/telegram.py: -------------------------------------------------------------------------------- 1 | from collections import deque 2 | import re 3 | from typing import ClassVar, Optional, final 4 | 5 | from arclet.alconna import Alconna 6 | from nonebot import get_driver 7 | from nonebot.adapters.telegram import Bot as TelegramBot 8 | from nonebot.adapters.telegram.model import BotCommand, BotCommandScope, BotCommandScopeDefault 9 | 10 | from nonebot_plugin_alconna import Extension 11 | 12 | commands: "deque[BotCommand]" = deque(maxlen=100) 13 | 14 | 15 | @final 16 | class TelegramSlashExtension(Extension): 17 | """ 18 | 用于将 Alconna 的命令自动转换为 Telegram 的 Command。 19 | 20 | Example: 21 | >>> from nonebot_plugin_alconna import on_alconna 22 | >>> from nonebot.adapters.telegram.model import BotCommandScopeChat 23 | >>> from nonebot_plugin_alconna.builtins.extensions.telegram import TelegramSlashExtension 24 | >>> 25 | >>> TelegramSlashExtension.set_scope(BotCommandScopeChat()) 26 | >>> 27 | >>> matcher = on_alconna("...", extensions=[TelegramSlashExtension()]) 28 | """ 29 | 30 | SCOPE: ClassVar[BotCommandScope] = BotCommandScopeDefault() 31 | LANGUAGE_CODE: ClassVar[Optional[str]] = None 32 | 33 | @classmethod 34 | def set_scope(cls, scope: BotCommandScope) -> None: 35 | cls.SCOPE = scope 36 | 37 | @classmethod 38 | def set_language_code(cls, language_code: Optional[str]) -> None: 39 | cls.LANGUAGE_CODE = language_code 40 | 41 | @property 42 | def priority(self) -> int: 43 | return 12 44 | 45 | @property 46 | def id(self) -> str: 47 | return "builtins.extensions.telegram:TelegramSlashExtension" 48 | 49 | def __init__(self): 50 | self.using = False 51 | 52 | def post_init(self, alc: Alconna) -> None: 53 | if "/" not in alc.prefixes or ( 54 | not alc.prefixes and isinstance(alc.command, str) and not alc.command.startswith("/") 55 | ): 56 | return 57 | if alc.command.startswith("/"): 58 | command = alc.command[1:] 59 | else: 60 | command = alc.command 61 | if not re.fullmatch("[a-z0-9_]{1,32}", command): 62 | return 63 | self.using = True 64 | commands.append(BotCommand(command=command, description=alc.meta.description[:256])) 65 | 66 | def validate(self, bot, event) -> bool: 67 | return self.using 68 | 69 | 70 | driver = get_driver() 71 | 72 | 73 | @driver.on_bot_connect 74 | async def on_bot_connect(bot: TelegramBot): 75 | await bot.set_my_commands( 76 | commands=list(commands), 77 | scope=TelegramSlashExtension.SCOPE, 78 | language_code=TelegramSlashExtension.LANGUAGE_CODE, 79 | ) 80 | 81 | 82 | __extension__ = TelegramSlashExtension 83 | -------------------------------------------------------------------------------- /src/nonebot_plugin_alconna/uniseg/adapters/red/builder.py: -------------------------------------------------------------------------------- 1 | from typing import TYPE_CHECKING 2 | 3 | from nonebot.adapters import Bot, Event 4 | from nonebot.adapters.red.event import MessageEvent 5 | from nonebot.adapters.red.message import MessageSegment 6 | 7 | from nonebot_plugin_alconna.uniseg.builder import MessageBuilder, build 8 | from nonebot_plugin_alconna.uniseg.constraint import SupportAdapter 9 | from nonebot_plugin_alconna.uniseg.segment import At, AtAll, Emoji, File, Hyper, Image, Reference, Reply, Video, Voice 10 | 11 | 12 | class RedMessageBuilder(MessageBuilder): 13 | @classmethod 14 | def get_adapter(cls) -> SupportAdapter: 15 | return SupportAdapter.red 16 | 17 | @build("at") 18 | def at(self, seg: MessageSegment): 19 | return At("user", str(seg.data["user_id"]), seg.data.get("user_name")) 20 | 21 | @build("at_all") 22 | def at_all(self, seg: MessageSegment): 23 | return AtAll() 24 | 25 | @build("face") 26 | def face(self, seg: MessageSegment): 27 | return Emoji(str(seg.data["face_id"])) 28 | 29 | @build("image") 30 | def image(self, seg: MessageSegment): 31 | return Image( 32 | id=seg.data["uuid"], 33 | path=seg.data["path"], 34 | name=seg.data["md5"], 35 | ) 36 | 37 | @build("video") 38 | def video(self, seg: MessageSegment): 39 | return Video( 40 | id=seg.data["videoMd5"], 41 | path=seg.data["filePath"], 42 | name=seg.data["fileName"], 43 | ) 44 | 45 | @build("voice") 46 | def voice(self, seg: MessageSegment): 47 | return Voice( 48 | id=seg.data["md5"], 49 | path=seg.data["path"], 50 | name=seg.data["name"], 51 | ) 52 | 53 | @build("file") 54 | def file(self, seg: MessageSegment): 55 | return File( 56 | id=seg.data["md5"], 57 | name=seg.data["name"], 58 | ) 59 | 60 | @build("reply") 61 | def reply(self, seg: MessageSegment): 62 | return Reply(f'{seg.data["msg_id"]}#{seg.data["msg_seq"]}', origin=seg.data["_origin"]) 63 | 64 | @build("forward") 65 | def forward(self, seg: MessageSegment): 66 | return Reference(seg.data["id"]) 67 | 68 | @build("ark") 69 | def ark(self, seg: MessageSegment): 70 | return Hyper("json", seg.data["data"]) 71 | 72 | async def extract_reply(self, event: Event, bot: Bot): 73 | if TYPE_CHECKING: 74 | assert isinstance(event, MessageEvent) 75 | 76 | if event.reply: 77 | return Reply( 78 | f"{event.reply.sourceMsgIdInRecords}#{event.reply.replayMsgSeq}", 79 | event.reply.sourceMsgTextElems, 80 | origin=event.reply, 81 | ) 82 | return None 83 | -------------------------------------------------------------------------------- /src/nonebot_plugin_alconna/builtins/plugins/with/__init__.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import datetime 3 | import json 4 | 5 | from arclet.alconna import namespace 6 | from nonebot import get_plugin_config 7 | from nonebot.plugin import PluginMetadata 8 | 9 | from nonebot_plugin_alconna import Command, Match, MsgTarget, __supported_adapters__, add_global_extension 10 | 11 | from .config import Config 12 | from .extension import PrefixAppendExtension 13 | 14 | __plugin_meta__ = PluginMetadata( 15 | name="with", 16 | description="设置局部命令前缀", 17 | usage="/with ", 18 | type="application", 19 | homepage="https://github.com/nonebot/plugin-alconna/blob/master/src/nonebot_plugin_alconna/builtins/plugins/with", 20 | config=Config, 21 | supported_adapters=__supported_adapters__, 22 | ) 23 | 24 | 25 | plugin_config = get_plugin_config(Config) 26 | 27 | data = {} 28 | 29 | 30 | def remove(key: str): 31 | data.pop(key, None) 32 | 33 | 34 | with namespace("builtin/with") as ns: 35 | ns.disable_builtin_options = {"shortcut", "completion"} 36 | 37 | with_ = ( 38 | Command(f"{plugin_config.nbp_alc_with_text} [name:str]", "with 指令") 39 | .config(compact=True) 40 | .option("expire", "expire #设置可能的生效时间") 41 | .option("unset", "unset #取消当前前缀") 42 | .usage("设置局部命令前缀") 43 | .build(use_cmd_start=True, priority=0, block=True) 44 | ) 45 | 46 | for alias in plugin_config.nbp_alc_with_alias: 47 | with_.shortcut(alias, {"prefix": True, "fuzzy": False}) 48 | 49 | @with_.assign("unset") 50 | async def unset(target: MsgTarget): 51 | key = json.dumps(target.dump(only_scope=True), ensure_ascii=False) 52 | if key not in data: 53 | await with_.finish("当前群组未设置前缀") 54 | del data[key] 55 | await with_.finish("取消设置成功") 56 | 57 | @with_.handle() 58 | async def _(name: Match[str], target: MsgTarget, time: Match[datetime.datetime]): 59 | key = json.dumps(target.dump(only_scope=True), ensure_ascii=False) 60 | if not name.available: 61 | if key not in data: 62 | await with_.finish("当前群组未设置前缀") 63 | await with_.finish(f"当前局部前缀为 {data[key]!r}") 64 | if name.result.startswith(plugin_config.nbp_alc_with_text): 65 | await with_.finish("无法设置该前缀") 66 | data[key] = name.result 67 | if time.available: 68 | asyncio.get_running_loop().call_later( 69 | abs((time.result - datetime.datetime.now()).total_seconds()), remove, key # noqa: DTZ005 70 | ) 71 | 72 | await with_.finish("设置前缀成功") 73 | 74 | 75 | PrefixAppendExtension.supplier = lambda _, target: data.get( 76 | json.dumps(target.dump(only_scope=True), ensure_ascii=False) 77 | ) 78 | add_global_extension(PrefixAppendExtension) 79 | -------------------------------------------------------------------------------- /src/nonebot_plugin_alconna/uniseg/adapters/bililive/exporter.py: -------------------------------------------------------------------------------- 1 | from typing import Union 2 | 3 | from nonebot.adapters import Bot, Event 4 | from nonebot.adapters.bilibili_live.bot import WebBot 5 | from nonebot.adapters.bilibili_live.event import DanmakuEvent, MessageEvent, SuperChatEvent 6 | from nonebot.adapters.bilibili_live.message import Message, MessageSegment 7 | 8 | from nonebot_plugin_alconna.uniseg.constraint import SupportScope 9 | from nonebot_plugin_alconna.uniseg.exporter import MessageExporter, SupportAdapter, Target, export 10 | from nonebot_plugin_alconna.uniseg.segment import At, Emoji, Reply, Text 11 | 12 | 13 | class BiliLiveMessageExporter(MessageExporter[Message]): 14 | def get_message_type(self): 15 | return Message 16 | 17 | @classmethod 18 | def get_adapter(cls) -> SupportAdapter: 19 | return SupportAdapter.bililive 20 | 21 | def get_target(self, event: Event, bot: Union[Bot, None] = None) -> Target: 22 | 23 | if isinstance(event, MessageEvent): 24 | return Target( 25 | str(event.room_id), 26 | adapter=self.get_adapter(), 27 | self_id=bot.self_id if bot else None, 28 | scope=SupportScope.bililive, 29 | ) 30 | raise NotImplementedError 31 | 32 | def get_message_id(self, event: Event) -> str: 33 | if isinstance(event, DanmakuEvent): 34 | return event.msg_id or f"{event.room_id}:{hash(event.content)}" 35 | if isinstance(event, SuperChatEvent): 36 | return str(event.msg_id or event.id) 37 | raise NotImplementedError 38 | 39 | @export 40 | async def text(self, seg: Text, bot: Union[Bot, None]) -> "MessageSegment": 41 | return MessageSegment.text(seg.text) 42 | 43 | @export 44 | async def at(self, seg: At, bot: Union[Bot, None]) -> "MessageSegment": 45 | return MessageSegment.at(seg.target, seg.display) 46 | 47 | @export 48 | async def emoji(self, seg: Emoji, bot: Union[Bot, None]) -> "MessageSegment": 49 | return MessageSegment.emoticon(seg.id) 50 | 51 | @export 52 | async def reply(self, seg: Reply, bot: Union[Bot, None]) -> "MessageSegment": 53 | return MessageSegment.at(seg.id) 54 | 55 | async def send_to(self, target: Union[Target, Event], bot: Bot, message: Message, **kwargs): 56 | if isinstance(target, Target): 57 | assert isinstance(bot, WebBot), "BiliLive currently only supports WebBot for sending messages." 58 | reply_mid = message["at"][-1].data.get("uid", 0) if message["at"] else 0 59 | msg = "".join(str(seg) for seg in message) 60 | return await bot.send_danmaku(room_id=int(target.id), msg=msg, reply_mid=reply_mid, **kwargs) 61 | return await bot.send(target, message=message, **kwargs) # type: ignore 62 | -------------------------------------------------------------------------------- /src/nonebot_plugin_alconna/uniseg/adapters/feishu/builder.py: -------------------------------------------------------------------------------- 1 | from typing import TYPE_CHECKING 2 | 3 | from nonebot.adapters import Bot, Event 4 | from nonebot.adapters.feishu.event import MessageEvent 5 | from nonebot.adapters.feishu.message import At as AtSegment 6 | from nonebot.adapters.feishu.message import Audio as AudioSegment 7 | from nonebot.adapters.feishu.message import File as FileSegment 8 | from nonebot.adapters.feishu.message import Folder as FolderSegment 9 | from nonebot.adapters.feishu.message import Image as ImageSegment 10 | from nonebot.adapters.feishu.message import Media as MediaSegment 11 | from nonebot.adapters.feishu.message import Post as PostSegment 12 | from nonebot.adapters.feishu.message import Sticker as StickerSegment 13 | 14 | from nonebot_plugin_alconna.uniseg.builder import MessageBuilder, build 15 | from nonebot_plugin_alconna.uniseg.constraint import SupportAdapter 16 | from nonebot_plugin_alconna.uniseg.segment import At, AtAll, Audio, File, Hyper, Image, Reply, Video 17 | 18 | 19 | class FeishuMessageBuilder(MessageBuilder): 20 | @classmethod 21 | def get_adapter(cls) -> SupportAdapter: 22 | return SupportAdapter.feishu 23 | 24 | @build("at") 25 | def at(self, seg: AtSegment): 26 | if seg.data["user_id"] in ("all", "here"): 27 | return AtAll(here=seg.data["user_id"] == "here") 28 | return At("user", str(seg.data["user_id"])) 29 | 30 | @build("image") 31 | def image(self, seg: ImageSegment): 32 | return Image(id=seg.data["image_key"]) 33 | 34 | @build("sticker") 35 | def sticker(self, seg: StickerSegment): 36 | return Image(id=seg.data["file_key"], sticker=True) 37 | 38 | @build("media") 39 | def media(self, seg: MediaSegment): 40 | return Video(id=seg.data["file_key"], name=seg.data["file_name"] or "video.mp4") 41 | 42 | @build("audio") 43 | def audio(self, seg: AudioSegment): 44 | return Audio(url=seg.data["file_key"]) 45 | 46 | @build("file") 47 | def file(self, seg: FileSegment): 48 | return File( 49 | id=seg.data["file_key"], 50 | name=seg.data.get("file_name") or seg.data["file_key"], 51 | ) 52 | 53 | @build("folder") 54 | def folder(self, seg: FolderSegment): 55 | return File( 56 | id=seg.data["file_key"], 57 | name=seg.data.get("file_name") or seg.data["file_key"], 58 | ) 59 | 60 | @build("post") 61 | def post(self, seg: PostSegment): 62 | return Hyper("json", content=dict(seg.data)) 63 | 64 | async def extract_reply(self, event: Event, bot: Bot): 65 | if TYPE_CHECKING: 66 | assert isinstance(event, MessageEvent) 67 | if event.reply: 68 | return Reply(event.reply.message_id, event.reply.body.content, event.reply) 69 | return None 70 | -------------------------------------------------------------------------------- /src/nonebot_plugin_alconna/uniseg/adapters/github/exporter.py: -------------------------------------------------------------------------------- 1 | from typing import Union 2 | 3 | from nonebot.adapters import Bot, Event 4 | from nonebot.adapters.github.event import CommitCommentCreated # type: ignore 5 | from nonebot.adapters.github.event import IssueCommentCreated # type: ignore 6 | from nonebot.adapters.github.event import PullRequestReviewCommentCreated # type: ignore 7 | from nonebot.adapters.github.message import Message, MessageSegment # type: ignore 8 | 9 | from nonebot_plugin_alconna.uniseg.constraint import SupportScope 10 | from nonebot_plugin_alconna.uniseg.exporter import MessageExporter, SupportAdapter, Target, export 11 | from nonebot_plugin_alconna.uniseg.segment import At, Image, Text 12 | 13 | 14 | class GithubMessageExporter(MessageExporter["Message"]): 15 | def get_message_type(self): 16 | return Message 17 | 18 | @classmethod 19 | def get_adapter(cls) -> SupportAdapter: 20 | return SupportAdapter.github 21 | 22 | def get_target(self, event: Event, bot: Union[Bot, None] = None) -> Target: 23 | return Target( 24 | event.get_user_id(), 25 | adapter=self.get_adapter(), 26 | self_id=bot.self_id if bot else None, 27 | scope=SupportScope.github, 28 | ) 29 | 30 | def get_message_id(self, event: Event) -> str: 31 | assert isinstance(event, (CommitCommentCreated, IssueCommentCreated, PullRequestReviewCommentCreated)) 32 | return str(event.id) # type: ignore 33 | 34 | @export 35 | async def text(self, seg: Text, bot: Union[Bot, None]) -> "MessageSegment": 36 | return MessageSegment.markdown(seg.text) 37 | 38 | @export 39 | async def at(self, seg: At, bot: Union[Bot, None]) -> "MessageSegment": 40 | return MessageSegment.markdown(f"@{seg.target}") 41 | 42 | @export 43 | async def image(self, seg: Image, bot: Union[Bot, None]) -> "MessageSegment": 44 | if seg.url: 45 | return MessageSegment.markdown(f"![]({seg.url})") 46 | if seg.__class__.to_url and seg.path: 47 | url = await seg.__class__.to_url(seg.path, bot, None if seg.name == seg.__default_name__ else seg.name) 48 | return MessageSegment.markdown(f"![]({url})") 49 | if seg.__class__.to_url and seg.raw: 50 | url = await seg.__class__.to_url(seg.raw, bot, None if seg.name == seg.__default_name__ else seg.name) 51 | return MessageSegment.markdown(f"![]({url})") 52 | raise ValueError("github image segment must have url") 53 | 54 | async def send_to(self, target: Union[Target, Event], bot: Bot, message: Message, **kwargs): 55 | if isinstance(target, Target): 56 | raise NotImplementedError 57 | return await bot.send( 58 | target, # type: ignore 59 | message=message, 60 | ) 61 | -------------------------------------------------------------------------------- /src/nonebot_plugin_alconna/i18n/en-US.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": ".lang.schema.json", 3 | "nbp-alc": { 4 | "completion": { 5 | "tab": "Send a {cmd} to toggle prompt", 6 | "enter": "Send {cmd} to confirm the prompt as the input", 7 | "exit": "Send {cmd} to exit the completion session", 8 | "other": "Input which not in above will be confirmed as the input", 9 | "timeout": "Completion session timed out. Exited automatically", 10 | "exited": "Completion session exited" 11 | }, 12 | "log": { 13 | "load_global_extensions": "Loading global extension from {path}", 14 | "got_path": { 15 | "ms": "MessageSegment for got_path({path}) is {ms!r}", 16 | "validate": "{validate} for got_path({path})" 17 | }, 18 | "discord": { 19 | "ambiguous_command": "{cmd} which have both Args and Option/Subcommand can make unintended consequences when you translate it to Discord slash-command", 20 | "ambiguous_subcommand": "Subcommand {name} which have both Args and sub Option/Subcommand can make unintended consequences when you translate Alconna to Discord slash-command" 21 | }, 22 | "parse": "Parse result of \"{msg}\" by {cmd} is ({arp})" 23 | }, 24 | "error": { 25 | "discord_prefix": "The Alconna obj must have '/' prefix when use to translate to Discord slash-command", 26 | "existed_command": "Command {cmd} already existed", 27 | "extension": { 28 | "forbid_exclude": "Extension which id starts with '!' cannot be excluded", 29 | "path_load": "Value of {path} is not a subclass of Extension", 30 | "path_invalid": "{path} is not a valid extension path" 31 | }, 32 | "matcher_got_path": "Path {path} not found in Alconna {cmd}" 33 | }, 34 | "test": { 35 | "command_unusable": "{cmd} is not usable", 36 | "parse_failed": "Test failed: {cmd} cannot parse \"{msg}\"", 37 | "check_failed": "Test failed: Arg {arg} of {cmd} expected {expected!r} but got {got!r}", 38 | "passed": "{cmd} passed the test" 39 | } 40 | }, 41 | "nbp-alc/builtin": { 42 | "lang": { 43 | "help": { 44 | "list": "View the list of supported languages", 45 | "switch": "Switch languages", 46 | "main": "Related functions for Internationalization" 47 | }, 48 | "list": "Following languages are supported:", 49 | "switch": "Switch to '{locale}' successfully.", 50 | "locale_missing": "{:At(user, $event.get_user_id())}Missing locale argument, please input:", 51 | "locale_timeout": "{:At(user, $event.get_user_id())}Timeout for waiting locale input.", 52 | "config_name_error": "{:At(user, $event.get_user_id())}Invalid name of i18n parent dir: {name}" 53 | }, 54 | "help": { 55 | "plugin_name_unknown": "Unknown plugin", 56 | "plugin_id": "ID ", 57 | "plugin_name": "Name ", 58 | "plugin_version": "Version", 59 | "plugin_module": "Module ", 60 | "plugin_path": "Path " 61 | } 62 | } 63 | } -------------------------------------------------------------------------------- /src/nonebot_plugin_alconna/uniseg/utils/fleep.py: -------------------------------------------------------------------------------- 1 | """ 2 | Name: fleep.py 3 | Description: File format determination library 4 | Author: Mykyta Paliienko 5 | License: MIT 6 | """ 7 | 8 | import json 9 | from pathlib import Path 10 | 11 | with (Path(__file__).parent / "data.json").open(encoding="utf-8") as data_file: 12 | data = json.load(data_file) 13 | 14 | 15 | class Info: 16 | """ 17 | Generates object with given arguments 18 | 19 | Args: 20 | types (list) -> list of file types 21 | extensions (list) -> list of file extensions 22 | mimes (list) -> list of file MIME types 23 | 24 | Returns: 25 | () -> Class instance 26 | """ 27 | 28 | def __init__(self, types: list, extensions: list, mimes: list): 29 | self.types = types 30 | self.extensions = extensions 31 | self.mimes = mimes 32 | 33 | def type_matches(self, type_: str): 34 | """Checks if file type matches with given type""" 35 | return type_ in self.types 36 | 37 | def extension_matches(self, extension: str): 38 | """Checks if file extension matches with given extension""" 39 | return extension in self.extensions 40 | 41 | def mime_matches(self, mime: str): 42 | """Checks if file MIME type matches with given MIME type""" 43 | return mime in self.mimes 44 | 45 | 46 | def get(obj: bytes): 47 | """ 48 | Determines file format and picks suitable file types, extensions and MIME types 49 | 50 | Args: 51 | obj (bytes) -> byte sequence (128 bytes are enough) 52 | 53 | Returns: 54 | () -> Class instance 55 | """ 56 | 57 | if not isinstance(obj, bytes): 58 | raise TypeError("object type must be bytes") 59 | 60 | stream = " ".join([f"{byte:02X}" for byte in obj]) 61 | 62 | types = {} 63 | extensions = {} 64 | mimes = {} 65 | for element in data: 66 | for signature in element["signature"]: 67 | offset = element["offset"] * 2 + element["offset"] 68 | if signature == stream[offset : len(signature) + offset]: 69 | types[element["type"]] = len(signature) 70 | extensions[element["extension"]] = len(signature) 71 | mimes[element["mime"]] = len(signature) 72 | return Info( 73 | sorted(types, key=lambda x: types.get(x, False), reverse=True), 74 | sorted(extensions.keys(), key=lambda x: extensions.get(x, False), reverse=True), 75 | sorted(mimes.keys(), key=lambda x: mimes.get(x, False), reverse=True), 76 | ) 77 | 78 | 79 | def supported_types(): 80 | """Returns a list of supported file types""" 81 | return sorted({x["type"] for x in data}) 82 | 83 | 84 | def supported_extensions(): 85 | """Returns a list of supported file extensions""" 86 | return sorted({x["extension"] for x in data}) 87 | 88 | 89 | def supported_mimes(): 90 | """Returns a list of supported file MIME types""" 91 | return sorted({x["mime"] for x in data}) 92 | -------------------------------------------------------------------------------- /src/nonebot_plugin_alconna/uniseg/builder.py: -------------------------------------------------------------------------------- 1 | from abc import ABCMeta, abstractmethod 2 | from collections.abc import Sequence 3 | from typing import Any, Callable, Generic, Optional, TypeVar, Union 4 | 5 | from nonebot.adapters import Bot, Event, Message, MessageSegment 6 | 7 | from .constraint import SupportAdapter 8 | from .segment import Other, Reply, Segment, Text, custom 9 | 10 | TS = TypeVar("TS", bound=MessageSegment) 11 | 12 | 13 | def build(*types: str): 14 | def wrapper(func: Union[Callable[[Any, TS], Optional[Segment]], Callable[[Any, TS], list[Segment]]]): 15 | if types: 16 | func.__build_target__ = types 17 | return func 18 | 19 | return wrapper 20 | 21 | 22 | class MessageBuilder(Generic[TS], metaclass=ABCMeta): 23 | _mapping: dict[ 24 | str, 25 | Union[Callable[[MessageSegment], Optional[Segment]], Callable[[MessageSegment], list[Segment]]], 26 | ] 27 | 28 | @classmethod 29 | @abstractmethod 30 | def get_adapter(cls) -> SupportAdapter: ... 31 | 32 | def wildcard_build(self, seg: TS) -> Union[Optional[Segment], list[Segment]]: 33 | return None 34 | 35 | def __init__(self): 36 | self._mapping = {} 37 | for attr in self.__class__.__dict__.values(): 38 | if callable(attr) and hasattr(attr, "__build_target__"): 39 | method = getattr(self, attr.__name__) 40 | target = attr.__build_target__ 41 | for _type in target: 42 | self._mapping[_type] = method 43 | 44 | def preprocess(self, source: Message[TS]) -> Message[TS]: 45 | return source 46 | 47 | def convert(self, seg: TS) -> Union[Segment, list[Segment]]: 48 | seg_type = seg.type 49 | if seg_type in self._mapping: 50 | res = self._mapping[seg_type](seg) 51 | if not res: 52 | return custom.solve(self, seg) or self.wildcard_build(seg) or Other(seg) 53 | if isinstance(res, list): 54 | for _seg in res: 55 | _seg.origin = seg 56 | else: 57 | res.origin = seg 58 | return res 59 | if seg.is_text(): 60 | if seg.type == "text": 61 | if "styles" in seg.data: 62 | res = Text(seg.data["text"], seg.data["styles"]) 63 | else: 64 | res = Text(seg.data["text"]) 65 | else: 66 | res = Text(seg.data["text"]).mark(0, len(seg.data["text"]), seg.type) 67 | res.origin = seg 68 | return res 69 | return custom.solve(self, seg) or self.wildcard_build(seg) or Other(seg) 70 | 71 | def generate(self, source: Sequence[TS]) -> list[Segment]: 72 | result = [] 73 | for ms in self.preprocess(source): # type: ignore 74 | seg = self.convert(ms) 75 | result.extend(seg if isinstance(seg, list) else [seg]) 76 | return result 77 | 78 | async def extract_reply(self, event: Event, bot: Bot) -> Union[Reply, None]: 79 | return 80 | -------------------------------------------------------------------------------- /src/nonebot_plugin_alconna/uniseg/params.py: -------------------------------------------------------------------------------- 1 | from typing import Annotated 2 | 3 | from nonebot.exception import SkippedException 4 | from nonebot.internal.adapter import Bot, Event, Message 5 | from nonebot.internal.params import Depends 6 | from nonebot.typing import T_State 7 | 8 | from .constraint import UNISEG_MESSAGE, UNISEG_MESSAGE_ID, UNISEG_TARGET 9 | from .exporter import SerializeFailed, Target 10 | from .functions import get_message_id, get_target 11 | from .message import UniMessage 12 | from .segment import TS 13 | 14 | 15 | async def _uni_msg(bot: Bot, event: Event, state: T_State) -> UniMessage: 16 | if event.get_type() != "message": 17 | raise SkippedException from None 18 | if UNISEG_MESSAGE in state: 19 | return state[UNISEG_MESSAGE] 20 | try: 21 | msg = event.get_message() 22 | except (NotImplementedError, ValueError): 23 | raise SkippedException from None 24 | return UniMessage.of(msg, bot=bot) 25 | 26 | 27 | async def _orig_uni_msg(bot: Bot, event: Event, state: T_State) -> UniMessage: 28 | if event.get_type() != "message": 29 | raise SkippedException from None 30 | try: 31 | msg: Message = event.get_message() 32 | except (NotImplementedError, ValueError): 33 | raise SkippedException from None 34 | try: 35 | msg: Message = getattr(event, "original_message", msg) # type: ignore 36 | except (NotImplementedError, ValueError): 37 | pass 38 | ans = UniMessage.of(msg, bot=bot) 39 | return await ans.attach_reply(event=event, bot=bot) 40 | 41 | 42 | def _target(bot: Bot, event: Event, state: T_State) -> Target: 43 | if UNISEG_TARGET in state: 44 | return state[UNISEG_TARGET] 45 | try: 46 | return get_target(event=event, bot=bot) 47 | except (SerializeFailed, NotImplementedError, ValueError): 48 | raise SkippedException from None 49 | 50 | 51 | def _msg_id(bot: Bot, event: Event, state: T_State) -> str: 52 | if UNISEG_MESSAGE_ID in state: 53 | return state[UNISEG_MESSAGE_ID] 54 | try: 55 | event.get_message() 56 | except ValueError: 57 | raise SkippedException from None 58 | return get_message_id(event=event, bot=bot) 59 | 60 | 61 | def MessageTarget() -> Target: 62 | return Depends(_target, use_cache=True) 63 | 64 | 65 | def UniversalMessage(origin: bool = False) -> UniMessage: 66 | return Depends(_orig_uni_msg, use_cache=True) if origin else Depends(_uni_msg, use_cache=True) 67 | 68 | 69 | def MessageId() -> str: 70 | return Depends(_msg_id, use_cache=True) 71 | 72 | 73 | def UniversalSegment(t: type[TS], index: int = 0) -> TS: 74 | async def _uni_seg(bot: Bot, event: Event, state: T_State) -> TS: 75 | message = await _uni_msg(bot, event, state) 76 | return message[t, index] 77 | 78 | return Depends(_uni_seg, use_cache=True) 79 | 80 | 81 | UniMsg = Annotated[UniMessage, UniversalMessage()] 82 | OriginalUniMsg = Annotated[UniMessage, UniversalMessage(origin=True)] 83 | MsgId = Annotated[str, MessageId()] 84 | MsgTarget = Annotated[Target, MessageTarget()] 85 | -------------------------------------------------------------------------------- /src/nonebot_plugin_alconna/uniseg/adapters/satori/target.py: -------------------------------------------------------------------------------- 1 | from typing import TYPE_CHECKING, Union 2 | 3 | from nonebot.adapters import Bot 4 | from nonebot.adapters.satori.bot import Bot as SatoriBot 5 | 6 | from nonebot_plugin_alconna.uniseg.constraint import SupportAdapter 7 | from nonebot_plugin_alconna.uniseg.target import Target, TargetFetcher 8 | 9 | 10 | class SatoriTargetFetcher(TargetFetcher): 11 | @classmethod 12 | def get_adapter(cls) -> SupportAdapter: 13 | return SupportAdapter.satori 14 | 15 | async def fetch(self, bot: Bot, target: Union[Target, None] = None): 16 | if TYPE_CHECKING: 17 | assert isinstance(bot, SatoriBot) 18 | if not target or target.private: 19 | friends = await bot.friend_list() 20 | for friend in friends.data: 21 | yield Target( 22 | str(friend.id), 23 | private=True, 24 | adapter=self.get_adapter(), 25 | platform=bot.platform, 26 | self_id=bot.self_id, 27 | ) 28 | while friends.next: 29 | friends = await bot.friend_list(next_token=friends.next) 30 | for friend in friends.data: 31 | yield Target( 32 | str(friend.id), 33 | private=True, 34 | adapter=self.get_adapter(), 35 | platform=bot.platform, 36 | self_id=bot.self_id, 37 | ) 38 | if not target or not target.private: 39 | if target and target.parent_id: 40 | guilds = [await bot.guild_get(guild_id=target.parent_id)] 41 | else: 42 | guilds = [] 43 | resp = await bot.guild_list() 44 | guilds.extend(resp.data) 45 | while resp.next: 46 | resp = await bot.guild_list(next_token=resp.next) 47 | guilds.extend(resp.data) 48 | for guild in guilds: 49 | channels = await bot.channel_list(guild_id=guild.id) 50 | for channel in channels.data: 51 | yield Target( 52 | str(channel.id), 53 | str(guild.id), 54 | adapter=self.get_adapter(), 55 | platform=bot.platform, 56 | self_id=bot.self_id, 57 | extra={"channel_type": channel.type}, 58 | ) 59 | while channels.next: 60 | channels = await bot.channel_list(guild_id=guild.id, next_token=channels.next) 61 | for channel in channels.data: 62 | yield Target( 63 | str(channel.id), 64 | str(guild.id), 65 | adapter=self.get_adapter(), 66 | platform=bot.platform, 67 | self_id=bot.self_id, 68 | extra={"channel_type": channel.type}, 69 | ) 70 | -------------------------------------------------------------------------------- /tests/test_i18n.py: -------------------------------------------------------------------------------- 1 | from arclet.alconna import Alconna, Args 2 | from nonebot import get_adapter 3 | from nonebot.adapters.qq import Adapter, Bot, Message, MessageSegment 4 | from nonebug import App 5 | import pytest 6 | 7 | from tests.fake import fake_message_event_guild 8 | 9 | 10 | @pytest.mark.asyncio() 11 | async def test_send(app: App): 12 | from nonebot_plugin_alconna import UniMessage, lang, on_alconna 13 | from nonebot_plugin_alconna.uniseg.segment import I18n 14 | 15 | cmd = on_alconna(Alconna("test", Args["name", str])) 16 | 17 | lang.load_data( 18 | "zh-CN", 19 | { 20 | "test-i18n": { 21 | "command.test.1": "测试!", 22 | "command.test.2": "{:At(user, $event.get_user_id())} 你好!", 23 | "command.test.3": "这是 {abcd} 测试!", 24 | "command.test.4": "这是嵌套: {:I18n(test-i18n, command.test.1)}", 25 | } 26 | }, 27 | ) 28 | lang.load_data( 29 | "en-US", 30 | { 31 | "test-i18n": { 32 | "command.test.1": "test!", 33 | "command.test.2": "{:At(user, $event.get_user_id())} hello!", 34 | "command.test.3": "This is {abcd} test!", 35 | "command.test.4": "This is nested: {test-i18n@command.test.1}", 36 | } 37 | }, 38 | ) 39 | 40 | @cmd.handle() 41 | async def _(): 42 | await cmd.send(I18n("test-i18n", "command.test.1")) 43 | await cmd.send(I18n("test-i18n", "command.test.2")) 44 | await cmd.send(cmd.i18n("test-i18n", "command.test.3", abcd="test")) 45 | await cmd.send(UniMessage.template("{test-i18n @ command.test.3:abcd=test1}")) 46 | await cmd.finish(cmd.i18n("test-i18n", "command.test.4")) 47 | 48 | lang.select("zh-CN") 49 | async with app.test_matcher(cmd) as ctx: 50 | adapter = get_adapter(Adapter) 51 | bot = ctx.create_bot(base=Bot, adapter=adapter, bot_info=None) 52 | event = fake_message_event_guild(message=Message("test aaaa"), user_id="5678") 53 | ctx.receive_event(bot, event) 54 | ctx.should_call_send(event, Message("测试!")) 55 | ctx.should_call_send(event, MessageSegment.mention_user("5678") + " 你好!") 56 | ctx.should_call_send(event, Message("这是 test 测试!")) 57 | ctx.should_call_send(event, Message("这是 test1 测试!")) 58 | ctx.should_call_send(event, Message("这是嵌套: 测试!")) 59 | ctx.should_finished() 60 | 61 | lang.select("en-US") 62 | async with app.test_matcher(cmd) as ctx: 63 | adapter = get_adapter(Adapter) 64 | bot = ctx.create_bot(base=Bot, adapter=adapter, bot_info=None) 65 | event = fake_message_event_guild(message=Message("test aaaa"), user_id="5678") 66 | ctx.receive_event(bot, event) 67 | ctx.should_call_send(event, Message("test!")) 68 | ctx.should_call_send(event, MessageSegment.mention_user("5678") + " hello!") 69 | ctx.should_call_send(event, Message("This is test test!")) 70 | ctx.should_call_send(event, Message("This is test1 test!")) 71 | ctx.should_call_send(event, Message("This is nested: test!")) 72 | ctx.should_finished() 73 | 74 | lang.select("zh-CN") 75 | -------------------------------------------------------------------------------- /src/nonebot_plugin_alconna/uniseg/adapters/ding/exporter.py: -------------------------------------------------------------------------------- 1 | from typing import Union 2 | 3 | from nonebot.adapters import Bot, Event 4 | from nonebot.adapters.ding import Bot as DingBot 5 | from nonebot.adapters.ding.event import ConversationType, MessageEvent 6 | from nonebot.adapters.ding.message import Message, MessageSegment 7 | 8 | from nonebot_plugin_alconna.uniseg.constraint import SupportScope 9 | from nonebot_plugin_alconna.uniseg.exporter import MessageExporter, SupportAdapter, Target, export 10 | from nonebot_plugin_alconna.uniseg.segment import At, AtAll, Image, Text 11 | 12 | 13 | class DingMessageExporter(MessageExporter[Message]): 14 | def get_message_type(self): 15 | return Message 16 | 17 | @classmethod 18 | def get_adapter(cls) -> SupportAdapter: 19 | return SupportAdapter.ding 20 | 21 | def get_target(self, event: Event, bot: Union[Bot, None] = None) -> Target: 22 | if isinstance(event, MessageEvent): 23 | if event.conversationType == ConversationType.private: 24 | return Target( 25 | event.senderId, 26 | private=True, 27 | adapter=self.get_adapter(), 28 | self_id=bot.self_id if bot else None, 29 | scope=SupportScope.ding, 30 | ) 31 | return Target( 32 | event.conversationId, 33 | adapter=self.get_adapter(), 34 | self_id=bot.self_id if bot else None, 35 | scope=SupportScope.ding, 36 | ) 37 | raise NotImplementedError 38 | 39 | def get_message_id(self, event: Event) -> str: 40 | assert isinstance(event, MessageEvent) 41 | return str(event.msgId) 42 | 43 | @export 44 | async def text(self, seg: Text, bot: Union[Bot, None]) -> "MessageSegment": 45 | return MessageSegment.text(seg.text) 46 | 47 | @export 48 | async def at(self, seg: At, bot: Union[Bot, None]) -> "MessageSegment": 49 | return MessageSegment.atDingtalkIds(seg.target) 50 | 51 | @export 52 | async def at_all(self, seg: AtAll, bot: Union[Bot, None]) -> "MessageSegment": 53 | return MessageSegment.atAll() 54 | 55 | @export 56 | async def image(self, seg: Image, bot: Union[Bot, None]) -> "MessageSegment": 57 | if seg.url: 58 | return MessageSegment.image(seg.url) 59 | if seg.__class__.to_url and seg.path: 60 | return MessageSegment.image( 61 | await seg.__class__.to_url(seg.path, bot, None if seg.name == seg.__default_name__ else seg.name) 62 | ) 63 | if seg.__class__.to_url and seg.raw: 64 | return MessageSegment.image( 65 | await seg.__class__.to_url(seg.raw, bot, None if seg.name == seg.__default_name__ else seg.name) 66 | ) 67 | raise ValueError("github image segment must have url") 68 | 69 | async def send_to(self, target: Union[Target, Event], bot: Bot, message: Message, **kwargs): 70 | assert isinstance(bot, DingBot) 71 | if isinstance(target, Target): 72 | raise NotImplementedError 73 | return await DingBot.send(bot, target, message=message, **kwargs) # type: ignore 74 | -------------------------------------------------------------------------------- /src/nonebot_plugin_alconna/uniseg/adapters/kook/target.py: -------------------------------------------------------------------------------- 1 | from typing import TYPE_CHECKING, Union 2 | 3 | from nonebot.adapters import Bot 4 | from nonebot.adapters.kaiheila.bot import Bot as KookBot 5 | 6 | from nonebot_plugin_alconna.uniseg.constraint import SupportAdapter 7 | from nonebot_plugin_alconna.uniseg.target import Target, TargetFetcher 8 | 9 | 10 | class KookTargetFetcher(TargetFetcher): 11 | @classmethod 12 | def get_adapter(cls) -> SupportAdapter: 13 | return SupportAdapter.kook 14 | 15 | async def fetch(self, bot: Bot, target: Union[Target, None] = None): 16 | if TYPE_CHECKING: 17 | assert isinstance(bot, KookBot) 18 | if target and not target.channel: 19 | return 20 | if target and target.parent_id: 21 | guilds = [await bot.guild_view(guild_id=target.parent_id)] 22 | else: 23 | guilds = [] 24 | resp = await bot.guild_list() 25 | if resp.guilds: 26 | guilds.extend(resp.guilds) 27 | while resp.meta and resp.meta.page != resp.meta.page_total: 28 | resp = await bot.guild_list(page=(resp.meta.page or 0) + 1) 29 | if resp.guilds: 30 | guilds.extend(resp.guilds) 31 | for guild in guilds: 32 | resp1 = await bot.channel_list(guild_id=guild.id_) # type: ignore 33 | for channel in resp1.channels or []: 34 | yield Target( 35 | str(channel.id_), 36 | str(guild.id_), 37 | channel=True, 38 | adapter=self.get_adapter(), 39 | self_id=bot.self_id, 40 | extra={"channel_type": channel.type}, 41 | ) 42 | while resp1.meta and resp1.meta.page != resp1.meta.page_total: 43 | resp1 = await bot.channel_list(guild_id=guild.id_, page=resp1.meta.page + 1) # type: ignore 44 | for channel in resp1.channels or []: 45 | yield Target( 46 | str(channel.id_), 47 | str(guild.id_), 48 | channel=True, 49 | adapter=self.get_adapter(), 50 | self_id=bot.self_id, 51 | extra={"channel_type": channel.type}, 52 | ) 53 | if not target or target.private: 54 | resp2 = await bot.userChat_list() 55 | for chat in resp2.user_chats or []: 56 | assert chat.target_info 57 | yield Target( 58 | str(chat.target_info.id_), 59 | adapter=self.get_adapter(), 60 | self_id=bot.self_id, 61 | ) 62 | while resp2.meta and resp2.meta.page != resp2.meta.page_total: 63 | resp2 = await bot.userChat_list(page=resp2.meta.page + 1) # type: ignore 64 | for chat in resp2.user_chats or []: 65 | assert chat.target_info 66 | yield Target( 67 | str(chat.target_info.id_), 68 | adapter=self.get_adapter(), 69 | self_id=bot.self_id, 70 | ) 71 | -------------------------------------------------------------------------------- /src/nonebot_plugin_alconna/builtins/extensions/markdown.py: -------------------------------------------------------------------------------- 1 | from collections.abc import Awaitable 2 | import re 3 | from typing import Callable, Optional 4 | 5 | from arclet.alconna import Alconna 6 | from arclet.alconna.tools.formatter import MarkdownTextFormatter 7 | from nonebot.internal.adapter import Bot, Event 8 | 9 | from nonebot_plugin_alconna import Extension, Image, Text, UniMessage 10 | from nonebot_plugin_alconna.extension import TM, OutputType 11 | 12 | 13 | class MarkdownOutputExtension(Extension): 14 | """ 15 | 用于将 Alconna 的自动输出转换为 Markdown 格式 16 | 17 | Example: 18 | >>> from nonebot_plugin_alconna import MsgId, on_alconna 19 | >>> from nonebot_plugin_alconna.builtins.extensions import MarkdownOutputExtension 20 | >>> 21 | >>> matcher = on_alconna("...", extensions=[MarkdownOutputExtension(escape_dot=..., text_to_image=...)]) 22 | """ 23 | 24 | @property 25 | def priority(self) -> int: 26 | return 16 27 | 28 | @property 29 | def id(self) -> str: 30 | return "builtins.extensions.markdown:MarkdownOutputExtension" 31 | 32 | def __init__(self, escape_dot: bool = False, text_to_image: Optional[Callable[[str], Awaitable[Image]]] = None): 33 | """ 34 | Args: 35 | escape_dot: 是否转义句中的点号(用来避免被识别为 url) 36 | text_to_image: 文字转图片的函数 37 | """ 38 | self.escape_dot = escape_dot 39 | self.text_to_image = text_to_image 40 | 41 | def post_init(self, alc: Alconna) -> None: 42 | alc.formatter = MarkdownTextFormatter().add(alc) 43 | 44 | async def output_converter(self, output_type: OutputType, content: str): 45 | if output_type in ("shortcut", "error"): 46 | if self.escape_dot: 47 | content = re.sub(r"\w\.\w", lambda mat: mat[0].replace(".", ". "), content) 48 | msg = UniMessage.text(content) 49 | elif output_type == "completion": 50 | content = ( 51 | content.replace("\n\n", "\n") 52 | .replace("<", "<") 53 | .replace(">", ">") 54 | .replace("{", "{") 55 | .replace("}", "}") 56 | ) 57 | if self.escape_dot: 58 | content = re.sub(r"\w\.\w", lambda mat: mat[0].replace(".", ". "), content) 59 | msg = UniMessage.text(content) 60 | else: 61 | if not content.startswith("#"): 62 | content = f"# {content}" 63 | content = ( 64 | content.replace("\n\n", "\n") 65 | .replace("\n", "\n\n") 66 | .replace("#", "##") 67 | .replace("<", "<") 68 | .replace(">", ">") 69 | ) 70 | if self.escape_dot: 71 | content = re.sub(r"\w\.\w", lambda mat: mat[0].replace(".", ". "), content) 72 | msg = UniMessage([Text(content).mark(0, len(content), "markdown")]) 73 | return msg 74 | 75 | async def send_wrapper(self, bot: Bot, event: Event, send: TM) -> TM: 76 | if self.text_to_image and isinstance(send, UniMessage) and send.has(Text): 77 | text = send[Text, 0] 78 | if text.extract_most_style() == "markdown": 79 | img = await self.text_to_image(text.text) 80 | index = send.index(text) 81 | send[index] = img 82 | return send 83 | 84 | 85 | __extension__ = MarkdownOutputExtension 86 | -------------------------------------------------------------------------------- /src/nonebot_plugin_alconna/uniseg/adapters/tailchat/builder.py: -------------------------------------------------------------------------------- 1 | from nonebot.adapters import Bot, Event 2 | from nonebot_adapter_tailchat.event import DefaultMessageEvent 3 | from nonebot_adapter_tailchat.message import MessageSegment 4 | 5 | from nonebot_plugin_alconna.uniseg.builder import MessageBuilder, build 6 | from nonebot_plugin_alconna.uniseg.constraint import SupportAdapter 7 | from nonebot_plugin_alconna.uniseg.segment import At, Emoji, File, Image, Reply, Text 8 | 9 | STYLE_TYPE_MAP = { 10 | "b": "bold", 11 | "strong": "bold", 12 | "bold": "bold", 13 | "i": "italic", 14 | "em": "italic", 15 | "italic": "italic", 16 | "u": "underline", 17 | "ins": "underline", 18 | "underline": "underline", 19 | "s": "strikethrough", 20 | "del": "strikethrough", 21 | "strike": "strikethrough", 22 | "strikethrough": "strikethrough", 23 | "spl": "spoiler", 24 | "spoiler": "spoiler", 25 | "code": "code", 26 | "sup": "superscript", 27 | "superscript": "superscript", 28 | "sub": "subscript", 29 | "subscript": "subscript", 30 | "p": "paragraph", 31 | "paragraph": "paragraph", 32 | } 33 | 34 | 35 | class TailChatMessageBuilder(MessageBuilder): 36 | @classmethod 37 | def get_adapter(cls) -> SupportAdapter: 38 | return SupportAdapter.tail_chat 39 | 40 | @build("text") 41 | def text(self, seg: MessageSegment): 42 | return Text(seg.data["text"]) 43 | 44 | @build("b") 45 | def b(self, seg: MessageSegment): 46 | return Text(seg.data["text"]).bold() 47 | 48 | @build("i") 49 | def i(self, seg: MessageSegment): 50 | return Text(seg.data["text"]).italic() 51 | 52 | @build("u") 53 | def u(self, seg: MessageSegment): 54 | return Text(seg.data["text"]).underline() 55 | 56 | @build("s") 57 | def s(self, seg: MessageSegment): 58 | return Text(seg.data["text"]).strikethrough() 59 | 60 | @build("rich") 61 | def rich(self, seg: MessageSegment): 62 | tags: list[str] = [t.__name__ for t in seg.tags] 63 | return Text(seg.data["text"]).mark(0, len(seg.data["text"]), *[STYLE_TYPE_MAP.get(t, t) for t in tags]) 64 | 65 | @build("url") 66 | def url(self, seg: MessageSegment): 67 | _url = seg.data["extra"]["url"] 68 | if _url.startswith("/main/group/"): 69 | return At("channel", _url[12:], seg.data["text"]) 70 | text = Text(_url).link() 71 | text._children = [Text(seg.data["text"])] 72 | return text 73 | 74 | @build("code") 75 | def code(self, seg: MessageSegment): 76 | return Text(seg.data["text"]).code() 77 | 78 | @build("markdown") 79 | def markdown(self, seg: MessageSegment): 80 | return Text(seg.data["text"]).markdown() 81 | 82 | @build("at") 83 | def at(self, seg: MessageSegment): 84 | return At("user", seg.data["extra"]["at"], seg.data["text"]) 85 | 86 | @build("emoji") 87 | def emoji(self, seg: MessageSegment): 88 | return Emoji(seg.data["text"], seg.data["text"]) 89 | 90 | @build("img") 91 | def img(self, seg: MessageSegment): 92 | return Image(url=seg.data["text"]) 93 | 94 | @build("file") 95 | def file(self, seg: MessageSegment): 96 | return File(url=seg.data["extra"]["url"], name=seg.data["text"]) 97 | 98 | async def extract_reply(self, event: Event, bot: Bot): 99 | if isinstance(event, DefaultMessageEvent) and event.reply: 100 | return Reply(event.reply.id, event.reply.content, event.reply) 101 | return None 102 | -------------------------------------------------------------------------------- /src/nonebot_plugin_alconna/i18n/model.py: -------------------------------------------------------------------------------- 1 | # This file is @generated by tarina.lang CLI tool 2 | # It is not intended for manual editing. 3 | 4 | from tarina.lang.model import LangItem, LangModel 5 | 6 | 7 | class NbpAlcCompletion: 8 | tab: LangItem = LangItem("nbp-alc", "completion.tab") 9 | enter: LangItem = LangItem("nbp-alc", "completion.enter") 10 | exit: LangItem = LangItem("nbp-alc", "completion.exit") 11 | other: LangItem = LangItem("nbp-alc", "completion.other") 12 | timeout: LangItem = LangItem("nbp-alc", "completion.timeout") 13 | exited: LangItem = LangItem("nbp-alc", "completion.exited") 14 | 15 | 16 | class NbpAlcLogGotPath: 17 | ms: LangItem = LangItem("nbp-alc", "log.got_path.ms") 18 | validate: LangItem = LangItem("nbp-alc", "log.got_path.validate") 19 | 20 | 21 | class NbpAlcLogDiscord: 22 | ambiguous_command: LangItem = LangItem("nbp-alc", "log.discord.ambiguous_command") 23 | ambiguous_subcommand: LangItem = LangItem("nbp-alc", "log.discord.ambiguous_subcommand") 24 | 25 | 26 | class NbpAlcLog: 27 | load_global_extensions: LangItem = LangItem("nbp-alc", "log.load_global_extensions") 28 | got_path = NbpAlcLogGotPath 29 | discord = NbpAlcLogDiscord 30 | parse: LangItem = LangItem("nbp-alc", "log.parse") 31 | 32 | 33 | class NbpAlcErrorExtension: 34 | forbid_exclude: LangItem = LangItem("nbp-alc", "error.extension.forbid_exclude") 35 | path_load: LangItem = LangItem("nbp-alc", "error.extension.path_load") 36 | path_invalid: LangItem = LangItem("nbp-alc", "error.extension.path_invalid") 37 | 38 | 39 | class NbpAlcError: 40 | discord_prefix: LangItem = LangItem("nbp-alc", "error.discord_prefix") 41 | existed_command: LangItem = LangItem("nbp-alc", "error.existed_command") 42 | extension = NbpAlcErrorExtension 43 | matcher_got_path: LangItem = LangItem("nbp-alc", "error.matcher_got_path") 44 | 45 | 46 | class NbpAlcTest: 47 | command_unusable: LangItem = LangItem("nbp-alc", "test.command_unusable") 48 | parse_failed: LangItem = LangItem("nbp-alc", "test.parse_failed") 49 | check_failed: LangItem = LangItem("nbp-alc", "test.check_failed") 50 | passed: LangItem = LangItem("nbp-alc", "test.passed") 51 | 52 | 53 | class NbpAlc: 54 | completion = NbpAlcCompletion 55 | log = NbpAlcLog 56 | error = NbpAlcError 57 | test = NbpAlcTest 58 | 59 | 60 | class NbpAlcBuiltinLangHelp: 61 | list: LangItem = LangItem("nbp-alc/builtin", "lang.help.list") 62 | switch: LangItem = LangItem("nbp-alc/builtin", "lang.help.switch") 63 | main: LangItem = LangItem("nbp-alc/builtin", "lang.help.main") 64 | 65 | 66 | class NbpAlcBuiltinLang: 67 | help = NbpAlcBuiltinLangHelp 68 | list: LangItem = LangItem("nbp-alc/builtin", "lang.list") 69 | switch: LangItem = LangItem("nbp-alc/builtin", "lang.switch") 70 | locale_missing: LangItem = LangItem("nbp-alc/builtin", "lang.locale_missing") 71 | locale_timeout: LangItem = LangItem("nbp-alc/builtin", "lang.locale_timeout") 72 | config_name_error: LangItem = LangItem("nbp-alc/builtin", "lang.config_name_error") 73 | 74 | 75 | class NbpAlcBuiltinHelp: 76 | plugin_name_unknown: LangItem = LangItem("nbp-alc/builtin", "help.plugin_name_unknown") 77 | plugin_name: LangItem = LangItem("nbp-alc/builtin", "help.plugin_name") 78 | plugin_id: LangItem = LangItem("nbp-alc/builtin", "help.plugin_id") 79 | plugin_path: LangItem = LangItem("nbp-alc/builtin", "help.plugin_path") 80 | plugin_module: LangItem = LangItem("nbp-alc/builtin", "help.plugin_module") 81 | plugin_version: LangItem = LangItem("nbp-alc/builtin", "help.plugin_version") 82 | 83 | 84 | class NbpAlcBuiltin: 85 | lang = NbpAlcBuiltinLang 86 | help = NbpAlcBuiltinHelp 87 | 88 | 89 | class Lang(LangModel): 90 | nbp_alc = NbpAlc 91 | nbp_alc_builtin = NbpAlcBuiltin 92 | -------------------------------------------------------------------------------- /intro.md: -------------------------------------------------------------------------------- 1 | # Nonebot Plugin Alconna 介绍 2 | 3 | ## 安装 4 | 5 | ```shell 6 | pip install nonebot-plugin-alconna 7 | ``` 8 | 9 | 或 10 | 11 | ```shell 12 | nb plugin install nonebot-plugin-alconna 13 | ``` 14 | 15 | ## 概览 16 | 17 | 该插件使用 [`Alconna`](https://github.com/ArcletProject/Alconna) 作为命令解析器, 18 | 其是一个简单、灵活、高效的命令参数解析器, 并且不局限于解析命令式字符串。 19 | 20 | 其特点包括: 21 | 22 | * 高效 23 | * 直观的命令组件创建方式 24 | * 强大的类型解析与类型转换功能 25 | * 自定义的帮助信息格式 26 | * 多语言支持 27 | * 易用的快捷命令创建与使用 28 | * 可创建命令补全会话, 以实现多轮连续的补全提示 29 | * 可嵌套的多级子命令 30 | * 正则匹配支持 31 | 32 | 该插件提供了一类新的事件响应器辅助函数 `on_alconna`,以及 `AlconnaResult` 等依赖注入函数。 33 | 34 | 同时,基于 [`Annotated` 支持](https://github.com/nonebot/nonebot2/pull/1832), 添加了两类注解 `AlcMatches` 与`AlcResult` 35 | 36 | 该插件还可以通过 `handle(parameterless)` 来控制一个具体的响应函数是否在不满足条件时跳过响应。 37 | 38 | 例如: 39 | - `pip.handle([Check(assign("add.name", "nb"))])` 表示仅在命令为 `role-group add` 并且 name 为 `nb` 时响应 40 | - `pip.handle([Check(assign("list"))])` 表示仅在命令为 `role-group list` 时响应 41 | - `pip.handle([Check(assign("add"))])` 表示仅在命令为 `role-group add` 时响应 42 | 43 | 该插件基于 `Alconna` 的特性,同时提供了一系列便捷的 `MessageSegment` 标注。 44 | 45 | 标注可用于在 `Alconna` 中匹配消息中除 text 外的其他 `MessageSegment`,也可用于快速创建各适配器下的 `MessageSegment`。 46 | 47 | 所有标注位于 `nonebot_plugin_alconna.adapters` 中。 48 | 49 | ## 展示 50 | 51 | ```python 52 | from nonebot.adapters.onebot.v12 import Message 53 | from nonebot_plugin_alconna import on_alconna, AlconnaMatches, At 54 | from nonebot_plugin_alconna.adapters.onebot12 import Image 55 | from arclet.alconna import Alconna, Args, Option, Arparma, Subcommand, MultiVar 56 | 57 | alc = Alconna( 58 | "role-group", 59 | Subcommand( 60 | "add", Args["name", str], 61 | Option("member", Args["target", MultiVar(At)]), 62 | ), 63 | Option("list"), 64 | ) 65 | rg = on_alconna(alc, auto_send_output=True) 66 | 67 | @rg.handle() 68 | async def _(result: Arparma = AlconnaMatches()): 69 | if result.find("list"): 70 | img = await gen_role_group_list_image() 71 | await rg.finish(Message([Image(img)])) 72 | if result.find("add"): 73 | group = await create_role_group(result["add.name"]) 74 | if result.find("add.member"): 75 | ats: tuple[At] = result["add.member.target"] 76 | group.extend(member.target for member in ats) 77 | await rg.finish("添加成功") 78 | ``` 79 | 80 | 我们可以看到主要的两大组件:**Option** 与 **Subcommand**。 81 | 82 | `Option` 可以传入一组 `alias`,如 `Option("--foo|-F|--FOO|-f")` 或 `Option("--foo", alias=["-F"]` 83 | 84 | `Subcommand` 则可以传入自己的 **Option** 与 **Subcommand**: 85 | 86 | 他们拥有如下共同参数: 87 | 88 | - `help_text`: 传入该组件的帮助信息 89 | - `dest`: 被指定为解析完成时标注匹配结果的标识符,不传入时默认为选项或子命令的名称 (name) 90 | - `requires`: 一段指定顺序的字符串列表,作为唯一的前置序列与命令嵌套替换 91 | - `default`: 默认值,在该组件未被解析时使用使用该值替换。 92 | 93 | 其次使用了 `MessageSegment` 标注,其中 `At` 属于通用标注,而 `Image` 属于 `onebot12` 适配器下的标注。 94 | 95 | `on_alconna` 的所有参数如下: 96 | 97 | - `command: Alconna | str`: Alconna 命令 98 | - `skip_for_unmatch: bool = True`: 是否在命令不匹配时跳过该响应 99 | - `auto_send_output: bool = False`: 是否自动发送输出信息并跳过响应 100 | - `aliases: set[str | tuple[str, ...]] | None = None`: 命令别名, 作用类似于 `on_command` 中的 aliases 101 | - `comp_config: CompConfig | None = None`: 补全会话配置, 不传入则不启用补全会话 102 | - `extensions`: 需要加载的匹配扩展, 可以是扩展类或扩展实例 103 | - `exclude_ext`: 需要排除的匹配扩展, 可以是扩展类或扩展的id 104 | - `use_origin: bool = False`: 是否使用未经 to_me 等处理过的消息 105 | - `use_cmd_start`: 是否使用 COMMAND_START 作为命令前缀 106 | - `use_cmd_sep`: 是否使用 COMMAND_SEP 作为命令分隔符 107 | 108 | `AlconnaMatches` 是一个依赖注入函数,可注入 `Alconna` 命令解析结果。 109 | 110 | ## References 111 | 112 | Nonebot 文档: [📚文档](https://nonebot.dev/docs/next/best-practice/alconna/alconna) 113 | 114 | 官方文档: [👉指路](https://arclet.top/) 115 | 116 | QQ 交流群: [🔗链接](https://jq.qq.com/?_wv=1027&k=PUPOnCSH) 117 | 118 | 友链: [📦这里](https://graiax.cn/guide/message_parser/alconna.html) 119 | -------------------------------------------------------------------------------- /tests/test_satori.py: -------------------------------------------------------------------------------- 1 | from arclet.alconna import Alconna, Args 2 | from nepattern import Dot 3 | from nonebot import get_adapter 4 | from nonebot.adapters.satori import Adapter, Bot, Message, MessageSegment 5 | from nonebot.adapters.satori.element import parse 6 | from nonebug import App 7 | import pytest 8 | 9 | from tests.fake import fake_message_event_satori, fake_satori_bot_params 10 | 11 | 12 | def test_message_rollback(): 13 | from nonebot_plugin_alconna import Image, UniMessage, select 14 | 15 | text = """\ 16 | 捏 17 | 18 | 19 | """ 20 | msg = Message.from_satori_element(parse(text)) 21 | 22 | text1 = '捏' 23 | 24 | msg1 = Message.from_satori_element(parse(text1)) 25 | 26 | alc = Alconna("捏", Args["img", Dot(select(Image).first, str, "url")]) 27 | 28 | res = alc.parse(msg, {"$adapter.name": "Satori"}) 29 | assert res.matched 30 | assert res.query[str]("img") == "http://127.0.0.1:5500/v1/assets/eyJ0eXBlIjoibWF..." 31 | 32 | res1 = alc.parse(msg1, {"$adapter.name": "Satori"}) 33 | assert res1.matched 34 | assert res1.query[str]("img") == "http://127.0.0.1:5500/v1/assets/eyJ0eXBlIjoibWF..." 35 | 36 | assert UniMessage.text("123").style("\n", "br").text("456").export_sync(adapter="Satori") == Message( 37 | [ 38 | MessageSegment.text("123"), 39 | MessageSegment.br(), 40 | MessageSegment.text("456"), 41 | ] 42 | ) 43 | 44 | 45 | @pytest.mark.asyncio() 46 | async def test_satori(app: App): 47 | from nonebot_plugin_alconna import Bold, Italic, Underline 48 | 49 | msg = Message("/command some_arg some_arg some_arg") 50 | 51 | alc = Alconna("/command", Args["some_arg", Bold]["some_arg1", Underline]["some_arg2", Bold + Italic]) 52 | 53 | res = alc.parse(msg, {"$adapter.name": "Satori"}) 54 | assert res.matched 55 | some_arg: MessageSegment = res["some_arg"] 56 | assert some_arg.type == "text" 57 | assert str(some_arg) == "some_arg" 58 | some_arg1: MessageSegment = res["some_arg1"] 59 | assert some_arg1.type == "text" 60 | assert some_arg1.data["styles"] == {(0, 8): ["underline"]} 61 | some_arg2: MessageSegment = res["some_arg2"] 62 | assert some_arg2.type == "text" 63 | assert some_arg2.data["styles"] == {(0, 8): ["bold", "italic"]} 64 | 65 | msg1 = "/command " + Bold("foo bar baz") 66 | 67 | alc1 = Alconna("/command", Args["foo", str]["bar", Bold]["baz", Bold]) 68 | 69 | res1 = alc1.parse(msg1) 70 | assert res1.matched 71 | assert isinstance(res1.foo, str) 72 | assert res1["bar"].type == "text" 73 | assert res1["baz"].data["text"] == "baz" 74 | 75 | msg2 = "/command " + Bold("foo bar baz") 76 | 77 | alc2 = Alconna("/command", Args["foo", str]["bar", Bold]["baz", Underline]) 78 | assert not alc2.parse(msg2).matched 79 | 80 | 81 | @pytest.mark.asyncio() 82 | async def test_send(app: App): 83 | from nonebot_plugin_alconna import Image, Text, on_alconna 84 | 85 | test_cmd = on_alconna(Alconna("test", Args["img", Image])) 86 | 87 | @test_cmd.handle() 88 | async def tt_h(img: Image): 89 | await test_cmd.send(Text("ok") + img) 90 | 91 | async with app.test_matcher(test_cmd) as ctx: 92 | adapter = get_adapter(Adapter) 93 | bot = ctx.create_bot(base=Bot, adapter=adapter, **fake_satori_bot_params()) 94 | msg = "test" + MessageSegment.image(raw=b"123", mime="image/png") 95 | event = fake_message_event_satori(message=msg, id=123) 96 | ctx.receive_event(bot, event) 97 | ctx.should_call_send(event, Message('ok')) 98 | -------------------------------------------------------------------------------- /src/nonebot_plugin_alconna/uniseg/adapters/wxmp/exporter.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | from typing import Union 3 | 4 | from nonebot.adapters import Bot, Event 5 | from nonebot.adapters.wxmp import Bot as WXMPBot 6 | from nonebot.adapters.wxmp.event import Event as WXMPEvent 7 | from nonebot.adapters.wxmp.event import MessageEvent 8 | from nonebot.adapters.wxmp.message import EmjoyType, Message, MessageSegment 9 | from tarina import lang 10 | 11 | from nonebot_plugin_alconna.uniseg.constraint import SerializeFailed, SupportScope 12 | from nonebot_plugin_alconna.uniseg.exporter import MessageExporter, SupportAdapter, Target, export 13 | from nonebot_plugin_alconna.uniseg.segment import Audio, Emoji, Hyper, Image, Text, Video, Voice 14 | 15 | 16 | class WXMPMessageExporter(MessageExporter[Message]): 17 | def get_message_type(self): 18 | return Message 19 | 20 | @classmethod 21 | def get_adapter(cls) -> SupportAdapter: 22 | return SupportAdapter.wxmp 23 | 24 | def get_target(self, event: Event, bot: Union[Bot, None] = None) -> Target: 25 | assert isinstance(event, WXMPEvent) 26 | return Target( 27 | event.get_user_id(), 28 | private=True, 29 | adapter=self.get_adapter(), 30 | self_id=bot.self_id if bot else None, 31 | scope=SupportScope.wechat_oap, 32 | ) 33 | 34 | def get_message_id(self, event: Event) -> str: 35 | assert isinstance(event, MessageEvent) 36 | return str(event.message_id) 37 | 38 | @export 39 | async def text(self, seg: Text, bot: Union[Bot, None]) -> "MessageSegment": 40 | if not seg.styles: 41 | return MessageSegment.text(seg.text) 42 | style = seg.extract_most_style() 43 | if style == "link": 44 | title = desc = url = seg.text 45 | if getattr(seg, "_children", []): 46 | title = desc = seg._children[0].text # type: ignore 47 | return MessageSegment.link(title, desc, url) 48 | return MessageSegment.text(seg.text) 49 | 50 | @export 51 | async def media(self, seg: Union[Image, Voice, Video, Audio], bot: Union[Bot, None]) -> "MessageSegment": 52 | name = seg.__class__.__name__.lower() 53 | methods = { 54 | "image": MessageSegment.image, 55 | "voice": MessageSegment.voice, 56 | "video": MessageSegment.video, 57 | "audio": MessageSegment.voice, 58 | } 59 | if seg.id: 60 | return methods[name](media_id=seg.id) 61 | if seg.raw: 62 | if name in ("voice", "audio"): 63 | return MessageSegment.voice(file=seg.raw_bytes, format=seg.mimetype) 64 | return methods[name](file=seg.raw_bytes) 65 | if seg.path: 66 | return methods[name](file_path=Path(seg.path)) 67 | if seg.url and name == "image": 68 | return MessageSegment.image(file_url=seg.url) # type: ignore 69 | raise SerializeFailed(lang.require("nbp-uniseg", "invalid_segment").format(type=name, seg=seg)) 70 | 71 | @export 72 | async def emoji(self, seg: Emoji, bot: Union[Bot, None]) -> "MessageSegment": 73 | t = EmjoyType(seg.name) 74 | return MessageSegment.emjoy(t) 75 | 76 | @export 77 | async def hyper(self, seg: Hyper, bot: Union[Bot, None]) -> "MessageSegment": 78 | if isinstance(seg.content, dict): 79 | return MessageSegment.miniprogrampage(**seg.content) 80 | raise SerializeFailed(lang.require("nbp-uniseg", "invalid_segment").format(type="hyper", seg=seg)) 81 | 82 | async def send_to(self, target: Union[Target, Event], bot: Bot, message: Message, **kwargs): 83 | assert isinstance(bot, WXMPBot) 84 | 85 | if isinstance(target, Event): 86 | return await bot.send(target, message, **kwargs) # type: ignore 87 | return await bot.send_custom_message(user_id=target.id, message=message) 88 | -------------------------------------------------------------------------------- /src/nonebot_plugin_alconna/uniseg/adapters/ntchat/exporter.py: -------------------------------------------------------------------------------- 1 | from typing import TYPE_CHECKING, Union 2 | 3 | from nonebot.adapters import Bot, Event 4 | from tarina import lang 5 | 6 | from nonebot_plugin_alconna.uniseg.constraint import SupportScope 7 | from nonebot_plugin_alconna.uniseg.exporter import MessageExporter, SerializeFailed, SupportAdapter, Target, export 8 | from nonebot_plugin_alconna.uniseg.segment import File, Hyper, Image, Text, Video 9 | 10 | if TYPE_CHECKING: 11 | from nonebot.adapters.ntchat.message import Message, MessageSegment # type: ignore 12 | 13 | 14 | class NTChatMessageExporter(MessageExporter["Message"]): 15 | def get_message_type(self): 16 | from nonebot.adapters.ntchat.message import Message # type: ignore 17 | 18 | return Message 19 | 20 | @classmethod 21 | def get_adapter(cls) -> SupportAdapter: 22 | return SupportAdapter.ntchat 23 | 24 | def get_target(self, event: Event, bot: Union[Bot, None] = None) -> Target: 25 | from_wxid = getattr(event, "from_wxid", None) 26 | room_wxid = getattr(event, "room_wxid", "") 27 | if from_wxid: 28 | return Target( 29 | from_wxid, 30 | room_wxid, 31 | adapter=self.get_adapter(), 32 | self_id=bot.self_id if bot else None, 33 | scope=SupportScope.wechat, 34 | ) 35 | raise NotImplementedError 36 | 37 | def get_message_id(self, event: Event) -> str: 38 | from nonebot.adapters.ntchat.event import MessageEvent # type: ignore 39 | 40 | assert isinstance(event, MessageEvent) 41 | return str(event.msgid) # type: ignore 42 | 43 | @export 44 | async def text(self, seg: Text, bot: Union[Bot, None]) -> "MessageSegment": 45 | from nonebot.adapters.ntchat.message import MessageSegment # type: ignore 46 | 47 | return MessageSegment.text(seg.text) 48 | 49 | @export 50 | async def res(self, seg: Union[Image, File, Video], bot: Union[Bot, None]) -> "MessageSegment": 51 | from nonebot.adapters.ntchat.message import MessageSegment # type: ignore 52 | 53 | name = seg.__class__.__name__.lower() 54 | method = { 55 | "image": MessageSegment.image, 56 | "video": MessageSegment.video, 57 | "file": MessageSegment.file, 58 | }[name] 59 | if seg.path: 60 | return method(seg.path) 61 | if seg.raw: 62 | return method(seg.raw_bytes) 63 | if seg.url or seg.id: 64 | return method(seg.url or seg.id) # type: ignore 65 | raise SerializeFailed(lang.require("nbp-uniseg", "invalid_segment").format(type=name, seg=seg)) 66 | 67 | @export 68 | async def hyper(self, seg: Hyper, bot: Union[Bot, None]) -> "MessageSegment": 69 | from nonebot.adapters.ntchat.message import MessageSegment # type: ignore 70 | 71 | if seg.format == "json" and seg.content and "card_wxid" in seg.content: 72 | return MessageSegment.card(seg.content["card_wxid"]) # type: ignore 73 | if seg.format != "xml" or not seg.raw: 74 | raise SerializeFailed(lang.require("nbp-uniseg", "invalid_segment").format(type="hyper", seg=seg)) 75 | return MessageSegment.xml(seg.raw) 76 | 77 | async def send_to(self, target: Union[Target, Event], bot: Bot, message: "Message", **kwargs): 78 | from nonebot.adapters.ntchat.bot import Bot as NTChatBot # type: ignore 79 | from nonebot.adapters.ntchat.bot import send # type: ignore 80 | 81 | assert isinstance(bot, NTChatBot) 82 | 83 | if isinstance(target, Event): 84 | return await send(bot, target, message, **kwargs) # type: ignore 85 | 86 | class FakeEvent: 87 | from_wxid = target.id 88 | if target.parent_id: 89 | room_wxid = target.parent_id 90 | 91 | return await send(bot, FakeEvent, message, **kwargs) # type: ignore 92 | -------------------------------------------------------------------------------- /src/nonebot_plugin_alconna/uniseg/adapters/heybox/exporter.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | from typing import TYPE_CHECKING, Union 3 | 4 | from nonebot.adapters import Bot, Event 5 | from nonebot.adapters.heybox.bot import Bot as HeyboxBot # type: ignore 6 | from nonebot.adapters.heybox.event import UserIMMessageEvent # type: ignore 7 | from nonebot.adapters.heybox.message import Message, MessageSegment # type: ignore 8 | from tarina import lang 9 | 10 | from nonebot_plugin_alconna.uniseg.constraint import SupportScope 11 | from nonebot_plugin_alconna.uniseg.exporter import MessageExporter, SerializeFailed, SupportAdapter, Target, export 12 | from nonebot_plugin_alconna.uniseg.segment import At, Image, Reply, Text 13 | 14 | 15 | class HeyboxMessageExporter(MessageExporter[Message]): 16 | @classmethod 17 | def get_adapter(cls) -> SupportAdapter: 18 | return SupportAdapter.heybox 19 | 20 | def get_message_type(self): 21 | return Message 22 | 23 | def get_target(self, event: Event, bot: Union[Bot, None] = None) -> Target: 24 | if isinstance(event, UserIMMessageEvent): 25 | return Target( 26 | event.channel_id, # type: ignore 27 | parent_id=event.room_id, # type: ignore 28 | channel=True, 29 | adapter=self.get_adapter(), 30 | self_id=bot.self_id if bot else None, 31 | scope=SupportScope.heybox, 32 | ) 33 | raise NotImplementedError 34 | 35 | def get_message_id(self, event: Event) -> str: 36 | if isinstance(event, UserIMMessageEvent): 37 | return str(event.im_seq) # type: ignore 38 | raise NotImplementedError 39 | 40 | @export 41 | async def text(self, seg: Text, bot: Union[Bot, None]) -> "MessageSegment": 42 | return MessageSegment.text(seg.text) 43 | 44 | @export 45 | async def at(self, seg: At, bot: Union[Bot, None]) -> "MessageSegment": 46 | if seg.flag == "user": 47 | return MessageSegment.mention(seg.target) 48 | raise SerializeFailed( 49 | lang.require("nbp-uniseg", "failed_segment").format(adapter="qq", seg=seg, target="mention") 50 | ) 51 | 52 | @export 53 | async def image(self, seg: Image, bot: Union[Bot, None]) -> "MessageSegment": 54 | if seg.url: 55 | return MessageSegment.image(url=seg.url, width=seg.width or 0, height=seg.height or 0) 56 | if seg.path: 57 | path = Path(seg.path) 58 | return MessageSegment.local_image( 59 | path.read_bytes(), 60 | width=seg.width or 0, 61 | height=seg.height or 0, 62 | filename=path.name if seg.name == seg.__default_name__ else seg.name, 63 | ) 64 | if seg.raw: 65 | return MessageSegment.local_image( 66 | seg.raw_bytes, width=seg.width or 0, height=seg.height or 0, filename=seg.name 67 | ) 68 | raise SerializeFailed(lang.require("nbp-uniseg", "invalid_segment").format(type="image", seg=seg)) 69 | 70 | @export 71 | async def reply(self, seg: Reply, bot: Union[Bot, None]) -> "MessageSegment": 72 | return MessageSegment("$heybox:reply", {"message_id": seg.id}) # type: ignore 73 | 74 | async def send_to(self, target: Union[Target, Event], bot: Bot, message: Message, **kwargs): 75 | assert isinstance(bot, HeyboxBot) 76 | 77 | reply_id = None 78 | if message.has("$heybox:reply"): 79 | reply_id = message["$heybox:reply", 0].data["message_id"] 80 | message = message.exclude("$heybox:reply") 81 | 82 | if isinstance(target, Event): 83 | if TYPE_CHECKING: 84 | assert isinstance(target, UserIMMessageEvent) 85 | return await bot.send(event=target, message=message, **kwargs, is_reply=reply_id is not None) 86 | return await bot.send_to_channel( 87 | target.parent_id, # type: ignore 88 | target.id, 89 | message, 90 | reply_id=reply_id, 91 | ) 92 | -------------------------------------------------------------------------------- /src/nonebot_plugin_alconna/uniseg/adapters/efchat/exporter.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | from typing import TYPE_CHECKING, Union 3 | 4 | from nonebot.adapters import Bot, Event 5 | from nonebot.adapters.efchat.bot import Bot as EFBot 6 | from nonebot.adapters.efchat.event import ChannelMessageEvent, MessageEvent 7 | from nonebot.adapters.efchat.message import Message, MessageSegment 8 | from tarina import lang 9 | 10 | from nonebot_plugin_alconna.uniseg.constraint import SupportScope 11 | from nonebot_plugin_alconna.uniseg.exporter import MessageExporter, SerializeFailed, SupportAdapter, Target, export 12 | from nonebot_plugin_alconna.uniseg.segment import At, Audio, Image, Reply, Text, Voice 13 | 14 | 15 | class EFChatMessageExporter(MessageExporter["Message"]): 16 | def get_message_type(self): 17 | return Message 18 | 19 | @classmethod 20 | def get_adapter(cls) -> SupportAdapter: 21 | return SupportAdapter.efchat 22 | 23 | def get_target(self, event: Event, bot: Union[Bot, None] = None) -> Target: 24 | if isinstance(event, ChannelMessageEvent): 25 | return Target( 26 | id=event.channel, 27 | self_id=bot.self_id if bot else None, 28 | scope=SupportScope.efchat, 29 | adapter=self.get_adapter(), 30 | ) 31 | if nick := getattr(event, "nick", None): 32 | return Target( 33 | id=nick, 34 | private=True, 35 | self_id=bot.self_id if bot else None, 36 | scope=SupportScope.efchat, 37 | adapter=self.get_adapter(), 38 | ) 39 | raise NotImplementedError 40 | 41 | def get_message_id(self, event: Event) -> str: 42 | assert isinstance(event, MessageEvent) 43 | return f"> {event.trip} {event.nick}:\n> {event.get_message()}\n\n" 44 | 45 | @export 46 | async def text(self, seg: Text, bot: Union[Bot, None]) -> "MessageSegment": 47 | return MessageSegment.text(seg.text) 48 | 49 | @export 50 | async def at(self, seg: At, bot: Union[Bot, None]) -> "MessageSegment": 51 | if seg.flag != "user": 52 | raise SerializeFailed(lang.require("nbp-uniseg", "invalid_segment").format(type="at", seg=seg)) 53 | return MessageSegment.at(seg.target) 54 | 55 | @export 56 | async def image(self, seg: Image, bot: Union[Bot, None]) -> "MessageSegment": 57 | if seg.url: 58 | return MessageSegment.image(url=seg.url) 59 | if seg.path: 60 | return MessageSegment.image(path=seg.path) 61 | if seg.raw: 62 | return MessageSegment.image(raw=seg.raw_bytes) 63 | raise SerializeFailed(lang.require("nbp-uniseg", "invalid_segment").format(type="image", seg=seg)) 64 | 65 | @export 66 | async def voice(self, seg: Union[Voice, Audio], bot: Union[Bot, None]) -> "MessageSegment": 67 | if seg.url: 68 | return MessageSegment.voice(url=seg.url) 69 | if seg.path: 70 | return MessageSegment.voice(path=Path(seg.path)) 71 | if seg.raw: 72 | return MessageSegment.voice(raw=seg.raw_bytes) 73 | if seg.id: 74 | return MessageSegment.voice(src_name=seg.id) 75 | raise SerializeFailed(lang.require("nbp-uniseg", "invalid_segment").format(type="voice", seg=seg)) 76 | 77 | @export 78 | async def reply(self, seg: Reply, bot: Union[Bot, None]) -> "MessageSegment": 79 | return MessageSegment.text(seg.id) 80 | 81 | async def send_to(self, target: Union[Target, Event], bot: Bot, message: Message, **kwargs): 82 | assert isinstance(bot, EFBot) 83 | if TYPE_CHECKING: 84 | assert isinstance(message, self.get_message_type()) 85 | 86 | if isinstance(target, MessageEvent): 87 | return await bot.send(target, message, **kwargs) 88 | if isinstance(target, Event): 89 | raise NotImplementedError 90 | if target.private: 91 | await bot.call_api("whisper", nick=target.id, text=str(message)) 92 | await bot.move(target.id) 93 | await bot.send_chat_message(message=message, **kwargs) 94 | -------------------------------------------------------------------------------- /src/nonebot_plugin_alconna/uniseg/adapters/feishu/target.py: -------------------------------------------------------------------------------- 1 | from typing import TYPE_CHECKING, Union 2 | 3 | from nonebot.adapters import Bot 4 | from nonebot.adapters.feishu.bot import Bot as FeishuBot 5 | 6 | from nonebot_plugin_alconna.uniseg.constraint import SupportAdapter 7 | from nonebot_plugin_alconna.uniseg.target import Target, TargetFetcher 8 | 9 | 10 | class FeishuTargetFetcher(TargetFetcher): 11 | @classmethod 12 | def get_adapter(cls) -> SupportAdapter: 13 | return SupportAdapter.feishu 14 | 15 | async def fetch(self, bot: Bot, target: Union[Target, None] = None): 16 | if TYPE_CHECKING: 17 | assert isinstance(bot, FeishuBot) 18 | if target and target.channel: 19 | return 20 | if not target or not target.private: 21 | result = await bot.call_api("im/v1/chats", method="GET") 22 | for chat in result["data"]["items"]: 23 | yield Target( 24 | chat["chat_id"], 25 | adapter=self.get_adapter(), 26 | self_id=bot.self_id, 27 | ) 28 | while result["data"]["has_more"]: 29 | result = await bot.call_api( 30 | "im/v1/chats", method="GET", params={"page_token": result["data"]["page_token"]} 31 | ) 32 | for chat in result["data"]["items"]: 33 | yield Target( 34 | chat["chat_id"], 35 | adapter=self.get_adapter(), 36 | self_id=bot.self_id, 37 | ) 38 | if target and target.private and target.parent_id: 39 | params = {"department_id": target.parent_id} 40 | result = await bot.call_api("contact/v3/users/find_by_department", method="GET") 41 | for user in result["data"]["items"]: 42 | yield Target( 43 | user["open_id"], 44 | target.parent_id, 45 | private=True, 46 | adapter=self.get_adapter(), 47 | self_id=bot.self_id, 48 | ) 49 | while result["data"]["has_more"]: 50 | params["page_token"] = result["data"]["page_token"] 51 | result = await bot.call_api("contact/v3/users/find_by_department", method="GET", params=params) 52 | for user in result["data"]["items"]: 53 | yield Target( 54 | user["open_id"], 55 | target.parent_id, 56 | private=True, 57 | adapter=self.get_adapter(), 58 | self_id=bot.self_id, 59 | ) 60 | result = await bot.call_api("contact/v3/group/simplelist", method="GET") 61 | groups = [group["id"] for group in result["data"]["grouplist"]] 62 | while result["data"]["has_more"]: 63 | result = await bot.call_api( 64 | "contact/v3/group/simplelist", method="GET", params={"page_token": result["data"]["page_token"]} 65 | ) 66 | groups.extend(group["id"] for group in result["data"]["grouplist"]) 67 | for group_id in groups: 68 | result = await bot.call_api(f"contact/v3/group/{group_id}/member/simplelist", method="GET") 69 | for user in result["data"]["memberlist"]: 70 | yield Target( 71 | user["member_id"], 72 | private=True, 73 | adapter=self.get_adapter(), 74 | self_id=bot.self_id, 75 | ) 76 | while result["data"]["has_more"]: 77 | result = await bot.call_api( 78 | f"contact/v3/group/{group_id}/member/simplelist", 79 | method="GET", 80 | params={"page_token": result["data"]["page_token"]}, 81 | ) 82 | for user in result["data"]["memberlist"]: 83 | yield Target( 84 | user["member_id"], 85 | private=True, 86 | adapter=self.get_adapter(), 87 | self_id=bot.self_id, 88 | ) 89 | --------------------------------------------------------------------------------