├── src └── nekobox │ ├── __init__.py │ ├── apis │ ├── types.py │ ├── utils.py │ ├── __init__.py │ └── handler.py │ ├── consts.py │ ├── uid.py │ ├── msgid.py │ ├── events │ ├── utils.py │ ├── __init__.py │ └── handler.py │ ├── log.py │ ├── transformer.py │ ├── utils.py │ ├── main.py │ └── __main__.py ├── main.py ├── .github ├── actions │ └── setup-python │ │ └── action.yml └── workflows │ └── release.yml ├── pyproject.toml ├── README.md ├── .gitignore └── LICENSE /src/nekobox/__init__.py: -------------------------------------------------------------------------------- 1 | from importlib.metadata import version 2 | 3 | from .main import NekoBoxAdapter as NekoBoxAdapter 4 | 5 | __version__ = version("nekobox") 6 | -------------------------------------------------------------------------------- /src/nekobox/apis/types.py: -------------------------------------------------------------------------------- 1 | from typing import Any, Callable, Coroutine 2 | 3 | from satori.server import Request 4 | from lagrange.client.client import Client 5 | 6 | API_HANDLER = Callable[[Client, Request], Coroutine[None, None, Any]] 7 | -------------------------------------------------------------------------------- /src/nekobox/consts.py: -------------------------------------------------------------------------------- 1 | from typing import Optional 2 | from satori.server import Server 3 | 4 | PLATFORM = "nekobox" 5 | SERVER: Optional[Server] = None 6 | 7 | 8 | def _set_server(server: Server): 9 | global SERVER 10 | SERVER = server 11 | return server 12 | 13 | 14 | def get_server(): 15 | return SERVER 16 | -------------------------------------------------------------------------------- /main.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | from satori.server import Server 4 | 5 | from nekobox import NekoBoxAdapter 6 | 7 | server = Server(host="localhost", port=7777, token="fa1ccfd6a9fcac523f3af2f67575e54230b1aef5df69a6886a3bae140e39a13b") 8 | server.apply( 9 | NekoBoxAdapter( 10 | int(os.environ.get("LAGRANGE_UIN", "0")), 11 | os.environ.get("LAGRANGE_SIGN_URL", ""), 12 | "linux", 13 | "DEBUG", 14 | ) 15 | ) 16 | server.run() 17 | -------------------------------------------------------------------------------- /src/nekobox/apis/utils.py: -------------------------------------------------------------------------------- 1 | from typing import Union 2 | 3 | from satori import Api 4 | from lagrange.client.client import Client 5 | from satori.server import Adapter, Request 6 | 7 | from .types import API_HANDLER 8 | 9 | 10 | def register_api( 11 | api: Union[Api, str], 12 | adapter: Adapter, 13 | client: Client, 14 | handler: API_HANDLER, 15 | ): 16 | @adapter.route(api) 17 | async def handler_wrapper(request: Request): 18 | return await handler(client, request) 19 | -------------------------------------------------------------------------------- /.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.10" 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:all 21 | shell: bash -------------------------------------------------------------------------------- /src/nekobox/uid.py: -------------------------------------------------------------------------------- 1 | from typing import Dict 2 | 3 | uid_dict: Dict[int, str] = {} 4 | 5 | 6 | def resolve_uid(uin: int) -> str: 7 | if uin in uid_dict: 8 | return uid_dict[uin] 9 | raise ValueError(f"uin {uin} not in uid_dict") 10 | 11 | 12 | def resolve_uin(uid: str) -> int: 13 | for k, v in uid_dict.items(): 14 | if v == uid: 15 | return k 16 | raise ValueError(f"uid {uid} not found in uid_dict") 17 | 18 | 19 | def save_uid(uin: int, uid: str) -> None: 20 | if uin not in uid_dict: 21 | uid_dict[uin] = uid 22 | -------------------------------------------------------------------------------- /src/nekobox/msgid.py: -------------------------------------------------------------------------------- 1 | from typing import Tuple 2 | 3 | # msg type map: 4 | # 1: guild(group) 5 | # 2: private(friend) 6 | 7 | 8 | def encode_msgid(typ: int, seq: int) -> str: 9 | if typ == 1: 10 | msg_id = str(seq) 11 | elif typ == 2: 12 | msg_id = f"private:{seq}" 13 | else: 14 | raise ValueError(f"Unsupported message type: {typ}") 15 | return msg_id 16 | 17 | 18 | def decode_msgid(msg_id: str) -> Tuple[int, int]: 19 | if msg_id.isdigit(): # guild 20 | return 1, int(msg_id) 21 | elif msg_id.find("private:") == 0: 22 | return 2, int(msg_id[len("private:") :]) 23 | else: 24 | raise ValueError(f"Invalid msg id: {msg_id}") 25 | -------------------------------------------------------------------------------- /src/nekobox/events/utils.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | from typing import Type, TypeVar, Callable, Optional, Coroutine 3 | 4 | from loguru import logger 5 | from satori import EventType, Login 6 | from satori.server import Event 7 | from lagrange.client.client import Client 8 | from lagrange.client.events import BaseEvent 9 | 10 | TEvent = TypeVar("TEvent", bound=BaseEvent) 11 | LOGIN_GETTER = Callable[[], Login] 12 | 13 | 14 | def event_register( 15 | client: Client, 16 | queue: asyncio.Queue[Event], 17 | event_type: Type[TEvent], 18 | handler: Callable[["Client", TEvent, Login], Coroutine[None, None, Optional[Event]]], 19 | login_getter: LOGIN_GETTER, 20 | ): 21 | async def _after_handle(_client: Client, event: TEvent): 22 | ev = await handler(_client, event, login_getter()) 23 | if ev: 24 | if ev.type != EventType.MESSAGE_CREATED: 25 | logger.trace(f"Event '{ev.type}' was triggered") 26 | await queue.put(ev) 27 | 28 | client.events.subscribe(event_type, handler=_after_handle) 29 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | # from https://github.com/RF-Tar-Railt/nonebot-plugin-template/blob/main/.github/workflows/release.yml 2 | name: Release action 3 | 4 | on: 5 | push: 6 | tags: 7 | - v* 8 | 9 | jobs: 10 | release: 11 | runs-on: ubuntu-latest 12 | permissions: 13 | id-token: write 14 | contents: write 15 | steps: 16 | - uses: actions/checkout@v4 17 | 18 | - name: Setup Python environment 19 | uses: ./.github/actions/setup-python 20 | 21 | - name: Get Version 22 | id: version 23 | run: | 24 | echo "VERSION=$(pdm show --version)" >> $GITHUB_OUTPUT 25 | echo "TAG_VERSION=${GITHUB_REF#refs/tags/v}" >> $GITHUB_OUTPUT 26 | echo "TAG_NAME=${GITHUB_REF#refs/tags/}" >> $GITHUB_OUTPUT 27 | 28 | - name: Check Version 29 | if: steps.version.outputs.VERSION != steps.version.outputs.TAG_VERSION 30 | run: exit 1 31 | 32 | - name: Publish Package 33 | run: | 34 | pdm publish 35 | gh release upload --clobber ${{ steps.version.outputs.TAG_NAME }} dist/*.tar.gz dist/*.whl 36 | env: 37 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 38 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [project] 2 | name = "nekobox" 3 | version = "0.1.6" 4 | description = "A Satori backend powered by lagrange-python" 5 | authors = [ 6 | {name = "wyapx", email = "admin@nullcat.cn"}, 7 | {name = "RF-Tar-Railt", email = "rf_tar_railt@qq.com"} 8 | ] 9 | dependencies = [ 10 | "satori-python-server>=0.16.3", 11 | "lagrange-python>=0.1.7", 12 | ] 13 | requires-python = ">=3.9" 14 | readme = "README.md" 15 | license = {text = "AGPL"} 16 | 17 | [project.optional-dependencies] 18 | audio = ["pysilk-mod"] 19 | 20 | [project.scripts] 21 | nekobox = "nekobox.__main__:main" 22 | 23 | [build-system] 24 | requires = ["pdm-backend"] 25 | build-backend = "pdm.backend" 26 | 27 | [tool.pdm] 28 | distribution = true 29 | 30 | [tool.pdm.scripts] 31 | format = {composite = ["isort .", "black ."]} 32 | 33 | [tool.pdm.dev-dependencies] 34 | dev = [ 35 | "satori-python>=0.16.3", 36 | "pysilk-mod", 37 | "isort>=5.13.2", 38 | "black>=24.4.2", 39 | "lagrange-python @ git+https://github.com/LagrangeDev/lagrange-python@broken", 40 | ] 41 | 42 | [tool.black] 43 | line-length = 110 44 | target-version = ["py39", "py310", "py311", "py312"] 45 | include = '\.pyi?$' 46 | extend-exclude = ''' 47 | ''' 48 | 49 | [tool.isort] 50 | profile = "black" 51 | line_length = 110 52 | length_sort = true 53 | skip_gitignore = true 54 | force_sort_within_sections = true 55 | extra_standard_library = ["typing_extensions"] 56 | -------------------------------------------------------------------------------- /src/nekobox/apis/__init__.py: -------------------------------------------------------------------------------- 1 | from typing import List, Tuple 2 | 3 | from satori.server import Api, Adapter 4 | from lagrange.client.client import Client 5 | 6 | from .types import API_HANDLER 7 | from .utils import register_api 8 | from .handler import ( 9 | msg_get, 10 | msg_create, 11 | msg_delete, 12 | friend_list, 13 | channel_list, 14 | friend_channel, 15 | guild_get_list, 16 | reaction_clear, 17 | reaction_create, 18 | reaction_delete, 19 | guild_member_get, 20 | guild_member_kick, 21 | guild_member_list, 22 | guild_member_mute, 23 | guild_member_req_approve, 24 | ) 25 | 26 | __all__ = ["apply_api_handlers"] 27 | 28 | ALL_APIS: List[Tuple[Api, API_HANDLER]] = [ 29 | (Api.MESSAGE_CREATE, msg_create), 30 | (Api.MESSAGE_DELETE, msg_delete), 31 | (Api.MESSAGE_GET, msg_get), 32 | (Api.GUILD_MEMBER_KICK, guild_member_kick), 33 | (Api.GUILD_MEMBER_MUTE, guild_member_mute), 34 | (Api.GUILD_LIST, guild_get_list), 35 | (Api.GUILD_MEMBER_GET, guild_member_get), 36 | (Api.GUILD_MEMBER_LIST, guild_member_list), 37 | (Api.CHANNEL_LIST, channel_list), 38 | (Api.USER_CHANNEL_CREATE, friend_channel), 39 | (Api.GUILD_MEMBER_APPROVE, guild_member_req_approve), 40 | (Api.FRIEND_LIST, friend_list), 41 | (Api.REACTION_CREATE, reaction_create), 42 | (Api.REACTION_DELETE, reaction_delete), 43 | (Api.REACTION_CLEAR, reaction_clear), 44 | ] 45 | 46 | 47 | def apply_api_handlers(adapter: Adapter, client: Client): 48 | for api, api_handler in ALL_APIS: 49 | register_api(api, adapter, client, api_handler) 50 | -------------------------------------------------------------------------------- /src/nekobox/events/__init__.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | 3 | from satori import Event 4 | from lagrange.client.client import Client 5 | from lagrange.client.events.friend import FriendMessage 6 | from lagrange.client.events.service import ClientOnline, ClientOffline 7 | from lagrange.client.events.group import ( 8 | GroupRecall, 9 | GroupMessage, 10 | GroupReaction, 11 | GroupMemberQuit, 12 | GroupNameChanged, 13 | GroupMemberJoined, 14 | GroupMemberJoinedByInvite, 15 | GroupMemberJoinRequest, 16 | ) 17 | 18 | from .utils import event_register, LOGIN_GETTER 19 | from .handler import ( 20 | on_grp_msg, 21 | on_friend_msg, 22 | on_grp_recall, 23 | on_member_quit, 24 | on_grp_reaction, 25 | on_client_online, 26 | on_member_joined, 27 | on_client_offline, 28 | on_grp_name_changed, 29 | on_grp_member_request, 30 | ) 31 | 32 | __all__ = ["apply_event_handler"] 33 | ALL_EVENT_HANDLERS = [ 34 | (GroupMessage, on_grp_msg), 35 | (FriendMessage, on_friend_msg), 36 | (GroupRecall, on_grp_recall), 37 | (ClientOnline, on_client_online), 38 | (ClientOffline, on_client_offline), 39 | (GroupMemberJoined, on_member_joined), 40 | (GroupMemberJoinedByInvite, on_member_joined), 41 | (GroupMemberQuit, on_member_quit), 42 | (GroupNameChanged, on_grp_name_changed), 43 | (GroupMemberJoinRequest, on_grp_member_request), 44 | (GroupReaction, on_grp_reaction), 45 | ] 46 | 47 | 48 | def apply_event_handler(client: Client, queue: asyncio.Queue[Event], login_getter: LOGIN_GETTER): 49 | for event, ev_handler in ALL_EVENT_HANDLERS: 50 | event_register(client, queue, event, ev_handler, login_getter) 51 | -------------------------------------------------------------------------------- /src/nekobox/log.py: -------------------------------------------------------------------------------- 1 | import sys 2 | import logging 3 | import traceback 4 | from typing import Optional 5 | from types import TracebackType 6 | 7 | from loguru import logger 8 | from lagrange.utils.log import log 9 | 10 | 11 | def loguru_exc_callback(cls: type[BaseException], val: BaseException, tb: Optional[TracebackType], *_, **__): 12 | """loguru 异常回调 13 | 14 | Args: 15 | cls (Type[Exception]): 异常类 16 | val (Exception): 异常的实际值 17 | tb (TracebackType): 回溯消息 18 | """ 19 | logger.opt(exception=(cls, val, tb)).error("Exception:") 20 | 21 | 22 | def loguru_exc_callback_async(loop, context: dict): 23 | """loguru 异步异常回调 24 | 25 | Args: 26 | loop (AbstractEventLoop): 异常发生的事件循环 27 | context (dict): 异常上下文 28 | """ 29 | message = context.get("message") or "Unhandled exception in event loop" 30 | if ( 31 | handle := context.get("handle") 32 | ) and handle._callback.__qualname__ == "ClientConnectionRider.connection_manage..": 33 | logger.warning("Uncompleted aiohttp transport", style="yellow bold") 34 | return 35 | exception = context.get("exception") 36 | if exception is None: 37 | exc_info = False 38 | else: 39 | exc_info = (type(exception), exception, exception.__traceback__) 40 | if ( 41 | "source_traceback" not in context 42 | and loop._current_handle is not None 43 | and loop._current_handle._source_traceback 44 | ): 45 | context["handle_traceback"] = loop._current_handle._source_traceback 46 | 47 | log_lines = [message] 48 | for key in sorted(context): 49 | if key in {"message", "exception"}: 50 | continue 51 | value = context[key] 52 | if key == "handle_traceback": 53 | tb = "".join(traceback.format_list(value)) 54 | value = "Handle created at (most recent call last):\n" + tb.rstrip() 55 | elif key == "source_traceback": 56 | tb = "".join(traceback.format_list(value)) 57 | value = "Object created at (most recent call last):\n" + tb.rstrip() 58 | else: 59 | value = repr(value) 60 | log_lines.append(f"{key}: {value}") 61 | 62 | logger.opt(exception=exc_info).error("\n".join(log_lines)) 63 | 64 | 65 | def patch_logging(level="INFO"): 66 | for name in logging.root.manager.loggerDict: 67 | _logger = logging.getLogger(name) 68 | for handler in _logger.handlers: 69 | if isinstance(handler, logging.StreamHandler): 70 | _logger.removeHandler(handler) 71 | sys.excepthook = loguru_exc_callback 72 | traceback.print_exception = loguru_exc_callback 73 | log.set_level(level) 74 | logger.add( 75 | "./logs/latest.log", 76 | format="{time:MM-DD HH:mm:ss} | {level: <8} | {name} | {message}", 77 | level=level.upper(), 78 | enqueue=False, 79 | rotation="00:00", 80 | compression="zip", 81 | encoding="utf-8", 82 | backtrace=True, 83 | diagnose=True, 84 | colorize=False, 85 | ) 86 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # NekoBox 2 | 3 | 基于 [`lagrange-python`](https://github.com/LagrangeDev/lagrange-python) 的 4 | [`Satori协议`](https://satori.js.org/zh-CN) 实现,使用 [`satori-python`](https://github.com/RF-Tar-Railt/satori-python) 5 | 6 | 7 | ## 如何启动 8 | 9 | 使用稳定版(推荐) 10 | 11 | ```shell 12 | # 安装环境 13 | python -m pip install nekobox 14 | 15 | # 可选依赖,用于支持非silk格式audio发送 16 | python -m pip install pysilk-mod 17 | 18 | # 跟随步骤生成配置文件后,使用手Q扫码即可登录 19 | nekobox run 20 | ``` 21 | 22 | 使用开发版 23 | 24 | ```shell 25 | # 安装环境 26 | git clone https://github.com/wyapx/nekobox 27 | cd nekobox 28 | 29 | python -m pip install . 30 | 31 | # 可选依赖,用于支持非silk格式audio发送 32 | python -m pip install pysilk-mod 33 | 34 | # 跟随步骤生成配置文件后,使用手Q扫码即可登录 35 | nekobox run 36 | ``` 37 | 38 | 使用 `PDM`: 39 | 40 | ```shell 41 | # 安装环境 42 | git clone https://github.com/wyapx/nekobox 43 | cd nekobox 44 | 45 | pdm install 46 | 47 | # 可选依赖,用于支持非silk格式audio发送 48 | pdm sync --group audio 49 | 50 | # 跟随步骤生成配置文件后,使用手Q扫码即可登录 51 | pdm run nekobox run 52 | ``` 53 | 54 | ## CLI 工具 55 | 56 | ```shell 57 | $ nekobox 58 | usage: nekobox [-h] {run,gen,clear,default} ... 59 | 60 | NekoBox/lagrange-python-satori Server 工具 61 | 62 | options: 63 | -h, --help show this help message and exit 64 | -v, --version show program's version number and exit 65 | 66 | commands: 67 | {run,gen,list,show,clear,delete,default} 68 | run 启动服务器 69 | gen 生成或更新配置文件 70 | list 列出所有账号 71 | show 显示账号配置 72 | clear 清除数据 73 | delete 删除账号配置 74 | default 设置默认账号 75 | ``` 76 | 77 | ### 生成或更新配置文件 78 | 79 | 使用 `nekobox gen` 生成或更新配置文件。 80 | 81 | 当未传入 `uin` 参数或 `uin` 为 `?` 时,若配置文件已存在则会出现交互式选择: 82 | 83 | ```shell 84 | $ nekobox gen 85 | 正在更新配置文件... 86 | - 987654 87 | - 123456 88 | 请选择一个账号 (987654): 89 | ``` 90 | 91 | ### 启动服务器 92 | 93 | 使用 `nekobox run` 启动服务器。 94 | - 若未传入 `uin` 参数,会使用默认账号(可由 `nekobox default` 指定)。 95 | - 若 `uin` 为 `?`,会出现交互式选择。 96 | 97 | ```shell 98 | $ nekobox run ? 99 | - 987654 100 | - 123456 101 | 请选择一个账号 (987654): 102 | ``` 103 | - 可以使用 `--debug` 参数强制日志启用调试等级。 104 | 105 | 106 | ## 特性支持情况 107 | 108 | 1. 消息类型 109 | - [x] Text 110 | - [x] At, AtAll 111 | - [x] Image 112 | - [x] Quote 113 | - [x] Audio 114 | 115 | 2. 主动操作 116 | - [x] message-create 117 | - [x] message-delete `部分支持:群聊` 118 | - [x] message-get `部分支持:群聊` 119 | - [x] guild-member-kick 120 | - [x] guild-member-mute 121 | - [x] guild-member-get 122 | - [x] guild-member-list 123 | - [x] guild-list 124 | - [x] channel-list 125 | - [x] login-get 126 | - [x] user-channel-create `用于向 user 发起私聊 (前提是好友)` 127 | - [x] guild-member-approve 128 | - [x] friend-list 129 | - [x] reaction-create 130 | - [x] reaction-delete 131 | - [x] reaction-clear 132 | 133 | 3. 事件 134 | - [x] message-created 135 | - [x] message-deleted `部分支持:群聊` 136 | - [x] guild-member-added 137 | - [x] guild-member-removed 138 | - [x] guild-updated `群名更改` 139 | - [x] guild-member-request 140 | - [x] reaction-added 141 | - [x] reaction-deleted 142 | 143 | 由于大多数事件和操作没有标准参考,特性的新增可能需要一些时间. 144 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | share/python-wheels/ 24 | *.egg-info/ 25 | .installed.cfg 26 | *.egg 27 | MANIFEST 28 | 29 | # PyInstaller 30 | # Usually these files are written by a python script from a template 31 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 32 | *.manifest 33 | *.spec 34 | 35 | # Installer logs 36 | pip-log.txt 37 | pip-delete-this-directory.txt 38 | 39 | # Unit test / coverage reports 40 | htmlcov/ 41 | .tox/ 42 | .nox/ 43 | .coverage 44 | .coverage.* 45 | .cache 46 | nosetests.xml 47 | coverage.xml 48 | *.cover 49 | *.py,cover 50 | .hypothesis/ 51 | .pytest_cache/ 52 | cover/ 53 | 54 | # Translations 55 | *.mo 56 | *.pot 57 | 58 | # Django stuff: 59 | *.log 60 | local_settings.py 61 | db.sqlite3 62 | db.sqlite3-journal 63 | 64 | # Flask stuff: 65 | instance/ 66 | .webassets-cache 67 | 68 | # Scrapy stuff: 69 | .scrapy 70 | 71 | # Sphinx documentation 72 | docs/_build/ 73 | 74 | # PyBuilder 75 | .pybuilder/ 76 | target/ 77 | 78 | # Jupyter Notebook 79 | .ipynb_checkpoints 80 | 81 | # IPython 82 | profile_default/ 83 | ipython_config.py 84 | 85 | # pyenv 86 | # For a library or package, you might want to ignore these files since the code is 87 | # intended to run in multiple environments; otherwise, check them in: 88 | # .python-version 89 | 90 | # pipenv 91 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 92 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 93 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 94 | # install all needed dependencies. 95 | #Pipfile.lock 96 | 97 | # poetry 98 | # Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. 99 | # This is especially recommended for binary packages to ensure reproducibility, and is more 100 | # commonly ignored for libraries. 101 | # https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control 102 | #poetry.lock 103 | 104 | # pdm 105 | # Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. 106 | #pdm.lock 107 | # pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it 108 | # in version control. 109 | # https://pdm.fming.dev/#use-with-ide 110 | .pdm.toml 111 | 112 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm 113 | __pypackages__/ 114 | 115 | # Celery stuff 116 | celerybeat-schedule 117 | celerybeat.pid 118 | 119 | # SageMath parsed files 120 | *.sage.py 121 | 122 | # Environments 123 | .env 124 | .venv 125 | env/ 126 | venv/ 127 | ENV/ 128 | env.bak/ 129 | venv.bak/ 130 | 131 | # Spyder project settings 132 | .spyderproject 133 | .spyproject 134 | 135 | # Rope project settings 136 | .ropeproject 137 | 138 | # mkdocs documentation 139 | /site 140 | 141 | # mypy 142 | .mypy_cache/ 143 | .dmypy.json 144 | dmypy.json 145 | 146 | # Pyre type checker 147 | .pyre/ 148 | 149 | # pytype static type analyzer 150 | .pytype/ 151 | 152 | # Cython debug symbols 153 | cython_debug/ 154 | 155 | # PyCharm 156 | # JetBrains specific template is maintained in a separate JetBrains.gitignore that can 157 | # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore 158 | # and can be added to the global gitignore or merged into this file. For a more nuclear 159 | # option (not recommended) you can uncomment the following to ignore the entire idea folder. 160 | .idea/ 161 | 162 | bots/ 163 | logs/ -------------------------------------------------------------------------------- /src/nekobox/transformer.py: -------------------------------------------------------------------------------- 1 | import os 2 | import sys 3 | import base64 4 | from io import BytesIO 5 | from pathlib import Path 6 | from urllib.parse import quote, unquote, unquote_to_bytes 7 | from typing import TYPE_CHECKING, List, Tuple, Union 8 | 9 | from yarl import URL 10 | from loguru import logger 11 | from satori import At as SatoriAt 12 | from satori import Link as SatoriLink 13 | from satori import Text as SatoriText 14 | from satori import Audio as SatoriAudio 15 | from satori import Image as SatoriImage 16 | from satori import Quote as SatoriQuote 17 | from satori import Author as SatoriAuthor 18 | from satori.element import Br as SatoriBr 19 | from satori import Element as SatoriElement 20 | from satori import Message as SatoriMessage 21 | from satori.element import Style as SatoriStyle 22 | from lagrange.client.message.types import Element 23 | from satori.element import Custom as SatoriCustom 24 | from satori.element import Paragraph as SatoriParagraph 25 | from lagrange.client.message.elems import At, Text, AtAll, Audio, Image, Quote, MarketFace 26 | from starlette.responses import FileResponse 27 | 28 | from .consts import PLATFORM, get_server 29 | from .utils import get_public_ip, transform_audio, download_resource 30 | 31 | if TYPE_CHECKING: 32 | from lagrange.client.client import Client 33 | 34 | 35 | def encode_data_url(data: Union[str, bytes], mime_type=""): 36 | if isinstance(data, str): 37 | encoded = quote(data) 38 | elif isinstance(data, bytes): 39 | encoded = base64.b64encode(data) 40 | mime_type += ";base64" 41 | else: 42 | raise TypeError(f"Type {type(data)} not supported") 43 | return f"data:{mime_type},{encoded}" 44 | 45 | 46 | def decode_data_url(url: str) -> Tuple[str, bytes]: 47 | if url.find("data:") != 0: 48 | raise ValueError("Not a valid Data URL") 49 | head, data = url[5:].split(",", 1) 50 | if head.find(";") != -1: 51 | mime, enc_type = head.split(";", 1) 52 | if enc_type == "base64": 53 | decoded = base64.b64decode(data) 54 | else: 55 | raise TypeError(f"Type {enc_type} not supported") 56 | else: 57 | mime = head 58 | decoded = unquote(data).encode() 59 | return mime, decoded 60 | 61 | 62 | async def parse_resource(url: str) -> bytes: 63 | logger.debug(f"loading resource: {url[:80]}") 64 | if url.startswith("internal:"): 65 | server = get_server() 66 | if not server: 67 | raise ValueError("No server found") 68 | resp = await server.fetch_proxy(url) 69 | if isinstance(resp, FileResponse): 70 | with open(resp.path, "rb") as f: 71 | return f.read() 72 | return bytes(resp.body) 73 | if url.find("http") == 0: 74 | return await download_resource(url) 75 | elif url.find("data") == 0: 76 | _, data = decode_data_url(url) 77 | return data 78 | elif url.find("file://") == 0: 79 | if sys.version_info >= (3, 13): 80 | path = Path.from_uri(url) 81 | else: 82 | decoded = os.fsdecode( 83 | unquote_to_bytes(url[8:] if sys.platform == "win32" else url[7:]) 84 | ) 85 | path = Path(decoded) 86 | if not path.exists(): 87 | raise FileNotFoundError(f"File not found: {path.absolute()}") 88 | with open(path, "rb") as f: 89 | return f.read() 90 | else: 91 | raise ValueError(f"Unsupported URL: {url}") 92 | 93 | 94 | async def msg_to_satori(msgs: List[Element], self_uin: int, gid=None, uid=None) -> List[SatoriElement]: 95 | new_msg: List[SatoriElement] = [] 96 | for m in msgs: 97 | if isinstance(m, At): 98 | new_msg.append(SatoriAt(str(m.uin), m.text)) 99 | elif isinstance(m, AtAll): 100 | new_msg.append(SatoriAt.all()) 101 | elif isinstance(m, Quote): 102 | new_msg.append(SatoriQuote(str(m.seq))(SatoriAuthor(str(m.uin)), m.msg)) 103 | elif isinstance(m, (Image, MarketFace)): 104 | url = URL(m.url.replace("&", "&")) 105 | if "rkey" in url.query: 106 | url = url.with_query({k: v for k, v in url.query.items() if k != "rkey"}) 107 | if server := get_server(): 108 | url = URL(server.url_base) / "proxy" / str(url) 109 | if url.host == "0.0.0.0": 110 | url = url.with_host(get_public_ip()) 111 | new_msg.append(SatoriImage.of(str(url), extra={"width": m.width, "height": m.height})) 112 | elif isinstance(m, Audio): 113 | assert gid or uid, "gid or uid must be specified" 114 | new_msg.append( 115 | SatoriAudio( 116 | f"internal:{PLATFORM}/{self_uin}" 117 | f"/audio/{'gid' if gid else 'uid'}/{gid or uid}/{m.file_key}", 118 | title=m.text, 119 | duration=m.time 120 | ) 121 | ) 122 | elif isinstance(m, Text): 123 | new_msg.append(SatoriText(m.text)) 124 | else: 125 | logger.warning("cannot parse message to satori " + repr(m)[:100]) 126 | return new_msg 127 | 128 | 129 | async def satori_to_msg(client: "Client", msgs: List[SatoriElement], *, grp_id=0, uid="") -> List[Element]: 130 | new_msg: List[Element] = [] 131 | for m in msgs: 132 | if isinstance(m, SatoriAt): 133 | if m.type: 134 | new_msg.append(AtAll("@全体成员")) 135 | elif m.id: 136 | new_msg.append(At(f"@{m.name or m.id}", int(m.id), "")) 137 | elif isinstance(m, SatoriQuote): 138 | target = await client.get_grp_msg(grp_id, int(m.id or 0)) 139 | new_msg.append(Quote.build(target[0])) 140 | elif isinstance(m, SatoriImage): 141 | data = await parse_resource(m.src) 142 | if grp_id: 143 | new_msg.append(await client.upload_grp_image(BytesIO(data), grp_id)) 144 | elif uid: 145 | new_msg.append(await client.upload_friend_image(BytesIO(data), uid)) 146 | else: 147 | raise AssertionError 148 | elif isinstance(m, SatoriText): 149 | new_msg.append(Text(m.text)) 150 | elif isinstance(m, SatoriLink): 151 | parsed = await satori_to_msg(client, m._children, grp_id=grp_id, uid=uid) 152 | new_msg.extend(parsed) 153 | new_msg.append(Text(f"{': ' if parsed else ''}{m.url}")) 154 | elif isinstance(m, (SatoriMessage, SatoriStyle)): 155 | if isinstance(m, SatoriBr): 156 | new_msg.append(Text("\n")) 157 | else: 158 | new_msg.extend(await satori_to_msg(client, m._children, grp_id=grp_id, uid=uid)) 159 | if isinstance(m, SatoriParagraph): 160 | new_msg.append(Text("\n")) 161 | elif isinstance(m, SatoriAudio): 162 | data = await transform_audio(BytesIO(await parse_resource(m.src))) 163 | if grp_id: 164 | new_msg.append(await client.upload_grp_audio(data, grp_id)) 165 | elif uid: 166 | new_msg.append(await client.upload_friend_audio(data, uid)) 167 | else: 168 | raise AssertionError 169 | elif isinstance(m, SatoriCustom): 170 | if m.type == "template": 171 | new_msg.extend(await satori_to_msg(client, m._children, grp_id=grp_id, uid=uid)) 172 | else: 173 | logger.warning("unknown message type on Custom: %s", m.type) 174 | else: 175 | logger.warning("cannot trans message to lag " + repr(m)[:100]) 176 | return new_msg 177 | -------------------------------------------------------------------------------- /src/nekobox/utils.py: -------------------------------------------------------------------------------- 1 | import os 2 | import ssl 3 | import socket 4 | import asyncio 5 | import warnings 6 | from io import BytesIO 7 | from shutil import which 8 | from typing import BinaryIO 9 | from urllib.request import getproxies 10 | from tempfile import TemporaryDirectory 11 | 12 | from lagrange.utils.audio.enum import AudioType 13 | from loguru import logger 14 | from lagrange.utils.audio.decoder import decode 15 | from lagrange.utils.httpcat import HttpCat, HttpResponse 16 | 17 | try: 18 | from pysilk import async_encode_file, async_decode 19 | except ImportError: 20 | async_encode_file = None 21 | async_decode = None 22 | 23 | 24 | def get_public_ip(): 25 | st = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) 26 | try: 27 | st.connect(("10.255.255.255", 1)) 28 | IP = st.getsockname()[0] 29 | except Exception: 30 | IP = "localhost" 31 | finally: 32 | st.close() 33 | return IP 34 | 35 | 36 | class HttpCatProxies(HttpCat): 37 | @classmethod 38 | async def _parse_proxy_response(cls, reader: asyncio.StreamReader) -> HttpResponse: 39 | stat = await cls._read_line(reader) 40 | if not stat: 41 | raise ConnectionResetError 42 | _, code, status = stat.split(" ", 2) 43 | header = {} 44 | cookies = {} 45 | while True: 46 | head_block = await cls._read_line(reader) 47 | if head_block: 48 | k, v = head_block.split(": ") 49 | if k.title() == "Set-Cookie": 50 | name, value = v[: v.find(";")].split("=", 1) 51 | cookies[name] = value 52 | else: 53 | header[k.title()] = v 54 | else: 55 | break 56 | return HttpResponse(int(code), status, header, b"", cookies) 57 | 58 | async def connect_http_proxy(self, url: str, conn_timeout=0): 59 | address, path, with_ssl = self._parse_url(url) 60 | if conn_timeout: 61 | reader, writer = await asyncio.wait_for( 62 | asyncio.open_connection(*address, ssl=with_ssl), conn_timeout 63 | ) 64 | else: 65 | reader, writer = await asyncio.open_connection(*address, ssl=with_ssl) 66 | addr = f"{self.host}:{self.port}" 67 | await self._request(addr, reader, writer, "CONNECT", addr, header=self.header, wait_rsp=False) 68 | rsp = await self._parse_proxy_response(reader) 69 | 70 | logger.debug(f"open_tunnel[{rsp.code}]: {rsp.status}") 71 | if rsp.code == 200: 72 | self._reader = reader 73 | self._writer = writer 74 | else: 75 | raise ConnectionError(f"proxy error: {rsp.code}") 76 | 77 | async def send_request( 78 | self, method: str, path: str, body=None, follow_redirect=True, conn_timeout=0 79 | ) -> HttpResponse: 80 | if not (self._reader and self._writer): 81 | proxies = getproxies() 82 | if "http" in proxies: 83 | await self.connect_http_proxy(proxies.get("http"), conn_timeout) 84 | if self.ssl: 85 | loop = asyncio.get_running_loop() 86 | self._writer._protocol._over_ssl = True # noqa, suppress warning 87 | _transport = await loop.start_tls( 88 | self._writer.transport, 89 | self._writer.transport.get_protocol(), 90 | ssl.create_default_context(), 91 | server_side=False, 92 | server_hostname=self.host, 93 | ) 94 | self._writer._transport = _transport 95 | return await super().send_request(method, path, body, follow_redirect, conn_timeout) 96 | 97 | 98 | async def download_resource(url: str, retry=5, timeout=10) -> bytes: 99 | if retry > 0: 100 | try: 101 | address, path, with_ssl = HttpCatProxies._parse_url(url) 102 | async with HttpCatProxies(*address, ssl=with_ssl) as req: 103 | rsp = await req.send_request("GET", path, conn_timeout=timeout) 104 | length = int(rsp.header.get("Content-Length", 0)) 105 | if length and length != len(rsp.body): 106 | raise BufferError(f"Content-Length mismatch: {length} != {len(rsp.body)}") 107 | elif rsp.code != 200: 108 | raise LookupError(f"Request failed with status {rsp.code}:{rsp.status} {rsp.text()}") 109 | else: 110 | return rsp.decompressed_body 111 | except (asyncio.TimeoutError, BufferError, ConnectionError) as e: 112 | logger.error(f"Request failed: {repr(e)}") 113 | except ssl.SSLError as e: 114 | # Suppress SSL Error 115 | # like: [SSL: APPLICATION_DATA_AFTER_CLOSE_NOTIFY] application data after close notify (_ssl.c:2706) 116 | logger.error(f"SSL error: {repr(e)}") 117 | return await download_resource(url, retry - 1, timeout=timeout) 118 | else: 119 | raise ConnectionError(f"Request failed after many tries") 120 | 121 | 122 | async def transform_audio(audio: BinaryIO) -> BinaryIO: 123 | try: 124 | typ = decode(audio) 125 | except ValueError: 126 | typ = None 127 | 128 | if typ: 129 | return audio 130 | elif not typ and async_encode_file: 131 | ffmpeg = which("ffmpeg") 132 | if not ffmpeg: 133 | raise RuntimeError("ffmpeg not found, transform fail") 134 | 135 | with TemporaryDirectory() as temp_dir: 136 | input_path = os.path.join(temp_dir, f"{os.urandom(16).hex()}.tmp") 137 | with open(input_path, "wb") as f: 138 | f.write(audio.read()) 139 | 140 | out_path = os.path.join(temp_dir, f"{os.urandom(16).hex()}.tmp") 141 | proc = await asyncio.create_subprocess_exec( 142 | ffmpeg, "-i", input_path, "-f", "s16le", "-ar", "24000", "-ac", "1", "-y", out_path 143 | ) 144 | if await proc.wait() != 0: 145 | raise ProcessLookupError(proc.returncode) 146 | 147 | data = await async_encode_file(out_path) 148 | return BytesIO(data) 149 | else: 150 | raise RuntimeError("module 'pysilk-mod' not install, transform fail") 151 | 152 | 153 | async def decode_audio(typ: AudioType, audio: bytes) -> bytes: 154 | """audio to wav""" 155 | if async_decode and (typ == AudioType.tx_silk or typ == AudioType.silk_v3): 156 | return await async_decode(audio, to_wav=True) 157 | elif typ == AudioType.amr and (ffmpeg := which("ffmpeg")): 158 | with TemporaryDirectory() as temp_dir: 159 | input_path = os.path.join(temp_dir, f"{os.urandom(16).hex()}.tmp") 160 | with open(input_path, "wb") as f: 161 | f.write(audio) 162 | 163 | out_path = os.path.join(temp_dir, f"{os.urandom(16).hex()}.tmp") 164 | proc = await asyncio.create_subprocess_exec( 165 | ffmpeg, "-i", input_path, "-ab", "12.2k", "-ar", "16000", "-ac", "1", "-y", "-f", "wav", out_path 166 | ) 167 | if await proc.wait() != 0: 168 | raise ProcessLookupError(proc.returncode) 169 | 170 | with open(out_path, "rb") as f: 171 | return f.read() 172 | raise NotImplementedError(typ) 173 | 174 | 175 | def decode_audio_available(typ: AudioType) -> bool: 176 | if typ == AudioType.tx_silk or typ == AudioType.silk_v3: 177 | if not async_decode: 178 | warnings.warn("module 'pysilk-mod' not install, decode fail") 179 | else: 180 | return True 181 | elif typ == AudioType.amr: 182 | if not which("ffmpeg"): 183 | warnings.warn("ffmpeg not found, decode fail") 184 | else: 185 | return True 186 | return False 187 | -------------------------------------------------------------------------------- /src/nekobox/main.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import asyncio 4 | from io import BytesIO 5 | from pathlib import Path 6 | from contextlib import suppress 7 | from typing import Set, List, Literal, Optional 8 | from datetime import datetime, timedelta 9 | 10 | from loguru import logger 11 | from lagrange import version 12 | from qrcode.main import QRCode 13 | from satori.server import Adapter 14 | from lagrange.info import InfoManager 15 | from lagrange.info.app import AppInfo, app_list 16 | from lagrange.client.client import Client 17 | from launart import Launart, any_completed 18 | from satori import User, LoginStatus, Api 19 | from satori.model import Login 20 | from satori.server import Request 21 | from lagrange.utils.sign import sign_provider 22 | from lagrange.utils.audio.decoder import decode 23 | from starlette.responses import Response 24 | from graia.amnesia.builtins.memcache import MemcacheService 25 | 26 | from .consts import PLATFORM, _set_server 27 | from .utils import HttpCatProxies, decode_audio_available, decode_audio 28 | from .log import patch_logging 29 | from .apis import apply_api_handlers 30 | from .events import apply_event_handler 31 | 32 | 33 | class NekoBoxAdapter(Adapter): 34 | def ensure_manager(self, manager: Launart): 35 | super().ensure_manager(manager) 36 | with suppress(ValueError): 37 | manager.add_component(MemcacheService()) 38 | 39 | def get_platform(self) -> str: 40 | return PLATFORM 41 | 42 | @staticmethod 43 | def proxy_urls() -> List[str]: 44 | return [ 45 | "http://thirdqq.qlogo.cn", 46 | "https://p.qlogo.cn/", 47 | "https://q1.qlogo.cn", 48 | "https://gchat.qpic.cn", 49 | "https://multimedia.nt.qq.com.cn/", 50 | ] 51 | 52 | async def publisher(self): 53 | seq = 0 54 | while True: 55 | ev = await self.queue.get() 56 | ev.id = seq 57 | yield ev 58 | seq += 1 59 | 60 | def ensure(self, platform: str, self_id: str) -> bool: 61 | # upload://{platform}/{self_id}/{path}... 62 | return platform == PLATFORM and self_id == str(self.uin) 63 | 64 | async def handle_internal(self, request: Request, path: str) -> Response: 65 | if not path.startswith("_raw"): 66 | res_typ, src_typ, src, key = path.split("/", 3) 67 | if res_typ == "audio": 68 | if src_typ == "gid": 69 | link = await self.client.fetch_audio_url(key, gid=int(src)) 70 | elif src_typ == "uid": 71 | link = await self.client.fetch_audio_url(key, uid=src) 72 | else: 73 | raise ValueError(f"Unknown source type: {src_typ}") 74 | raw = await HttpCatProxies.request( 75 | "GET", 76 | link.replace("https", "http"), # multimedia server certificate check failure 77 | conn_timeout=15 78 | ) 79 | if raw.code != 200: 80 | raise ConnectionError(raw.code, raw.text()) 81 | data = raw.decompressed_body 82 | typ = decode(BytesIO(data)) 83 | if decode_audio_available(typ.type): 84 | return Response(await decode_audio(typ.type, data)) 85 | else: 86 | return Response(data) 87 | raise NotImplementedError(path) 88 | 89 | async def handle_proxied(self, prefix: str, url: str) -> Optional[Response]: 90 | url = url.replace("&", "&") 91 | if prefix == "https://multimedia.nt.qq.com.cn/": 92 | _, rkey = await self.client.get_rkey() 93 | url = f"{url}{rkey}" 94 | elif prefix == "https://gchat.qpic.cn": 95 | if url.startswith("https://gchat.qpic.cn/download"): 96 | _, rkey = await self.client.get_rkey() 97 | url = f"{url}{rkey}" 98 | return await super().handle_proxied(prefix, url) 99 | 100 | def _get_login(self): 101 | return Login( 102 | 0, 103 | ( 104 | (LoginStatus.ONLINE if self.client.online.is_set() else LoginStatus.CONNECT) 105 | if not self.client._network._stop_flag 106 | else LoginStatus.DISCONNECT 107 | ), 108 | "nekobox", 109 | PLATFORM, 110 | User( 111 | str(self.client.uin), 112 | name=self.name or str(self.client.uin), 113 | avatar=f"https://q1.qlogo.cn/g?b=qq&nk={self.client.uin}&s=640", 114 | ), 115 | features=[ 116 | "message.delete", 117 | "guild.plain", 118 | ], 119 | ) 120 | 121 | async def get_logins(self) -> List[Login]: 122 | return [self._get_login()] 123 | 124 | @property 125 | def required(self) -> Set[str]: 126 | return set() 127 | 128 | def __init__( 129 | self, 130 | uin: int, 131 | sign_url: str | None = None, 132 | protocol: Literal["linux", "macos", "windows", "remote"] = "linux", 133 | log_level: str = "INFO", 134 | use_png: bool = False, 135 | _patch_logging: bool = False, 136 | ): 137 | self.log_level = log_level.upper() 138 | 139 | scope = Path.cwd() / "bots" / str(uin) 140 | scope.mkdir(exist_ok=True, parents=True) 141 | 142 | self.im = InfoManager(uin, scope / "device.json", scope / "sig.bin") 143 | self.uin = uin 144 | self.name = "" 145 | self.sign = sign_provider(sign_url) if sign_url else None 146 | self.queue = asyncio.Queue() 147 | self.use_png = use_png 148 | 149 | self._protocol = protocol 150 | self._sign_url = sign_url 151 | 152 | if _patch_logging: 153 | patch_logging(self.log_level) 154 | 155 | super().__init__() 156 | 157 | client: Client 158 | 159 | @property 160 | def stages(self) -> Set[Literal["preparing", "blocking", "cleanup"]]: 161 | return {"preparing", "blocking", "cleanup"} 162 | 163 | async def qrlogin(self, client: Client) -> bool: 164 | fetch_rsp = await client.fetch_qrcode() 165 | if isinstance(fetch_rsp, int): 166 | raise AssertionError(f"Failed to fetch QR code: {fetch_rsp}") 167 | png, link = fetch_rsp 168 | if self.use_png: 169 | path = Path.cwd() / "login_qrcode.png" 170 | logger.info(f"save QRCode to '{path.resolve()}'") 171 | with open(path, "wb+") as f: 172 | f.write(png) 173 | else: 174 | logger.debug(f"QR code link: {link}") 175 | qr = QRCode() 176 | qr.add_data(link) 177 | qr.print_ascii() 178 | logger.info("Please use Tencent QQ to scan QR code") 179 | try: 180 | return await client.qrcode_login(3) 181 | except AssertionError as e: 182 | logger.error(f"qrlogin error: {e.args[0]}") 183 | return False 184 | 185 | async def launch(self, manager: Launart): 186 | 187 | logger.info(f"Running on '{version.__version__}' for {self.uin}") 188 | _set_server(self.server) 189 | 190 | with self.im as im: 191 | if ( 192 | im.sig_info.last_update 193 | and (datetime.fromtimestamp(im.sig_info.last_update) + timedelta(30)) < datetime.now() 194 | ): 195 | logger.warning("siginfo expired") 196 | im.renew_sig_info() 197 | 198 | if self._protocol == "remote": 199 | if not self._sign_url: 200 | raise ValueError("sign_url is required for remote protocol") 201 | url = self._sign_url + "/appinfo" # appinfo endpoint 202 | logger.debug("load remote protocol from %s" % url) 203 | rsp = await HttpCatProxies.request("GET", url) 204 | 205 | app_info = AppInfo.load_custom(rsp.json()) 206 | else: 207 | app_info = app_list[self._protocol] 208 | logger.info(f"AppInfo: platform={app_info.os}, ver={app_info.build_version}({app_info.sub_app_id})") 209 | 210 | self.client = client = Client( 211 | self.uin, 212 | app_info, 213 | im.device, 214 | im.sig_info, 215 | self.sign, 216 | ) 217 | apply_event_handler(client, self.queue, self._get_login) 218 | apply_api_handlers(self, client) 219 | 220 | # special api handling 221 | @self.route(Api.LOGIN_GET) 222 | async def login_get(request: Request): 223 | logins = await self.get_logins() 224 | return logins[0] 225 | 226 | async with self.stage("preparing"): 227 | client.connect() 228 | success = True 229 | if (datetime.fromtimestamp(im.sig_info.last_update) + timedelta(14)) > datetime.now(): 230 | logger.info("try to fast login") 231 | if not await client.register(): 232 | logger.error("fast login failed, try to re-login...") 233 | success = await client.easy_login() 234 | elif im.sig_info.last_update: 235 | logger.warning("Refresh siginfo") 236 | success = await client.easy_login() 237 | else: 238 | success = False 239 | if not success: 240 | if not await self.qrlogin(client): 241 | logger.error("login error") 242 | else: 243 | if self.client.uin != self.uin: 244 | logger.critical("Profile not matched!") 245 | logger.critical(f"'{self.uin}' required, but '{self.client.uin}' got") 246 | im.renew_sig_info() # flush 247 | else: 248 | success = await self.client.register() 249 | 250 | async with self.stage("blocking"): 251 | if success: 252 | im.save_all() 253 | self.name = (await client.get_user_info(client.uin)).name 254 | await any_completed(manager.status.wait_for_sigexit(), client._network.wait_closed()) 255 | 256 | async with self.stage("cleanup"): 257 | logger.debug("stopping client...") 258 | await client.stop() 259 | 260 | logger.success("Client stopped") 261 | 262 | -------------------------------------------------------------------------------- /src/nekobox/apis/handler.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime, timedelta 2 | 3 | from launart import Launart 4 | from satori.parser import parse 5 | from loguru import logger as log 6 | from satori.server import Request, route 7 | from lagrange.client.client import Client 8 | from lagrange.pb.service.group import FetchGrpRspBody 9 | from graia.amnesia.builtins.memcache import MemcacheService 10 | from satori import ( 11 | User, 12 | Guild, 13 | Member, 14 | Channel, 15 | PageResult, 16 | ChannelType, 17 | MessageObject, 18 | transform, 19 | ) 20 | 21 | from ..uid import resolve_uid, save_uid 22 | from ..msgid import decode_msgid, encode_msgid 23 | from ..transformer import msg_to_satori, satori_to_msg 24 | 25 | logger = log.patch(lambda r: r.update(name="nekobox.apis")) 26 | 27 | 28 | async def channel_list(client: Client, request: Request[route.ChannelListParam]): 29 | guild_id = int(request.params["guild_id"]) 30 | guilds = await guild_get_list(client, request) # type: ignore 31 | 32 | # _next = request.params.get("next") 33 | guild = next((i for i in guilds.data if i.id == str(guild_id)), None) 34 | return { 35 | "data": [Channel(encode_msgid(1, guild_id), ChannelType.TEXT, guild.name if guild else None).dump()] 36 | } 37 | 38 | 39 | async def msg_create(client: Client, req: Request[route.MessageParam]): 40 | typ, uin = decode_msgid(req.params["channel_id"]) 41 | if req.params["content"]: 42 | tp = transform(parse(req.params["content"])) 43 | 44 | if typ == 1: 45 | rsp = await client.send_grp_msg(await satori_to_msg(client, tp, grp_id=uin), uin) 46 | elif typ == 2: 47 | try: 48 | uid = resolve_uid(uin) 49 | except ValueError: # Cache miss 50 | logger.warning(f"uin {uin} not in cache, fetching from server") 51 | friends = await client.get_friend_list() 52 | for friend in friends: 53 | if friend.uid: 54 | save_uid(friend.uin, friend.uid) 55 | uid = resolve_uid(uin) 56 | rsp = await client.send_friend_msg( 57 | await satori_to_msg(client, tp, uid=uid), uid 58 | ) 59 | else: 60 | raise NotImplementedError(typ) 61 | return [MessageObject.from_elements(str(rsp), tp)] 62 | else: 63 | logger.warning("Empty message, ignore") 64 | return [] 65 | 66 | 67 | async def msg_delete(client: Client, req: Request[route.MessageOpParam]): 68 | typ, grp_id = decode_msgid(req.params["channel_id"]) 69 | seq = int(req.params["message_id"]) 70 | if typ == 1: 71 | rsp = await client.recall_grp_msg(grp_id, seq) 72 | else: 73 | raise NotImplementedError(typ) 74 | 75 | return [{"id": str(rsp), "content": "ok"}] 76 | 77 | 78 | async def msg_get(client: Client, req: Request[route.MessageOpParam]): 79 | typ, grp_id = decode_msgid(req.params["channel_id"]) 80 | seq = int(req.params["message_id"]) 81 | if typ == 1: 82 | rsp = (await client.get_grp_msg(grp_id, seq))[0] 83 | else: 84 | raise NotImplementedError(typ) 85 | 86 | return MessageObject.from_elements( 87 | str(rsp), 88 | await msg_to_satori(rsp.msg_chain, client.uin, gid=grp_id), 89 | channel=Channel(encode_msgid(1, rsp.grp_id), ChannelType.TEXT, rsp.grp_name), 90 | user=User(str(rsp.uin), rsp.nickname, avatar=f"https://q1.qlogo.cn/g?b=qq&nk={rsp.uin}&s=640"), 91 | ) 92 | 93 | 94 | async def msg_list(client: Client, req: Request[route.MessageListParam]): 95 | typ, grp_id = decode_msgid(req.params["channel_id"]) 96 | seq = 0 97 | 98 | if typ == 1: 99 | rsp = await client.get_grp_msg(grp_id, seq) 100 | else: 101 | raise NotImplementedError(typ) 102 | 103 | return [ 104 | MessageObject.from_elements( 105 | str(r), 106 | await msg_to_satori(r.msg_chain, client.uin, gid=grp_id), 107 | channel=Channel(encode_msgid(1, r.grp_id), ChannelType.TEXT, r.grp_name), 108 | user=User(str(r.uin), r.nickname, avatar=f"https://q1.qlogo.cn/g?b=qq&nk={r.uin}&s=640"), 109 | ) 110 | for r in rsp 111 | ] 112 | 113 | 114 | async def guild_member_kick(client: Client, req: Request[route.GuildMemberKickParam]): 115 | grp_id = int(req.params["guild_id"]) 116 | user_id = int(req.params["user_id"]) 117 | permanent = req.params.get("permanent", False) 118 | 119 | await client.kick_grp_member(grp_id, user_id, permanent) 120 | 121 | return [{"content": "ok"}] 122 | 123 | 124 | async def guild_member_mute(client: Client, req: Request[route.GuildMemberMuteParam]): 125 | grp_id = int(req.params["guild_id"]) 126 | user_id = int(req.params["user_id"]) 127 | duration = int(req.params["duration"]) // 1000 # ms to s 128 | 129 | await client.set_mute_member(grp_id, user_id, duration) 130 | 131 | return [{"content": "ok"}] 132 | 133 | 134 | async def guild_member_list(client: Client, req: Request[route.GuildXXXListParam]): 135 | grp_id = int(req.params["guild_id"]) 136 | next_key = req.params.get("next") 137 | 138 | rsp = await client.get_grp_members(grp_id, next_key=next_key) 139 | 140 | data = [ 141 | Member( 142 | user=User( 143 | id=str(body.account.uin), 144 | name=body.nickname, 145 | avatar=f"http://thirdqq.qlogo.cn/headimg_dl?dst_uin={body.account.uin}&spec=640", 146 | ), 147 | nick=body.name.string if body.name else body.nickname, 148 | avatar=f"http://thirdqq.qlogo.cn/headimg_dl?dst_uin={body.account.uin}&spec=640", 149 | joined_at=datetime.fromtimestamp(body.joined_time), 150 | ).dump() 151 | for body in rsp.body 152 | ] 153 | 154 | return { 155 | "data": data, 156 | "next": rsp.next_key, 157 | } 158 | 159 | 160 | async def guild_member_get(client: Client, req: Request[route.GuildMemberGetParam]): 161 | grp_id = int(req.params["guild_id"]) 162 | user_id = int(req.params["user_id"]) 163 | 164 | try: 165 | uid = resolve_uid(user_id) 166 | except ValueError: 167 | logger.warning(f"uin {user_id} not in cache, fetching from server") 168 | next_key = None 169 | uid = None 170 | while True: 171 | rsp = await client.get_grp_members(grp_id, next_key=next_key) 172 | for body in rsp.body: 173 | if body.account.uin is not None and body.account.uin == user_id: 174 | save_uid(body.account.uin, body.account.uid) 175 | uid = body.account.uid 176 | break 177 | else: 178 | if rsp.next_key is not None: 179 | next_key = rsp.next_key.decode() 180 | if not uid and not next_key: 181 | raise ValueError(f"uin {user_id} not found in {grp_id}") 182 | elif uid: 183 | break 184 | 185 | rsp = (await client.get_grp_member_info(grp_id, uid)).body[0] 186 | 187 | return [ 188 | Member( 189 | user=User( 190 | id=str(rsp.account.uin), 191 | name=rsp.nickname, 192 | avatar=f"http://thirdqq.qlogo.cn/headimg_dl?dst_uin={rsp.account.uin}&spec=640", 193 | ), 194 | nick=rsp.name.string if rsp.name else rsp.nickname, 195 | avatar=f"http://thirdqq.qlogo.cn/headimg_dl?dst_uin={rsp.account.uin}&spec=640", 196 | joined_at=datetime.fromtimestamp(rsp.joined_time), 197 | ).dump() 198 | ] 199 | 200 | 201 | async def guild_get_list(client: Client, req: Request[route.GuildListParam]) -> PageResult[Guild]: 202 | _next_key = req.params.get("next") 203 | cache = Launart.current().get_component(MemcacheService).cache 204 | 205 | if data := await cache.get("guild_list"): 206 | return PageResult(data, None) 207 | 208 | rsp = await client.get_grp_list() 209 | data = [ 210 | Guild(str(i.grp_id), i.info.grp_name, f"https://p.qlogo.cn/gh/{i.grp_id}/{i.grp_id}/640") 211 | for i in rsp.grp_list 212 | ] 213 | 214 | await cache.set("guild_list", data, timedelta(minutes=5)) 215 | 216 | return PageResult(data, None) 217 | 218 | 219 | async def friend_channel(client: Client, req: Request[route.UserChannelCreateParam]): 220 | user_id = int(req.params["user_id"]) 221 | try: 222 | pid = resolve_uid(user_id) 223 | except ValueError: 224 | pid = req.params.get("guild_id", None) 225 | return Channel( 226 | encode_msgid(2, user_id), 227 | ChannelType.DIRECT, 228 | str(user_id), 229 | pid, 230 | ) 231 | 232 | 233 | async def guild_member_req_approve(client: Client, req: Request[route.ApproveParam]): 234 | cache = Launart.current().get_component(MemcacheService).cache 235 | data: FetchGrpRspBody = await cache.get(f"grp_mbr_req#{req.params['message_id']}") 236 | await client.set_grp_request( 237 | data.group.grp_id, 238 | int(req.params["message_id"]), 239 | data.event_type, 240 | 1 if req.params["approve"] else 2, 241 | req.params["comment"], 242 | ) 243 | return [{"content": "ok"}] 244 | 245 | 246 | async def friend_list(client: Client, req: Request[route.FriendListParam]): 247 | cache = Launart.current().get_component(MemcacheService).cache 248 | 249 | if data := await cache.get("friend_list"): 250 | return PageResult(data, None) 251 | 252 | friends = await client.get_friend_list() 253 | data = [ 254 | User( 255 | id=str(f.uin), 256 | name=f.nickname, 257 | avatar=f"http://thirdqq.qlogo.cn/headimg_dl?dst_uin={f.uin}&spec=640", 258 | ) 259 | for f in friends 260 | ] 261 | 262 | await cache.set("friend_list", data, timedelta(minutes=5)) 263 | return PageResult(data, None) 264 | 265 | 266 | async def _reaction_process(client: Client, req: Request, is_del: bool): 267 | typ, grp_id = decode_msgid(req.params["channel_id"]) 268 | seq = int(req.params["message_id"]) 269 | emoji = req.params["emoji"] 270 | 271 | if len(emoji) == 1: 272 | pass 273 | elif emoji.find("face:") == 0 and emoji[5:].isdigit(): 274 | emoji = int(emoji[5:]) 275 | else: 276 | raise ValueError(f"Invalid emoji value '{emoji}'") 277 | 278 | if typ == 1: 279 | await client.send_grp_reaction(grp_id, seq, emoji, is_cancel=is_del) 280 | else: 281 | raise TypeError("Guild only") 282 | 283 | return [{"content": "ok"}] 284 | 285 | 286 | async def reaction_create(client: Client, req: Request[route.ReactionCreateParam]): 287 | return await _reaction_process(client, req, False) 288 | 289 | 290 | async def reaction_delete(client: Client, req: Request[route.ReactionDeleteParam]): 291 | if "user_id" in req.params and req.params.get("user_id") != client.uin: 292 | raise ValueError("Cannot delete other user's reaction") 293 | return await _reaction_process(client, req, True) 294 | 295 | 296 | async def reaction_clear(client: Client, req: Request[route.ReactionClearParam]): 297 | return await _reaction_process(client, req, True) 298 | -------------------------------------------------------------------------------- /src/nekobox/__main__.py: -------------------------------------------------------------------------------- 1 | import sys 2 | import shutil 3 | import asyncio 4 | import secrets 5 | from pathlib import Path 6 | from argparse import ArgumentParser 7 | from configparser import ConfigParser 8 | from typing import List, Optional, overload 9 | 10 | from creart import it 11 | from loguru import logger 12 | from satori.server import Server 13 | from lagrange import install_loguru 14 | 15 | from nekobox import __version__ 16 | from nekobox.main import NekoBoxAdapter 17 | from nekobox.log import loguru_exc_callback_async 18 | 19 | CONFIG_FILE = Path("nekobox.ini") 20 | cyan = "\033[96m" 21 | reset = "\033[0m" 22 | green = "\033[32m" 23 | gold = "\033[33m" 24 | yellow = "\033[93m" 25 | purple = "\033[95m" 26 | magnet = "\033[35m" 27 | red = "\033[31m" 28 | ul = "\033[4m" 29 | bd = "\033[1m" 30 | 31 | 32 | def run(uin: int, host: str, port: int, token: str, path: str, protocol: str, sign_url: str, level: str, use_png: bool): 33 | install_loguru() 34 | loop = it(asyncio.AbstractEventLoop) 35 | loop.set_exception_handler(loguru_exc_callback_async) 36 | server = Server(host=host, port=port, path=path, token=token, stream_threshold=4 * 1024 * 1024) 37 | server.apply(NekoBoxAdapter(uin, sign_url, protocol, level, use_png, _patch_logging=True)) # type: ignore 38 | server.run() 39 | 40 | 41 | @overload 42 | def set_cfg( 43 | cfg: ConfigParser, section: str, option: str, description: str, *, unique: Optional[List[str]] = None 44 | ) -> str: ... 45 | 46 | 47 | @overload 48 | def set_cfg( 49 | cfg: ConfigParser, 50 | section: str, 51 | option: str, 52 | description: str, 53 | default: str, 54 | unique: Optional[List[str]] = None, 55 | ) -> str: ... 56 | 57 | 58 | def set_cfg( 59 | cfg: ConfigParser, 60 | section: str, 61 | option: str, 62 | description: str, 63 | default: Optional[str] = None, 64 | unique: Optional[List[str]] = None, 65 | ): 66 | if not unique: 67 | unique = [] 68 | try: 69 | i = str( 70 | input( 71 | f"{description}{f' {cyan}(必填项){reset}' if default is None else f' {cyan}({default}){reset}'}: " 72 | ) 73 | ) 74 | if not i: 75 | if default is None: 76 | raise ValueError 77 | else: 78 | i = default 79 | if i and unique and i not in unique: 80 | raise ValueError 81 | 82 | if not cfg.has_section(section): 83 | cfg.add_section(section) 84 | cfg.set(section, option, i) 85 | 86 | return i 87 | except (TypeError, ValueError): 88 | print(f">>> 输入不符合约束: {green}{unique}{reset}") 89 | return set_cfg(cfg, section, option, description, default, unique) # type: ignore 90 | 91 | 92 | def generate_cfg(args): 93 | cfg = ConfigParser() 94 | exist = False 95 | if (Path.cwd() / CONFIG_FILE).exists(): 96 | exist = True 97 | cfg.read(CONFIG_FILE, encoding="utf-8") 98 | print(f"{cyan}正在更新配置文件...{reset}") 99 | else: 100 | print(f"{cyan}正在生成配置文件...{reset}") 101 | uin = args.uin 102 | if not uin or uin == "?": 103 | if exist: 104 | for section in cfg.sections(): 105 | if section == "default": 106 | continue 107 | print(f" - {magnet}{ul}{section}{reset}") 108 | uin = ( 109 | input(f"{gold}请选择一个账号{reset} {cyan}({cfg['default']['uin']}){reset}: ").strip() 110 | or cfg["default"]["uin"] 111 | ) 112 | else: 113 | uin = set_cfg(cfg, "default", "uin", "Bot 的 QQ 号") 114 | else: 115 | cfg.add_section("default") 116 | cfg.set("default", "uin", uin) 117 | if uin not in cfg: 118 | cfg.add_section(uin) 119 | set_cfg(cfg, uin, "sign", "Bot 的 SignUrl") 120 | set_cfg(cfg, uin, "protocol", "Bot 的协议类型(默认跟随签名服务器)", default="remote", unique=["linux", "macos", "windows", "remote"]) 121 | set_cfg(cfg, uin, "token", "Satori 服务器的验证 token", default=secrets.token_hex(8)) 122 | set_cfg(cfg, uin, "host", "Satori 服务器绑定地址", default="127.0.0.1") 123 | set_cfg(cfg, uin, "port", "Satori 服务器绑定端口", default="7777") 124 | set_cfg(cfg, uin, "path", "Satori 服务器部署路径 (可以为空)", default="") 125 | set_cfg( 126 | cfg, 127 | uin, 128 | "log_level", 129 | "默认日志等级", 130 | default="INFO", 131 | unique=["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"], 132 | ) 133 | 134 | with (Path.cwd() / CONFIG_FILE).open("w+", encoding="utf-8") as f: 135 | cfg.write(f) 136 | print(f"{green}配置文件已保存{reset}") 137 | 138 | 139 | def _delete(args): 140 | if not (Path.cwd() / CONFIG_FILE).exists(): 141 | print(f"请先使用 {yellow}`nekobox gen {args.uin or ''}`{reset} 生成配置文件", file=sys.stderr) 142 | return True 143 | cfg = ConfigParser() 144 | cfg.read(CONFIG_FILE, encoding="utf-8") 145 | if not args.uin or args.uin == "?": 146 | exists = [section for section in cfg.sections() if section != "default"] 147 | for section in exists: 148 | print(f" - {magnet}{ul}{section}{reset}") 149 | args.uin = input(f"{gold}请选择一个账号{reset}: ") 150 | if args.uin not in cfg: 151 | print(f"账号 {purple}{ul}{args.uin}{reset} 的相关配置不存在", file=sys.stderr) 152 | return True 153 | cfg.remove_section(args.uin) 154 | with (Path.cwd() / CONFIG_FILE).open("w+", encoding="utf-8") as f: 155 | cfg.write(f) 156 | print(f"账号 {purple}{ul}{args.uin}{reset} 的配置已删除") 157 | 158 | 159 | def _run(args): 160 | if not (Path.cwd() / CONFIG_FILE).exists(): 161 | if args.uin and args.uin != "?": 162 | print(f"请先使用 {yellow}`nekobox gen {args.uin}`{reset} 生成配置文件", file=sys.stderr) 163 | else: 164 | print(f"请先使用 {yellow}`nekobox gen`{reset} 生成配置文件", file=sys.stderr) 165 | return True 166 | cfg = ConfigParser() 167 | cfg.read(CONFIG_FILE, encoding="utf-8") 168 | uin = args.uin or cfg["default"]["uin"] 169 | if uin == "?": 170 | for section in cfg.sections(): 171 | if section == "default": 172 | continue 173 | print(f" - {magnet}{ul}{section}{reset}") 174 | uin = ( 175 | input(f"{gold}请选择一个账号{reset} {cyan}({cfg['default']['uin']}){reset}: ").strip() 176 | or cfg["default"]["uin"] 177 | ) 178 | if uin not in cfg: 179 | print( 180 | f"账号 {purple}{ul}{uin}{reset} 的相关配置不存在\n请先使用 {yellow}`nekobox gen {uin}`{reset} 生成对应账号的配置文件", file=sys.stderr 181 | ) 182 | return True 183 | host = cfg[uin]["host"] 184 | port = int(cfg[uin]["port"]) 185 | token = cfg[uin]["token"] 186 | sign_url = cfg[uin]["sign"] 187 | path = cfg[uin].get("path", "") 188 | protocol = cfg[uin]["protocol"] 189 | level = "DEBUG" if args.debug else cfg[uin]["log_level"] 190 | logger.success("读取配置文件完成") 191 | run(int(uin), host, port, token, path, protocol, sign_url, level, args.use_png) 192 | 193 | 194 | def _show(args): 195 | if not (Path.cwd() / CONFIG_FILE).exists(): 196 | print(f"请先使用 {yellow}`nekobox gen {args.uin or ''}`{reset} 生成配置文件", file=sys.stderr) 197 | return True 198 | cfg = ConfigParser() 199 | cfg.read(CONFIG_FILE, encoding="utf-8") 200 | if not args.uin or args.uin == "?": 201 | exists = [section for section in cfg.sections() if section != "default"] 202 | for section in exists: 203 | print(f" - {magnet}{ul}{section}{reset}") 204 | args.uin = ( 205 | input(f"{gold}请选择一个账号{reset} {cyan}({cfg['default']['uin']}){reset}: ") 206 | or cfg["default"]["uin"] 207 | ) 208 | if args.uin not in cfg: 209 | print(f"账号 {purple}{ul}{args.uin}{reset} 的相关配置不存在", file=sys.stderr) 210 | return True 211 | print(f"{green}SignUrl: {reset}{cfg[args.uin]['sign']}") 212 | print(f"{green}协议类型: {reset}{cfg[args.uin]['protocol']}") 213 | print(f"{green}验证 token: {reset}{cfg[args.uin]['token']}") 214 | print(f"{green}服务器绑定地址: {reset}{cfg[args.uin]['host']}") 215 | print(f"{green}服务器绑定端口: {reset}{cfg[args.uin]['port']}") 216 | print(f"{green}服务器部署路径: {reset}{cfg[args.uin].get('path', '')}") 217 | print(f"{green}默认日志等级: {reset}{cfg[args.uin]['log_level']}") 218 | 219 | 220 | def _clear(args): 221 | if (bots := Path("./bots")).exists(): 222 | if not args.uin: 223 | res = input( 224 | f"{gold}即将清理: {green}{bots.resolve()} {gold}下的所有数据,是否继续? {bd}{magnet}[y/n] {cyan}(y): {reset}" 225 | ) 226 | if res.lower() == "y": 227 | shutil.rmtree(bots) 228 | print(f"{green}{bots.resolve()}{reset} 清理完毕") 229 | return 230 | if args.uin == "?": 231 | accounts = [bot.name for bot in bots.iterdir()] 232 | for index, bot in enumerate(accounts): 233 | print(f" {index}. {magnet}{ul}{bot}{reset}") 234 | args.uin = accounts[int(input(f"{gold}请选择一个账号{reset} {cyan}(0){reset}: ").strip() or "0")] 235 | if (dir_ := (bots / str(args.uin))).exists(): 236 | res = input( 237 | f"{gold}即将清理: {green}{dir_.resolve()} {gold}下的所有数据,是否继续? {bd}{magnet}[y/n] {cyan}(y): {reset}" 238 | ) 239 | if res.lower() == "y": 240 | shutil.rmtree(dir_) 241 | print(f"{green}{dir_.resolve()}{reset} 数据清理完毕") 242 | else: 243 | print(f"{green}{dir_.resolve()}{reset} 不存在", file=sys.stderr) 244 | return True 245 | 246 | 247 | def _default(args): 248 | if not (Path.cwd() / CONFIG_FILE).exists(): 249 | print(f"请先使用 {yellow}`nekobox gen {args.uin or ''}`{reset} 生成配置文件", file=sys.stderr) 250 | return True 251 | cfg = ConfigParser() 252 | cfg.read(CONFIG_FILE, encoding="utf-8") 253 | if not args.uin or args.uin == "?": 254 | exists = [section for section in cfg.sections() if section != "default"] 255 | for section in exists: 256 | print(f" - {magnet}{ul}{section}{reset}") 257 | args.uin = input(f"{gold}请选择一个账号{reset}: ") 258 | if args.uin not in cfg: 259 | print(f"账号 {purple}{ul}{args.uin}{reset} 的相关配置不存在", file=sys.stderr) 260 | return True 261 | cfg.set("default", "uin", args.uin) 262 | with (Path.cwd() / CONFIG_FILE).open("w+", encoding="utf-8") as f: 263 | cfg.write(f) 264 | print(f"默认账号已设置为 {purple}{ul}{args.uin}{reset}") 265 | 266 | 267 | def _list(args): 268 | if not (Path.cwd() / CONFIG_FILE).exists(): 269 | print(f"请先使用 {yellow}`nekobox gen`{reset} 生成配置文件", file=sys.stderr) 270 | return True 271 | cfg = ConfigParser() 272 | cfg.read(CONFIG_FILE, encoding="utf-8") 273 | print(f"{cyan}当前配置文件中的账号有:{reset}") 274 | for section in cfg.sections(): 275 | if section == "default": 276 | continue 277 | print(f" - {magnet}{ul}{section}{reset}") 278 | 279 | 280 | def main(): 281 | parser = ArgumentParser(description=f"{cyan}NekoBox/lagrange-python-satori Server 工具{reset}") 282 | parser.add_argument("-v", "--version", action="version", version=f"%(prog)s {__version__}") 283 | command = parser.add_subparsers(dest="command", title=f"commands") 284 | run_parser = command.add_parser("run", help="启动服务器") 285 | run_parser.add_argument("uin", type=str, nargs="?", help="选择账号; 输入 '?' 以交互式选择账号") 286 | run_parser.add_argument("--debug", action="store_true", default=False, help="强制启用调试等级日志") 287 | run_parser.add_argument("--file-qrcode", "-Q", dest="use_png", action="store_true", default=False, help="使用文件保存二维码") 288 | run_parser.set_defaults(func=_run) 289 | gen_parser = command.add_parser("gen", help="生成或更新配置文件") 290 | gen_parser.add_argument("uin", type=str, nargs="?", help="选择账号") 291 | gen_parser.set_defaults(func=generate_cfg) 292 | list_parser = command.add_parser("list", help="列出所有账号") 293 | list_parser.set_defaults(func=_list) 294 | show_parser = command.add_parser("show", help="显示账号配置") 295 | show_parser.add_argument("uin", type=str, nargs="?", help="选择账号; 输入 '?' 以交互式选择账号") 296 | show_parser.set_defaults(func=_show) 297 | clean_parser = command.add_parser("clear", help="清除数据") 298 | clean_parser.add_argument("uin", type=str, nargs="?", help="选择账号; 输入 '?' 以交互式选择账号") 299 | clean_parser.set_defaults(func=_clear) 300 | delete_parser = command.add_parser("delete", help="删除账号配置") 301 | delete_parser.add_argument("uin", type=str, nargs="?", help="选择账号; 输入 '?' 以交互式选择账号") 302 | delete_parser.set_defaults(func=_delete) 303 | default_parser = command.add_parser("default", help="设置默认账号") 304 | default_parser.add_argument("uin", type=str, nargs="?", help="选择账号") 305 | default_parser.set_defaults(func=_default) 306 | 307 | args = parser.parse_args() 308 | 309 | try: 310 | if args.command: 311 | if args.func(args): 312 | sys.exit(1) # err 313 | else: 314 | parser.print_help() 315 | except KeyboardInterrupt: 316 | print(f"\n{red}运行已中断。{reset}") 317 | 318 | 319 | if __name__ == "__main__": 320 | main() 321 | -------------------------------------------------------------------------------- /src/nekobox/events/handler.py: -------------------------------------------------------------------------------- 1 | import re 2 | from typing import Optional, Union 3 | from datetime import datetime, timedelta 4 | 5 | from launart import Launart 6 | from loguru import logger as log 7 | from lagrange.client.client import Client 8 | from lagrange.client.events.friend import FriendMessage 9 | from graia.amnesia.builtins.memcache import MemcacheService 10 | from lagrange.client.events.service import ClientOnline, ClientOffline 11 | from satori import ( 12 | User, 13 | Event, 14 | Guild, 15 | Login, 16 | Member, 17 | Channel, 18 | EventType, 19 | ChannelType, 20 | LoginStatus, 21 | MessageObject, 22 | ) 23 | from lagrange.client.events.group import ( 24 | GroupRecall, 25 | GroupMessage, 26 | GroupReaction, 27 | GroupMemberQuit, 28 | GroupNameChanged, 29 | GroupMemberJoined, 30 | GroupMemberJoinedByInvite, 31 | GroupMemberJoinRequest, 32 | ) 33 | 34 | from ..msgid import encode_msgid 35 | from ..transformer import msg_to_satori 36 | from ..uid import save_uid, resolve_uin, resolve_uid 37 | 38 | logger = log.patch(lambda r: r.update(name="nekobox.events")) 39 | 40 | 41 | def escape_tag(s: str) -> str: 42 | """用于记录带颜色日志时转义 `` 类型特殊标签 43 | 44 | 参考: [loguru color 标签](https://loguru.readthedocs.io/en/stable/api/logger.html#color) 45 | 46 | Args: 47 | s: 需要转义的字符串 48 | """ 49 | return re.sub(r"\s]*)>", r"\\\g<0>", s) 50 | 51 | 52 | async def on_grp_msg(client: Client, event: GroupMessage, login: Login) -> Optional[Event]: 53 | save_uid(event.uin, event.uid) 54 | content = await msg_to_satori(event.msg_chain, client.uin, gid=event.grp_id) 55 | msg = "".join(str(i) for i in content) 56 | logger.info(f"[message-created] {event.nickname}({event.uin})@{event.grp_id}: {escape_tag(msg)!r}") 57 | usr = User( 58 | str(event.uin), 59 | event.nickname, 60 | avatar=f"https://q1.qlogo.cn/g?b=qq&nk={event.uin}&s=640", 61 | is_bot=event.is_bot, 62 | ) 63 | channel = Channel(encode_msgid(1, event.grp_id), ChannelType.TEXT, event.grp_name) 64 | guild = Guild( 65 | str(event.grp_id), event.grp_name, f"https://p.qlogo.cn/gh/{event.grp_id}/{event.grp_id}/640" 66 | ) 67 | member = Member(usr, event.nickname, avatar=usr.avatar) 68 | cache = Launart.current().get_component(MemcacheService).cache 69 | await cache.set(f"guild@{guild.id}", guild, timedelta(minutes=5)) 70 | await cache.set(f"channel@{channel.id}", channel, timedelta(minutes=5)) 71 | await cache.set(f"user@{usr.id}", usr, timedelta(minutes=5)) 72 | await cache.set(f"member@{guild.id}#{usr.id}", member, timedelta(minutes=5)) 73 | return Event( 74 | EventType.MESSAGE_CREATED, 75 | datetime.fromtimestamp(event.time), 76 | login, 77 | channel=channel, 78 | guild=guild, 79 | user=usr, 80 | member=member, 81 | message=MessageObject.from_elements(str(event.seq), content), 82 | ) 83 | 84 | 85 | async def on_grp_recall(client: Client, event: GroupRecall, login: Login) -> Optional[Event]: 86 | uin = resolve_uin(event.uid) 87 | cache = Launart.current().get_component(MemcacheService).cache 88 | usr = await cache.get(f"user@{uin}") 89 | guild = await cache.get(f"guild@{event.grp_id}") 90 | channel = await cache.get(f"channel@{encode_msgid(1, event.grp_id)}") 91 | member = await cache.get(f"member@{event.grp_id}#{uin}") 92 | if not usr or not member: 93 | info = (await client.get_grp_member_info(event.grp_id, event.uid)).body[0] 94 | usr = User( 95 | str(uin), 96 | info.nickname, 97 | info.name.string if info.name else None, 98 | avatar=f"https://q1.qlogo.cn/g?b=qq&nk={uin}&s=640", 99 | ) 100 | member = Member(usr, info.name.string if info.name else info.nickname, avatar=usr.avatar) 101 | await cache.set(f"user@{uin}", usr, timedelta(minutes=5)) 102 | await cache.set(f"member@{event.grp_id}#{uin}", member, timedelta(minutes=5)) 103 | if not guild or not channel: 104 | grp_list = (await client.get_grp_list()).grp_list 105 | for g in grp_list: 106 | _guild = Guild(str(g.grp_id), g.info.grp_name, f"https://p.qlogo.cn/gh/{g.grp_id}/{g.grp_id}/640") 107 | _channel = Channel(encode_msgid(1, g.grp_id), ChannelType.TEXT, g.info.grp_name) 108 | await cache.set(f"guild@{g.grp_id}", _guild, timedelta(minutes=5)) 109 | await cache.set(f"channel@{_channel.id}", _channel, timedelta(minutes=5)) 110 | if _guild.id == str(event.grp_id): 111 | guild = _guild 112 | channel = _channel 113 | if not guild or not channel: 114 | guild = Guild( 115 | str(event.grp_id), 116 | str(event.grp_id), 117 | f"https://p.qlogo.cn/gh/{event.grp_id}/{event.grp_id}/640", 118 | ) 119 | channel = Channel(encode_msgid(1, event.grp_id), ChannelType.TEXT, str(event.grp_id)) 120 | 121 | logger.info(f"[message-deleted] {usr.nick}({usr.id})@{guild.id}: {event.seq}") 122 | return Event( 123 | EventType.MESSAGE_DELETED, 124 | datetime.fromtimestamp(event.time), 125 | login, 126 | channel=channel, 127 | guild=guild, 128 | user=usr, 129 | member=member, 130 | message=MessageObject(str(event.seq), event.suffix), 131 | ) 132 | 133 | 134 | async def on_friend_msg(client: Client, event: FriendMessage, login: Login) -> Optional[Event]: 135 | save_uid(event.from_uin, event.from_uid) 136 | content = await msg_to_satori(event.msg_chain, client.uin, uid=event.from_uid) 137 | msg = "".join(str(i) for i in content) 138 | cache = Launart.current().get_component(MemcacheService).cache 139 | user = await cache.get(f"user@{event.from_uin}") 140 | if not user: 141 | frd_list = await client.get_friend_list() 142 | for frd in frd_list: 143 | _user = User( 144 | str(frd.uin), 145 | frd.nickname, 146 | frd.remark, 147 | avatar=f"https://q1.qlogo.cn/g?b=qq&nk={frd.uin}&s=640", 148 | ) 149 | await cache.set(f"user@{frd.uin}", _user, timedelta(minutes=5)) 150 | if frd.uin == event.from_uin: 151 | user = _user 152 | if not user: 153 | info = await client.get_user_info(event.from_uid) 154 | user = User( 155 | str(event.from_uin), info.name, avatar=f"https://q1.qlogo.cn/g?b=qq&nk={event.from_uin}&s=640" 156 | ) 157 | logger.info(f"[message-created] {user.nick or user.name}({user.id}): {escape_tag(msg)!r}") 158 | return Event( 159 | EventType.MESSAGE_CREATED, 160 | datetime.fromtimestamp(event.timestamp), 161 | login, 162 | user=user, 163 | channel=Channel(encode_msgid(2, event.from_uin), ChannelType.DIRECT, user.name), 164 | message=MessageObject.from_elements(str(event.seq), content), 165 | ) 166 | 167 | 168 | async def on_client_online(client: Client, event: ClientOnline, login: Login) -> Optional[Event]: 169 | logger.debug("[login-updated]: online") 170 | login.status = LoginStatus.ONLINE 171 | return Event( 172 | EventType.LOGIN_UPDATED, 173 | datetime.now(), 174 | login, 175 | ) 176 | 177 | 178 | async def on_client_offline(client: Client, event: ClientOffline, login: Login) -> Optional[Event]: 179 | logger.debug(f"[login-updated]: {'reconnect' if event.recoverable else 'disconnect'}") 180 | login.status = LoginStatus.RECONNECT if event.recoverable else LoginStatus.DISCONNECT 181 | return Event( 182 | EventType.LOGIN_UPDATED, 183 | datetime.now(), 184 | login, 185 | ) 186 | 187 | 188 | async def on_grp_name_changed(client: Client, event: GroupNameChanged, login: Login) -> Event: 189 | operator_id = resolve_uin(event.operator_uid) 190 | cache = Launart.current().get_component(MemcacheService).cache 191 | guild = Guild( 192 | str(event.grp_id), event.name_new, f"https://p.qlogo.cn/gh/{event.grp_id}/{event.grp_id}/640" 193 | ) 194 | await cache.set(f"guild@{guild.id}", guild, timedelta(minutes=5)) 195 | operator = await cache.get(f"member@{event.grp_id}#{operator_id}") 196 | if not operator: 197 | info = (await client.get_grp_member_info(event.grp_id, event.operator_uid)).body[0] 198 | info1 = await client.get_user_info(event.operator_uid) 199 | operator = Member( 200 | User( 201 | str(operator_id), 202 | info1.name, 203 | info.nickname, 204 | avatar=f"https://q1.qlogo.cn/g?b=qq&nk={operator_id}&s=640", 205 | ), 206 | info.name.string if info.name else info.nickname, 207 | avatar=f"https://q1.qlogo.cn/g?b=qq&nk={operator_id}&s=640", 208 | ) 209 | await cache.set(f"member@{event.grp_id}#{operator_id}", operator, timedelta(minutes=5)) 210 | logger.info(f"[guild-updated] {operator.nick} changed the group name to {event.name_new}") 211 | return Event( 212 | EventType.GUILD_UPDATED, 213 | datetime.now(), 214 | login, 215 | guild=guild, 216 | user=User( 217 | str(client.uin), str(client.uin), avatar=f"https://q1.qlogo.cn/g?b=qq&nk={client.uin}&s=640" 218 | ), 219 | operator=operator.user, 220 | ) 221 | 222 | 223 | async def on_member_joined(client: Client, event: Union[GroupMemberJoined, GroupMemberJoinedByInvite], login: Login) -> Event: 224 | cache = Launart.current().get_component(MemcacheService).cache 225 | try: 226 | if isinstance(event, GroupMemberJoined): 227 | uid = event.uid 228 | uin = resolve_uin(event.uid) 229 | else: 230 | uin = event.uin 231 | uid = resolve_uid(event.uin) 232 | member = await cache.get(f"member@{event.grp_id}#{uin}") 233 | if not member: 234 | info = (await client.get_grp_member_info(event.grp_id, uid)).body[0] 235 | user = User(str(uin), info.nickname, avatar=f"https://q1.qlogo.cn/g?b=qq&nk={uin}&s=640") 236 | member = Member( 237 | user, 238 | info.name.string if info.name else info.nickname, 239 | avatar=f"https://q1.qlogo.cn/g?b=qq&nk={uin}&s=640", 240 | ) 241 | await cache.set(f"member@{event.grp_id}#{uin}", member, timedelta(minutes=5)) 242 | except ValueError: 243 | uin = str(getattr(event, "uin", getattr(event, "uid", "0"))) 244 | user = User(uin, uin, avatar=f"https://q1.qlogo.cn/g?b=qq&nk={uin}&s=640") 245 | member = Member(user, user.name, avatar=user.avatar) 246 | guild = await cache.get(f"guild@{event.grp_id}") 247 | if not guild: 248 | grp_list = (await client.get_grp_list()).grp_list 249 | for g in grp_list: 250 | _guild = Guild(str(g.grp_id), g.info.grp_name, f"https://p.qlogo.cn/gh/{g.grp_id}/{g.grp_id}/640") 251 | await cache.set(f"guild@{g.grp_id}", _guild, timedelta(minutes=5)) 252 | if _guild.id == str(event.grp_id): 253 | guild = _guild 254 | if not guild: 255 | guild = Guild( 256 | str(event.grp_id), 257 | str(event.grp_id), 258 | f"https://p.qlogo.cn/gh/{event.grp_id}/{event.grp_id}/640", 259 | ) 260 | logger.info(f"[guild-member-added] {member.nick}({uin}) joined {guild.name}({guild.id})") 261 | return Event( 262 | EventType.GUILD_MEMBER_ADDED, 263 | datetime.now(), 264 | login, 265 | guild=guild, 266 | member=member, 267 | user=member.user, 268 | ) 269 | 270 | 271 | async def on_member_quit(client: Client, event: GroupMemberQuit, login: Login) -> Optional[Event]: 272 | cache = Launart.current().get_component(MemcacheService).cache 273 | member = await cache.get(f"member@{event.grp_id}#{event.uin}") 274 | if not member: 275 | info = (await client.get_grp_member_info(event.grp_id, event.uid)).body[0] 276 | user = User(str(event.uin), info.nickname, avatar=f"https://q1.qlogo.cn/g?b=qq&nk={event.uin}&s=640") 277 | member = Member( 278 | user, 279 | info.name.string if info.name else info.nickname, 280 | avatar=f"https://q1.qlogo.cn/g?b=qq&nk={event.uin}&s=640", 281 | ) 282 | await cache.set(f"member@{event.grp_id}#{event.uin}", member, timedelta(minutes=5)) 283 | guild = await cache.get(f"guild@{event.grp_id}") 284 | if not guild: 285 | grp_list = (await client.get_grp_list()).grp_list 286 | for g in grp_list: 287 | _guild = Guild(str(g.grp_id), g.info.grp_name, f"https://p.qlogo.cn/gh/{g.grp_id}/{g.grp_id}/640") 288 | await cache.set(f"guild@{g.grp_id}", _guild, timedelta(minutes=5)) 289 | if _guild.id == str(event.grp_id): 290 | guild = _guild 291 | if not guild: 292 | guild = Guild( 293 | str(event.grp_id), 294 | str(event.grp_id), 295 | f"https://p.qlogo.cn/gh/{event.grp_id}/{event.grp_id}/640", 296 | ) 297 | operator = None 298 | if event.is_kicked and event.operator_uid: 299 | operator_id = resolve_uin(event.operator_uid) 300 | operator = await cache.get(f"member@{event.grp_id}#{operator_id}") 301 | if not operator: 302 | info = (await client.get_grp_member_info(event.grp_id, event.operator_uid)).body[0] 303 | info1 = await client.get_user_info(event.operator_uid) 304 | operator = Member( 305 | User( 306 | str(operator_id), 307 | info1.name, 308 | info.nickname, 309 | avatar=f"https://q1.qlogo.cn/g?b=qq&nk={operator_id}&s=640", 310 | ), 311 | info.name.string if info.name else info.nickname, 312 | avatar=f"https://q1.qlogo.cn/g?b=qq&nk={operator_id}&s=640", 313 | ) 314 | await cache.set(f"member@{event.grp_id}#{operator_id}", operator, timedelta(minutes=5)) 315 | logger.info( 316 | f"[guild-member-removed] {member.nick}({event.uin}) left {guild.name}({guild.id}) " 317 | f"{f'by {operator.nick}({operator.user.id})' if operator else ''}" # type: ignore 318 | ) 319 | return Event( 320 | EventType.GUILD_MEMBER_REMOVED, 321 | datetime.now(), 322 | login, 323 | guild=guild, 324 | member=member, 325 | user=member.user, 326 | operator=operator.user if operator else None, 327 | ) 328 | 329 | 330 | async def on_grp_member_request(client: Client, event: GroupMemberJoinRequest, login: Login) -> Optional[Event]: 331 | reqs = await client.fetch_grp_request(4) 332 | for req in reqs.requests: 333 | if req.group.grp_id == event.grp_id and req.target.uid == event.uid: 334 | break 335 | else: 336 | return 337 | cache = Launart.current().get_component(MemcacheService).cache 338 | await cache.set(f"grp_mbr_req#{req.seq}", req, timedelta(minutes=30)) 339 | user_id = resolve_uin(event.uid) 340 | user = User(str(user_id), req.target.name, avatar=f"https://q1.qlogo.cn/g?b=qq&nk={user_id}&s=640") 341 | await cache.set(f"user@{user_id}", user, timedelta(minutes=5)) 342 | guild = Guild( 343 | str(event.grp_id), req.group.grp_name, f"https://p.qlogo.cn/gh/{event.grp_id}/{event.grp_id}/640" 344 | ) 345 | await cache.set(f"guild@{event.grp_id}", guild, timedelta(minutes=5)) 346 | logger.info(f"[guild-member-request] {user.nick}({user.id}) requested to join {guild.name}({guild.id})") 347 | return Event( 348 | EventType.GUILD_MEMBER_REQUEST, 349 | datetime.now(), 350 | login, 351 | guild=guild, 352 | user=user, 353 | member=Member(user, user.name), 354 | message=MessageObject(id=str(req.seq), content=req.comment), 355 | ) 356 | 357 | 358 | async def on_grp_reaction(client: Client, event: GroupReaction, login: Login) -> Optional[Event]: 359 | user_id = resolve_uin(event.uid) 360 | 361 | if event.is_emoji: 362 | emoji = chr(event.emoji_id) 363 | else: 364 | emoji = f"face:{event.emoji_id}" 365 | 366 | cache = Launart.current().get_component(MemcacheService).cache 367 | member = await cache.get(f"member@{event.grp_id}#{user_id}") 368 | if not member: 369 | info = (await client.get_grp_member_info(event.grp_id, event.uid)).body[0] 370 | user = User(str(user_id), info.nickname, avatar=f"https://q1.qlogo.cn/g?b=qq&nk={user_id}&s=640") 371 | member = Member( 372 | user, 373 | info.name.string if info.name else info.nickname, 374 | avatar=f"https://q1.qlogo.cn/g?b=qq&nk={user_id}&s=640", 375 | ) 376 | await cache.set(f"member@{event.grp_id}#{user_id}", member, timedelta(minutes=5)) 377 | guild = await cache.get(f"guild@{event.grp_id}") 378 | if not guild: 379 | grp_list = (await client.get_grp_list()).grp_list 380 | for g in grp_list: 381 | _guild = Guild(str(g.grp_id), g.info.grp_name, f"https://p.qlogo.cn/gh/{g.grp_id}/{g.grp_id}/640") 382 | await cache.set(f"guild@{g.grp_id}", _guild, timedelta(minutes=5)) 383 | if _guild.id == str(event.grp_id): 384 | guild = _guild 385 | if not guild: 386 | guild = Guild( 387 | str(event.grp_id), 388 | str(event.grp_id), 389 | f"https://p.qlogo.cn/gh/{event.grp_id}/{event.grp_id}/640", 390 | ) 391 | if event.is_increase: 392 | action = "added" 393 | else: 394 | action = "removed" 395 | logger.info(f"[reaction-{action}] {member.nick}({user_id}) reacted {emoji} to message {event.seq}") 396 | return Event( 397 | EventType.REACTION_ADDED if event.is_increase else EventType.REACTION_REMOVED, 398 | datetime.now(), 399 | login, 400 | guild=guild, 401 | user=member.user, 402 | member=member, 403 | _type="reaction", 404 | _data={"message_id": event.seq, "emoji": emoji, "count": event.emoji_count}, 405 | ) 406 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | GNU AFFERO GENERAL PUBLIC LICENSE 2 | Version 3, 19 November 2007 3 | 4 | Copyright (C) 2007 Free Software Foundation, Inc. 5 | Everyone is permitted to copy and distribute verbatim copies 6 | of this license document, but changing it is not allowed. 7 | 8 | Preamble 9 | 10 | The GNU Affero General Public License is a free, copyleft license for 11 | software and other kinds of works, specifically designed to ensure 12 | cooperation with the community in the case of network server software. 13 | 14 | The licenses for most software and other practical works are designed 15 | to take away your freedom to share and change the works. By contrast, 16 | our General Public Licenses are intended to guarantee your freedom to 17 | share and change all versions of a program--to make sure it remains free 18 | software for all its users. 19 | 20 | When we speak of free software, we are referring to freedom, not 21 | price. Our General Public Licenses are designed to make sure that you 22 | have the freedom to distribute copies of free software (and charge for 23 | them if you wish), that you receive source code or can get it if you 24 | want it, that you can change the software or use pieces of it in new 25 | free programs, and that you know you can do these things. 26 | 27 | Developers that use our General Public Licenses protect your rights 28 | with two steps: (1) assert copyright on the software, and (2) offer 29 | you this License which gives you legal permission to copy, distribute 30 | and/or modify the software. 31 | 32 | A secondary benefit of defending all users' freedom is that 33 | improvements made in alternate versions of the program, if they 34 | receive widespread use, become available for other developers to 35 | incorporate. Many developers of free software are heartened and 36 | encouraged by the resulting cooperation. However, in the case of 37 | software used on network servers, this result may fail to come about. 38 | The GNU General Public License permits making a modified version and 39 | letting the public access it on a server without ever releasing its 40 | source code to the public. 41 | 42 | The GNU Affero General Public License is designed specifically to 43 | ensure that, in such cases, the modified source code becomes available 44 | to the community. It requires the operator of a network server to 45 | provide the source code of the modified version running there to the 46 | users of that server. Therefore, public use of a modified version, on 47 | a publicly accessible server, gives the public access to the source 48 | code of the modified version. 49 | 50 | An older license, called the Affero General Public License and 51 | published by Affero, was designed to accomplish similar goals. This is 52 | a different license, not a version of the Affero GPL, but Affero has 53 | released a new version of the Affero GPL which permits relicensing under 54 | this license. 55 | 56 | The precise terms and conditions for copying, distribution and 57 | modification follow. 58 | 59 | TERMS AND CONDITIONS 60 | 61 | 0. Definitions. 62 | 63 | "This License" refers to version 3 of the GNU Affero General Public License. 64 | 65 | "Copyright" also means copyright-like laws that apply to other kinds of 66 | works, such as semiconductor masks. 67 | 68 | "The Program" refers to any copyrightable work licensed under this 69 | License. Each licensee is addressed as "you". "Licensees" and 70 | "recipients" may be individuals or organizations. 71 | 72 | To "modify" a work means to copy from or adapt all or part of the work 73 | in a fashion requiring copyright permission, other than the making of an 74 | exact copy. The resulting work is called a "modified version" of the 75 | earlier work or a work "based on" the earlier work. 76 | 77 | A "covered work" means either the unmodified Program or a work based 78 | on the Program. 79 | 80 | To "propagate" a work means to do anything with it that, without 81 | permission, would make you directly or secondarily liable for 82 | infringement under applicable copyright law, except executing it on a 83 | computer or modifying a private copy. Propagation includes copying, 84 | distribution (with or without modification), making available to the 85 | public, and in some countries other activities as well. 86 | 87 | To "convey" a work means any kind of propagation that enables other 88 | parties to make or receive copies. Mere interaction with a user through 89 | a computer network, with no transfer of a copy, is not conveying. 90 | 91 | An interactive user interface displays "Appropriate Legal Notices" 92 | to the extent that it includes a convenient and prominently visible 93 | feature that (1) displays an appropriate copyright notice, and (2) 94 | tells the user that there is no warranty for the work (except to the 95 | extent that warranties are provided), that licensees may convey the 96 | work under this License, and how to view a copy of this License. If 97 | the interface presents a list of user commands or options, such as a 98 | menu, a prominent item in the list meets this criterion. 99 | 100 | 1. Source Code. 101 | 102 | The "source code" for a work means the preferred form of the work 103 | for making modifications to it. "Object code" means any non-source 104 | form of a work. 105 | 106 | A "Standard Interface" means an interface that either is an official 107 | standard defined by a recognized standards body, or, in the case of 108 | interfaces specified for a particular programming language, one that 109 | is widely used among developers working in that language. 110 | 111 | The "System Libraries" of an executable work include anything, other 112 | than the work as a whole, that (a) is included in the normal form of 113 | packaging a Major Component, but which is not part of that Major 114 | Component, and (b) serves only to enable use of the work with that 115 | Major Component, or to implement a Standard Interface for which an 116 | implementation is available to the public in source code form. A 117 | "Major Component", in this context, means a major essential component 118 | (kernel, window system, and so on) of the specific operating system 119 | (if any) on which the executable work runs, or a compiler used to 120 | produce the work, or an object code interpreter used to run it. 121 | 122 | The "Corresponding Source" for a work in object code form means all 123 | the source code needed to generate, install, and (for an executable 124 | work) run the object code and to modify the work, including scripts to 125 | control those activities. However, it does not include the work's 126 | System Libraries, or general-purpose tools or generally available free 127 | programs which are used unmodified in performing those activities but 128 | which are not part of the work. For example, Corresponding Source 129 | includes interface definition files associated with source files for 130 | the work, and the source code for shared libraries and dynamically 131 | linked subprograms that the work is specifically designed to require, 132 | such as by intimate data communication or control flow between those 133 | subprograms and other parts of the work. 134 | 135 | The Corresponding Source need not include anything that users 136 | can regenerate automatically from other parts of the Corresponding 137 | Source. 138 | 139 | The Corresponding Source for a work in source code form is that 140 | same work. 141 | 142 | 2. Basic Permissions. 143 | 144 | All rights granted under this License are granted for the term of 145 | copyright on the Program, and are irrevocable provided the stated 146 | conditions are met. This License explicitly affirms your unlimited 147 | permission to run the unmodified Program. The output from running a 148 | covered work is covered by this License only if the output, given its 149 | content, constitutes a covered work. This License acknowledges your 150 | rights of fair use or other equivalent, as provided by copyright law. 151 | 152 | You may make, run and propagate covered works that you do not 153 | convey, without conditions so long as your license otherwise remains 154 | in force. You may convey covered works to others for the sole purpose 155 | of having them make modifications exclusively for you, or provide you 156 | with facilities for running those works, provided that you comply with 157 | the terms of this License in conveying all material for which you do 158 | not control copyright. Those thus making or running the covered works 159 | for you must do so exclusively on your behalf, under your direction 160 | and control, on terms that prohibit them from making any copies of 161 | your copyrighted material outside their relationship with you. 162 | 163 | Conveying under any other circumstances is permitted solely under 164 | the conditions stated below. Sublicensing is not allowed; section 10 165 | makes it unnecessary. 166 | 167 | 3. Protecting Users' Legal Rights From Anti-Circumvention Law. 168 | 169 | No covered work shall be deemed part of an effective technological 170 | measure under any applicable law fulfilling obligations under article 171 | 11 of the WIPO copyright treaty adopted on 20 December 1996, or 172 | similar laws prohibiting or restricting circumvention of such 173 | measures. 174 | 175 | When you convey a covered work, you waive any legal power to forbid 176 | circumvention of technological measures to the extent such circumvention 177 | is effected by exercising rights under this License with respect to 178 | the covered work, and you disclaim any intention to limit operation or 179 | modification of the work as a means of enforcing, against the work's 180 | users, your or third parties' legal rights to forbid circumvention of 181 | technological measures. 182 | 183 | 4. Conveying Verbatim Copies. 184 | 185 | You may convey verbatim copies of the Program's source code as you 186 | receive it, in any medium, provided that you conspicuously and 187 | appropriately publish on each copy an appropriate copyright notice; 188 | keep intact all notices stating that this License and any 189 | non-permissive terms added in accord with section 7 apply to the code; 190 | keep intact all notices of the absence of any warranty; and give all 191 | recipients a copy of this License along with the Program. 192 | 193 | You may charge any price or no price for each copy that you convey, 194 | and you may offer support or warranty protection for a fee. 195 | 196 | 5. Conveying Modified Source Versions. 197 | 198 | You may convey a work based on the Program, or the modifications to 199 | produce it from the Program, in the form of source code under the 200 | terms of section 4, provided that you also meet all of these conditions: 201 | 202 | a) The work must carry prominent notices stating that you modified 203 | it, and giving a relevant date. 204 | 205 | b) The work must carry prominent notices stating that it is 206 | released under this License and any conditions added under section 207 | 7. This requirement modifies the requirement in section 4 to 208 | "keep intact all notices". 209 | 210 | c) You must license the entire work, as a whole, under this 211 | License to anyone who comes into possession of a copy. This 212 | License will therefore apply, along with any applicable section 7 213 | additional terms, to the whole of the work, and all its parts, 214 | regardless of how they are packaged. This License gives no 215 | permission to license the work in any other way, but it does not 216 | invalidate such permission if you have separately received it. 217 | 218 | d) If the work has interactive user interfaces, each must display 219 | Appropriate Legal Notices; however, if the Program has interactive 220 | interfaces that do not display Appropriate Legal Notices, your 221 | work need not make them do so. 222 | 223 | A compilation of a covered work with other separate and independent 224 | works, which are not by their nature extensions of the covered work, 225 | and which are not combined with it such as to form a larger program, 226 | in or on a volume of a storage or distribution medium, is called an 227 | "aggregate" if the compilation and its resulting copyright are not 228 | used to limit the access or legal rights of the compilation's users 229 | beyond what the individual works permit. Inclusion of a covered work 230 | in an aggregate does not cause this License to apply to the other 231 | parts of the aggregate. 232 | 233 | 6. Conveying Non-Source Forms. 234 | 235 | You may convey a covered work in object code form under the terms 236 | of sections 4 and 5, provided that you also convey the 237 | machine-readable Corresponding Source under the terms of this License, 238 | in one of these ways: 239 | 240 | a) Convey the object code in, or embodied in, a physical product 241 | (including a physical distribution medium), accompanied by the 242 | Corresponding Source fixed on a durable physical medium 243 | customarily used for software interchange. 244 | 245 | b) Convey the object code in, or embodied in, a physical product 246 | (including a physical distribution medium), accompanied by a 247 | written offer, valid for at least three years and valid for as 248 | long as you offer spare parts or customer support for that product 249 | model, to give anyone who possesses the object code either (1) a 250 | copy of the Corresponding Source for all the software in the 251 | product that is covered by this License, on a durable physical 252 | medium customarily used for software interchange, for a price no 253 | more than your reasonable cost of physically performing this 254 | conveying of source, or (2) access to copy the 255 | Corresponding Source from a network server at no charge. 256 | 257 | c) Convey individual copies of the object code with a copy of the 258 | written offer to provide the Corresponding Source. This 259 | alternative is allowed only occasionally and noncommercially, and 260 | only if you received the object code with such an offer, in accord 261 | with subsection 6b. 262 | 263 | d) Convey the object code by offering access from a designated 264 | place (gratis or for a charge), and offer equivalent access to the 265 | Corresponding Source in the same way through the same place at no 266 | further charge. You need not require recipients to copy the 267 | Corresponding Source along with the object code. If the place to 268 | copy the object code is a network server, the Corresponding Source 269 | may be on a different server (operated by you or a third party) 270 | that supports equivalent copying facilities, provided you maintain 271 | clear directions next to the object code saying where to find the 272 | Corresponding Source. Regardless of what server hosts the 273 | Corresponding Source, you remain obligated to ensure that it is 274 | available for as long as needed to satisfy these requirements. 275 | 276 | e) Convey the object code using peer-to-peer transmission, provided 277 | you inform other peers where the object code and Corresponding 278 | Source of the work are being offered to the general public at no 279 | charge under subsection 6d. 280 | 281 | A separable portion of the object code, whose source code is excluded 282 | from the Corresponding Source as a System Library, need not be 283 | included in conveying the object code work. 284 | 285 | A "User Product" is either (1) a "consumer product", which means any 286 | tangible personal property which is normally used for personal, family, 287 | or household purposes, or (2) anything designed or sold for incorporation 288 | into a dwelling. In determining whether a product is a consumer product, 289 | doubtful cases shall be resolved in favor of coverage. For a particular 290 | product received by a particular user, "normally used" refers to a 291 | typical or common use of that class of product, regardless of the status 292 | of the particular user or of the way in which the particular user 293 | actually uses, or expects or is expected to use, the product. A product 294 | is a consumer product regardless of whether the product has substantial 295 | commercial, industrial or non-consumer uses, unless such uses represent 296 | the only significant mode of use of the product. 297 | 298 | "Installation Information" for a User Product means any methods, 299 | procedures, authorization keys, or other information required to install 300 | and execute modified versions of a covered work in that User Product from 301 | a modified version of its Corresponding Source. The information must 302 | suffice to ensure that the continued functioning of the modified object 303 | code is in no case prevented or interfered with solely because 304 | modification has been made. 305 | 306 | If you convey an object code work under this section in, or with, or 307 | specifically for use in, a User Product, and the conveying occurs as 308 | part of a transaction in which the right of possession and use of the 309 | User Product is transferred to the recipient in perpetuity or for a 310 | fixed term (regardless of how the transaction is characterized), the 311 | Corresponding Source conveyed under this section must be accompanied 312 | by the Installation Information. But this requirement does not apply 313 | if neither you nor any third party retains the ability to install 314 | modified object code on the User Product (for example, the work has 315 | been installed in ROM). 316 | 317 | The requirement to provide Installation Information does not include a 318 | requirement to continue to provide support service, warranty, or updates 319 | for a work that has been modified or installed by the recipient, or for 320 | the User Product in which it has been modified or installed. Access to a 321 | network may be denied when the modification itself materially and 322 | adversely affects the operation of the network or violates the rules and 323 | protocols for communication across the network. 324 | 325 | Corresponding Source conveyed, and Installation Information provided, 326 | in accord with this section must be in a format that is publicly 327 | documented (and with an implementation available to the public in 328 | source code form), and must require no special password or key for 329 | unpacking, reading or copying. 330 | 331 | 7. Additional Terms. 332 | 333 | "Additional permissions" are terms that supplement the terms of this 334 | License by making exceptions from one or more of its conditions. 335 | Additional permissions that are applicable to the entire Program shall 336 | be treated as though they were included in this License, to the extent 337 | that they are valid under applicable law. If additional permissions 338 | apply only to part of the Program, that part may be used separately 339 | under those permissions, but the entire Program remains governed by 340 | this License without regard to the additional permissions. 341 | 342 | When you convey a copy of a covered work, you may at your option 343 | remove any additional permissions from that copy, or from any part of 344 | it. (Additional permissions may be written to require their own 345 | removal in certain cases when you modify the work.) You may place 346 | additional permissions on material, added by you to a covered work, 347 | for which you have or can give appropriate copyright permission. 348 | 349 | Notwithstanding any other provision of this License, for material you 350 | add to a covered work, you may (if authorized by the copyright holders of 351 | that material) supplement the terms of this License with terms: 352 | 353 | a) Disclaiming warranty or limiting liability differently from the 354 | terms of sections 15 and 16 of this License; or 355 | 356 | b) Requiring preservation of specified reasonable legal notices or 357 | author attributions in that material or in the Appropriate Legal 358 | Notices displayed by works containing it; or 359 | 360 | c) Prohibiting misrepresentation of the origin of that material, or 361 | requiring that modified versions of such material be marked in 362 | reasonable ways as different from the original version; or 363 | 364 | d) Limiting the use for publicity purposes of names of licensors or 365 | authors of the material; or 366 | 367 | e) Declining to grant rights under trademark law for use of some 368 | trade names, trademarks, or service marks; or 369 | 370 | f) Requiring indemnification of licensors and authors of that 371 | material by anyone who conveys the material (or modified versions of 372 | it) with contractual assumptions of liability to the recipient, for 373 | any liability that these contractual assumptions directly impose on 374 | those licensors and authors. 375 | 376 | All other non-permissive additional terms are considered "further 377 | restrictions" within the meaning of section 10. If the Program as you 378 | received it, or any part of it, contains a notice stating that it is 379 | governed by this License along with a term that is a further 380 | restriction, you may remove that term. If a license document contains 381 | a further restriction but permits relicensing or conveying under this 382 | License, you may add to a covered work material governed by the terms 383 | of that license document, provided that the further restriction does 384 | not survive such relicensing or conveying. 385 | 386 | If you add terms to a covered work in accord with this section, you 387 | must place, in the relevant source files, a statement of the 388 | additional terms that apply to those files, or a notice indicating 389 | where to find the applicable terms. 390 | 391 | Additional terms, permissive or non-permissive, may be stated in the 392 | form of a separately written license, or stated as exceptions; 393 | the above requirements apply either way. 394 | 395 | 8. Termination. 396 | 397 | You may not propagate or modify a covered work except as expressly 398 | provided under this License. Any attempt otherwise to propagate or 399 | modify it is void, and will automatically terminate your rights under 400 | this License (including any patent licenses granted under the third 401 | paragraph of section 11). 402 | 403 | However, if you cease all violation of this License, then your 404 | license from a particular copyright holder is reinstated (a) 405 | provisionally, unless and until the copyright holder explicitly and 406 | finally terminates your license, and (b) permanently, if the copyright 407 | holder fails to notify you of the violation by some reasonable means 408 | prior to 60 days after the cessation. 409 | 410 | Moreover, your license from a particular copyright holder is 411 | reinstated permanently if the copyright holder notifies you of the 412 | violation by some reasonable means, this is the first time you have 413 | received notice of violation of this License (for any work) from that 414 | copyright holder, and you cure the violation prior to 30 days after 415 | your receipt of the notice. 416 | 417 | Termination of your rights under this section does not terminate the 418 | licenses of parties who have received copies or rights from you under 419 | this License. If your rights have been terminated and not permanently 420 | reinstated, you do not qualify to receive new licenses for the same 421 | material under section 10. 422 | 423 | 9. Acceptance Not Required for Having Copies. 424 | 425 | You are not required to accept this License in order to receive or 426 | run a copy of the Program. Ancillary propagation of a covered work 427 | occurring solely as a consequence of using peer-to-peer transmission 428 | to receive a copy likewise does not require acceptance. However, 429 | nothing other than this License grants you permission to propagate or 430 | modify any covered work. These actions infringe copyright if you do 431 | not accept this License. Therefore, by modifying or propagating a 432 | covered work, you indicate your acceptance of this License to do so. 433 | 434 | 10. Automatic Licensing of Downstream Recipients. 435 | 436 | Each time you convey a covered work, the recipient automatically 437 | receives a license from the original licensors, to run, modify and 438 | propagate that work, subject to this License. You are not responsible 439 | for enforcing compliance by third parties with this License. 440 | 441 | An "entity transaction" is a transaction transferring control of an 442 | organization, or substantially all assets of one, or subdividing an 443 | organization, or merging organizations. If propagation of a covered 444 | work results from an entity transaction, each party to that 445 | transaction who receives a copy of the work also receives whatever 446 | licenses to the work the party's predecessor in interest had or could 447 | give under the previous paragraph, plus a right to possession of the 448 | Corresponding Source of the work from the predecessor in interest, if 449 | the predecessor has it or can get it with reasonable efforts. 450 | 451 | You may not impose any further restrictions on the exercise of the 452 | rights granted or affirmed under this License. For example, you may 453 | not impose a license fee, royalty, or other charge for exercise of 454 | rights granted under this License, and you may not initiate litigation 455 | (including a cross-claim or counterclaim in a lawsuit) alleging that 456 | any patent claim is infringed by making, using, selling, offering for 457 | sale, or importing the Program or any portion of it. 458 | 459 | 11. Patents. 460 | 461 | A "contributor" is a copyright holder who authorizes use under this 462 | License of the Program or a work on which the Program is based. The 463 | work thus licensed is called the contributor's "contributor version". 464 | 465 | A contributor's "essential patent claims" are all patent claims 466 | owned or controlled by the contributor, whether already acquired or 467 | hereafter acquired, that would be infringed by some manner, permitted 468 | by this License, of making, using, or selling its contributor version, 469 | but do not include claims that would be infringed only as a 470 | consequence of further modification of the contributor version. For 471 | purposes of this definition, "control" includes the right to grant 472 | patent sublicenses in a manner consistent with the requirements of 473 | this License. 474 | 475 | Each contributor grants you a non-exclusive, worldwide, royalty-free 476 | patent license under the contributor's essential patent claims, to 477 | make, use, sell, offer for sale, import and otherwise run, modify and 478 | propagate the contents of its contributor version. 479 | 480 | In the following three paragraphs, a "patent license" is any express 481 | agreement or commitment, however denominated, not to enforce a patent 482 | (such as an express permission to practice a patent or covenant not to 483 | sue for patent infringement). To "grant" such a patent license to a 484 | party means to make such an agreement or commitment not to enforce a 485 | patent against the party. 486 | 487 | If you convey a covered work, knowingly relying on a patent license, 488 | and the Corresponding Source of the work is not available for anyone 489 | to copy, free of charge and under the terms of this License, through a 490 | publicly available network server or other readily accessible means, 491 | then you must either (1) cause the Corresponding Source to be so 492 | available, or (2) arrange to deprive yourself of the benefit of the 493 | patent license for this particular work, or (3) arrange, in a manner 494 | consistent with the requirements of this License, to extend the patent 495 | license to downstream recipients. "Knowingly relying" means you have 496 | actual knowledge that, but for the patent license, your conveying the 497 | covered work in a country, or your recipient's use of the covered work 498 | in a country, would infringe one or more identifiable patents in that 499 | country that you have reason to believe are valid. 500 | 501 | If, pursuant to or in connection with a single transaction or 502 | arrangement, you convey, or propagate by procuring conveyance of, a 503 | covered work, and grant a patent license to some of the parties 504 | receiving the covered work authorizing them to use, propagate, modify 505 | or convey a specific copy of the covered work, then the patent license 506 | you grant is automatically extended to all recipients of the covered 507 | work and works based on it. 508 | 509 | A patent license is "discriminatory" if it does not include within 510 | the scope of its coverage, prohibits the exercise of, or is 511 | conditioned on the non-exercise of one or more of the rights that are 512 | specifically granted under this License. You may not convey a covered 513 | work if you are a party to an arrangement with a third party that is 514 | in the business of distributing software, under which you make payment 515 | to the third party based on the extent of your activity of conveying 516 | the work, and under which the third party grants, to any of the 517 | parties who would receive the covered work from you, a discriminatory 518 | patent license (a) in connection with copies of the covered work 519 | conveyed by you (or copies made from those copies), or (b) primarily 520 | for and in connection with specific products or compilations that 521 | contain the covered work, unless you entered into that arrangement, 522 | or that patent license was granted, prior to 28 March 2007. 523 | 524 | Nothing in this License shall be construed as excluding or limiting 525 | any implied license or other defenses to infringement that may 526 | otherwise be available to you under applicable patent law. 527 | 528 | 12. No Surrender of Others' Freedom. 529 | 530 | If conditions are imposed on you (whether by court order, agreement or 531 | otherwise) that contradict the conditions of this License, they do not 532 | excuse you from the conditions of this License. If you cannot convey a 533 | covered work so as to satisfy simultaneously your obligations under this 534 | License and any other pertinent obligations, then as a consequence you may 535 | not convey it at all. For example, if you agree to terms that obligate you 536 | to collect a royalty for further conveying from those to whom you convey 537 | the Program, the only way you could satisfy both those terms and this 538 | License would be to refrain entirely from conveying the Program. 539 | 540 | 13. Remote Network Interaction; Use with the GNU General Public License. 541 | 542 | Notwithstanding any other provision of this License, if you modify the 543 | Program, your modified version must prominently offer all users 544 | interacting with it remotely through a computer network (if your version 545 | supports such interaction) an opportunity to receive the Corresponding 546 | Source of your version by providing access to the Corresponding Source 547 | from a network server at no charge, through some standard or customary 548 | means of facilitating copying of software. This Corresponding Source 549 | shall include the Corresponding Source for any work covered by version 3 550 | of the GNU General Public License that is incorporated pursuant to the 551 | following paragraph. 552 | 553 | Notwithstanding any other provision of this License, you have 554 | permission to link or combine any covered work with a work licensed 555 | under version 3 of the GNU General Public License into a single 556 | combined work, and to convey the resulting work. The terms of this 557 | License will continue to apply to the part which is the covered work, 558 | but the work with which it is combined will remain governed by version 559 | 3 of the GNU General Public License. 560 | 561 | 14. Revised Versions of this License. 562 | 563 | The Free Software Foundation may publish revised and/or new versions of 564 | the GNU Affero General Public License from time to time. Such new versions 565 | will be similar in spirit to the present version, but may differ in detail to 566 | address new problems or concerns. 567 | 568 | Each version is given a distinguishing version number. If the 569 | Program specifies that a certain numbered version of the GNU Affero General 570 | Public License "or any later version" applies to it, you have the 571 | option of following the terms and conditions either of that numbered 572 | version or of any later version published by the Free Software 573 | Foundation. If the Program does not specify a version number of the 574 | GNU Affero General Public License, you may choose any version ever published 575 | by the Free Software Foundation. 576 | 577 | If the Program specifies that a proxy can decide which future 578 | versions of the GNU Affero General Public License can be used, that proxy's 579 | public statement of acceptance of a version permanently authorizes you 580 | to choose that version for the Program. 581 | 582 | Later license versions may give you additional or different 583 | permissions. However, no additional obligations are imposed on any 584 | author or copyright holder as a result of your choosing to follow a 585 | later version. 586 | 587 | 15. Disclaimer of Warranty. 588 | 589 | THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY 590 | APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT 591 | HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY 592 | OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, 593 | THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR 594 | PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM 595 | IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF 596 | ALL NECESSARY SERVICING, REPAIR OR CORRECTION. 597 | 598 | 16. Limitation of Liability. 599 | 600 | IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING 601 | WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS 602 | THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY 603 | GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE 604 | USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF 605 | DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD 606 | PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), 607 | EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF 608 | SUCH DAMAGES. 609 | 610 | 17. Interpretation of Sections 15 and 16. 611 | 612 | If the disclaimer of warranty and limitation of liability provided 613 | above cannot be given local legal effect according to their terms, 614 | reviewing courts shall apply local law that most closely approximates 615 | an absolute waiver of all civil liability in connection with the 616 | Program, unless a warranty or assumption of liability accompanies a 617 | copy of the Program in return for a fee. 618 | 619 | END OF TERMS AND CONDITIONS 620 | 621 | How to Apply These Terms to Your New Programs 622 | 623 | If you develop a new program, and you want it to be of the greatest 624 | possible use to the public, the best way to achieve this is to make it 625 | free software which everyone can redistribute and change under these terms. 626 | 627 | To do so, attach the following notices to the program. It is safest 628 | to attach them to the start of each source file to most effectively 629 | state the exclusion of warranty; and each file should have at least 630 | the "copyright" line and a pointer to where the full notice is found. 631 | 632 | 633 | Copyright (C) 634 | 635 | This program is free software: you can redistribute it and/or modify 636 | it under the terms of the GNU Affero General Public License as published 637 | by the Free Software Foundation, either version 3 of the License, or 638 | (at your option) any later version. 639 | 640 | This program is distributed in the hope that it will be useful, 641 | but WITHOUT ANY WARRANTY; without even the implied warranty of 642 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 643 | GNU Affero General Public License for more details. 644 | 645 | You should have received a copy of the GNU Affero General Public License 646 | along with this program. If not, see . 647 | 648 | Also add information on how to contact you by electronic and paper mail. 649 | 650 | If your software can interact with users remotely through a computer 651 | network, you should also make sure that it provides a way for users to 652 | get its source. For example, if your program is a web application, its 653 | interface could display a "Source" link that leads users to an archive 654 | of the code. There are many ways you could offer source, and different 655 | solutions will be better for different programs; see section 13 for the 656 | specific requirements. 657 | 658 | You should also get your employer (if you work as a programmer) or school, 659 | if any, to sign a "copyright disclaimer" for the program, if necessary. 660 | For more information on this, and how to apply and follow the GNU AGPL, see 661 | . 662 | --------------------------------------------------------------------------------