├── .github ├── actions │ └── setup-python │ │ └── action.yml └── workflows │ └── release.yml ├── .gitignore ├── LICENSE ├── README.markdown ├── lagrange ├── __init__.py ├── client │ ├── __init__.py │ ├── base.py │ ├── client.py │ ├── event.py │ ├── events │ │ ├── __init__.py │ │ ├── friend.py │ │ ├── group.py │ │ └── service.py │ ├── highway │ │ ├── __init__.py │ │ ├── encoders.py │ │ ├── frame.py │ │ ├── highway.py │ │ └── utils.py │ ├── message │ │ ├── __init__.py │ │ ├── decoder.py │ │ ├── elems.py │ │ ├── encoder.py │ │ └── types.py │ ├── models.py │ ├── network.py │ ├── packet.py │ ├── server_push │ │ ├── __init__.py │ │ ├── binder.py │ │ ├── log.py │ │ ├── msg.py │ │ └── service.py │ └── wtlogin │ │ ├── __init__.py │ │ ├── enum.py │ │ ├── exchange.py │ │ ├── ntlogin.py │ │ ├── oicq.py │ │ ├── sso.py │ │ ├── status_service.py │ │ └── tlv │ │ ├── __init__.py │ │ ├── common.py │ │ └── qrcode.py ├── info │ ├── __init__.py │ ├── app.py │ ├── device.py │ ├── serialize.py │ └── sig.py ├── pb │ ├── __init__.py │ ├── highway │ │ ├── __init__.py │ │ ├── comm.py │ │ ├── ext.py │ │ ├── head.py │ │ ├── httpconn.py │ │ ├── req.py │ │ └── rsp.py │ ├── login │ │ ├── __init__.py │ │ ├── ntlogin.py │ │ └── register.py │ ├── message │ │ ├── __init__.py │ │ ├── heads.py │ │ ├── msg.py │ │ ├── msg_push.py │ │ ├── rich_text │ │ │ ├── __init__.py │ │ │ └── elems.py │ │ └── send.py │ ├── service │ │ ├── __init__.py │ │ ├── comm.py │ │ ├── friend.py │ │ ├── group.py │ │ └── oidb.py │ └── status │ │ ├── __init__.py │ │ ├── friend.py │ │ ├── group.py │ │ └── kick.py ├── py.typed ├── utils │ ├── __init__.py │ ├── audio │ │ ├── __init__.py │ │ ├── decoder.py │ │ └── enum.py │ ├── binary │ │ ├── __init__.py │ │ ├── builder.py │ │ ├── protobuf │ │ │ ├── __init__.py │ │ │ ├── coder.py │ │ │ ├── models.py │ │ │ └── util.py │ │ └── reader.py │ ├── crypto │ │ ├── __init__.py │ │ ├── aes.py │ │ ├── ecdh │ │ │ ├── __init__.py │ │ │ ├── curve.py │ │ │ ├── ecdh.py │ │ │ ├── impl.py │ │ │ └── point.py │ │ └── tea.py │ ├── httpcat.py │ ├── image │ │ ├── __init__.py │ │ ├── decoder.py │ │ └── enum.py │ ├── log.py │ ├── network.py │ ├── operator.py │ └── sign.py └── version.py ├── main.py ├── pdm.lock ├── pdm_build.py └── pyproject.toml /.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 -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | ### venv template 2 | # Virtualenv 3 | # http://iamzed.com/2009/05/07/a-primer-on-virtualenv/ 4 | .Python 5 | [Bb]in 6 | [Ii]nclude 7 | [Ll]ib 8 | [Ll]ib64 9 | [Ll]ocal 10 | [Ss]cripts 11 | pyvenv.cfg 12 | .venv 13 | pip-selfcheck.json 14 | 15 | ### VisualStudioCode template 16 | .vscode/* 17 | !.vscode/settings.json 18 | !.vscode/tasks.json 19 | !.vscode/launch.json 20 | !.vscode/extensions.json 21 | !.vscode/*.code-snippets 22 | 23 | # Local History for Visual Studio Code 24 | .history/ 25 | 26 | # Built Visual Studio Code Extensions 27 | *.vsix 28 | 29 | ### JetBrains template 30 | # Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio, WebStorm and Rider 31 | # Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839 32 | 33 | # User-specific stuff 34 | .idea/**/workspace.xml 35 | .idea/**/tasks.xml 36 | .idea/**/usage.statistics.xml 37 | .idea/**/dictionaries 38 | .idea/**/shelf 39 | 40 | # AWS User-specific 41 | .idea/**/aws.xml 42 | 43 | # Generated files 44 | .idea/**/contentModel.xml 45 | 46 | # Sensitive or high-churn files 47 | .idea/**/dataSources/ 48 | .idea/**/dataSources.ids 49 | .idea/**/dataSources.local.xml 50 | .idea/**/sqlDataSources.xml 51 | .idea/**/dynamic.xml 52 | .idea/**/uiDesigner.xml 53 | .idea/**/dbnavigator.xml 54 | 55 | # Gradle 56 | .idea/**/gradle.xml 57 | .idea/**/libraries 58 | 59 | # Gradle and Maven with auto-import 60 | # When using Gradle or Maven with auto-import, you should exclude module files, 61 | # since they will be recreated, and may cause churn. Uncomment if using 62 | # auto-import. 63 | # .idea/artifacts 64 | # .idea/compiler.xml 65 | # .idea/jarRepositories.xml 66 | # .idea/modules.xml 67 | # .idea/*.iml 68 | # .idea/modules 69 | # *.iml 70 | # *.ipr 71 | 72 | # CMake 73 | cmake-build-*/ 74 | 75 | # Mongo Explorer plugin 76 | .idea/**/mongoSettings.xml 77 | 78 | # File-based project format 79 | *.iws 80 | 81 | # IntelliJ 82 | out/ 83 | 84 | # mpeltonen/sbt-idea plugin 85 | .idea_modules/ 86 | 87 | # JIRA plugin 88 | atlassian-ide-plugin.xml 89 | 90 | # Cursive Clojure plugin 91 | .idea/replstate.xml 92 | 93 | # SonarLint plugin 94 | .idea/sonarlint/ 95 | 96 | # Crashlytics plugin (for Android Studio and IntelliJ) 97 | com_crashlytics_export_strings.xml 98 | crashlytics.properties 99 | crashlytics-build.properties 100 | fabric.properties 101 | 102 | # Editor-based Rest Client 103 | .idea/httpRequests 104 | 105 | # Android studio 3.1+ serialized cache file 106 | .idea/caches/build_file_checksums.ser 107 | 108 | ### Python template 109 | # Byte-compiled / optimized / DLL files 110 | __pycache__/ 111 | *.py[cod] 112 | *$py.class 113 | 114 | # C extensions 115 | *.so 116 | 117 | # Distribution / packaging 118 | build/ 119 | develop-eggs/ 120 | dist/ 121 | downloads/ 122 | eggs/ 123 | .eggs/ 124 | lib/ 125 | lib64/ 126 | parts/ 127 | sdist/ 128 | var/ 129 | wheels/ 130 | share/python-wheels/ 131 | *.egg-info/ 132 | .installed.cfg 133 | *.egg 134 | MANIFEST 135 | 136 | # PyInstaller 137 | # Usually these files are written by a python script from a template 138 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 139 | *.manifest 140 | *.spec 141 | 142 | # Installer logs 143 | pip-log.txt 144 | pip-delete-this-directory.txt 145 | 146 | # Unit test / coverage reports 147 | htmlcov/ 148 | .tox/ 149 | .nox/ 150 | .coverage 151 | .coverage.* 152 | .cache 153 | nosetests.xml 154 | coverage.xml 155 | *.cover 156 | *.py,cover 157 | .hypothesis/ 158 | .pytest_cache/ 159 | cover/ 160 | 161 | # Translations 162 | *.mo 163 | *.pot 164 | 165 | # Django stuff: 166 | *.log 167 | local_settings.py 168 | db.sqlite3 169 | db.sqlite3-journal 170 | 171 | # Flask stuff: 172 | instance/ 173 | .webassets-cache 174 | 175 | # Scrapy stuff: 176 | .scrapy 177 | 178 | # Sphinx documentation 179 | docs/_build/ 180 | 181 | # PyBuilder 182 | .pybuilder/ 183 | target/ 184 | 185 | # Jupyter Notebook 186 | .ipynb_checkpoints 187 | 188 | # IPython 189 | profile_default/ 190 | ipython_config.py 191 | 192 | # pyenv 193 | # For a library or package, you might want to ignore these files since the code is 194 | # intended to run in multiple environments; otherwise, check them in: 195 | # .python-version 196 | 197 | # pipenv 198 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 199 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 200 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 201 | # install all needed dependencies. 202 | #Pipfile.lock 203 | 204 | # poetry 205 | # Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. 206 | # This is especially recommended for binary packages to ensure reproducibility, and is more 207 | # commonly ignored for libraries. 208 | # https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control 209 | #poetry.lock 210 | 211 | # pdm 212 | # Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. 213 | #pdm.lock 214 | # pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it 215 | # in version control. 216 | # https://pdm.fming.dev/#use-with-ide 217 | .pdm.toml 218 | .pdm-python 219 | 220 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm 221 | __pypackages__/ 222 | 223 | # Celery stuff 224 | celerybeat-schedule 225 | celerybeat.pid 226 | 227 | # SageMath parsed files 228 | *.sage.py 229 | 230 | # Environments 231 | .env 232 | env/ 233 | venv/ 234 | ENV/ 235 | env.bak/ 236 | venv.bak/ 237 | 238 | # Spyder project settings 239 | .spyderproject 240 | .spyproject 241 | 242 | # Rope project settings 243 | .ropeproject 244 | 245 | # mkdocs documentation 246 | /site 247 | 248 | # mypy 249 | .mypy_cache/ 250 | .dmypy.json 251 | dmypy.json 252 | 253 | # Pyre type checker 254 | .pyre/ 255 | 256 | # pytype static type analyzer 257 | .pytype/ 258 | 259 | # Cython debug symbols 260 | cython_debug/ 261 | 262 | # PyCharm 263 | # JetBrains specific template is maintained in a separate JetBrains.gitignore that can 264 | # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore 265 | # and can be added to the global gitignore or merged into this file. For a more nuclear 266 | # option (not recommended) you can uncomment the following to ignore the entire idea folder. 267 | .idea/ 268 | 269 | device.json 270 | sig.bin -------------------------------------------------------------------------------- /README.markdown: -------------------------------------------------------------------------------- 1 |

✨ lagrange-python ✨

2 | 3 |

An Python Implementation of NTQQ PC Protocol

4 | -------------------------------------------------------------------------------- /lagrange/__init__.py: -------------------------------------------------------------------------------- 1 | import json 2 | from typing import Optional 3 | 4 | from lagrange.info import AppInfo 5 | from typing_extensions import Literal 6 | import asyncio 7 | 8 | from .client.client import Client as Client 9 | # from .client.server_push.msg import msg_push_handler 10 | # from .client.server_push.service import server_kick_handler 11 | from .utils.log import log as log 12 | from .utils.log import install_loguru as install_loguru 13 | from .utils.sign import sign_provider 14 | from .info import InfoManager 15 | from .info.app import app_list 16 | from .utils.binary.protobuf.models import evaluate_all 17 | 18 | 19 | class Lagrange: 20 | client: Client 21 | 22 | def __init__( 23 | self, 24 | uin: int, 25 | protocol: Literal["linux", "macos", "windows", "custom"] = "linux", 26 | sign_url: Optional[str] = None, 27 | device_info_path="./device.json", 28 | signinfo_path="./sig.bin", 29 | custom_protocol_path="./protocol.json", 30 | ): 31 | self.im = InfoManager(uin, device_info_path, signinfo_path) 32 | self.uin = uin 33 | self.sign = sign_provider(sign_url) if sign_url else None 34 | self.events = {} 35 | self.log = log 36 | self._protocol = protocol 37 | self._protocol_path = custom_protocol_path 38 | 39 | def subscribe(self, event, handler): 40 | self.events[event] = handler 41 | 42 | async def login(self, client: Client): 43 | if self.im.sig_info.d2: 44 | if not await client.register(): 45 | return await client.login() 46 | return True 47 | else: 48 | return await client.login() 49 | 50 | async def run(self): 51 | if self._protocol == "custom": 52 | log.root.debug("load custom protocol from %s" % self._protocol_path) 53 | with open(self._protocol_path, "r") as f: 54 | proto = json.loads(f.read()) 55 | app_info = AppInfo.load_custom(proto) 56 | else: 57 | app_info = app_list[self._protocol] 58 | log.root.info(f"AppInfo: platform={app_info.os}, ver={app_info.build_version}({app_info.sub_app_id})") 59 | 60 | with self.im as im: 61 | self.client = Client( 62 | self.uin, 63 | app_info, 64 | im.device, 65 | im.sig_info, 66 | self.sign, 67 | ) 68 | for event, handler in self.events.items(): 69 | self.client.events.subscribe(event, handler) 70 | self.client.connect() 71 | status = await self.login(self.client) 72 | if not status: 73 | log.login.error("Login failed") 74 | return 75 | await self.client.wait_closed() 76 | 77 | def launch(self): 78 | try: 79 | asyncio.run(self.run()) 80 | except KeyboardInterrupt: 81 | self.client._task_clear() 82 | log.root.info("Program exited by user") 83 | else: 84 | log.root.info("Program exited normally") 85 | 86 | 87 | evaluate_all() 88 | -------------------------------------------------------------------------------- /lagrange/client/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LagrangeDev/lagrange-python/974dfaa546a1fcfbd6461f284a950af4622ca09f/lagrange/client/__init__.py -------------------------------------------------------------------------------- /lagrange/client/event.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | from typing import TYPE_CHECKING, Any, Callable, TypeVar 3 | from collections.abc import Awaitable 4 | 5 | from lagrange.utils.log import log 6 | 7 | if TYPE_CHECKING: 8 | from .events import BaseEvent 9 | from .client import Client 10 | 11 | T = TypeVar("T", bound="BaseEvent") 12 | EVENT_HANDLER = Callable[["Client", T], Awaitable[Any]] 13 | 14 | 15 | class Events: 16 | def __init__(self): 17 | self._task_group: set[asyncio.Task] = set() 18 | self._handle_map: dict[type["BaseEvent"], EVENT_HANDLER] = {} 19 | 20 | def subscribe(self, event: type[T], handler: EVENT_HANDLER[T]): 21 | if event not in self._handle_map: 22 | self._handle_map[event] = handler 23 | else: 24 | raise AssertionError( 25 | f"Event already subscribed to {self._handle_map[event]}" 26 | ) 27 | 28 | def unsubscribe(self, event: type["BaseEvent"]): 29 | return self._handle_map.pop(event) 30 | 31 | async def _task_exec(self, client: "Client", event: "BaseEvent", handler: EVENT_HANDLER): 32 | try: 33 | await handler(client, event) 34 | except Exception as e: 35 | log.root.exception( 36 | f"Unhandled exception on task {event}", exc_info=e 37 | ) 38 | 39 | def emit(self, event: "BaseEvent", client: "Client"): 40 | typ = type(event) 41 | if typ not in self._handle_map: 42 | log.root.debug(f"Unhandled event: {event}") 43 | return 44 | 45 | t = asyncio.create_task(self._task_exec(client, event, self._handle_map[typ])) 46 | self._task_group.add(t) 47 | t.add_done_callback(self._task_group.discard) 48 | -------------------------------------------------------------------------------- /lagrange/client/events/__init__.py: -------------------------------------------------------------------------------- 1 | from abc import ABC 2 | 3 | 4 | class BaseEvent(ABC): 5 | ... 6 | -------------------------------------------------------------------------------- /lagrange/client/events/friend.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from dataclasses import dataclass 4 | from typing import TYPE_CHECKING 5 | from . import BaseEvent 6 | 7 | if TYPE_CHECKING: 8 | from lagrange.client.message.types import Element 9 | 10 | 11 | @dataclass 12 | class FriendEvent(BaseEvent): 13 | from_uin: int 14 | from_uid: str 15 | to_uin: int 16 | to_uid: str 17 | 18 | 19 | @dataclass 20 | class FriendMessage(FriendEvent): 21 | seq: int 22 | msg_id: int 23 | timestamp: int 24 | msg: str 25 | msg_chain: list[Element] 26 | 27 | 28 | @dataclass 29 | class FriendRecall(FriendEvent): 30 | seq: int 31 | msg_id: int 32 | timestamp: int 33 | 34 | @dataclass 35 | class FriendRequest(FriendEvent): 36 | from_uid: str 37 | to_uid: str 38 | message: str 39 | source: str -------------------------------------------------------------------------------- /lagrange/client/events/group.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from dataclasses import dataclass, field 4 | from typing import TYPE_CHECKING 5 | 6 | from . import BaseEvent 7 | 8 | if TYPE_CHECKING: 9 | from lagrange.client.message.types import Element 10 | 11 | 12 | @dataclass 13 | class MessageInfo: 14 | uid: str 15 | seq: int 16 | time: int 17 | rand: int 18 | 19 | 20 | @dataclass 21 | class GroupEvent(BaseEvent): 22 | grp_id: int 23 | 24 | 25 | @dataclass 26 | class GroupMessage(GroupEvent, MessageInfo): 27 | uin: int 28 | grp_name: str 29 | nickname: str 30 | sub_id: int = field(repr=False) # client ver identify 31 | sender_type: int = field(repr=False) 32 | msg: str 33 | msg_chain: list[Element] 34 | 35 | @property 36 | def is_bot(self) -> bool: 37 | return self.sender_type == 3091 38 | 39 | 40 | @dataclass 41 | class GroupRecall(GroupEvent, MessageInfo): 42 | suffix: str 43 | 44 | 45 | @dataclass 46 | class GroupNudge(GroupEvent): 47 | sender_uin: int 48 | target_uin: int 49 | action: str 50 | suffix: str 51 | attrs: dict[str, str | int] = field(repr=False) 52 | attrs_xml: str = field(repr=False) 53 | 54 | 55 | @dataclass 56 | class GroupSign(GroupEvent): 57 | """群打卡""" 58 | 59 | uin: int 60 | nickname: str 61 | timestamp: int 62 | attrs: dict[str, str | int] = field(repr=False) 63 | attrs_xml: str = field(repr=False) 64 | 65 | 66 | @dataclass 67 | class GroupMuteMember(GroupEvent): 68 | """when target_uid is empty, mute all member""" 69 | 70 | operator_uid: str 71 | target_uid: str 72 | duration: int 73 | 74 | 75 | @dataclass 76 | class GroupMemberJoinRequest(GroupEvent): 77 | uid: str 78 | invitor_uid: str | None = None 79 | answer: str | None = None # 问题:(.*)答案:(.*) 80 | 81 | 82 | @dataclass 83 | class GroupMemberJoined(GroupEvent): 84 | # uin: int //it cant get 85 | uid: str 86 | join_type: int 87 | 88 | 89 | @dataclass 90 | class GroupMemberQuit(GroupEvent): 91 | uin: int 92 | uid: str 93 | exit_type: int 94 | operator_uid: str = "" 95 | 96 | @property 97 | def is_kicked(self) -> bool: 98 | return self.exit_type in [3, 131] 99 | 100 | @property 101 | def is_kicked_self(self) -> bool: 102 | return self.exit_type == 3 103 | 104 | 105 | @dataclass 106 | class GroupMemberGotSpecialTitle(GroupEvent): 107 | grp_id: int 108 | member_uin: int 109 | special_title: str 110 | _detail_url: str = "" 111 | 112 | 113 | @dataclass 114 | class GroupNameChanged(GroupEvent): 115 | name_new: str 116 | timestamp: int 117 | operator_uid: str 118 | 119 | 120 | @dataclass 121 | class GroupReaction(GroupEvent): 122 | uid: str 123 | seq: int 124 | emoji_id: int 125 | emoji_type: int 126 | emoji_count: int 127 | type: int 128 | total_operations: int 129 | 130 | @property 131 | def is_increase(self) -> bool: 132 | return self.type == 1 133 | 134 | @property 135 | def is_emoji(self) -> bool: 136 | return self.emoji_type == 2 137 | 138 | 139 | @dataclass 140 | class GroupAlbumUpdate(GroupEvent): 141 | """群相册更新(上传)""" 142 | 143 | timestamp: int 144 | image_id: str 145 | 146 | 147 | @dataclass 148 | class GroupInvite(GroupEvent): 149 | invitor_uid: str 150 | 151 | 152 | @dataclass 153 | class GroupMemberJoinedByInvite(GroupEvent): 154 | invitor_uin: int 155 | uin: int 156 | 157 | 158 | @dataclass 159 | class GroupSelfJoined(GroupEvent): 160 | grp_id: int 161 | op_uid: str 162 | 163 | 164 | @dataclass 165 | class GroupSelfRequireReject(GroupEvent): 166 | grp_id: int 167 | message: str 168 | 169 | 170 | @dataclass 171 | class GroupBotAdded(GroupEvent): 172 | bot_uid: str 173 | 174 | 175 | @dataclass 176 | class BotGrayTip(GroupEvent): 177 | content: str 178 | 179 | 180 | @dataclass 181 | class GroupBotJoined(GroupEvent): 182 | opqq_uin: int 183 | nick_name: str 184 | robot_name: str 185 | robot_schema: str 186 | user_schema: str 187 | -------------------------------------------------------------------------------- /lagrange/client/events/service.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass 2 | from typing import List 3 | 4 | from . import BaseEvent 5 | 6 | 7 | @dataclass 8 | class ClientOffline(BaseEvent): 9 | recoverable: bool 10 | 11 | 12 | @dataclass 13 | class ClientOnline(BaseEvent): 14 | """after register completed""" 15 | 16 | 17 | @dataclass 18 | class ServerKick(BaseEvent): 19 | tips: str 20 | title: str 21 | 22 | 23 | @dataclass 24 | class OtherClientInfo(BaseEvent): 25 | @dataclass 26 | class ClientOnline(BaseEvent): 27 | sub_id: int 28 | os_name: str 29 | device_name: str 30 | 31 | clients: List[ClientOnline] 32 | -------------------------------------------------------------------------------- /lagrange/client/highway/__init__.py: -------------------------------------------------------------------------------- 1 | from .highway import HighWaySession 2 | 3 | __all__ = ["HighWaySession"] 4 | -------------------------------------------------------------------------------- /lagrange/client/highway/encoders.py: -------------------------------------------------------------------------------- 1 | import os 2 | from typing import TYPE_CHECKING 3 | 4 | from lagrange.pb.highway.comm import ( 5 | AudioExtInfo, 6 | CommonHead, 7 | ExtBizInfo, 8 | FileInfo, 9 | FileType, 10 | PicExtInfo, 11 | IndexNode, 12 | ) 13 | from lagrange.pb.highway.head import ( 14 | DataHighwayHead, 15 | HighwayTransReqHead, 16 | LoginSigHead, 17 | SegHead, 18 | ) 19 | from lagrange.pb.highway.req import ( 20 | C2CUserInfo, 21 | GroupInfo, 22 | MultiMediaReqHead, 23 | NTV2RichMediaReq, 24 | SceneInfo, 25 | UploadInfo, 26 | UploadReq, 27 | DownloadReq, 28 | ) 29 | 30 | if TYPE_CHECKING: 31 | from lagrange.utils.image.decoder import ImageInfo 32 | 33 | 34 | def encode_highway_head( 35 | uin: int, 36 | seq: int, 37 | cmd: str, 38 | cmd_id: int, 39 | file_size: int, 40 | file_offset: int, 41 | file_md5: bytes, 42 | blk_size: int, 43 | blk_md5: bytes, 44 | ticket: bytes, 45 | tgt: bytes, 46 | app_id: int, 47 | sub_app_id: int, 48 | timestamp: int, 49 | ext_info: bytes, 50 | ) -> HighwayTransReqHead: 51 | return HighwayTransReqHead( 52 | msg_head=DataHighwayHead( 53 | uin=str(uin), command=cmd, seq=seq, app_id=sub_app_id, command_id=cmd_id 54 | ), 55 | seg_head=SegHead( 56 | file_size=file_size, 57 | data_offset=file_offset, 58 | data_length=blk_size, 59 | ticket=ticket, 60 | md5=blk_md5, 61 | file_md5=file_md5, 62 | ), 63 | req_ext_info=ext_info, 64 | timestamp=timestamp, 65 | login_head=LoginSigHead( 66 | login_sig_type=8, 67 | login_sig=tgt, 68 | app_id=app_id, 69 | ), 70 | ) 71 | 72 | 73 | def encode_upload_img_req( 74 | grp_id: int, 75 | uid: str, 76 | md5: bytes, 77 | sha1: bytes, 78 | size: int, 79 | info: "ImageInfo", 80 | is_origin=True, 81 | ) -> NTV2RichMediaReq: 82 | assert not (grp_id and uid) 83 | fn = f"{md5.hex().upper()}.{info.name or 'jpg'}" 84 | c2c_info = None 85 | grp_info = None 86 | c2c_pb = b"" 87 | grp_pb = b"" 88 | if grp_id: 89 | scene_type = 2 90 | grp_info = GroupInfo(grp_id=grp_id) 91 | grp_pb = bytes.fromhex( 92 | "0800180020004a00500062009201009a0100aa010c080012001800200028003a00" 93 | ) 94 | else: 95 | scene_type = 1 96 | c2c_info = C2CUserInfo(uid=uid) 97 | c2c_pb = bytes.fromhex( 98 | "0800180020004200500062009201009a0100a2010c080012001800200028003a00" 99 | ) 100 | 101 | return NTV2RichMediaReq( 102 | req_head=MultiMediaReqHead( 103 | common=CommonHead(cmd=100), 104 | scene=SceneInfo( 105 | req_type=2, 106 | bus_type=1, 107 | scene_type=scene_type, 108 | c2c=c2c_info, 109 | grp=grp_info, 110 | ), 111 | ), 112 | upload=UploadReq( 113 | infos=[ 114 | UploadInfo( 115 | file_info=FileInfo( 116 | size=size, 117 | hash=md5.hex(), 118 | sha1=sha1.hex(), 119 | name=fn, 120 | type=FileType(type=1, pic_format=info.pic_type.value), 121 | width=info.width, 122 | height=info.height, 123 | is_origin=is_origin, 124 | ), 125 | sub_type=0, 126 | ) 127 | ], 128 | compat_stype=scene_type, 129 | client_rand_id=int.from_bytes(os.urandom(4), "big"), 130 | biz_info=ExtBizInfo( 131 | pic=PicExtInfo(c2c_reserved=c2c_pb, troop_reserved=grp_pb) 132 | ), 133 | ), 134 | ) 135 | 136 | 137 | def encode_audio_upload_req( 138 | grp_id: int, uid: str, md5: bytes, sha1: bytes, size: int, time: int 139 | ) -> NTV2RichMediaReq: 140 | assert not (grp_id and uid) 141 | c2c_info = None 142 | grp_info = None 143 | if grp_id: 144 | scene_type = 2 145 | grp_info = GroupInfo(grp_id=grp_id) 146 | else: 147 | scene_type = 1 148 | c2c_info = C2CUserInfo(uid=uid) 149 | return NTV2RichMediaReq( 150 | req_head=MultiMediaReqHead( 151 | common=CommonHead( 152 | req_id=4 if grp_id else 1, 153 | cmd=100 154 | ), 155 | scene=SceneInfo( 156 | req_type=2, 157 | bus_type=3, 158 | scene_type=scene_type, 159 | c2c=c2c_info, 160 | grp=grp_info, 161 | ), 162 | ), 163 | upload=UploadReq( 164 | infos=[ 165 | UploadInfo( 166 | file_info=FileInfo( 167 | size=size, 168 | hash=md5.hex(), 169 | sha1=sha1.hex(), 170 | name=f"{md5.hex()}.amr", 171 | type=FileType(type=3, audio_format=1), 172 | width=0, 173 | height=0, 174 | time=time, 175 | is_origin=False, 176 | ), 177 | sub_type=0, 178 | ) 179 | ], 180 | compat_stype=scene_type, 181 | client_rand_id=int.from_bytes(os.urandom(4), "big"), 182 | biz_info=ExtBizInfo( 183 | audio=AudioExtInfo( 184 | bytes_reserved=b"\x08\x00\x38\x00", 185 | pb_reserved=b"", 186 | general_flags=b"\x9a\x01\x07\xaa\x03\x04\x08\x08\x12\x00" 187 | if grp_id 188 | else b"\x9a\x01\x0b\xaa\x03\x08\x08\x04\x12\x04\x00\x00\x00\x00", 189 | ) 190 | ), 191 | ), 192 | ) 193 | 194 | 195 | def encode_audio_down_req(uuid: str, grp_id: int, uid: str): 196 | assert not (grp_id and uid) 197 | c2c_info = None 198 | grp_info = None 199 | if grp_id: 200 | scene_type = 2 201 | grp_info = GroupInfo(grp_id=grp_id) 202 | else: 203 | scene_type = 1 204 | c2c_info = C2CUserInfo(uid=uid) 205 | 206 | return NTV2RichMediaReq( 207 | req_head=MultiMediaReqHead( 208 | common=CommonHead( 209 | req_id=4 if grp_id else 1, 210 | cmd=200 211 | ), 212 | scene=SceneInfo( 213 | req_type=1, 214 | bus_type=3, 215 | scene_type=scene_type, 216 | c2c=c2c_info, 217 | grp=grp_info 218 | ), 219 | ), 220 | download=DownloadReq( 221 | node=IndexNode( 222 | file_uuid=uuid 223 | ) 224 | ), 225 | ) 226 | 227 | 228 | def encode_grp_img_download_req(grp_id: int, node: IndexNode) -> NTV2RichMediaReq: 229 | return NTV2RichMediaReq( 230 | req_head=MultiMediaReqHead( 231 | common=CommonHead(cmd=200), 232 | scene=SceneInfo( 233 | req_type=2, 234 | bus_type=1, 235 | scene_type=2, 236 | grp=GroupInfo(grp_id=grp_id), 237 | ) 238 | ), 239 | download=DownloadReq(node=node), 240 | ) 241 | 242 | 243 | def encode_pri_img_download_req(uid: str, node: IndexNode) -> NTV2RichMediaReq: 244 | return NTV2RichMediaReq( 245 | req_head=MultiMediaReqHead( 246 | common=CommonHead(cmd=200), 247 | scene=SceneInfo( 248 | req_type=2, 249 | bus_type=1, 250 | scene_type=1, 251 | c2c=C2CUserInfo(uid=uid), 252 | ) 253 | ), 254 | download=DownloadReq(node=node), 255 | ) 256 | 257 | # def encode_video_upload_req( 258 | # seq: int, 259 | # from_uin: int, 260 | # to_uin: int, 261 | # video_md5: bytes, 262 | # thumb_md5: bytes, 263 | # video_size: int, 264 | # thumb_size: int, 265 | # ) -> VideoReqBody: 266 | # return VideoReqBody( 267 | # cmd=300, 268 | # seq=seq, 269 | # PttShortVideoUpload_Req=PttShortVideoUploadReq( 270 | # fromuin=from_uin, 271 | # touin=to_uin, 272 | # chatType=1, 273 | # clientType=2, 274 | # groupCode=to_uin, 275 | # businessType=1, 276 | # flagSupportLargeSize=1, 277 | # PttShortVideoFileInfo=PttShortVideoFileInfo( 278 | # fileName=video_md5.hex() + ".mp4", 279 | # fileMd5=video_md5, 280 | # thumbFileMd5=thumb_md5, 281 | # fileSize=video_size, 282 | # # will be parse info? 283 | # fileResLength=1280, 284 | # fileResWidth=760, 285 | # fileFormat=3, 286 | # fileTime=120, 287 | # thumbFileSize=thumb_size 288 | # ) 289 | # ), 290 | # extensionReq=[ExtensionReq( 291 | # subBusiType=0, 292 | # userCnt=1 293 | # )] 294 | # ) 295 | # 296 | # 297 | # def encode_get_ptt_url_req( 298 | # group_code: int, 299 | # uin: int, 300 | # file_id: int, 301 | # file_md5: bytes, 302 | # file_key: bytes 303 | # ) -> ReqBody: 304 | # return encode_d388_req( 305 | # subcmd=4, 306 | # getptt_url_req=[GetPttUrlReq( 307 | # group_code=group_code, 308 | # dst_uin=uin, 309 | # fileid=file_id, 310 | # file_md5=file_md5, 311 | # file_key=file_key, 312 | # req_term=5, 313 | # req_platform_type=9, 314 | # inner_ip=0, 315 | # bu_type=3, 316 | # build_ver=b"8.8.50.2324", 317 | # file_id=0, 318 | # codec=1, 319 | # req_transfer_type=2, 320 | # is_auto=1 321 | # )] 322 | # ) 323 | -------------------------------------------------------------------------------- /lagrange/client/highway/frame.py: -------------------------------------------------------------------------------- 1 | import struct 2 | from typing import BinaryIO 3 | 4 | from lagrange.pb.highway.head import HighwayTransRespHead 5 | 6 | 7 | def write_frame(head: bytes, body: bytes) -> bytes: 8 | buf = bytearray() 9 | buf.append(0x28) 10 | buf += struct.pack("!II", len(head), len(body)) 11 | buf += head 12 | buf += body 13 | buf.append(0x29) 14 | return buf 15 | 16 | 17 | def read_frame( 18 | reader: BinaryIO, 19 | ) -> tuple[HighwayTransRespHead, bytes]: 20 | head = reader.read(9) 21 | if len(head) != 9 and head[0] != 0x28: 22 | raise ValueError("Invalid frame head", head) 23 | hl, bl = struct.unpack("!II", head[1:]) 24 | try: 25 | return ( 26 | HighwayTransRespHead.decode(reader.read(hl)), 27 | reader.read(bl), 28 | ) 29 | finally: 30 | reader.read(1) # flush end byte 31 | -------------------------------------------------------------------------------- /lagrange/client/highway/utils.py: -------------------------------------------------------------------------------- 1 | import time 2 | from hashlib import md5, sha1 3 | from typing import Any, BinaryIO 4 | from collections.abc import Awaitable 5 | 6 | 7 | def calc_file_hash_and_length(*files: BinaryIO, bs=4096) -> tuple[bytes, bytes, int]: 8 | fm, fs, length = md5(), sha1(), 0 9 | for f in files: 10 | try: 11 | while True: 12 | bl = f.read(bs) 13 | fm.update(bl) 14 | fs.update(bl) 15 | length += len(bl) 16 | if len(bl) != bs: 17 | break 18 | finally: 19 | f.seek(0) 20 | return fm.digest(), fs.digest(), length 21 | 22 | 23 | def itoa(i: int) -> str: # int to address(str) 24 | signed = False 25 | if i < 0: 26 | signed = True 27 | return ".".join([str(p) for p in i.to_bytes(4, "big", signed=signed)]) 28 | 29 | 30 | async def timeit(func: Awaitable) -> tuple[float, Any]: 31 | start = time.time() 32 | result = await func 33 | return time.time() - start, result 34 | -------------------------------------------------------------------------------- /lagrange/client/message/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LagrangeDev/lagrange-python/974dfaa546a1fcfbd6461f284a950af4622ca09f/lagrange/client/message/__init__.py -------------------------------------------------------------------------------- /lagrange/client/message/elems.py: -------------------------------------------------------------------------------- 1 | import json 2 | from dataclasses import dataclass, field 3 | from typing import Optional 4 | 5 | from lagrange.client.events.group import GroupMessage 6 | from lagrange.info.serialize import JsonSerializer 7 | 8 | 9 | @dataclass 10 | class BaseElem(JsonSerializer): 11 | @property 12 | def display(self) -> str: 13 | return "" 14 | 15 | @property 16 | def type(self) -> str: 17 | return self.__class__.__name__.lower() 18 | 19 | 20 | @dataclass 21 | class CompatibleText(BaseElem): 22 | """仅用于兼容性变更,不应作为判断条件""" 23 | 24 | @property 25 | def text(self) -> str: 26 | return self.display 27 | 28 | @text.setter 29 | def text(self, text: str): 30 | """ignore""" 31 | 32 | 33 | @dataclass 34 | class MediaInfo: 35 | name: str 36 | size: int 37 | url: str 38 | id: int = field(repr=False) 39 | md5: bytes = field(repr=False) 40 | qmsg: Optional[bytes] = field(repr=False) # not online image 41 | 42 | 43 | @dataclass 44 | class Text(BaseElem): 45 | text: str 46 | 47 | @property 48 | def display(self) -> str: 49 | return self.text 50 | 51 | 52 | @dataclass 53 | class Quote(CompatibleText): 54 | seq: int 55 | uin: int 56 | timestamp: int 57 | uid: str = "" 58 | msg: str = "" 59 | 60 | @classmethod 61 | def build(cls, ev: GroupMessage) -> "Quote": 62 | return cls( 63 | seq=ev.seq, 64 | uid=ev.uid, 65 | uin=ev.uin, 66 | timestamp=ev.time, 67 | msg=ev.msg, 68 | ) 69 | 70 | @property 71 | def display(self) -> str: 72 | return f"[quote:{self.msg}]" 73 | 74 | 75 | @dataclass 76 | class Json(CompatibleText): 77 | raw: bytes 78 | 79 | def to_dict(self) -> dict: 80 | return json.loads(self.raw) 81 | 82 | @property 83 | def display(self) -> str: 84 | return f"[json:{len(self.raw)}]" 85 | 86 | 87 | @dataclass 88 | class Service(Json): 89 | id: int 90 | 91 | @property 92 | def display(self) -> str: 93 | return f"[service:{self.id}]" 94 | 95 | 96 | @dataclass 97 | class AtAll(BaseElem): 98 | text: str 99 | 100 | @property 101 | def display(self) -> str: 102 | return self.text 103 | 104 | 105 | @dataclass 106 | class At(BaseElem): 107 | text: str 108 | uin: int 109 | uid: str 110 | 111 | @classmethod 112 | def build(cls, ev: GroupMessage) -> "At": 113 | return cls(uin=ev.uin, uid=ev.uid, text=f"@{ev.nickname or ev.uin}") 114 | 115 | 116 | @dataclass 117 | class Image(CompatibleText, MediaInfo): 118 | width: int 119 | height: int 120 | is_emoji: bool 121 | display_name: str 122 | 123 | @property 124 | def display(self) -> str: 125 | return self.display_name 126 | 127 | 128 | @dataclass 129 | class Video(CompatibleText, MediaInfo): 130 | width: int 131 | height: int 132 | time: int 133 | file_key: str = field(repr=True) 134 | 135 | @property 136 | def display(self) -> str: 137 | return f"[video:{self.width}x{self.height},{self.time}s]" 138 | 139 | 140 | @dataclass 141 | class Audio(CompatibleText, MediaInfo): 142 | time: int 143 | file_key: str = field(repr=True) 144 | 145 | @property 146 | def display(self) -> str: 147 | return f"[audio:{self.time}]" 148 | 149 | 150 | @dataclass 151 | class Raw(CompatibleText): 152 | data: bytes 153 | 154 | @property 155 | def display(self) -> str: 156 | return f"[raw:{len(self.data)}]" 157 | 158 | 159 | @dataclass 160 | class Emoji(CompatibleText): 161 | id: int 162 | 163 | @property 164 | def display(self) -> str: 165 | return f"[emoji:{self.id}]" 166 | 167 | 168 | @dataclass 169 | class Reaction(Emoji): 170 | """QQ: super emoji""" 171 | 172 | # text: str 173 | # show_type: int # size - sm: 33, bg: 37 174 | 175 | 176 | @dataclass 177 | class Poke(CompatibleText): 178 | id: int 179 | f7: int = 0 180 | f8: int = 0 181 | 182 | @property 183 | def display(self) -> str: 184 | return f"[poke:{self.id}]" 185 | 186 | 187 | @dataclass 188 | class MarketFace(CompatibleText): 189 | name: str 190 | face_id: bytes 191 | tab_id: int 192 | width: int 193 | height: int 194 | 195 | @property 196 | def url(self) -> str: 197 | pic_id = self.face_id.hex() 198 | return f"https://i.gtimg.cn/club/item/parcel/item/{pic_id[:2]}/{pic_id}/{self.width}x{self.height}.png" 199 | 200 | @property 201 | def display(self) -> str: 202 | return f"[marketface:{self.name}]" 203 | 204 | 205 | @dataclass 206 | class File(CompatibleText): 207 | file_size: int 208 | file_name: str 209 | file_md5: bytes 210 | file_url: Optional[str] 211 | file_id: Optional[str] # only in group 212 | file_uuid: Optional[str] # only in private 213 | file_hash: Optional[str] 214 | 215 | @property 216 | def display(self) -> str: 217 | return f"[file:{self.file_name}]" 218 | 219 | @classmethod 220 | def _paste_build( 221 | cls, 222 | file_size: int, 223 | file_name: str, 224 | file_md5: bytes, 225 | file_id: Optional[str] = None, 226 | file_uuid: Optional[str] = None, 227 | file_hash: Optional[str] = None, 228 | ) -> "File": 229 | return cls( 230 | file_size=file_size, 231 | file_name=file_name, 232 | file_md5=file_md5, 233 | file_url=None, 234 | file_id=file_id, 235 | file_uuid=file_uuid, 236 | file_hash=file_hash, 237 | ) 238 | 239 | @classmethod 240 | def grp_paste_build(cls, file_size: int, file_name: str, file_md5: bytes, file_id: str) -> "File": 241 | return cls._paste_build(file_size, file_name, file_md5, file_id=file_id) 242 | 243 | @classmethod 244 | def pri_paste_build(cls, file_size: int, file_name: str, file_md5: bytes, file_uuid: str, file_hash: str) -> "File": 245 | return cls._paste_build(file_size, file_name, file_md5, file_uuid=file_uuid, file_hash=file_hash) 246 | 247 | 248 | @dataclass 249 | class GreyTips(BaseElem): 250 | """ 251 | 奇怪的整活元素 252 | 建议搭配Text使用 253 | 冷却3分钟左右? 254 | """ 255 | 256 | text: str 257 | 258 | @property 259 | def display(self) -> str: 260 | return f"" 261 | 262 | 263 | @dataclass 264 | class Markdown(BaseElem): 265 | content: str 266 | 267 | @property 268 | def display(self) -> str: 269 | return f"[markdown:{self.content}]" 270 | 271 | 272 | class Permission: 273 | type: int 274 | specify_role_ids: Optional[list[str]] 275 | specify_user_ids: Optional[list[str]] 276 | 277 | 278 | class RenderData: 279 | label: Optional[str] 280 | visited_label: Optional[str] 281 | style: int 282 | 283 | 284 | class Action: 285 | type: Optional[int] 286 | permission: Optional[Permission] 287 | data: str 288 | reply: bool 289 | enter: bool 290 | anchor: Optional[int] 291 | unsupport_tips: Optional[str] 292 | click_limit: Optional[int] # deprecated 293 | at_bot_show_channel_list: bool # deprecated 294 | 295 | 296 | class Button: 297 | id: Optional[str] 298 | render_data: Optional[RenderData] 299 | action: Optional[Action] 300 | 301 | 302 | class InlineKeyboardRow: 303 | buttons: Optional[list[Button]] 304 | 305 | 306 | class InlineKeyboard: 307 | rows: list[InlineKeyboardRow] 308 | 309 | 310 | @dataclass 311 | class Keyboard(BaseElem): 312 | content: Optional[list[InlineKeyboard]] 313 | bot_appid: int 314 | 315 | @property 316 | def display(self) -> str: 317 | return f"[keyboard:{self.bot_appid}]" 318 | -------------------------------------------------------------------------------- /lagrange/client/message/encoder.py: -------------------------------------------------------------------------------- 1 | import json 2 | import struct 3 | import zlib 4 | from typing import Optional 5 | 6 | from lagrange.pb.message.rich_text import Elems, RichText 7 | from lagrange.pb.message.rich_text.elems import ( 8 | CustomFace, 9 | ImageReserveArgs, 10 | Face, 11 | MiniApp, 12 | OpenData, 13 | Ptt, 14 | RichMsg, 15 | SrcMsg, 16 | CommonElem, 17 | MarketFace as PBMarketFace, 18 | NotOnlineImage, 19 | SrcMsgArgs, 20 | PBGreyTips, 21 | GeneralFlags, 22 | ) 23 | from lagrange.pb.message.rich_text.elems import Text as PBText 24 | 25 | from .elems import ( 26 | At, 27 | AtAll, 28 | Audio, 29 | Emoji, 30 | Image, 31 | Json, 32 | Quote, 33 | Raw, 34 | Reaction, 35 | Service, 36 | Text, 37 | Poke, 38 | MarketFace, 39 | GreyTips, 40 | ) 41 | from .types import Element 42 | 43 | 44 | def build_message(msg_chain: list[Element], compatible=True) -> RichText: 45 | if not msg_chain: 46 | raise ValueError("Message chain is empty") 47 | msg_pb: list[Elems] = [] 48 | msg_ptt: Optional[Ptt] = None 49 | if not isinstance(msg_chain[0], Audio): 50 | for msg in msg_chain: 51 | if isinstance(msg, AtAll): 52 | msg_pb.append( 53 | Elems( 54 | text=PBText( 55 | string=msg.text, 56 | attr6_buf=b"\x00\x01\x00\x00\x00\x05\x01\x00\x00\x00\x00\x00\x00", 57 | ) 58 | ) 59 | ) 60 | elif isinstance(msg, At): 61 | msg_pb.append( 62 | Elems( 63 | text=PBText( 64 | string=msg.text, 65 | attr6_buf=struct.pack( 66 | "!xb3xbbI2x", 1, len(msg.text), 0, msg.uin 67 | ), 68 | pb_reserved={3: 2, 4: 0, 5: 0, 9: msg.uid, 11: 0}, 69 | ) 70 | ) 71 | ) 72 | elif isinstance(msg, Quote): 73 | msg_pb.append( 74 | Elems( 75 | src_msg=SrcMsg( 76 | seq=msg.seq, 77 | uin=msg.uin, 78 | timestamp=msg.timestamp, 79 | elems=[{1: {1: msg.msg}}], 80 | pb_reserved=SrcMsgArgs(uid=msg.uid) if msg.uid else None, 81 | ) 82 | ) 83 | ) 84 | if compatible: 85 | text = f"@{msg.uin}" 86 | msg_pb.append( 87 | Elems( 88 | text=PBText( 89 | string=text, 90 | attr6_buf=struct.pack( 91 | "!xb3xbbI2x", 1, len(text), 0, msg.uin 92 | ), 93 | pb_reserved={3: 2, 4: 0, 5: 0, 9: msg.uid, 11: 0}, 94 | ) 95 | ) 96 | ) 97 | elif isinstance(msg, Emoji): 98 | msg_pb.append(Elems(face=Face(index=msg.id))) 99 | elif isinstance(msg, Json): 100 | msg_pb.append( 101 | Elems(mini_app=MiniApp(template=b"\x01" + zlib.compress(msg.raw))) 102 | ) 103 | elif isinstance(msg, Image): 104 | if msg.id: # customface 105 | msg_pb.append( 106 | Elems( 107 | custom_face=CustomFace( 108 | file_path=msg.name, 109 | fileid=msg.id, 110 | file_type=4294967273, 111 | md5=msg.md5, 112 | original_url=msg.url[21:], 113 | image_type=1001, 114 | width=msg.width, 115 | height=msg.height, 116 | size=msg.size, 117 | args=ImageReserveArgs( 118 | is_emoji=msg.is_emoji, 119 | display_name=msg.display_name 120 | or ("[动画表情]" if msg.is_emoji else "[图片]"), 121 | ), 122 | ) 123 | ) 124 | ) 125 | else: 126 | msg_pb.append( 127 | Elems(not_online_image=NotOnlineImage.decode(msg.qmsg)) 128 | ) 129 | elif isinstance(msg, Service): 130 | msg_pb.append( 131 | Elems( 132 | rich_msg=RichMsg( 133 | template=b"\x01" + zlib.compress(msg.raw), service_id=msg.id 134 | ) 135 | ) 136 | ) 137 | elif isinstance(msg, Raw): 138 | msg_pb.append(Elems(open_data=OpenData(data=msg.data))) 139 | elif isinstance(msg, Reaction): 140 | pass 141 | # if msg.show_type == 33: # sm size 142 | # body = { 143 | # 1: msg.id 144 | # } 145 | # elif msg.show_type == 37: 146 | # body = { 147 | # 1: '1', 148 | # 2: '15', 149 | # 3: msg.id, 150 | # 4: 1, 151 | # 5: 1, 152 | # 6: '', 153 | # 7: msg.text, 154 | # 9: 1 155 | # } 156 | # else: 157 | # raise ValueError(f"Unknown reaction show_type: {msg.show_type}") 158 | # msg_pb.append({ 159 | # 53: { 160 | # 1: msg.show_type, 161 | # 2: body, 162 | # 3: 1 163 | # } 164 | # }) 165 | elif isinstance(msg, MarketFace): 166 | msg_pb.append( 167 | Elems( 168 | market_face=PBMarketFace( 169 | name=msg.name, 170 | item_type=6, 171 | face_info=1, 172 | face_id=msg.face_id, 173 | tab_id=msg.tab_id, 174 | sub_type=3, 175 | key="0000000000000000", 176 | width=msg.width, 177 | height=msg.height, 178 | pb_reserved={1: {1: msg.width, 2: msg.height}, 8: 1}, 179 | ) 180 | ) 181 | ) 182 | elif isinstance(msg, GreyTips): 183 | content = json.dumps({ 184 | "gray_tip": msg.text, 185 | "object_type": 3, 186 | "sub_type": 2, 187 | "type": 4, 188 | }) 189 | msg_pb.append( 190 | Elems( 191 | general_flags=GeneralFlags( 192 | PbReserve=PBGreyTips.build(content) 193 | ) 194 | ) 195 | ) 196 | elif isinstance(msg, Text): 197 | msg_pb.append(Elems(text=PBText(string=msg.text))) 198 | elif isinstance(msg, Poke): 199 | msg_pb.append( 200 | Elems( 201 | common_elem=CommonElem( 202 | service_type=2, 203 | pb_elem={1: msg.id, 7: msg.f7, 8: msg.f8}, 204 | bus_type=1, 205 | ) 206 | ) 207 | ) 208 | 209 | else: 210 | raise NotImplementedError 211 | else: 212 | audio = msg_chain[0] # type: Audio 213 | if audio.id: # grp 214 | msg_ptt = Ptt( 215 | md5=audio.md5, 216 | name=audio.name, 217 | size=audio.size, 218 | file_id=audio.id, 219 | group_file_key=audio.file_key, 220 | time=audio.time, 221 | ) 222 | else: # friend 223 | msg_ptt = Ptt.decode(audio.qmsg) 224 | return RichText(content=msg_pb, ptt=msg_ptt) 225 | -------------------------------------------------------------------------------- /lagrange/client/message/types.py: -------------------------------------------------------------------------------- 1 | from typing import Union, TYPE_CHECKING 2 | from typing_extensions import TypeAlias 3 | 4 | if TYPE_CHECKING: 5 | from .elems import ( 6 | Text, 7 | At, 8 | AtAll, 9 | Image, 10 | Emoji, 11 | Json, 12 | Quote, 13 | Raw, 14 | Audio, 15 | Poke, 16 | MarketFace, 17 | GreyTips, 18 | Video, 19 | Service, 20 | File, 21 | Markdown, 22 | Keyboard, 23 | ) 24 | 25 | # T = TypeVar( 26 | # "T", 27 | # "Text", 28 | # "AtAll", 29 | # "At", 30 | # "Image", 31 | # "Emoji", 32 | # "Json", 33 | # "Quote", 34 | # "Raw", 35 | # "Audio", 36 | # "Poke", 37 | # "MarketFace", 38 | # ) 39 | Element: TypeAlias = Union[ 40 | "Text", 41 | "AtAll", 42 | "At", 43 | "Image", 44 | "Emoji", 45 | "Json", 46 | "Quote", 47 | "Raw", 48 | "Audio", 49 | "Poke", 50 | "MarketFace", 51 | "GreyTips", 52 | "Video", 53 | "Service", 54 | "File", 55 | "Markdown", 56 | "Keyboard", 57 | ] 58 | -------------------------------------------------------------------------------- /lagrange/client/models.py: -------------------------------------------------------------------------------- 1 | import struct 2 | from dataclasses import dataclass 3 | from datetime import datetime 4 | from enum import IntEnum 5 | from typing import Optional 6 | from lagrange.pb.service.group import GetInfoRspBody 7 | 8 | 9 | class Sex(IntEnum): 10 | notset = 0 11 | male = 1 12 | female = 2 13 | unknown = 255 14 | 15 | 16 | @dataclass 17 | class UserInfo: 18 | name: str = "" 19 | country: str = "" 20 | province: str = "" 21 | city: str = "" 22 | email: str = "" 23 | school: str = "" 24 | sex: Sex = Sex.notset 25 | age: int = 0 26 | birthday: datetime = datetime(1, 1, 1) 27 | registered_on: datetime = datetime(1, 1, 1) 28 | 29 | @classmethod 30 | def from_pb(cls, pb: GetInfoRspBody) -> "UserInfo": 31 | rsp = cls() 32 | for str_field in pb.fields.str_t: 33 | if not str_field.value: 34 | continue 35 | if str_field.type == 20002: 36 | rsp.name = str_field.to_str 37 | elif str_field.type == 20003: 38 | rsp.country = str_field.to_str 39 | elif str_field.type == 20004: 40 | rsp.province = str_field.to_str 41 | elif str_field.type == 20011: 42 | rsp.email = str_field.to_str 43 | elif str_field.type == 20020: 44 | rsp.city = str_field.to_str 45 | elif str_field.type == 20021: 46 | rsp.school = str_field.to_str 47 | elif str_field.type == 20031: 48 | if str_field.value == b"\x00\x00\x00\x00": 49 | continue 50 | year, month, day = struct.unpack("!HBB", str_field.value) 51 | if year == 0: 52 | year = 1 53 | if not (month and day): 54 | rsp.birthday = datetime(year, 1, 1) 55 | else: 56 | rsp.birthday = datetime(year, month, day) 57 | else: 58 | pass 59 | for int_field in pb.fields.int_t: 60 | if int_field.type == 20009: 61 | rsp.sex = Sex(int_field.value) 62 | elif int_field.type == 20026: 63 | rsp.registered_on = datetime.fromtimestamp(int_field.value) 64 | elif int_field.type == 20037: 65 | rsp.age = int_field.value 66 | else: 67 | pass 68 | return rsp 69 | 70 | 71 | @dataclass 72 | class BotFriend: 73 | uin: int 74 | uid: Optional[str] = None 75 | nickname: Optional[str] = None 76 | remark: Optional[str] = None 77 | personal_sign: Optional[str] = None 78 | qid: Optional[str] = None 79 | -------------------------------------------------------------------------------- /lagrange/client/network.py: -------------------------------------------------------------------------------- 1 | """ 2 | ClientNetwork Implement 3 | """ 4 | 5 | import asyncio 6 | import ipaddress 7 | import sys 8 | from typing import Callable, overload, Optional 9 | from collections.abc import Coroutine 10 | from typing_extensions import Literal 11 | 12 | from lagrange.info import SigInfo 13 | from lagrange.utils.log import log 14 | from lagrange.utils.network import Connection 15 | 16 | from .wtlogin.sso import SSOPacket, parse_sso_frame, parse_sso_header 17 | 18 | 19 | class ClientNetwork(Connection): 20 | V4UPSTREAM = ("msfwifi.3g.qq.com", 8080) 21 | V6UPSTREAM = ("msfwifiv6.3g.qq.com", 8080) 22 | 23 | def __init__( 24 | self, 25 | sig_info: SigInfo, 26 | push_store: asyncio.Queue[SSOPacket], 27 | reconnect_cb: Callable[[], Coroutine], 28 | disconnect_cb: Callable[[bool], Coroutine], 29 | use_v6=False, 30 | *, 31 | manual_address: Optional[tuple[str, int]] = None, 32 | ): 33 | if not manual_address: 34 | host, port = self.V6UPSTREAM if use_v6 else self.V4UPSTREAM 35 | else: 36 | host, port = manual_address 37 | super().__init__(host, port) 38 | 39 | self.conn_event = asyncio.Event() 40 | self._using_v6 = use_v6 41 | self._push_store = push_store 42 | self._reconnect_cb = reconnect_cb 43 | self._disconnect_cb = disconnect_cb 44 | self._wait_fut_map: dict[int, asyncio.Future[SSOPacket]] = {} 45 | self._connected = False 46 | self._sig = sig_info 47 | 48 | @property 49 | def using_v6(self) -> bool: 50 | if not self.closed: 51 | return self._using_v6 52 | raise RuntimeError("Network not connect, execute 'connect' first") 53 | 54 | def destroy_connection(self): 55 | if self._writer: 56 | self._writer.close() 57 | 58 | async def write(self, buf: bytes): 59 | await self.conn_event.wait() 60 | self.writer.write(buf) 61 | await self.writer.drain() 62 | 63 | @overload 64 | async def send( 65 | self, buf: bytes, wait_seq: Literal[-1], timeout=10 66 | ) -> None: ... 67 | 68 | @overload 69 | async def send(self, buf: bytes, wait_seq: int, timeout=10) -> SSOPacket: ... # type: ignore 70 | 71 | async def send(self, buf: bytes, wait_seq: int, timeout: int = 10): # type: ignore 72 | await self.write(buf) 73 | if wait_seq != -1: 74 | fut: asyncio.Future[SSOPacket] = asyncio.Future() 75 | self._wait_fut_map[wait_seq] = fut 76 | try: 77 | await asyncio.wait_for(fut, timeout=timeout) 78 | return fut.result() 79 | finally: 80 | self._wait_fut_map.pop(wait_seq) # noqa 81 | 82 | def _cancel_all_task(self): 83 | for _, fut in self._wait_fut_map.items(): 84 | if not fut.done(): 85 | fut.cancel("connection closed") 86 | 87 | async def on_connected(self): 88 | self.conn_event.set() 89 | host, port = self.writer.get_extra_info("peername")[:2] # for v6 ip 90 | if ipaddress.ip_address(host).version != 6 and self._using_v6: 91 | log.network.debug("using v4 address, disable v6 support") 92 | self._using_v6 = False 93 | log.network.info(f"Connected to {host}:{port}") 94 | if self._connected and not self._stop_flag: 95 | asyncio.create_task(self._reconnect_cb(), name="reconnect_cb") 96 | else: 97 | self._connected = True 98 | 99 | async def on_close(self): 100 | self.conn_event.clear() 101 | log.network.warning("Connection closed") 102 | self._cancel_all_task() 103 | asyncio.create_task(self._disconnect_cb(False), name="disconnect_cb") 104 | 105 | async def on_error(self) -> bool: 106 | _, err, _ = sys.exc_info() 107 | 108 | # OSError: timeout 109 | if isinstance(err, (asyncio.IncompleteReadError, ConnectionError, OSError)): 110 | log.network.warning("Connection lost, reconnecting...") 111 | log.network.debug(f"{repr(err)}") 112 | recover = True 113 | else: 114 | log.network.error(f"Connection got an unexpected error: {repr(err)}") 115 | recover = False 116 | self._cancel_all_task() 117 | asyncio.create_task(self._disconnect_cb(recover), name="disconnect_cb") 118 | return recover 119 | 120 | async def on_message(self, message_length: int): 121 | raw = await self.reader.readexactly(message_length) 122 | enc_flag, uin, sso_body = parse_sso_header(raw, self._sig.d2_key) 123 | 124 | packet = parse_sso_frame(sso_body, enc_flag == 2) 125 | 126 | if packet.seq > 0: # uni rsp 127 | log.network.debug( 128 | f"{packet.seq}({packet.ret_code})-> {packet.cmd or packet.extra}" 129 | ) 130 | if packet.ret_code != 0 and packet.seq in self._wait_fut_map: 131 | return self._wait_fut_map[packet.seq].set_exception( 132 | AssertionError(packet.ret_code, packet.extra) 133 | ) 134 | elif packet.ret_code != 0: 135 | return log.network.error( 136 | f"Unexpected error on sso layer: {packet.ret_code}: {packet.extra}" 137 | ) 138 | 139 | if packet.seq not in self._wait_fut_map: 140 | log.network.warning( 141 | f"Unknown packet: {packet.cmd}({packet.seq}), ignore" 142 | ) 143 | else: 144 | self._wait_fut_map[packet.seq].set_result(packet) 145 | elif packet.seq == 0: 146 | raise AssertionError(packet.ret_code, packet.extra) 147 | else: # server pushed 148 | log.network.debug( 149 | f"{packet.seq}({packet.ret_code})<- {packet.cmd or packet.extra}" 150 | ) 151 | await self._push_store.put(packet) 152 | -------------------------------------------------------------------------------- /lagrange/client/packet.py: -------------------------------------------------------------------------------- 1 | from typing_extensions import Literal, Self 2 | 3 | from lagrange.utils.binary.builder import BYTES_LIKE, Builder 4 | 5 | LENGTH_PREFIX = Literal["none", "u8", "u16", "u32", "u64"] 6 | 7 | 8 | class PacketBuilder(Builder): 9 | def write_bytes( 10 | self, v: BYTES_LIKE, prefix: LENGTH_PREFIX = "none", with_prefix: bool = True, *, with_length: bool = False 11 | ) -> Self: 12 | if with_prefix: 13 | if prefix == "none": 14 | pass 15 | elif prefix == "u8": 16 | self.write_u8(len(v) + 1) 17 | elif prefix == "u16": 18 | self.write_u16(len(v) + 2) 19 | elif prefix == "u32": 20 | self.write_u32(len(v) + 4) 21 | elif prefix == "u64": 22 | self.write_u64(len(v) + 8) 23 | else: 24 | raise ArithmeticError("Invaild prefix") 25 | else: 26 | if prefix == "none": 27 | pass 28 | elif prefix == "u8": 29 | self.write_u8(len(v)) 30 | elif prefix == "u16": 31 | self.write_u16(len(v)) 32 | elif prefix == "u32": 33 | self.write_u32(len(v)) 34 | elif prefix == "u64": 35 | self.write_u64(len(v)) 36 | else: 37 | raise ArithmeticError("Invaild prefix") 38 | 39 | self._buffer += v 40 | return self 41 | 42 | def write_string( 43 | self, s: str, prefix: LENGTH_PREFIX = "u32", with_prefix: bool = True 44 | ) -> Self: 45 | return self.write_bytes(s.encode(), prefix=prefix, with_prefix=with_prefix) 46 | -------------------------------------------------------------------------------- /lagrange/client/server_push/__init__.py: -------------------------------------------------------------------------------- 1 | from .binder import PushDeliver 2 | from .msg import msg_push_handler 3 | from .service import ( 4 | server_kick_handler, 5 | server_info_sync_handler, 6 | server_push_param_handler, 7 | server_push_req_handler 8 | ) 9 | 10 | 11 | def bind_services(pd: PushDeliver): 12 | pd.subscribe("trpc.msg.olpush.OlPushService.MsgPush", msg_push_handler) 13 | 14 | pd.subscribe("trpc.qq_new_tech.status_svc.StatusService.KickNT", server_kick_handler) 15 | pd.subscribe("trpc.msg.register_proxy.RegisterProxy.InfoSyncPush", server_info_sync_handler) 16 | pd.subscribe("trpc.msg.register_proxy.RegisterProxy.PushParams", server_push_param_handler) 17 | pd.subscribe("ConfigPushSvc.PushReq", server_push_req_handler) 18 | -------------------------------------------------------------------------------- /lagrange/client/server_push/binder.py: -------------------------------------------------------------------------------- 1 | from typing import Any, Callable, TYPE_CHECKING 2 | from collections.abc import Coroutine 3 | 4 | from lagrange.client.wtlogin.sso import SSOPacket 5 | 6 | from .log import logger 7 | 8 | if TYPE_CHECKING: 9 | from lagrange.client.client import Client 10 | 11 | 12 | class PushDeliver: 13 | def __init__(self, client: "Client"): 14 | self._client = client 15 | self._handle_map: dict[ 16 | str, Callable[["Client", SSOPacket], Coroutine[None, None, Any]] 17 | ] = {} 18 | 19 | def subscribe(self, cmd: str, func: Callable[["Client", SSOPacket], Coroutine[None, None, Any]]): 20 | self._handle_map[cmd] = func 21 | 22 | async def execute(self, cmd: str, sso: SSOPacket): 23 | if cmd not in self._handle_map: 24 | logger.warning(f"Unsupported command: {cmd}({len(sso.data)})") 25 | else: 26 | return await self._handle_map[cmd](self._client, sso) 27 | -------------------------------------------------------------------------------- /lagrange/client/server_push/log.py: -------------------------------------------------------------------------------- 1 | from lagrange.utils.log import log 2 | 3 | logger = log.fork("server_push") 4 | -------------------------------------------------------------------------------- /lagrange/client/server_push/service.py: -------------------------------------------------------------------------------- 1 | import os 2 | import datetime 3 | 4 | from lagrange.pb.status.kick import KickNT 5 | from lagrange.pb.login.register import PBSsoInfoSyncPush, PBServerPushParams 6 | 7 | from ..events.service import ServerKick, OtherClientInfo 8 | from ..wtlogin.sso import SSOPacket 9 | 10 | DBG_EN = bool(os.environ.get("PUSH_DEBUG", False)) 11 | 12 | async def server_kick_handler(_, sso: SSOPacket): 13 | ev = KickNT.decode(sso.data) 14 | return ServerKick(tips=ev.tips, title=ev.title) 15 | 16 | 17 | async def server_info_sync_handler(_, sso: SSOPacket): 18 | if not DBG_EN: 19 | return 20 | ev = PBSsoInfoSyncPush.decode(sso.data) 21 | if ev.cmd_type == 5: # grp info 22 | print("GroupInfo Sync:") 23 | for i in ev.grp_info: 24 | print( 25 | "%i(%s): lostsync: %i, time: %s" % ( 26 | i.grp_id, i.grp_name, i.last_msg_seq - i.last_msg_seq_read, 27 | datetime.datetime.fromtimestamp(i.last_msg_timestamp).strftime("%Y-%m-%d %H:%M:%S") 28 | ) 29 | ) 30 | elif ev.cmd_type == 2: 31 | print("MsgPush Sync:") 32 | for i in ev.grp_msgs.inner: 33 | print( 34 | "%i msgs(%i->%i) in %i, time: %s" %( 35 | len(i.msgs), i.start_seq, i.end_seq, i.grp_id, 36 | datetime.datetime.fromtimestamp(i.last_msg_time).strftime("%Y-%m-%d %H:%M:%S") 37 | ) 38 | ) 39 | print("EventPush Sync:") 40 | for i in ev.sys_events.inner: 41 | print( 42 | "%i events in %i, time: %s" % ( 43 | len(i.events), i.grp_id, 44 | datetime.datetime.fromtimestamp(i.last_evt_time).strftime("%Y-%m-%d %H:%M:%S") 45 | ) 46 | ) 47 | else: 48 | print(f"Unknown cmd_type: {ev.cmd_type}({ev.f4})") 49 | print("END") 50 | 51 | 52 | async def server_push_param_handler(_, sso: SSOPacket): 53 | ev = PBServerPushParams.decode(sso.data) 54 | return OtherClientInfo([ 55 | OtherClientInfo.ClientOnline(i.sub_id, i.os_name, i.device_name) 56 | for i in ev.online_devices 57 | ]) 58 | 59 | 60 | async def server_push_req_handler(_, sso: SSOPacket): 61 | """ 62 | JCE packet, ignore 63 | """ 64 | return None -------------------------------------------------------------------------------- /lagrange/client/wtlogin/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LagrangeDev/lagrange-python/974dfaa546a1fcfbd6461f284a950af4622ca09f/lagrange/client/wtlogin/__init__.py -------------------------------------------------------------------------------- /lagrange/client/wtlogin/enum.py: -------------------------------------------------------------------------------- 1 | from enum import IntEnum 2 | 3 | 4 | class QrCodeResult(IntEnum): 5 | confirmed = 0 6 | expired = 17 7 | waiting_for_scan = 48 8 | waiting_for_confirm = 53 9 | canceled = 54 10 | 11 | @property 12 | def waitable(self) -> bool: 13 | if self in (self.waiting_for_scan, self.waiting_for_confirm): 14 | return True 15 | return False 16 | 17 | @property 18 | def success(self) -> bool: 19 | return self == self.confirmed 20 | 21 | 22 | class LoginErrorCode(IntEnum): 23 | token_expired = 140022015 24 | unusual_verify = 140022011 25 | login_failure = 140022013 26 | user_token_expired = 140022016 27 | server_failure = 140022002 # unknown reason 28 | wrong_captcha = 140022007 29 | wrong_argument = 140022001 30 | new_device_verify = 140022010 31 | captcha_verify = 140022008 32 | unknown_error = -1 33 | success = 0 34 | 35 | @classmethod 36 | def _missing_(cls, value): 37 | return cls.unknown_error 38 | 39 | @property 40 | def successful(self) -> bool: 41 | return self == self.success 42 | -------------------------------------------------------------------------------- /lagrange/client/wtlogin/exchange.py: -------------------------------------------------------------------------------- 1 | import hashlib 2 | 3 | from lagrange.info import SigInfo 4 | from lagrange.utils.binary.builder import Builder 5 | from lagrange.utils.binary.protobuf import proto_decode, proto_encode 6 | from lagrange.utils.crypto.aes import aes_gcm_decrypt, aes_gcm_encrypt 7 | from lagrange.utils.crypto.ecdh import ecdh 8 | from lagrange.utils.operator import timestamp 9 | 10 | _enc_key = bytes.fromhex( 11 | "e2733bf403149913cbf80c7a95168bd4ca6935ee53cd39764beebe2e007e3aee" 12 | ) 13 | 14 | 15 | def build_key_exchange_request(uin: int, guid: str) -> bytes: 16 | p1 = proto_encode({1: uin, 2: guid}) 17 | 18 | enc1 = aes_gcm_encrypt(p1, ecdh["prime256v1"].share_key) 19 | 20 | p2 = ( 21 | Builder() 22 | .write_bytes(ecdh["prime256v1"].public_key) 23 | .write_u32(1) 24 | .write_bytes(enc1) 25 | .write_u32(0) 26 | .write_u32(timestamp()) 27 | ).pack() 28 | p2_hash = hashlib.sha256(p2).digest() 29 | enc_p2_hash = aes_gcm_encrypt(p2_hash, _enc_key) 30 | 31 | return proto_encode( 32 | { 33 | 1: ecdh["prime256v1"].public_key, 34 | 2: 1, 35 | 3: enc1, 36 | 4: timestamp(), 37 | 5: enc_p2_hash, 38 | } 39 | ) 40 | 41 | 42 | def parse_key_exchange_response(response: bytes, sig: SigInfo): 43 | p = proto_decode(response, 0) 44 | 45 | share_key = ecdh["prime256v1"].exchange(p[3]) 46 | dec_pb = proto_decode(aes_gcm_decrypt(p[1], share_key), 0) 47 | 48 | sig.exchange_key = dec_pb[1] 49 | sig.key_sig = dec_pb[2] 50 | -------------------------------------------------------------------------------- /lagrange/client/wtlogin/ntlogin.py: -------------------------------------------------------------------------------- 1 | from lagrange.info import AppInfo, DeviceInfo, SigInfo 2 | from lagrange.utils.binary.protobuf import proto_decode, proto_encode 3 | from lagrange.utils.crypto.aes import aes_gcm_decrypt, aes_gcm_encrypt 4 | from lagrange.utils.log import log 5 | 6 | from lagrange.client.wtlogin.enum import LoginErrorCode 7 | from lagrange.pb.login.ntlogin import NTLoginRsp 8 | 9 | 10 | def build_ntlogin_captcha_submit(ticket: str, rand_str: str, aid: str): 11 | return {1: ticket, 2: rand_str, 3: aid} 12 | 13 | 14 | def build_ntlogin_request( 15 | uin: int, 16 | app: AppInfo, 17 | device: DeviceInfo, 18 | sig: SigInfo, 19 | captcha: list, 20 | credential: bytes, 21 | ) -> bytes: 22 | body = { 23 | 1: { 24 | 1: {1: str(uin)}, 25 | 2: { 26 | 1: app.os, 27 | 2: device.device_name, 28 | 3: app.nt_login_type, 29 | 4: bytes.fromhex(device.guid), 30 | }, 31 | 3: {1: device.kernel_version, 2: app.app_id, 3: app.package_name}, 32 | }, 33 | 2: {1: credential}, 34 | } 35 | 36 | if sig.cookies: 37 | body[1][5] = {1: sig.cookies} 38 | if all(captcha): 39 | log.login.debug("login with captcha info") 40 | body[2][2] = build_ntlogin_captcha_submit(*captcha) 41 | 42 | return proto_encode( 43 | {1: sig.key_sig, 3: aes_gcm_encrypt(proto_encode(body), sig.exchange_key), 4: 1} 44 | ) 45 | 46 | 47 | def parse_ntlogin_response( 48 | response: bytes, sig: SigInfo, captcha: list 49 | ) -> LoginErrorCode: 50 | frame = proto_decode(response, 0) 51 | rsp = NTLoginRsp.decode(aes_gcm_decrypt(frame.into(3, bytes), sig.exchange_key)) 52 | 53 | if not rsp.head.error and rsp.body and rsp.body.credentials: 54 | cr = rsp.body.credentials 55 | sig.tgt = cr.tgt 56 | sig.d2 = cr.d2 57 | sig.d2_key = cr.d2_key 58 | sig.temp_pwd = cr.temp_pwd 59 | sig.info_updated() 60 | 61 | log.login.debug("SigInfo got") 62 | 63 | return LoginErrorCode.success 64 | else: 65 | ret = LoginErrorCode(rsp.head.error.code) 66 | if ret == LoginErrorCode.captcha_verify: 67 | sig.cookies = rsp.head.cookies.str 68 | verify_url = rsp.body.verify.url 69 | aid = verify_url.split("&sid=")[1].split("&")[0] 70 | captcha[2] = aid 71 | log.login.warning("need captcha verify: " + verify_url) 72 | else: 73 | stat = rsp.head.error 74 | title = stat.title 75 | content = stat.message 76 | log.login.error( 77 | f"Login fail on ntlogin({ret.name}): [{title}]>{content}" 78 | ) 79 | 80 | return ret 81 | -------------------------------------------------------------------------------- /lagrange/client/wtlogin/oicq.py: -------------------------------------------------------------------------------- 1 | import hashlib 2 | import os 3 | 4 | from lagrange.client.packet import PacketBuilder 5 | from lagrange.info import AppInfo, DeviceInfo, SigInfo 6 | from lagrange.utils.binary.protobuf import proto_decode, proto_encode 7 | from lagrange.utils.binary.reader import Reader 8 | from lagrange.utils.crypto.ecdh import ecdh 9 | from lagrange.utils.crypto.tea import qqtea_decrypt, qqtea_encrypt 10 | from lagrange.utils.log import log 11 | from lagrange.utils.operator import timestamp 12 | 13 | 14 | def build_code2d_packet(uin: int, cmd_id: int, app_info: AppInfo, body: bytes) -> bytes: 15 | """need build_uni_packet function to wrapper""" 16 | return build_login_packet( 17 | uin, 18 | "wtlogin.trans_emp", 19 | app_info, 20 | ( 21 | PacketBuilder() 22 | .write_u8(0) 23 | .write_u16(len(body) + 53) 24 | .write_u32(app_info.app_id) 25 | .write_u32(0x72) 26 | .write_bytes(bytes(3)) 27 | .write_u32(timestamp()) 28 | .write_u8(2) 29 | .write_u16(len(body) + 49) 30 | .write_u16(cmd_id) 31 | .write_bytes(bytes(21)) 32 | .write_u8(3) 33 | .write_u32(50) 34 | .write_bytes(bytes(14)) 35 | .write_u32(app_info.app_id) 36 | .write_bytes(body) 37 | ).pack(), 38 | ) 39 | 40 | 41 | def build_login_packet(uin: int, cmd: str, app_info: AppInfo, body: bytes) -> bytes: 42 | enc_body = qqtea_encrypt(body, ecdh["secp192k1"].share_key) 43 | 44 | frame_body = ( 45 | PacketBuilder() 46 | .write_u16(8001) 47 | .write_u16(2064 if cmd == "wtlogin.login" else 2066) 48 | .write_u16(0) 49 | .write_u32(uin) 50 | .write_u8(3) 51 | .write_u8(135) 52 | .write_u32(0) 53 | .write_u8(19) 54 | .write_u16(0) 55 | .write_u16(app_info.app_client_version) 56 | .write_u32(0) 57 | .write_u8(1) 58 | .write_u8(1) 59 | .write_bytes(bytes(16)) 60 | .write_u16(0x102) 61 | .write_u16(len(ecdh["secp192k1"].public_key)) 62 | .write_bytes(ecdh["secp192k1"].public_key) 63 | .write_bytes(enc_body) 64 | .write_u8(3) 65 | ).pack() 66 | 67 | frame = ( 68 | PacketBuilder() 69 | .write_u8(2) 70 | .write_u16(len(frame_body) + 3) # + 2 + 1 71 | .write_bytes(frame_body) 72 | ).pack() 73 | 74 | return frame 75 | 76 | 77 | def build_uni_packet( 78 | uin: int, 79 | seq: int, 80 | cmd: str, 81 | sign: dict, 82 | app_info: AppInfo, 83 | device_info: DeviceInfo, 84 | sig_info: SigInfo, 85 | body: bytes, 86 | ) -> bytes: 87 | trace = f"00-{os.urandom(16).hex()}-{os.urandom(8).hex()}-01" 88 | 89 | head: dict = {15: trace, 16: sig_info.uid} 90 | if sign: 91 | head[24] = { 92 | 1: bytes.fromhex(sign["sign"]), 93 | 2: bytes.fromhex(sign["token"]), 94 | 3: bytes.fromhex(sign["extra"]), 95 | } 96 | 97 | sso_header = ( 98 | PacketBuilder() 99 | .write_u32(seq) 100 | .write_u32(app_info.sub_app_id) 101 | .write_u32(2052) # locale id 102 | .write_bytes(bytes.fromhex("020000000000000000000000")) 103 | .write_bytes(sig_info.tgt, "u32") 104 | .write_string(cmd, "u32") 105 | .write_bytes(b"", "u32") 106 | .write_bytes(bytes.fromhex(device_info.guid), "u32") 107 | .write_bytes(b"", "u32") 108 | .write_string(app_info.current_version, "u16") 109 | .write_bytes(proto_encode(head), "u32") 110 | ).pack() 111 | 112 | sso_packet = ( 113 | PacketBuilder().write_bytes(sso_header, "u32").write_bytes(body, "u32") 114 | ).pack() 115 | 116 | encrypted = qqtea_encrypt(sso_packet, sig_info.d2_key) 117 | 118 | service = ( 119 | PacketBuilder() 120 | .write_u32(12) 121 | .write_u8(1 if sig_info.d2 else 2) 122 | .write_bytes(sig_info.d2, "u32") 123 | .write_u8(0) 124 | .write_string(str(uin), "u32") 125 | .write_bytes(encrypted) 126 | ).pack() 127 | 128 | return PacketBuilder().write_bytes(service, "u32").pack() 129 | 130 | 131 | def decode_login_response(buf: bytes, sig: SigInfo): 132 | reader = Reader(buf) 133 | reader.read_bytes(2) 134 | typ = reader.read_u8() 135 | tlv = reader.read_tlv() 136 | 137 | if typ == 0: 138 | reader = Reader(qqtea_decrypt(tlv[0x119], sig.tgtgt)) 139 | tlv = reader.read_tlv() 140 | sig.tgt = tlv.get(0x10A) or sig.tgt 141 | sig.d2 = tlv.get(0x143) or sig.d2 142 | sig.d2_key = tlv.get(0x305) or sig.d2_key 143 | sig.tgtgt = hashlib.md5(sig.d2_key).digest() 144 | sig.temp_pwd = tlv[0x106] 145 | sig.uid = proto_decode(tlv[0x543]).into((9, 11, 1), bytes).decode() 146 | sig.info_updated() 147 | 148 | log.login.debug("SigInfo got") 149 | log.login.info(f"Login success, username: {tlv[0x11A][5:].decode()}") 150 | 151 | return True 152 | elif 0x146 in tlv: 153 | err_buf = Reader(tlv[0x146]) 154 | err_buf.read_bytes(4) 155 | title = err_buf.read_string(err_buf.read_u16()) 156 | content = err_buf.read_string(err_buf.read_u16()) 157 | elif 0x149 in tlv: 158 | err_buf = Reader(tlv[0x149]) 159 | err_buf.read_bytes(2) 160 | title = err_buf.read_string(err_buf.read_u16()) 161 | content = err_buf.read_string(err_buf.read_u16()) 162 | else: 163 | title = "未知错误" 164 | content = "无法解析错误原因,请将完整日志提交给开发者" 165 | 166 | log.login.error(f"Login fail on oicq({hex(typ)}): [{title}]>{content}") 167 | 168 | return False 169 | -------------------------------------------------------------------------------- /lagrange/client/wtlogin/sso.py: -------------------------------------------------------------------------------- 1 | import struct 2 | import zlib 3 | from dataclasses import dataclass, field 4 | from io import BytesIO 5 | 6 | from lagrange.utils.binary.reader import Reader 7 | from lagrange.utils.crypto.ecdh import ecdh 8 | from lagrange.utils.crypto.tea import qqtea_decrypt 9 | 10 | 11 | @dataclass 12 | class SSOPacket: 13 | seq: int 14 | ret_code: int 15 | extra: str 16 | session_id: bytes 17 | cmd: str = field(default="") 18 | data: bytes = field(default=b"") 19 | 20 | 21 | def parse_lv(buffer: BytesIO): # u32 len only 22 | length = struct.unpack(">I", buffer.read(4))[0] 23 | return buffer.read(length - 4) 24 | 25 | 26 | def parse_sso_header(raw: bytes, d2_key: bytes) -> tuple[int, str, bytes]: 27 | buf = BytesIO(raw) 28 | # parse sso header 29 | buf.read(4) 30 | flag, _ = struct.unpack("!BB", buf.read(2)) 31 | uin = parse_lv(buf).decode() 32 | 33 | if flag == 0: # no encrypted 34 | dec = buf.read() 35 | elif flag == 1: # enc with d2key 36 | dec = qqtea_decrypt(buf.read(), d2_key) 37 | elif flag == 2: # enc with \x00*16 38 | dec = qqtea_decrypt(buf.read(), bytes(16)) 39 | else: 40 | raise TypeError(f"invalid encrypt flag: {flag}") 41 | return flag, uin, dec 42 | 43 | 44 | def parse_sso_frame(buffer: bytes, is_oicq_body=False) -> SSOPacket: 45 | reader = Reader(buffer) 46 | head_len, seq, ret_code = reader.read_struct("!I2i") 47 | extra = reader.read_string_with_length("u32") # extra 48 | cmd = reader.read_string_with_length("u32") 49 | session_id = reader.read_bytes_with_length("u32") 50 | 51 | if ret_code != 0: 52 | return SSOPacket(seq=seq, ret_code=ret_code, session_id=session_id, extra=extra) 53 | 54 | compress_type = reader.read_u32() 55 | reader.read_bytes_with_length("u32", False) 56 | 57 | data = reader.read_bytes_with_length("u32", False) 58 | if data: 59 | if compress_type == 0: 60 | pass 61 | elif compress_type == 1: 62 | data = zlib.decompress(data) 63 | elif compress_type == 8: 64 | data = data[4:] 65 | else: 66 | raise TypeError(f"Unsupported compress type {compress_type}") 67 | 68 | if is_oicq_body and cmd.find("wtlogin") == 0: 69 | data = parse_oicq_body(data) 70 | 71 | return SSOPacket( 72 | seq=seq, 73 | ret_code=ret_code, 74 | session_id=session_id, 75 | extra=extra, 76 | cmd=cmd, 77 | data=data, 78 | ) 79 | 80 | 81 | def parse_oicq_body(buffer: bytes) -> bytes: 82 | flag, enc_type = struct.unpack("!B12xHx", buffer[:16]) 83 | 84 | if flag != 2: 85 | raise ValueError(f"Invalid OICQ response flag. Expected 2, got {flag}.") 86 | 87 | body = buffer[16:-1] 88 | if enc_type == 0: 89 | return qqtea_decrypt(body, ecdh["secp192k1"].share_key) 90 | else: 91 | raise ValueError(f"Unknown encrypt type: {enc_type}") 92 | -------------------------------------------------------------------------------- /lagrange/client/wtlogin/status_service.py: -------------------------------------------------------------------------------- 1 | from lagrange.info import AppInfo, DeviceInfo 2 | from lagrange.utils.binary.protobuf import proto_encode 3 | from lagrange.utils.log import log 4 | from lagrange.pb.login.register import ( 5 | PBRegisterRequest, 6 | PBRegisterResponse, 7 | PBSsoInfoSyncRequest, 8 | PBSsoInfoSyncResponse, 9 | ) 10 | 11 | 12 | # trpc.qq_new_tech.status_svc.StatusService.Register 13 | def build_register_request(app: AppInfo, device: DeviceInfo) -> bytes: 14 | return PBRegisterRequest.build(app, device).encode() 15 | 16 | 17 | # trpc.msg.register_proxy.RegisterProxy.SsoInfoSync 18 | def build_sso_info_sync(app: AppInfo, device: DeviceInfo) -> bytes: 19 | return PBSsoInfoSyncRequest.build(app, device).encode() 20 | 21 | 22 | # trpc.qq_new_tech.status_svc.StatusService.SsoHeartBeat 23 | def build_sso_heartbeat_request() -> bytes: 24 | return proto_encode({1: 1}) 25 | 26 | 27 | def parse_register_response(response: bytes) -> bool: 28 | pb = PBRegisterResponse.decode(response) 29 | if pb.message == "register success": 30 | return True 31 | log.network.error("register fail, reason: %s", pb.message) 32 | return False 33 | 34 | 35 | def parse_sso_info_sync_rsp(response: bytes) -> bool: 36 | pb = PBSsoInfoSyncResponse.decode(response) 37 | if pb.reg_rsp: 38 | if pb.reg_rsp.message == "register success": 39 | return True 40 | else: 41 | log.network.error("register fail, reason: %s", pb.reg_rsp.message) 42 | else: 43 | log.network.error("register fail, reason: WrongRsp") 44 | return False 45 | -------------------------------------------------------------------------------- /lagrange/client/wtlogin/tlv/__init__.py: -------------------------------------------------------------------------------- 1 | from .common import CommonTlvBuilder 2 | from .qrcode import QrCodeTlvBuilder 3 | 4 | __all__ = ["CommonTlvBuilder", "QrCodeTlvBuilder"] 5 | -------------------------------------------------------------------------------- /lagrange/client/wtlogin/tlv/common.py: -------------------------------------------------------------------------------- 1 | import hashlib 2 | import random 3 | 4 | from lagrange.client.packet import PacketBuilder 5 | from lagrange.info import AppInfo, DeviceInfo 6 | from lagrange.utils.crypto.tea import qqtea_encrypt 7 | from lagrange.utils.operator import timestamp 8 | 9 | 10 | class CommonTlvBuilder(PacketBuilder): 11 | @classmethod 12 | def _rand_u32(cls) -> int: 13 | return random.randint(0x0, 0xFFFFFFFF) 14 | 15 | @classmethod 16 | def t18( 17 | cls, 18 | app_id: int, 19 | app_client_version: int, 20 | uin: int, 21 | _ping_version: int = 0, 22 | _sso_version: int = 5, 23 | unknown: int = 0, 24 | ) -> bytes: 25 | return ( 26 | cls() 27 | .write_u16(_ping_version) 28 | .write_u32(_sso_version) 29 | .write_u32(app_id) 30 | .write_u32(app_client_version) 31 | .write_u32(uin) 32 | .write_u16(unknown) 33 | .write_u16(0) 34 | ).pack(0x18) 35 | 36 | @classmethod 37 | def t100( 38 | cls, 39 | sso_version: int, 40 | app_id: int, 41 | sub_app_id: int, 42 | app_client_version: int, 43 | sigmap: int, 44 | _db_buf_ver: int = 0, 45 | ) -> bytes: 46 | return ( 47 | cls() 48 | .write_u16(_db_buf_ver) 49 | .write_u32(sso_version) 50 | .write_u32(app_id) 51 | .write_u32(sub_app_id) 52 | .write_u32(app_client_version) 53 | .write_u32(sigmap) 54 | ).pack(0x100) 55 | 56 | @classmethod 57 | def t106( 58 | cls, 59 | app_id: int, 60 | app_client_version: int, 61 | uin: int, 62 | password_md5: bytes, 63 | guid: str, 64 | tgtgt_key: bytes, 65 | ip: bytes = bytes(4), 66 | save_password: bool = True, 67 | ) -> bytes: 68 | key = hashlib.md5( 69 | password_md5 + bytes(4) + cls().write_u32(uin).pack() 70 | ).digest() 71 | 72 | body = ( 73 | cls() 74 | .write_struct( 75 | "HIIIIQ", 76 | 4, # tgtgt version 77 | cls._rand_u32(), 78 | 0, # sso_version, depreciated 79 | app_id, 80 | app_client_version, 81 | uin, 82 | ) 83 | .write_u32(timestamp() & 0xFFFFFFFF) 84 | .write_bytes(ip) 85 | .write_bool(save_password) 86 | .write_bytes(password_md5) 87 | .write_bytes(tgtgt_key) 88 | .write_u32(0) 89 | .write_bool(True) 90 | .write_bytes(bytes.fromhex(guid)) 91 | .write_u32(0) 92 | .write_u32(1) 93 | .write_string(str(uin), "u16", False) 94 | ).pack() 95 | 96 | return cls().write_bytes(qqtea_encrypt(body, key), "u32").pack(0x106) 97 | 98 | @classmethod 99 | def t107( 100 | cls, 101 | pic_type: int = 1, 102 | cap_type: int = 0x0D, 103 | pic_size: int = 0, 104 | ret_type: int = 1, 105 | ) -> bytes: 106 | return ( 107 | cls() 108 | .write_u16(pic_type) 109 | .write_u8(cap_type) 110 | .write_u16(pic_size) 111 | .write_u8(ret_type) 112 | ).pack(0x107) 113 | 114 | @classmethod 115 | def t116(cls, sub_sigmap: int) -> bytes: 116 | return ( 117 | cls() 118 | .write_u8(0) 119 | .write_u32(12058620) # unknown? 120 | .write_u32(sub_sigmap) 121 | .write_u8(0) 122 | ).pack(0x116) 123 | 124 | @classmethod 125 | def t124(cls) -> bytes: 126 | return cls().write_bytes(bytes(12)).pack(0x124) 127 | 128 | @classmethod 129 | def t128(cls, app_info_os: str, device_guid: bytes) -> bytes: 130 | return ( 131 | cls() 132 | .write_u16(0) 133 | .write_u8(0) 134 | .write_u8(1) 135 | .write_u8(0) 136 | .write_u32(0) 137 | .write_string(app_info_os, "u16", False) 138 | .write_bytes(device_guid, "u16", False) 139 | .write_string("", "u16", False) 140 | ).pack(0x128) 141 | 142 | @classmethod 143 | def t141( 144 | cls, 145 | sim_info: bytes, 146 | apn: bytes = bytes(0), 147 | ) -> bytes: 148 | return ( 149 | cls().write_bytes(sim_info, "u32", False).write_bytes(apn, "u32", False) 150 | ).pack(0x141) 151 | 152 | @classmethod 153 | def t142(cls, apk_id: str, _version: int = 0) -> bytes: 154 | return (cls().write_u16(_version).write_string(apk_id[:32], "u16", False)).pack( 155 | 0x142 156 | ) 157 | 158 | @classmethod 159 | def t144(cls, tgtgt_key: bytes, app_info: AppInfo, device: DeviceInfo) -> bytes: 160 | return ( 161 | cls(tgtgt_key).write_tlv( 162 | cls.t16e(device.device_name), 163 | cls.t147(app_info.app_id, app_info.pt_version, app_info.package_name), 164 | cls.t128(app_info.os, bytes.fromhex(device.guid)), 165 | cls.t124(), 166 | ) 167 | ).pack(0x144) 168 | 169 | @classmethod 170 | def t145(cls, guid: bytes) -> bytes: 171 | return (cls().write_bytes(guid)).pack(0x145) 172 | 173 | @classmethod 174 | def t147(cls, app_id: int, pt_version: str, package_name: str) -> bytes: 175 | return ( 176 | cls() 177 | .write_u32(app_id) 178 | .write_string(pt_version, "u16", False) 179 | .write_string(package_name, "u16", False) 180 | ).pack(0x147) 181 | 182 | @classmethod 183 | def t166(cls, image_type: int) -> bytes: 184 | return (cls().write_byte(image_type)).pack(0x166) 185 | 186 | @classmethod 187 | def t16a(cls, no_pic_sig: bytes) -> bytes: 188 | return (cls().write_bytes(no_pic_sig)).pack(0x16A) 189 | 190 | @classmethod 191 | def t16e(cls, device_name: str) -> bytes: 192 | return (cls().write_bytes(device_name.encode())).pack(0x16E) 193 | 194 | @classmethod 195 | def t177(cls, sdk_version: str, build_time: int = 0) -> bytes: 196 | return ( 197 | cls() 198 | .write_struct("BI", 1, build_time) 199 | .write_string(sdk_version, "u16", False) 200 | ).pack(0x177) 201 | 202 | @classmethod 203 | def t191(cls, can_web_verify: int = 0) -> bytes: 204 | return (cls().write_u8(can_web_verify)).pack(0x191) 205 | 206 | @classmethod 207 | def t318(cls, tgt_qr: bytes = bytes(0)) -> bytes: 208 | return (cls().write_bytes(tgt_qr)).pack(0x318) 209 | 210 | @classmethod 211 | def t521(cls, product_type: int = 0x13, product_desc: str = "basicim") -> bytes: 212 | return ( 213 | cls().write_u32(product_type).write_string(product_desc, "u16", False) 214 | ).pack(0x521) 215 | -------------------------------------------------------------------------------- /lagrange/client/wtlogin/tlv/qrcode.py: -------------------------------------------------------------------------------- 1 | from lagrange.client.packet import PacketBuilder 2 | from lagrange.utils.binary.protobuf import proto_encode 3 | 4 | 5 | class QrCodeTlvBuilder(PacketBuilder): 6 | @classmethod 7 | def t11(cls, unusual_sign: bytes) -> bytes: 8 | return (cls().write_bytes(unusual_sign)).pack(0x11) 9 | 10 | @classmethod 11 | def t16( 12 | cls, appid: int, sub_appid: int, guid: bytes, pt_version: str, package_name: str 13 | ) -> bytes: 14 | return ( 15 | cls() 16 | .write_u32(0) 17 | .write_u32(appid) 18 | .write_u32(sub_appid) 19 | .write_bytes(guid) 20 | .write_string(package_name, "u16", False) 21 | .write_string(pt_version, "u16", False) 22 | .write_string(package_name, "u16", False) 23 | ).pack(0x16) 24 | 25 | @classmethod 26 | def t1b(cls) -> bytes: 27 | return cls().write_struct("7IH", 0, 0, 3, 4, 72, 2, 2, 0).pack(0x1B) 28 | 29 | @classmethod 30 | def t1d(cls, misc_bitmap: int) -> bytes: 31 | return (cls().write_u8(1).write_u32(misc_bitmap).write_u32(0).write_u8(0)).pack( 32 | 0x1D 33 | ) 34 | 35 | @classmethod 36 | def t33(cls, guid: bytes) -> bytes: 37 | return cls().write_bytes(guid).pack(0x33) 38 | 39 | @classmethod 40 | def t35(cls, pt_os_version: int) -> bytes: 41 | return cls().write_u32(pt_os_version).pack(0x35) 42 | 43 | @classmethod 44 | def t66(cls, pt_os_version: int) -> bytes: 45 | return cls().write_u32(pt_os_version).pack(0x66) 46 | 47 | @classmethod 48 | def td1(cls, app_os: str, device_name: str) -> bytes: 49 | return ( 50 | cls() 51 | .write_bytes(proto_encode({1: {1: app_os, 2: device_name}, 4: {6: 1}})) 52 | .pack(0xD1) 53 | ) 54 | -------------------------------------------------------------------------------- /lagrange/info/__init__.py: -------------------------------------------------------------------------------- 1 | import os 2 | from pathlib import Path 3 | from typing import Union 4 | 5 | from .app import AppInfo 6 | from .device import DeviceInfo 7 | from .sig import SigInfo 8 | 9 | from ..utils.log import log 10 | 11 | __all__ = ["DeviceInfo", "AppInfo", "SigInfo", "InfoManager"] 12 | 13 | 14 | class InfoManager: 15 | def __init__( 16 | self, 17 | uin: int, 18 | device_info_path: Union[str, os.PathLike[str]], 19 | sig_info_path: Union[str, os.PathLike[str]], 20 | auto_save=True, 21 | ): 22 | self.uin: int = uin 23 | self._device_info_path = Path(device_info_path) 24 | self._sig_info_path = Path(sig_info_path) 25 | self._device = None 26 | self._sig_info = None 27 | self.auto_save = auto_save 28 | 29 | @property 30 | def device(self) -> DeviceInfo: 31 | assert self._device, "Device not initialized" 32 | return self._device 33 | 34 | @property 35 | def sig_info(self) -> SigInfo: 36 | assert self._sig_info, "SigInfo not initialized" 37 | return self._sig_info 38 | 39 | def renew_sig_info(self): 40 | self._sig_info = SigInfo.new() 41 | 42 | def save_all(self): 43 | with self._sig_info_path.open("wb") as f: 44 | f.write(self.sig_info.dump()) 45 | 46 | with self._device_info_path.open("wb") as f: 47 | f.write(self.device.dump()) 48 | 49 | log.root.success("device & sig_info saved") 50 | 51 | def __enter__(self): 52 | if self._device_info_path.is_file(): 53 | with self._device_info_path.open("rb") as f: 54 | self._device = DeviceInfo.load(f.read()) 55 | log.root.success(f"{self._device_info_path} loaded") 56 | else: 57 | log.root.info(f"{self._device_info_path} not found, generating...") 58 | self._device = DeviceInfo.generate(self.uin) 59 | 60 | if self._sig_info_path.is_file(): 61 | with self._sig_info_path.open("rb") as f: 62 | self._sig_info = SigInfo.load(f.read()) 63 | log.root.success(f"{self._sig_info_path} loaded") 64 | else: 65 | log.root.info(f"{self._sig_info_path} not found, generating...") 66 | self._sig_info = SigInfo.new(8848) 67 | return self 68 | 69 | def __exit__(self, *_): 70 | if self.auto_save: 71 | self.save_all() 72 | return 73 | -------------------------------------------------------------------------------- /lagrange/info/app.py: -------------------------------------------------------------------------------- 1 | import re 2 | from dataclasses import dataclass 3 | from typing import TypedDict, TypeVar, Union 4 | 5 | from .serialize import JsonSerializer 6 | 7 | 8 | _T = TypeVar("_T", bound=dict[str, Union[int, str]]) 9 | _trans_map = { 10 | "SsoVersion": "pt_os_version", 11 | "WtLoginSdk": "wtlogin_sdk", 12 | "AppIdQrCode": "app_id_qrcode", 13 | "MainSigMap": "main_sigmap", 14 | "SubSigMap": "sub_sigmap", 15 | } 16 | 17 | def _translate_appinfo(s: _T) -> _T: 18 | out: _T = {} 19 | for k, v in s.items(): 20 | if k in _trans_map: 21 | out[_trans_map[k]] = v 22 | else: 23 | k = re.sub( 24 | r"([A-Z])([^A-Z]+)", 25 | r"_\1\2", 26 | k 27 | ).lstrip("_").lower() 28 | out[k] = v 29 | return out 30 | 31 | 32 | @dataclass 33 | class AppInfo(JsonSerializer): 34 | os: str 35 | kernel: str 36 | vendor_os: str 37 | 38 | current_version: str 39 | # build_version: int 40 | misc_bitmap: int 41 | pt_version: str 42 | pt_os_version: int 43 | package_name: str 44 | wtlogin_sdk: str 45 | app_id: int 46 | sub_app_id: int 47 | app_id_qrcode: int 48 | app_client_version: int 49 | main_sigmap: int 50 | sub_sigmap: int 51 | nt_login_type: int 52 | 53 | @property 54 | def build_version(self) -> int: 55 | return int(self.current_version.split("-")[1]) 56 | 57 | @property 58 | def package_sign(self) -> str: 59 | # QUA? 60 | if self.os == "Windows": 61 | kernel = "WIN" 62 | elif self.os == "Linux": 63 | kernel = "LNX" 64 | elif self.os == "Mac": 65 | kernel = "MAC" 66 | else: 67 | raise NotImplementedError(self.os) 68 | return f"V1_{kernel}_NQ_{self.current_version}_RDM_B" 69 | 70 | @classmethod 71 | def load_custom(cls, d: _T) -> "AppInfo": 72 | return cls(**_translate_appinfo(d)) 73 | 74 | 75 | class AppInfoDict(TypedDict): 76 | linux: AppInfo 77 | macos: AppInfo 78 | windows: AppInfo 79 | 80 | 81 | app_list: AppInfoDict = { 82 | "linux": AppInfo( 83 | os="Linux", 84 | kernel="Linux", 85 | vendor_os="linux", 86 | current_version="3.2.10-25765", 87 | # build_version=25765, 88 | misc_bitmap=32764, 89 | pt_version="2.0.0", 90 | pt_os_version=19, 91 | package_name="com.tencent.qq", 92 | wtlogin_sdk="nt.wtlogin.0.0.1", 93 | # package_sign="V1_LNX_NQ_3.1.2-13107_RDM_B", 94 | app_id=1600001615, 95 | sub_app_id=537234773, 96 | app_id_qrcode=13697054, 97 | app_client_version=13172, 98 | main_sigmap=169742560, 99 | sub_sigmap=0, 100 | nt_login_type=1, 101 | ), 102 | "macos": AppInfo( 103 | os="Mac", 104 | kernel="Darwin", 105 | vendor_os="mac", 106 | current_version="6.9.20-17153", 107 | # build_version=17153, 108 | misc_bitmap=32764, 109 | pt_version="2.0.0", 110 | pt_os_version=23, 111 | package_name="com.tencent.qq", 112 | wtlogin_sdk="nt.wtlogin.0.0.1", 113 | # package_sign="V1_MAC_NQ_6.9.20-17153_RDM_B", 114 | app_id=1600001602, 115 | sub_app_id=537162356, 116 | app_id_qrcode=537162356, 117 | app_client_version=13172, 118 | main_sigmap=169742560, 119 | sub_sigmap=0, 120 | nt_login_type=5, 121 | ), 122 | "windows": AppInfo( 123 | os="Windows", 124 | kernel="Windows_NT", 125 | vendor_os="win32", 126 | current_version="9.9.2-15962", 127 | # build_version=15962, 128 | pt_version="2.0.0", 129 | misc_bitmap=32764, 130 | pt_os_version=23, 131 | package_name="com.tencent.qq", 132 | wtlogin_sdk="nt.wtlogin.0.0.1", 133 | # package_sign="V1_WIN_NQ_9.9.2-15962_RDM_B", 134 | app_id=1600001604, 135 | sub_app_id=537138217, 136 | app_id_qrcode=537138217, 137 | app_client_version=13172, 138 | main_sigmap=169742560, 139 | sub_sigmap=0, 140 | nt_login_type=5 141 | ) 142 | } 143 | -------------------------------------------------------------------------------- /lagrange/info/device.py: -------------------------------------------------------------------------------- 1 | import platform 2 | from dataclasses import dataclass 3 | from hashlib import md5 4 | from typing import Union 5 | 6 | from .serialize import JsonSerializer 7 | 8 | 9 | @dataclass 10 | class DeviceInfo(JsonSerializer): 11 | guid: str 12 | device_name: str 13 | system_kernel: str 14 | kernel_version: str 15 | 16 | @classmethod 17 | def generate(cls, uin: Union[str, int]) -> "DeviceInfo": 18 | if isinstance(uin, int): 19 | uin = md5(str(uin).encode()).hexdigest() 20 | 21 | return DeviceInfo( 22 | guid=uin, 23 | device_name=f"Lagrange-{md5(uin.encode()).digest()[:4].hex().upper()}", 24 | system_kernel=f"{platform.system()} {platform.version()}", 25 | kernel_version=platform.version(), 26 | ) 27 | -------------------------------------------------------------------------------- /lagrange/info/serialize.py: -------------------------------------------------------------------------------- 1 | import hashlib 2 | import json 3 | import pickle 4 | from abc import ABC 5 | from dataclasses import asdict, dataclass 6 | 7 | from typing_extensions import Self 8 | 9 | from lagrange.utils.binary.builder import Builder 10 | from lagrange.utils.binary.reader import Reader 11 | 12 | 13 | class BaseSerializer(ABC): 14 | @classmethod 15 | def load(cls, buffer: bytes) -> Self: 16 | raise NotImplementedError 17 | 18 | def dump(self) -> bytes: 19 | raise NotImplementedError 20 | 21 | 22 | @dataclass 23 | class JsonSerializer(BaseSerializer): 24 | @classmethod 25 | def load(cls, buffer: bytes) -> Self: 26 | return cls(**json.loads(buffer)) # noqa 27 | 28 | def dump(self) -> bytes: 29 | return json.dumps(asdict(self)).encode() 30 | 31 | 32 | @dataclass 33 | class BinarySerializer(BaseSerializer): 34 | def _encode(self) -> bytes: 35 | data = pickle.dumps(self) 36 | data_hash = hashlib.sha256(data).digest() 37 | 38 | return (Builder().write_bytes(data_hash, with_length=True).write_bytes(data, with_length=True)).pack() 39 | 40 | @classmethod 41 | def _decode(cls, buffer: bytes, verify=True) -> Self: 42 | reader = Reader(buffer) 43 | data_hash = reader.read_bytes_with_length("u16", False) 44 | data = reader.read_bytes_with_length("u16", False) 45 | if verify and data_hash != hashlib.sha256(data).digest(): 46 | raise AssertionError("Data hash does not match") 47 | 48 | return pickle.loads(data) 49 | 50 | @classmethod 51 | def load(cls, buffer: bytes) -> Self: 52 | return cls._decode(buffer) 53 | 54 | def dump(self) -> bytes: 55 | return self._encode() 56 | -------------------------------------------------------------------------------- /lagrange/info/sig.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass 2 | 3 | from .serialize import BinarySerializer 4 | from ..utils.operator import timestamp 5 | 6 | 7 | @dataclass 8 | class SigInfo(BinarySerializer): 9 | sequence: int 10 | tgtgt: bytes 11 | tgt: bytes 12 | d2: bytes 13 | d2_key: bytes 14 | qrsig: bytes 15 | 16 | exchange_key: bytes 17 | key_sig: bytes 18 | cookies: str 19 | unusual_sig: bytes 20 | temp_pwd: bytes 21 | 22 | uin: int 23 | uid: str 24 | nickname: str 25 | last_update: int 26 | 27 | def info_updated(self): 28 | self.last_update = timestamp() 29 | 30 | @classmethod 31 | def new(cls, seq=8830) -> "SigInfo": 32 | return cls( 33 | sequence=seq, 34 | tgtgt=b"", 35 | tgt=b"", 36 | d2=b"", 37 | d2_key=bytes(16), 38 | qrsig=b"", 39 | exchange_key=b"", 40 | key_sig=b"", 41 | cookies="", 42 | unusual_sig=b"", 43 | temp_pwd=b"", 44 | uin=0, 45 | uid="", 46 | nickname="", 47 | last_update=0, 48 | ) 49 | -------------------------------------------------------------------------------- /lagrange/pb/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LagrangeDev/lagrange-python/974dfaa546a1fcfbd6461f284a950af4622ca09f/lagrange/pb/__init__.py -------------------------------------------------------------------------------- /lagrange/pb/highway/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LagrangeDev/lagrange-python/974dfaa546a1fcfbd6461f284a950af4622ca09f/lagrange/pb/highway/__init__.py -------------------------------------------------------------------------------- /lagrange/pb/highway/comm.py: -------------------------------------------------------------------------------- 1 | from typing import Optional 2 | 3 | from lagrange.utils.binary.protobuf import proto_field, ProtoStruct 4 | 5 | 6 | class CommonHead(ProtoStruct): 7 | req_id: int = proto_field(1, default=1) 8 | cmd: int = proto_field(2) 9 | 10 | 11 | class PicExtInfo(ProtoStruct): 12 | biz_type: Optional[int] = proto_field(1, default=None) 13 | summary: Optional[str] = proto_field(2, default=None) 14 | c2c_reserved: bytes = proto_field(11, default=b"") 15 | troop_reserved: bytes = proto_field(12, default=b"") 16 | 17 | 18 | class VideoExtInfo(ProtoStruct): 19 | from_scene: Optional[int] = proto_field(1, default=None) 20 | to_scene: Optional[int] = proto_field(2, default=None) 21 | pb_reserved: bytes = proto_field(3, default=b"") 22 | 23 | 24 | class AudioExtInfo(ProtoStruct): 25 | src_uin: Optional[int] = proto_field(1, default=None) 26 | ptt_scene: Optional[int] = proto_field(2, default=None) 27 | ptt_type: Optional[int] = proto_field(3, default=None) 28 | change_voice: Optional[int] = proto_field(4, default=None) 29 | waveform: Optional[bytes] = proto_field(5, default=None) 30 | audio_convert_text: Optional[int] = proto_field(6, default=None) 31 | bytes_reserved: bytes = proto_field(11, default=b"") 32 | pb_reserved: bytes = proto_field(12, default=b"") 33 | general_flags: bytes = proto_field(13, default=b"") 34 | 35 | 36 | class ExtBizInfo(ProtoStruct): 37 | pic: Optional[PicExtInfo] = proto_field(1, default_factory=PicExtInfo) 38 | video: Optional[VideoExtInfo] = proto_field(2, default_factory=VideoExtInfo) 39 | audio: Optional[AudioExtInfo] = proto_field(3, default_factory=AudioExtInfo) 40 | bus_type: Optional[int] = proto_field(4, default=None) 41 | 42 | 43 | class PicUrlExtInfo(ProtoStruct): 44 | origin_params: str = proto_field(1) 45 | big_params: str = proto_field(2) 46 | thumb_params: str = proto_field(3) 47 | 48 | 49 | class PicInfo(ProtoStruct): 50 | url_path: str = proto_field(1) 51 | ext: PicUrlExtInfo = proto_field(2) 52 | domain: str = proto_field(3) 53 | 54 | 55 | class FileType(ProtoStruct): 56 | type: int = proto_field(1) 57 | pic_format: int = proto_field(2, default=0) 58 | video_format: int = proto_field(3, default=0) 59 | audio_format: int = proto_field(4, default=0) 60 | 61 | 62 | class FileInfo(ProtoStruct): 63 | size: int = proto_field(1, default=0) 64 | hash: str = proto_field(2) 65 | sha1: str = proto_field(3) 66 | name: str = proto_field(4) 67 | type: FileType = proto_field(5) 68 | width: int = proto_field(6, default=0) 69 | height: int = proto_field(7, default=0) 70 | time: int = proto_field(8, default=0) 71 | is_origin: bool = proto_field(9, default=True) 72 | 73 | 74 | class IndexNode(ProtoStruct): 75 | info: Optional[FileInfo] = proto_field(1, default=None) 76 | file_uuid: str = proto_field(2) 77 | store_id: Optional[int] = proto_field(3, default=None) 78 | upload_time: Optional[int] = proto_field(4, default=None) 79 | ttl: Optional[int] = proto_field(5, default=None) 80 | sub_type: Optional[int] = proto_field(6, default=None) 81 | 82 | 83 | class MsgInfoBody(ProtoStruct): 84 | index: IndexNode = proto_field(1) 85 | pic: Optional[PicInfo] = proto_field(2, default=None) 86 | video: Optional[dict] = proto_field(3, default=None) 87 | audio: Optional[dict] = proto_field(4, default=None) 88 | file_exists: Optional[bool] = proto_field(5, default=None) 89 | hashsum: bytes = proto_field(6, default=b"") 90 | 91 | 92 | class MsgInfo(ProtoStruct): 93 | body: list[MsgInfoBody] = proto_field(1) 94 | biz_info: ExtBizInfo = proto_field(2) 95 | 96 | 97 | class IPv4(ProtoStruct): 98 | out_ip: int = proto_field(1) 99 | out_port: int = proto_field(2) 100 | in_ip: int = proto_field(3) 101 | in_port: int = proto_field(4) 102 | ip_type: int = proto_field(5) 103 | 104 | 105 | class IPv6(ProtoStruct): 106 | out_ip: bytes = proto_field(1) 107 | out_port: int = proto_field(2) 108 | in_ip: Optional[bytes] = proto_field(3, default=None) 109 | in_port: Optional[int] = proto_field(4, default=None) 110 | ip_type: int = proto_field(5) 111 | -------------------------------------------------------------------------------- /lagrange/pb/highway/ext.py: -------------------------------------------------------------------------------- 1 | from lagrange.utils.binary.protobuf import proto_field, ProtoStruct 2 | 3 | from .comm import IPv4, MsgInfoBody 4 | 5 | 6 | def ipv4_to_network(ipv4: list[IPv4]) -> "NTHighwayNetwork": 7 | nets = [] 8 | for v4 in ipv4: 9 | ip = v4.out_ip.to_bytes(4, byteorder="little") 10 | nets.append( 11 | NTHighwayIPv4( 12 | domain=NTHighwayDomain(ip=f"{ip[0]}.{ip[1]}.{ip[2]}.{ip[3]}"), 13 | port=v4.out_port, 14 | ) 15 | ) 16 | return NTHighwayNetwork(v4_addrs=nets) 17 | 18 | 19 | class NTHighwayDomain(ProtoStruct): 20 | is_enable: bool = proto_field(1, default=True) 21 | ip: str = proto_field(2) 22 | 23 | 24 | class NTHighwayIPv4(ProtoStruct): 25 | domain: NTHighwayDomain = proto_field(1) 26 | port: int = proto_field(2) 27 | 28 | 29 | class NTHighwayNetwork(ProtoStruct): 30 | v4_addrs: list[NTHighwayIPv4] = proto_field(1) 31 | 32 | 33 | class NTHighwayHash(ProtoStruct): 34 | sha1: bytes = proto_field(1) 35 | 36 | 37 | class NTV2RichMediaHighwayExt(ProtoStruct): 38 | uuid: str = proto_field(1) 39 | ukey: str = proto_field(2) 40 | network: NTHighwayNetwork = proto_field(5) 41 | msg_info: list[MsgInfoBody] = proto_field(6) 42 | blk_size: int = proto_field(10) 43 | hash: NTHighwayHash = proto_field(11) 44 | 45 | @classmethod 46 | def build( 47 | cls, 48 | uuid: str, 49 | ukey: str, 50 | network: list[IPv4], 51 | msg_info: list[MsgInfoBody], 52 | blk_size: int, 53 | hash: bytes, 54 | ) -> "NTV2RichMediaHighwayExt": 55 | return cls( 56 | uuid=uuid, 57 | ukey=ukey, 58 | network=ipv4_to_network(network), 59 | msg_info=msg_info, 60 | blk_size=blk_size, 61 | hash=NTHighwayHash(sha1=hash), 62 | ) 63 | -------------------------------------------------------------------------------- /lagrange/pb/highway/head.py: -------------------------------------------------------------------------------- 1 | from typing import Optional 2 | 3 | from lagrange.utils.binary.protobuf import proto_field, ProtoStruct 4 | 5 | 6 | class DataHighwayHead(ProtoStruct): 7 | version: int = proto_field(1, default=1) 8 | uin: Optional[str] = proto_field(2, default=None) 9 | command: Optional[str] = proto_field(3, default=None) 10 | seq: int = proto_field(4) 11 | retry_times: int = proto_field(5, default=0) 12 | app_id: int = proto_field(6) 13 | data_flag: int = proto_field(7, default=16) 14 | command_id: int = proto_field(8) 15 | build_ver: bytes = proto_field(9, default=b"") 16 | 17 | 18 | class SegHead(ProtoStruct): 19 | service_id: Optional[int] = proto_field(1, default=None) 20 | file_size: int = proto_field(2) 21 | data_offset: int = proto_field(3) 22 | data_length: int = proto_field(4) 23 | ret_code: int = proto_field(5, default=0) 24 | ticket: bytes = proto_field(6, default=b"") 25 | md5: bytes = proto_field(8) 26 | file_md5: bytes = proto_field(9) 27 | cache_addr: Optional[int] = proto_field(10, default=None) 28 | cache_port: Optional[int] = proto_field(13, default=None) 29 | 30 | 31 | class LoginSigHead(ProtoStruct): 32 | login_sig_type: int = proto_field(1) 33 | login_sig: bytes = proto_field(2, default=b"") 34 | app_id: int = proto_field(3) 35 | 36 | 37 | class HighwayTransReqHead(ProtoStruct): 38 | msg_head: Optional[DataHighwayHead] = proto_field(1, default=None) 39 | seg_head: Optional[SegHead] = proto_field(2, default=None) 40 | req_ext_info: bytes = proto_field(3, default=b"") 41 | timestamp: int = proto_field(4) 42 | login_head: Optional[LoginSigHead] = proto_field(5, default=None) 43 | 44 | 45 | class HighwayTransRespHead(ProtoStruct): 46 | msg_head: Optional[DataHighwayHead] = proto_field(1, default=None) 47 | seg_head: Optional[SegHead] = proto_field(2, default=None) 48 | err_code: int = proto_field(3) 49 | allow_retry: int = proto_field(4) 50 | cache_cost: Optional[int] = proto_field(5, default=None) 51 | ht_cost: Optional[int] = proto_field(6, default=None) 52 | ext_info: bytes = proto_field(7, default=b"") 53 | timestamp: Optional[int] = proto_field(8, default=None) 54 | range: Optional[int] = proto_field(9, default=None) 55 | is_reset: Optional[int] = proto_field(10, default=None) 56 | -------------------------------------------------------------------------------- /lagrange/pb/highway/httpconn.py: -------------------------------------------------------------------------------- 1 | import ipaddress 2 | from typing import Optional 3 | 4 | from lagrange.utils.binary.protobuf import proto_field, ProtoStruct 5 | 6 | 7 | class X501ReqBody(ProtoStruct): 8 | field_1: int = proto_field(1, default=0) 9 | field_2: int = proto_field(2, default=0) 10 | field_3: int = proto_field(3, default=16) 11 | field_4: int = proto_field(4, default=1) 12 | tgt_hex: str = proto_field(5) 13 | field_6: int = proto_field(6, default=3) 14 | field_7: list[int] = proto_field(7, default_factory=lambda: [1, 5, 10, 21]) 15 | field_9: int = proto_field(9, default=2) 16 | field_10: int = proto_field(10, default=9) 17 | field_11: int = proto_field(11, default=8) 18 | ver: str = proto_field(15, default="1.0.1") 19 | 20 | 21 | class HttpConn0x6ffReq(ProtoStruct): 22 | body: X501ReqBody = proto_field(0x501) 23 | 24 | @classmethod 25 | def build(cls, tgt: bytes) -> "HttpConn0x6ffReq": 26 | return cls(body=X501ReqBody(tgt_hex=tgt.hex())) 27 | 28 | 29 | class BaseAddress(ProtoStruct): 30 | type: int = proto_field(1) 31 | port: int = proto_field(3) 32 | area: Optional[int] = proto_field(4, default=None) 33 | 34 | @property 35 | def ip(self) -> str: 36 | raise NotImplementedError 37 | 38 | 39 | class ServerV4Address(BaseAddress): 40 | ip_int: int = proto_field(2) 41 | 42 | @property 43 | def ip(self) -> str: 44 | return ipaddress.ip_address(self.ip_int).compressed 45 | 46 | 47 | class ServerV6Address(BaseAddress): 48 | ip_bytes: bytes = proto_field(2) # 16 bytes v6_address 49 | 50 | @property 51 | def ip(self) -> str: 52 | return ipaddress.ip_address(self.ip_bytes).compressed 53 | 54 | 55 | class ServerInfo(ProtoStruct): 56 | service_type: int = proto_field(1) 57 | v4_addr: list[ServerV4Address] = proto_field(2, default_factory=list) 58 | v6_addr: list[ServerV6Address] = proto_field(5, default_factory=list) 59 | 60 | 61 | class X501RspBody(ProtoStruct): 62 | sig_session: bytes = proto_field(1) 63 | sig_key: bytes = proto_field(2) 64 | servers: list[ServerInfo] = proto_field(3, default_factory=list) 65 | 66 | 67 | class HttpConn0x6ffRsp(ProtoStruct): 68 | body: X501RspBody = proto_field(0x501) 69 | -------------------------------------------------------------------------------- /lagrange/pb/highway/req.py: -------------------------------------------------------------------------------- 1 | from typing import Optional 2 | 3 | from lagrange.utils.binary.protobuf import proto_field, ProtoStruct 4 | 5 | from .comm import CommonHead, ExtBizInfo, FileInfo, MsgInfo, IndexNode 6 | 7 | 8 | class C2CUserInfo(ProtoStruct): 9 | account_type: int = proto_field(1, default=2) 10 | uid: str = proto_field(2) 11 | 12 | 13 | class GroupInfo(ProtoStruct): 14 | grp_id: int = proto_field(1) 15 | 16 | 17 | class ClientMeta(ProtoStruct): 18 | agent_type: int = proto_field(1, default=2) 19 | 20 | 21 | class SceneInfo(ProtoStruct): 22 | req_type: int = proto_field(101) 23 | bus_type: int = proto_field(102) 24 | scene_type: int = proto_field(200) 25 | c2c: Optional[C2CUserInfo] = proto_field(201, default=None) 26 | grp: Optional[GroupInfo] = proto_field(202, default=None) 27 | 28 | 29 | class MultiMediaReqHead(ProtoStruct): 30 | common: CommonHead = proto_field(1) 31 | scene: SceneInfo = proto_field(2) 32 | meta: ClientMeta = proto_field(3, default_factory=ClientMeta) 33 | 34 | 35 | class UploadInfo(ProtoStruct): 36 | file_info: FileInfo = proto_field(1) 37 | sub_type: int = proto_field(2) 38 | 39 | 40 | class UploadReq(ProtoStruct): 41 | infos: list[UploadInfo] = proto_field(1) 42 | try_fast_upload: bool = proto_field(2, default=True) 43 | serve_sendmsg: bool = proto_field(3, default=False) 44 | client_rand_id: int = proto_field(4) 45 | compat_stype: int = proto_field(5, default=1) # CompatQMsgSceneType 46 | biz_info: ExtBizInfo = proto_field(6) 47 | client_seq: int = proto_field(7, default=0) 48 | no_need_compat_msg: bool = proto_field(8, default=False) 49 | 50 | 51 | class UploadCompletedReq(ProtoStruct): 52 | serve_sendmsg: bool = proto_field(1) 53 | client_rand_id: int = proto_field(2) 54 | msg_info: MsgInfo = proto_field(3) 55 | client_seq: int = proto_field(4) 56 | 57 | 58 | class DownloadVideoExt(ProtoStruct): 59 | busi_type: int = proto_field(1, default=0) 60 | scene_type: int = proto_field(2, default=0) 61 | sub_busi_type: Optional[int] = proto_field(3, default=None) 62 | 63 | 64 | class DownloadExt(ProtoStruct): 65 | pic_ext: Optional[bytes] = proto_field(1, default=None) 66 | video_ext: DownloadVideoExt = proto_field(2, default_factory=DownloadVideoExt) 67 | ptt_ext: Optional[bytes] = proto_field(3, default=None) 68 | 69 | 70 | class DownloadReq(ProtoStruct): 71 | node: IndexNode = proto_field(1) 72 | ext: DownloadExt = proto_field(2, default_factory=DownloadExt) 73 | 74 | 75 | class NTV2RichMediaReq(ProtoStruct): 76 | req_head: MultiMediaReqHead = proto_field(1) 77 | upload: Optional[UploadReq] = proto_field(2, default=None) 78 | download: Optional[DownloadReq] = proto_field(3, default=None) 79 | upload_completed: Optional[UploadCompletedReq] = proto_field(6, default=None) 80 | ext: Optional[bytes] = proto_field(99, default=None) 81 | -------------------------------------------------------------------------------- /lagrange/pb/highway/rsp.py: -------------------------------------------------------------------------------- 1 | from typing import Optional 2 | 3 | from lagrange.utils.binary.protobuf import proto_field, ProtoStruct 4 | 5 | from .comm import CommonHead, IPv4, IPv6, MsgInfo, PicUrlExtInfo, VideoExtInfo 6 | 7 | 8 | class MultiMediaRspHead(ProtoStruct): 9 | common: CommonHead = proto_field(1) 10 | ret_code: int = proto_field(2, default=0) 11 | msg: str = proto_field(3) 12 | 13 | 14 | class RichMediaStorageTransInfo(ProtoStruct): 15 | sub_type: Optional[int] = proto_field(1, default=None) 16 | ext_type: int = proto_field(2) 17 | ext_value: bytes = proto_field(3) 18 | 19 | 20 | class SubFileInfo(ProtoStruct): 21 | sub_type: int = proto_field(1) 22 | ukey: str = proto_field(2) 23 | ukey_ttl: int = proto_field(3) 24 | v4_addrs: list[IPv4] = proto_field(4) 25 | v6_addrs: list[IPv6] = proto_field(5) 26 | 27 | 28 | class UploadRsp(ProtoStruct): 29 | ukey: Optional[str] = proto_field(1, default=None) # None when file exists 30 | ukey_ttl: int = proto_field(2) 31 | v4_addrs: list[IPv4] = proto_field(3) 32 | v6_addrs: list[IPv6] = proto_field(4) 33 | msg_seq: int = proto_field(5, default=0) 34 | msg_info: MsgInfo = proto_field(6) 35 | ext: list[RichMediaStorageTransInfo] = proto_field(7, default_factory=list) 36 | compat_qmsg: bytes = proto_field(8) 37 | sub_file_info: list[SubFileInfo] = proto_field(10, default_factory=list) 38 | 39 | 40 | class DownloadInfo(ProtoStruct): 41 | domain: str = proto_field(1) 42 | url_path: str = proto_field(2) 43 | https_port: Optional[int] = proto_field(3, default=None) 44 | v4_addrs: list[IPv4] = proto_field(4, default_factory=list) 45 | v6_addrs: list[IPv6] = proto_field(5, default_factory=list) 46 | pic_info: Optional[PicUrlExtInfo] = proto_field(6, default=None) 47 | video_info: Optional[VideoExtInfo] = proto_field(7, default=None) 48 | 49 | 50 | class DownloadRsp(ProtoStruct): 51 | rkey: str = proto_field(1) 52 | rkey_ttl: Optional[int] = proto_field(2, default=None) 53 | info: DownloadInfo = proto_field(3) 54 | rkey_created_at: Optional[int] = proto_field(4, default=None) 55 | 56 | 57 | class NTV2RichMediaResp(ProtoStruct): 58 | rsp_head: MultiMediaRspHead = proto_field(1) 59 | upload: Optional[UploadRsp] = proto_field(2, default=None) 60 | download: Optional[DownloadRsp] = proto_field(3, default=None) 61 | -------------------------------------------------------------------------------- /lagrange/pb/login/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LagrangeDev/lagrange-python/974dfaa546a1fcfbd6461f284a950af4622ca09f/lagrange/pb/login/__init__.py -------------------------------------------------------------------------------- /lagrange/pb/login/ntlogin.py: -------------------------------------------------------------------------------- 1 | from typing import Optional 2 | 3 | from lagrange.utils.binary.protobuf import ProtoStruct, proto_field 4 | 5 | 6 | class _LoginCookies(ProtoStruct, debug=True): 7 | str: str = proto_field(1) # type: ignore 8 | 9 | 10 | class _LoginVerify(ProtoStruct, debug=True): 11 | url: str = proto_field(3) 12 | 13 | 14 | class _LoginErrField(ProtoStruct, debug=True): 15 | code: int = proto_field(1) 16 | title: str = proto_field(2) 17 | message: str = proto_field(3) 18 | 19 | 20 | class _LoginRspHead(ProtoStruct, debug=True): 21 | account: dict = proto_field(1) # {1: uin} 22 | device: dict = proto_field( 23 | 2 24 | ) # {1: app.os, 2: device_name, 3: nt_login_type, 4: bytes(guid)} 25 | system: dict = proto_field( 26 | 3 27 | ) # {1: device.kernel_version, 2: app.app_id, 3: app.package_name} 28 | error: Optional[_LoginErrField] = proto_field(4, default=None) 29 | cookies: Optional[_LoginCookies] = proto_field(5, default=None) 30 | 31 | 32 | class _LoginCredentials(ProtoStruct, debug=True): 33 | credentials: Optional[bytes] = proto_field(1, default=None) # on login request 34 | temp_pwd: Optional[bytes] = proto_field(3, default=None) 35 | tgt: Optional[bytes] = proto_field(4, default=None) 36 | d2: Optional[bytes] = proto_field(5, default=None) 37 | d2_key: Optional[bytes] = proto_field(6, default=None) 38 | 39 | 40 | class _LoginRspBody(ProtoStruct, debug=True): 41 | credentials: Optional[_LoginCredentials] = proto_field(1, default=None) 42 | verify: Optional[_LoginVerify] = proto_field(2, default=None) 43 | 44 | 45 | class NTLoginRsp(ProtoStruct, debug=True): 46 | head: _LoginRspHead = proto_field(1) 47 | body: Optional[_LoginRspBody] = proto_field(2, default=None) 48 | -------------------------------------------------------------------------------- /lagrange/pb/login/register.py: -------------------------------------------------------------------------------- 1 | import random 2 | 3 | from lagrange.info import AppInfo, DeviceInfo 4 | from lagrange.pb.message.msg_push import MsgPushBody 5 | from lagrange.utils.binary.protobuf import proto_field, ProtoStruct 6 | 7 | 8 | # trpc.qq_new_tech.status_svc.StatusService.Register 9 | class _DeviceInfo(ProtoStruct): 10 | device_name: str = proto_field(1) 11 | vendor_os: str = proto_field(2) 12 | system_kernel: str = proto_field(3) 13 | vendor_name: str = proto_field(4, default="") 14 | vendor_os_lower: str = proto_field(5) 15 | 16 | 17 | class PBRegisterRequest(ProtoStruct): 18 | guid: str = proto_field(1) 19 | kick_pc: int = proto_field(2, default=0) # ? 20 | current_version: str = proto_field(3) 21 | field_4: int = proto_field(4, default=0) # IsFirstRegisterProxyOnline 22 | locale_id: int = proto_field(5, default=2052) 23 | device_info: _DeviceInfo = proto_field(6) 24 | set_mute: int = proto_field(7, default=0) # ? 25 | register_vendor_type: int = proto_field(8, default=0) # ? 26 | register_type: int = proto_field(9, default=1) 27 | 28 | @classmethod 29 | def build(cls, app: AppInfo, device: DeviceInfo) -> "PBRegisterRequest": 30 | return cls( 31 | guid=device.guid.upper(), 32 | current_version=app.current_version, 33 | device_info=_DeviceInfo( 34 | device_name=device.device_name, 35 | vendor_os=app.vendor_os.capitalize(), 36 | system_kernel=device.system_kernel, 37 | vendor_os_lower=app.vendor_os, 38 | ), 39 | ) 40 | 41 | 42 | class PBRegisterResponse(ProtoStruct): 43 | message: str = proto_field(2) 44 | timestamp: int = proto_field(3) 45 | 46 | 47 | # trpc.msg.register_proxy.RegisterProxy.SsoInfoSync 48 | class C2cMsgCookie(ProtoStruct): 49 | last_msg_time: int = proto_field(1) 50 | 51 | 52 | class SsoC2cInfo(ProtoStruct): 53 | msg_cookie: C2cMsgCookie = proto_field(1) 54 | last_msg_time: int = proto_field(2) 55 | last_msg_cookie: C2cMsgCookie = proto_field(3) 56 | 57 | @classmethod 58 | def build(cls, last_msg_time=0) -> "SsoC2cInfo": 59 | return cls( 60 | msg_cookie=C2cMsgCookie(last_msg_time=last_msg_time), 61 | last_msg_cookie=C2cMsgCookie(last_msg_time=last_msg_time), 62 | last_msg_time=last_msg_time, 63 | ) 64 | 65 | 66 | class NormalCfg(ProtoStruct): 67 | int_cfg: dict = proto_field(1, default=None) # dict[int, int] 68 | 69 | 70 | class CurrentAppState(ProtoStruct): 71 | is_delay_request: bool = proto_field(1) 72 | app_state: int = proto_field(2) 73 | silence_state: int = proto_field(3) 74 | 75 | @classmethod 76 | def build(cls) -> "CurrentAppState": 77 | return cls( 78 | is_delay_request=False, 79 | app_state=0, 80 | silence_state=0, 81 | ) 82 | 83 | 84 | class UnknownInfo(ProtoStruct): 85 | grp_code: int = proto_field(1, default=0) 86 | f2: int = proto_field(2, default=2) 87 | 88 | 89 | class PBSsoInfoSyncRequest(ProtoStruct): 90 | sync_flag: int = proto_field(1) 91 | req_rand: int = proto_field(2) 92 | current_active_stats: int = proto_field(4) 93 | grp_last_msg_time: int = proto_field(5) 94 | c2c_info: SsoC2cInfo = proto_field(6) 95 | normal_cfg: NormalCfg = proto_field(8) 96 | register_info: PBRegisterRequest = proto_field(9) 97 | unknown_f10: UnknownInfo = proto_field(10) 98 | app_state: CurrentAppState = proto_field(11) 99 | 100 | @classmethod 101 | def build(cls, app: AppInfo, device: DeviceInfo) -> "PBSsoInfoSyncRequest": 102 | return cls( 103 | sync_flag=735, 104 | req_rand=random.randint(114, 514), # ? 105 | current_active_stats=2, 106 | grp_last_msg_time=0, 107 | c2c_info=SsoC2cInfo.build(), 108 | normal_cfg=NormalCfg(int_cfg=dict()), 109 | register_info=PBRegisterRequest.build(app, device), 110 | unknown_f10=UnknownInfo(), 111 | app_state=CurrentAppState.build() 112 | ) 113 | 114 | 115 | class PBSsoInfoSyncResponse(ProtoStruct): 116 | # f3: int = proto_field(3) 117 | # f4: int = proto_field(4) 118 | # f6: int = proto_field(6) 119 | reg_rsp: PBRegisterResponse = proto_field(7) 120 | # f9: int = proto_field(9) 121 | 122 | 123 | # trpc.msg.register_proxy.RegisterProxy.InfoSyncPush: From Server 124 | class InfoSyncPushGrpInfo(ProtoStruct): 125 | grp_id: int = proto_field(1) 126 | last_msg_seq: int = proto_field(2) 127 | last_msg_seq_read: int = proto_field(3) # bot最后一次标记已读 128 | f4: int = proto_field(4) # 1 129 | last_msg_timestamp: int = proto_field(8, default=0) 130 | grp_name: str = proto_field(9) 131 | last_msg_seq_sent: int = proto_field(10, default=0) # bot最后一次发信 TODO: 可能不太对?确认下 132 | f10: int = proto_field(10, default=None) # u32, unknown 133 | f12: int = proto_field(12, default=None) # 1 134 | f13: int = proto_field(13, default=None) # 1 135 | f14: int = proto_field(14, default=None) # u16? 136 | f15: int = proto_field(15, default=None) # 1 137 | f16: int = proto_field(16, default=None) # u16? 138 | 139 | 140 | class InnerGrpMsg(ProtoStruct): 141 | grp_id: int = proto_field(3) 142 | start_seq: int = proto_field(4) 143 | end_seq: int = proto_field(5) 144 | msgs: list[MsgPushBody] = proto_field(6) # last 30 msgs 145 | last_msg_time: int = proto_field(8) 146 | 147 | 148 | class InfoSyncGrpMsgs(ProtoStruct): 149 | inner: list[InnerGrpMsg] = proto_field(3) 150 | 151 | 152 | class InnerSysEvt(ProtoStruct): 153 | grp_id: int = proto_field(1) 154 | grp_id_str: str = proto_field(2) 155 | last_evt_time: int = proto_field(5) 156 | events: list[MsgPushBody] = proto_field(8) # TODO: parse event (like MsgPush?) 157 | 158 | 159 | # with FriendMessage 160 | class InfoSyncSysEvents(ProtoStruct): 161 | # f3: dict = proto_field(3) # {1: LAST_EVT_TIME} 162 | inner: list[InnerSysEvt] = proto_field(4) 163 | # f5: dict = proto_field(5) # {1: LAST_EVT_TIME} 164 | 165 | 166 | class PBSsoInfoSyncPush(ProtoStruct): 167 | cmd_type: int = proto_field(3) # 5: GrpInfo(f6), 2: HUGE msg push block(f7&f8), 1&4: unknown(empty) 168 | f4: int = proto_field(4) # 393 169 | grp_info: list[InfoSyncPushGrpInfo] = proto_field(6, default=None) 170 | grp_msgs: InfoSyncGrpMsgs = proto_field(7, default=None) 171 | sys_events: InfoSyncSysEvents = proto_field(8, default=None) 172 | 173 | 174 | # trpc.msg.register_proxy.RegisterProxy.PushParams 175 | class PPOnlineDevices(ProtoStruct): 176 | sub_id: int = proto_field(1) 177 | # f2: int = proto_field(2) # 2 178 | # f3: int = proto_field(3) # 1 179 | # f4: int = proto_field(4) # 109 180 | os_name: str = proto_field(5) 181 | # f6:int = proto_field(6) 182 | device_name: str = proto_field(7) 183 | 184 | 185 | class PBServerPushParams(ProtoStruct): 186 | online_devices: list[PPOnlineDevices] = proto_field(4, default_factory=list) 187 | # f6: dict = proto_field(6) # {2: 9} 188 | # f7: str = proto_field(7) # value: ""(empty) 189 | # f8: list[int] = proto_field(8) # multi long int 190 | # f9: int = proto_field(9) # 8640000, 100days 191 | -------------------------------------------------------------------------------- /lagrange/pb/message/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LagrangeDev/lagrange-python/974dfaa546a1fcfbd6461f284a950af4622ca09f/lagrange/pb/message/__init__.py -------------------------------------------------------------------------------- /lagrange/pb/message/heads.py: -------------------------------------------------------------------------------- 1 | from typing import Optional 2 | 3 | from lagrange.utils.binary.protobuf import proto_field, ProtoStruct 4 | 5 | 6 | class ContentHead(ProtoStruct): 7 | type: int = proto_field(1) 8 | sub_type: int = proto_field(2, default=0) 9 | msg_id: int = proto_field(4, default=0) 10 | seq: int = proto_field(5, default=0) 11 | timestamp: int = proto_field(6, default=0) 12 | rand: int = proto_field(7, default=0) 13 | # new_id: int = proto_field(12) 14 | 15 | 16 | class Grp(ProtoStruct): 17 | gid: int = proto_field(1, default=0) 18 | sender_name: str = proto_field(4, default="") # empty in get_grp_msg 19 | grp_name: str = proto_field(7, default="") 20 | 21 | 22 | class ResponseHead(ProtoStruct): 23 | from_uin: int = proto_field(1, default=0) 24 | from_uid: str = proto_field(2, default="") 25 | type: int = proto_field(3, default=0) 26 | sigmap: int = proto_field(4, default=0) 27 | to_uin: int = proto_field(5, default=0) 28 | to_uid: str = proto_field(6, default="") 29 | rsp_grp: Optional[Grp] = proto_field(8, default=None) 30 | -------------------------------------------------------------------------------- /lagrange/pb/message/msg.py: -------------------------------------------------------------------------------- 1 | from typing import Optional 2 | 3 | from lagrange.utils.binary.protobuf import proto_field, ProtoStruct 4 | 5 | from .rich_text import RichText 6 | 7 | 8 | class Message(ProtoStruct): 9 | body: Optional[RichText] = proto_field(1, default=None) 10 | buf2: bytes = proto_field(2, default=b"") 11 | buf3: bytes = proto_field(3, default=b"") 12 | -------------------------------------------------------------------------------- /lagrange/pb/message/msg_push.py: -------------------------------------------------------------------------------- 1 | from typing import Optional 2 | 3 | from lagrange.utils.binary.protobuf import proto_field, ProtoStruct 4 | 5 | from .heads import ContentHead, ResponseHead 6 | from .msg import Message 7 | 8 | 9 | class MsgPushBody(ProtoStruct): 10 | response_head: ResponseHead = proto_field(1) 11 | content_head: ContentHead = proto_field(2) 12 | message: Optional[Message] = proto_field(3, default=None) 13 | 14 | 15 | class MsgPush(ProtoStruct): 16 | body: MsgPushBody = proto_field(1) 17 | -------------------------------------------------------------------------------- /lagrange/pb/message/rich_text/__init__.py: -------------------------------------------------------------------------------- 1 | from typing import Optional 2 | 3 | from lagrange.utils.binary.protobuf import proto_field, ProtoStruct 4 | 5 | from .elems import ( 6 | CommonElem, 7 | CustomFace, 8 | ExtraInfo, 9 | Face, 10 | MarketFace, 11 | MiniApp, 12 | NotOnlineImage, 13 | OnlineImage, 14 | OpenData, 15 | Ptt, 16 | RichMsg, 17 | SrcMsg, 18 | Text, 19 | TransElem, 20 | VideoFile, 21 | GeneralFlags, 22 | ) 23 | 24 | __all__ = ["Elems", "RichText"] 25 | 26 | 27 | class Elems(ProtoStruct, debug=True): 28 | text: Optional[Text] = proto_field(1, default=None) 29 | face: Optional[Face] = proto_field(2, default=None) 30 | online_image: Optional[OnlineImage] = proto_field(3, default=None) 31 | not_online_image: Optional[NotOnlineImage] = proto_field(4, default=None) 32 | trans_elem: Optional[TransElem] = proto_field(5, default=None) 33 | market_face: Optional[MarketFace] = proto_field(6, default=None) 34 | custom_face: Optional[CustomFace] = proto_field(8, default=None) 35 | elem_flags2: Optional[bytes] = proto_field(9, default=None) 36 | rich_msg: Optional[RichMsg] = proto_field(12, default=None) 37 | extra_info: Optional[ExtraInfo] = proto_field(16, default=None) 38 | video_file: Optional[VideoFile] = proto_field(19, default=None) 39 | general_flags: Optional[GeneralFlags] = proto_field(37, default=None) 40 | open_data: Optional[OpenData] = proto_field(41, default=None) 41 | src_msg: Optional[SrcMsg] = proto_field(45, default=None) 42 | mini_app: Optional[MiniApp] = proto_field(51, default=None) 43 | common_elem: Optional[CommonElem] = proto_field(53, default=None) 44 | 45 | 46 | class RichText(ProtoStruct): 47 | attrs: Optional[dict] = proto_field(1, default=None) 48 | content: list[Elems] = proto_field(2) 49 | not_online_file: Optional[dict] = proto_field(3, default=None) 50 | ptt: Optional[Ptt] = proto_field(4, default=None) 51 | -------------------------------------------------------------------------------- /lagrange/pb/message/rich_text/elems.py: -------------------------------------------------------------------------------- 1 | from typing import Optional 2 | 3 | from lagrange.utils.binary.protobuf import proto_field, ProtoStruct 4 | 5 | 6 | class ImageReserveArgs(ProtoStruct): 7 | is_emoji: bool = proto_field(1, default=False) 8 | display_name: str = proto_field(9, default="[图片]") 9 | 10 | 11 | class Ptt(ProtoStruct): 12 | type: int = proto_field(1, default=4) 13 | to_uin: Optional[int] = proto_field(2, default=None) 14 | friend_file_key: Optional[str] = proto_field(3, default=None) 15 | md5: bytes = proto_field(4) 16 | name: str = proto_field(5) 17 | size: int = proto_field(6) 18 | reserved: Optional[bytes] = proto_field(7, default=None) 19 | file_id: Optional[int] = proto_field(8, default=None) # available on grp msg 20 | is_valid: bool = proto_field(11, default=True) 21 | group_file_key: Optional[str] = proto_field(18, default=None) 22 | time: int = proto_field(19) 23 | format: int = proto_field(29, default=1) 24 | pb_reserved: dict = proto_field(30, default={1: 0}) 25 | 26 | 27 | class Text(ProtoStruct): 28 | string: str = proto_field(1, default="") 29 | # link: str = proto_field(2, default="") 30 | attr6_buf: Optional[bytes] = proto_field(3, default=None) 31 | # attr7_buf: bytes = proto_field(4, default=bytes()) 32 | # buf: bytes = proto_field(11, default=bytes()) 33 | pb_reserved: Optional[dict] = proto_field(12, default=None) 34 | 35 | 36 | class Face(ProtoStruct): 37 | index: int = proto_field(1) 38 | 39 | 40 | class OnlineImage(ProtoStruct): 41 | guid: bytes = proto_field(1) 42 | file_path: bytes = proto_field(2) 43 | 44 | 45 | class NotOnlineImage(ProtoStruct): 46 | file_path: str = proto_field(1) 47 | file_len: int = proto_field(2) 48 | download_path: str = proto_field(3) 49 | image_type: int = proto_field(5) 50 | # image_preview: bytes = proto_field(6) 51 | file_md5: bytes = proto_field(7) 52 | height: int = proto_field(8) 53 | width: int = proto_field(9) 54 | res_id: str = proto_field(10) 55 | origin_path: Optional[str] = proto_field(15, default=None) 56 | args: ImageReserveArgs = proto_field(34, default_factory=ImageReserveArgs) 57 | 58 | 59 | class TransElem(ProtoStruct): 60 | elem_type: int = proto_field(1) 61 | elem_value: bytes = proto_field(2) 62 | 63 | 64 | class MarketFace(ProtoStruct): 65 | name: str = proto_field(1, default="") 66 | item_type: int = proto_field(2) # 6 67 | face_info: int = proto_field(3) # 1 68 | face_id: bytes = proto_field(4) 69 | tab_id: int = proto_field(5) 70 | sub_type: int = proto_field(6) # 3 71 | key: str = proto_field(7) # hex, length=16 72 | # media_type: int = proto_field(9) 73 | width: int = proto_field(10) 74 | height: int = proto_field(11) 75 | pb_reserved: dict = proto_field(13) 76 | 77 | 78 | class CustomFace(ProtoStruct): 79 | # guid: str = proto_field(1) 80 | file_path: str = proto_field(2) 81 | # shortcut: str = proto_field(3) 82 | fileid: int = proto_field(7) 83 | file_type: int = proto_field(10) 84 | md5: bytes = proto_field(13) 85 | thumb_url: Optional[str] = proto_field(14, default=None) 86 | big_url: Optional[str] = proto_field(15, default=None) 87 | original_url: str = proto_field(16) 88 | # biz_type: int = proto_field(17) 89 | image_type: int = proto_field(20, default=1000) 90 | width: int = proto_field(22) 91 | height: int = proto_field(23) 92 | size: int = proto_field(25) 93 | args: ImageReserveArgs = proto_field(34, default_factory=ImageReserveArgs) 94 | 95 | 96 | class ExtraInfo(ProtoStruct): 97 | nickname: str = proto_field(1, default="") 98 | group_card: str = proto_field(2, default="") 99 | level: int = proto_field(3) 100 | # sender_title: str = proto_field(7) 101 | # uin: int = proto_field(9) 102 | 103 | 104 | class SrcMsgArgs(ProtoStruct): 105 | # new_id: int = proto_field(3, default=None) 106 | uid: Optional[str] = proto_field(6, default=None) 107 | 108 | 109 | class SrcMsg(ProtoStruct): 110 | seq: int = proto_field(1) 111 | uin: int = proto_field(2, default=0) 112 | timestamp: int = proto_field(3) 113 | elems: list[dict] = proto_field(5, default_factory=lambda: [{}]) 114 | pb_reserved: Optional[SrcMsgArgs] = proto_field(8, default=None) 115 | to_uin: int = proto_field(10, default=0) 116 | 117 | 118 | class MiniApp(ProtoStruct): 119 | template: bytes = proto_field(1) 120 | 121 | 122 | class OpenData(ProtoStruct): 123 | data: bytes = proto_field(1) 124 | 125 | 126 | class RichMsg(MiniApp): 127 | service_id: int = proto_field(2) 128 | 129 | 130 | class CommonElem(ProtoStruct): 131 | service_type: int = proto_field(1) 132 | pb_elem: dict = proto_field(2) 133 | bus_type: int = proto_field(3) 134 | 135 | 136 | class VideoFile(ProtoStruct): 137 | id: str = proto_field(1) 138 | video_md5: bytes = proto_field(2) 139 | name: str = proto_field(3) 140 | f4: int = proto_field(4) # 2 141 | length: int = proto_field(5) # 100: mp4 142 | size: int = proto_field(6) 143 | width: int = proto_field(7) 144 | height: int = proto_field(8) 145 | thumb_md5: bytes = proto_field(9) 146 | # thumb_name on field 10? 147 | thumb_size: int = proto_field(11) 148 | thumb_width: int = proto_field(16) 149 | thumb_height: int = proto_field(17) 150 | # reserve on field 24? 151 | 152 | 153 | class NotOnlineFile(ProtoStruct): 154 | file_type: Optional[int] = proto_field(1) 155 | # sig: Optional[bytes] = proto_field(2) 156 | file_uuid: Optional[str] = proto_field(3) 157 | file_md5: Optional[bytes] = proto_field(4) 158 | file_name: Optional[str] = proto_field(5) 159 | file_size: Optional[int] = proto_field(6) 160 | # note: Optional[bytes] = proto_field(7) 161 | # reserved: Optional[int] = proto_field(8) 162 | subcmd: Optional[int] = proto_field(9) 163 | # micro_cloud: Optional[int] = proto_field(10) 164 | # bytes_file_urls: Optional[list[bytes]] = proto_field(11) 165 | # download_flag: Optional[int] = proto_field(12) 166 | danger_evel: Optional[int] = proto_field(50) 167 | # life_time: Optional[int] = proto_field(51) 168 | # upload_time: Optional[int] = proto_field(52) 169 | # abs_file_type: Optional[int] = proto_field(53) 170 | # client_type: Optional[int] = proto_field(54) 171 | expire_time: Optional[int] = proto_field(55) 172 | pb_reserve: bytes = proto_field(56) 173 | file_hash: Optional[str] = proto_field(57) 174 | 175 | 176 | class FileExtra(ProtoStruct): 177 | file: NotOnlineFile = proto_field(1) 178 | 179 | 180 | class GroupFileExtraInfo(ProtoStruct): 181 | bus_id: int = proto_field(1) 182 | file_id: str = proto_field(2) 183 | file_size: int = proto_field(3) 184 | file_name: str = proto_field(4) 185 | f5: int = proto_field(5) 186 | f7: str = proto_field(7) 187 | file_md5: bytes = proto_field(8) 188 | 189 | 190 | class GroupFileExtraInner(ProtoStruct): 191 | info: GroupFileExtraInfo = proto_field(2) 192 | 193 | 194 | class GroupFileExtra(ProtoStruct): 195 | f1: int = proto_field(1) 196 | file_name: str = proto_field(2) 197 | display: str = proto_field(3) 198 | inner: GroupFileExtraInner = proto_field(7) 199 | 200 | 201 | class GreyTipsExtraInfo(ProtoStruct): 202 | typ: int = proto_field(1, default=1) 203 | content: str = proto_field(2) # json 204 | 205 | 206 | class GreyTipsExtra(ProtoStruct): 207 | body: GreyTipsExtraInfo = proto_field(1) 208 | 209 | 210 | class PBGreyTips(ProtoStruct): 211 | grey: Optional[GreyTipsExtra] = proto_field(101, default=None) 212 | 213 | @classmethod 214 | def build(cls, content: str) -> "PBGreyTips": 215 | return cls( 216 | grey=GreyTipsExtra( 217 | body=GreyTipsExtraInfo( 218 | content=content, 219 | ) 220 | ) 221 | ) 222 | 223 | 224 | class GeneralFlags(ProtoStruct): 225 | BubbleDiyTextId: Optional[int] = proto_field(1, default=None) 226 | GroupFlagNew: Optional[int] = proto_field(2, default=None) 227 | Uin: Optional[int] = proto_field(3, default=None) 228 | RpId: Optional[bytes] = proto_field(4, default=None) 229 | PrpFold: Optional[int] = proto_field(5, default=None) 230 | LongTextFlag: Optional[int] = proto_field(6, default=None) 231 | LongTextResId: Optional[str] = proto_field(7, default=None) 232 | GroupType: Optional[int] = proto_field(8, default=None) 233 | ToUinFlag: Optional[int] = proto_field(9, default=None) 234 | GlamourLevel: Optional[int] = proto_field(10, default=None) 235 | MemberLevel: Optional[int] = proto_field(11, default=None) 236 | GroupRankSeq: Optional[int] = proto_field(12, default=None) 237 | OlympicTorch: Optional[int] = proto_field(13, default=None) 238 | BabyqGuideMsgCookie: Optional[bytes] = proto_field(14, default=None) 239 | Uin32ExpertFlag: Optional[int] = proto_field(15, default=None) 240 | BubbleSubId: Optional[int] = proto_field(16, default=None) 241 | PendantId: Optional[int] = proto_field(17, default=None) 242 | RpIndex: Optional[bytes] = proto_field(18, default=None) 243 | PbReserve: Optional[PBGreyTips] = proto_field(19, default=None) 244 | 245 | 246 | # class Markdown(ProtoStruct): 247 | # content: str = proto_field(1) 248 | 249 | 250 | class Permission(ProtoStruct): 251 | type: int = proto_field(1, default=0) 252 | specify_role_ids: Optional[list[str]] = proto_field(2, default=None) 253 | specify_user_ids: Optional[list[str]] = proto_field(3, default=None) 254 | 255 | 256 | class RenderData(ProtoStruct): 257 | label: Optional[str] = proto_field(1, default=None) 258 | visited_label: Optional[str] = proto_field(2, default=None) 259 | style: int = proto_field(3, default=0) 260 | 261 | 262 | class Action(ProtoStruct): 263 | type: Optional[int] = proto_field(1, default=None) 264 | permission: Optional[Permission] = proto_field(2, default=None) 265 | data: str = proto_field(5) 266 | reply: bool = proto_field(7, default=False) 267 | enter: bool = proto_field(8, default=False) 268 | anchor: Optional[int] = proto_field(9, default=None) 269 | unsupport_tips: Optional[str] = proto_field(4, default=None) 270 | click_limit: Optional[int] = proto_field(3) # deprecated 271 | at_bot_show_channel_list: bool = proto_field(6, default=False) # deprecated 272 | 273 | 274 | class Button(ProtoStruct): 275 | id: Optional[str] = proto_field(1, default=None) 276 | render_data: Optional[RenderData] = proto_field(2, default=None) 277 | action: Optional[Action] = proto_field(3, default=None) 278 | 279 | 280 | class InlineKeyboardRow(ProtoStruct): 281 | buttons: Optional[list[Button]] = proto_field(1, default=None) 282 | 283 | 284 | class InlineKeyboard(ProtoStruct): 285 | rows: list[InlineKeyboardRow] = proto_field(1) 286 | 287 | 288 | class Keyboard(ProtoStruct): 289 | content: Optional[list[InlineKeyboard]] = proto_field(1, default=None) 290 | bot_appid: int = proto_field(2) 291 | 292 | 293 | class PBKeyboard(ProtoStruct): 294 | keyboard: Keyboard = proto_field(1) 295 | -------------------------------------------------------------------------------- /lagrange/pb/message/send.py: -------------------------------------------------------------------------------- 1 | from typing import Optional 2 | 3 | from lagrange.utils.binary.protobuf import proto_field, ProtoStruct 4 | 5 | 6 | class SendMsgRsp(ProtoStruct): 7 | ret_code: int = proto_field(1) 8 | err_msg: str = proto_field(2, default="") 9 | grp_seq: int = proto_field(11, default=0) 10 | timestamp: Optional[int] = proto_field(12, default=None) 11 | private_seq: int = proto_field(14, default=0) 12 | 13 | @property 14 | def seq(self) -> int: 15 | return self.grp_seq or self.private_seq 16 | -------------------------------------------------------------------------------- /lagrange/pb/service/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LagrangeDev/lagrange-python/974dfaa546a1fcfbd6461f284a950af4622ca09f/lagrange/pb/service/__init__.py -------------------------------------------------------------------------------- /lagrange/pb/service/comm.py: -------------------------------------------------------------------------------- 1 | from typing import Optional 2 | from lagrange.utils.binary.protobuf import proto_field, ProtoStruct 3 | 4 | 5 | class SendNudge(ProtoStruct): 6 | """ 7 | retcode == 1005: no permission 8 | """ 9 | 10 | to_dst1: int = proto_field(1) 11 | to_grp: Optional[int] = proto_field(2) 12 | to_uin: Optional[int] = proto_field(5) 13 | field6: int = proto_field(6, default=0) 14 | 15 | 16 | class SendGrpBotHD(ProtoStruct): 17 | bot_id: int = proto_field(3) 18 | seq: int = proto_field(4, default=111111) # nobody care 19 | B_id: str = proto_field(5, default="") # set button_id 20 | B_data: str = proto_field(6, default="") # set button_data 21 | IDD: int = proto_field(7, default=0) 22 | grp_id: int = proto_field(8, default=None) 23 | grp_type: int = proto_field(9, default=0) # 0guild 1grp 2C2C(need grp_id==None) 24 | 25 | 26 | class Propertys(ProtoStruct): 27 | key: str = proto_field(1) 28 | value: bytes = proto_field(2) 29 | 30 | 31 | class GetCookieRsp(ProtoStruct): 32 | urls: list[Propertys] = proto_field(1) 33 | 34 | 35 | class GetClientKeyRsp(ProtoStruct): 36 | f2: int = proto_field(2) 37 | client_key: str = proto_field(3) 38 | expiration: int = proto_field(4) 39 | 40 | # BQMallSvc.TabOpReq 41 | class _TabOpHeader(ProtoStruct): 42 | f1: int = proto_field(1) 43 | f2: int = proto_field(2, default=0) 44 | f20: int = proto_field(20, default=0) 45 | 46 | 47 | class _TabOpReq(ProtoStruct): 48 | face_id: int = proto_field(1) 49 | face_md5: list[str] = proto_field(3) 50 | f6: int = proto_field(6, default=1) 51 | 52 | 53 | class _TabOpRsp(ProtoStruct): 54 | enc_keys: list[str] = proto_field(1) 55 | 56 | 57 | class TabOpReq(_TabOpHeader): 58 | body: _TabOpReq = proto_field(5) 59 | f6: int = proto_field(6, default=1) 60 | version: str = proto_field(7, default="") 61 | 62 | @classmethod 63 | def build(cls, face_id: int, face_md5: list[str]) -> "TabOpReq": 64 | return cls( 65 | f1=3, 66 | f2=3127124559, 67 | version="10.0.22631", 68 | body=_TabOpReq(face_id=face_id, face_md5=face_md5) 69 | ) 70 | 71 | 72 | class TabOpRsp(_TabOpHeader): 73 | body: Optional[_TabOpRsp] = proto_field(5, default=None) 74 | 75 | def keys(self) -> list[str]: 76 | if not self.body: 77 | raise ValueError("invalid arguments") 78 | return self.body.enc_keys 79 | -------------------------------------------------------------------------------- /lagrange/pb/service/friend.py: -------------------------------------------------------------------------------- 1 | from typing import Optional 2 | 3 | from lagrange.utils.binary.protobuf import ProtoStruct, proto_field 4 | 5 | 6 | class FriendProperty(ProtoStruct): 7 | code: int = proto_field(1) 8 | value: Optional[str] = proto_field(2, default=None) 9 | 10 | 11 | class FriendLayer1(ProtoStruct): 12 | properties: list[FriendProperty] = proto_field(2, default=None) 13 | 14 | 15 | class FriendAdditional(ProtoStruct): 16 | type: int = proto_field(1) 17 | layer1: FriendLayer1 = proto_field(2) 18 | 19 | 20 | class FriendInfo(ProtoStruct): 21 | uid: str = proto_field(1) 22 | custom_group: Optional[int] = proto_field(2, default=None) 23 | uin: int = proto_field(3) 24 | additional: list[FriendAdditional] = proto_field(10001) 25 | 26 | 27 | class GetFriendNumbers(ProtoStruct): 28 | f1: list[int] = proto_field(1) 29 | 30 | 31 | class GetFriendBody(ProtoStruct): 32 | type: int = proto_field(1) 33 | f2: GetFriendNumbers = proto_field(2) 34 | 35 | 36 | class GetFriendListUin(ProtoStruct): 37 | uin: int = proto_field(1) 38 | 39 | 40 | class PBGetFriendListRequest(ProtoStruct): 41 | friend_count: int = proto_field(2, default=300) # paging get num 42 | f4: int = proto_field(4, default=0) 43 | next_uin: Optional[GetFriendListUin] = proto_field(5, default=None) 44 | f6: int = proto_field(6, default=1) 45 | f7: int = proto_field(7, default=2147483647) # MaxValue 46 | body: list[GetFriendBody] = proto_field( 47 | 10001, 48 | default_factory=lambda: [ 49 | GetFriendBody(type=1, f2=GetFriendNumbers(f1=[103, 102, 20002, 27394])), 50 | GetFriendBody(type=4, f2=GetFriendNumbers(f1=[100, 101, 102])), 51 | ], 52 | ) 53 | f10002: list[int] = proto_field(10002, default_factory=lambda: [13578, 13579, 13573, 13572, 13568]) 54 | f10003: int = proto_field(10003, default=4051) 55 | """ 56 | * GetFriendNumbers里是要拿到的东西 57 | * 102:个性签名 58 | * 103:备注 59 | * 20002:昵称 60 | * 27394:QID 61 | """ 62 | 63 | 64 | class GetFriendListRsp(ProtoStruct): 65 | next: Optional[GetFriendListUin] = proto_field(2, default=None) 66 | display_friend_count: int = proto_field(3) 67 | timestamp: int = proto_field(6) 68 | self_uin: int = proto_field(7) 69 | friend_list: list[FriendInfo] = proto_field(101) 70 | 71 | 72 | def propertys(properties: list[FriendProperty]): 73 | return {prop.code: prop.value for prop in properties} 74 | -------------------------------------------------------------------------------- /lagrange/pb/service/oidb.py: -------------------------------------------------------------------------------- 1 | from lagrange.utils.binary.protobuf import proto_field, ProtoStruct 2 | 3 | 4 | class OidbRequest(ProtoStruct): 5 | cmd: int = proto_field(1) 6 | sub_cmd: int = proto_field(2) 7 | data: bytes = proto_field(4) 8 | is_uid: bool = proto_field(12, default=False) 9 | 10 | 11 | class OidbResponse(OidbRequest): 12 | ret_code: int = proto_field(3) 13 | err_msg: str = proto_field(5) 14 | -------------------------------------------------------------------------------- /lagrange/pb/status/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LagrangeDev/lagrange-python/974dfaa546a1fcfbd6461f284a950af4622ca09f/lagrange/pb/status/__init__.py -------------------------------------------------------------------------------- /lagrange/pb/status/friend.py: -------------------------------------------------------------------------------- 1 | from lagrange.utils.binary.protobuf import proto_field, ProtoStruct 2 | 3 | class FriendRecallInfo(ProtoStruct): 4 | from_uid: str = proto_field(1) 5 | to_uid: str = proto_field(2) 6 | seq: int = proto_field(3) 7 | new_id: int = proto_field(4) 8 | time: int = proto_field(5) 9 | random: int = proto_field(6) 10 | package_num: int = proto_field(7) 11 | package_index: int = proto_field(8) 12 | div_seq: int = proto_field(9) 13 | 14 | class PBFriendRecall(ProtoStruct): 15 | info: FriendRecallInfo = proto_field(1) 16 | 17 | class FriendRequestInfo(ProtoStruct): 18 | to_uid: str = proto_field(1) 19 | from_uid: str = proto_field(2) 20 | source_new :str = proto_field(5) 21 | verify: str = proto_field(10) # 验证消息:我是... 22 | source: str = proto_field(11) 23 | 24 | class PBFriendRequest(ProtoStruct): 25 | info: FriendRequestInfo = proto_field(1) -------------------------------------------------------------------------------- /lagrange/pb/status/group.py: -------------------------------------------------------------------------------- 1 | """ 2 | Push Events 3 | """ 4 | 5 | from typing import Optional 6 | 7 | from lagrange.utils.binary.protobuf import proto_field, ProtoStruct 8 | 9 | 10 | class MemberChanged(ProtoStruct): 11 | uin: int = proto_field(1) 12 | uid: str = proto_field(3) 13 | exit_type: Optional[int] = proto_field(4, default=None) # 3kick_me, 131kick, 130exit 14 | operator_uid: str = proto_field(5, default="") 15 | join_type: Optional[int] = proto_field(6, default=None) # 6other, 0slef_invite 16 | join_type_new: Optional[int] = proto_field( 17 | 4, default=None 18 | ) # 130 by_other(click url,scan qr,input grpid), 131 by_invite 19 | 20 | 21 | class MemberJoinRequest(ProtoStruct): 22 | """JoinType: Direct(scan qrcode or search grp_id)""" 23 | 24 | grp_id: int = proto_field(1) 25 | uid: str = proto_field(3) 26 | src: int = proto_field(4) 27 | request_field: str = proto_field(5, default="") 28 | field_9: bytes = proto_field(9, default=b"") 29 | 30 | 31 | class InviteInner(ProtoStruct): 32 | grp_id: int = proto_field(1) 33 | uid: str = proto_field(5) 34 | invitor_uid: str = proto_field(6) 35 | 36 | 37 | class InviteInfo(ProtoStruct): 38 | inner: InviteInner = proto_field(1) 39 | 40 | 41 | class MemberInviteRequest(ProtoStruct): 42 | """JoinType: From Friends(share link or others)""" 43 | 44 | cmd: int = proto_field(1) 45 | info: InviteInfo = proto_field(2) 46 | 47 | 48 | class MemberGotTitleBody(ProtoStruct): 49 | string: str = proto_field(2) 50 | f3: int = proto_field(3) 51 | member_uin: int = proto_field(5) 52 | 53 | 54 | class RecallMsgInfo(ProtoStruct): 55 | seq: int = proto_field(1) 56 | time: int = proto_field(2) 57 | rand: int = proto_field(3) 58 | uid: str = proto_field(6) 59 | 60 | 61 | class RecallMsgExtra(ProtoStruct): 62 | suffix: str = proto_field(2, default="") 63 | 64 | 65 | class MemberRecallMsgBody(ProtoStruct): 66 | uid: str = proto_field(1) 67 | info: RecallMsgInfo = proto_field(3) 68 | extra: Optional[RecallMsgExtra] = proto_field(9, default=None) 69 | 70 | 71 | class MemberRecallMsg(ProtoStruct): 72 | body: MemberRecallMsgBody = proto_field(11) 73 | 74 | 75 | class GroupRenamedBody(ProtoStruct): 76 | type: int = proto_field(1) # unknown 77 | grp_name: str = proto_field(2) 78 | 79 | 80 | class GroupReactionMsg(ProtoStruct): 81 | id: int = proto_field(1) 82 | total_operations: int = proto_field(2) 83 | # f3: int = proto_field(3) # 4 84 | 85 | 86 | class GroupReactionDetail(ProtoStruct): 87 | emo_id: str = proto_field(1) # string type Unicode 88 | emo_type: int = proto_field(2) # 1: qq internal emoji, 2: unicode emoji 89 | count: int = proto_field(3, default=0) 90 | send_type: int = proto_field(5) # 1: set, 2: remove 91 | sender_uid: str = proto_field(4) 92 | 93 | 94 | class GroupReactionBody(ProtoStruct): 95 | op_id: int = proto_field(1) 96 | msg: GroupReactionMsg = proto_field(2) 97 | detail: GroupReactionDetail = proto_field(3) 98 | 99 | 100 | class GroupReactionInner(ProtoStruct): 101 | body: GroupReactionBody = proto_field(1) 102 | 103 | 104 | class PBGroupReaction(ProtoStruct): 105 | inner: GroupReactionInner = proto_field(1) 106 | 107 | 108 | class GroupSub16Head(ProtoStruct): 109 | timestamp: int = proto_field(2, default=0) 110 | uin: Optional[int] = proto_field(4, default=None) 111 | body: Optional[bytes] = proto_field(5, default=None) 112 | flag: Optional[int] = proto_field( 113 | 13, default=None 114 | ) # 12: renamed, 6: set special_title, 13: unknown, 35: set reaction, 38: bot add 115 | operator_uid: str = proto_field(21, default="") 116 | f44: Optional[PBGroupReaction] = proto_field(44, default=None) # set reaction only 117 | 118 | 119 | class GroupSub20Head(ProtoStruct): 120 | f1: int = proto_field(1, default=None) # 20 121 | grp_id: int = proto_field(4) 122 | f13: int = proto_field(13) # 19 123 | body: "GroupSub20Body" = proto_field(26) 124 | 125 | 126 | class GroupSub20Body(ProtoStruct): 127 | type: Optional[int] = proto_field(1, default=None) # 12: nudge, 14: group_sign 128 | f2: int = proto_field(2) # 1061 , bot added group:19217 129 | # f3: int = proto_field(3) # 7 130 | # f6: int = proto_field(6) # 1132 131 | attrs: list[dict] = proto_field(7, default_factory=list) 132 | attrs_xml: str = proto_field(8, default=None) 133 | f10: int = proto_field(10) # rand? 134 | 135 | 136 | class PBGroupAlbumUpdateBody(ProtoStruct): 137 | # f1: 6 138 | args: str = proto_field(2) 139 | 140 | 141 | class PBGroupAlbumUpdate(ProtoStruct): 142 | # f1: 38 143 | timestamp: int = proto_field(2) 144 | grp_id: int = proto_field(4) 145 | # f13: 37 146 | body: PBGroupAlbumUpdateBody = proto_field(46) 147 | 148 | 149 | # class InviteInner_what(ProtoStruct): 150 | # f1: int = proto_field(1) # 0 151 | # f3: int = proto_field(3) # 32 152 | # f4: bytes = proto_field(4) 153 | # f5: int = proto_field(5) 154 | # f6: str = proto_field(6) 155 | 156 | 157 | # class InviteInfo_what(ProtoStruct): 158 | # inner: InviteInner_what = proto_field(1) 159 | 160 | 161 | class PBGroupInvite(ProtoStruct): 162 | gid: int = proto_field(1) 163 | f2: int = proto_field(2) # 1 164 | f3: int = proto_field(3) # 4 165 | f4: int = proto_field(4) # 0 166 | invitor_uid: str = proto_field(5) 167 | invite_info: bytes = proto_field(6) 168 | 169 | 170 | class PBSelfJoinInGroup(ProtoStruct): 171 | gid: int = proto_field(1) 172 | f2: int = proto_field(2) 173 | f4: int = proto_field(4) # 0 174 | f6: int = proto_field(6) # 48 175 | f7: str = proto_field(7) 176 | operator_uid: str = proto_field(3) 177 | 178 | 179 | class PBGroupBotAddedBody(ProtoStruct): 180 | grp_id: int = proto_field(1) 181 | bot_uid_1: Optional[str] = proto_field(2, default=None) 182 | bot_uid_2: Optional[str] = proto_field(3, default=None) # f**k tx 183 | flag: int = proto_field(4) 184 | 185 | 186 | class PBGroupBotAdded(ProtoStruct): 187 | # f1: 39 188 | grp_id: int = proto_field(4) 189 | # f13: 38 190 | body: PBGroupBotAddedBody = proto_field(47) 191 | 192 | 193 | class PBGroupGrayTipBody(ProtoStruct): 194 | message: str = proto_field(2) 195 | flag: int = proto_field(3) 196 | 197 | 198 | class PBBotGrayTip(ProtoStruct): 199 | # f1: 1 200 | grp_id: int = proto_field(4) 201 | body: PBGroupGrayTipBody = proto_field(5) 202 | -------------------------------------------------------------------------------- /lagrange/pb/status/kick.py: -------------------------------------------------------------------------------- 1 | from lagrange.utils.binary.protobuf import proto_field, ProtoStruct 2 | 3 | 4 | class KickNT(ProtoStruct): 5 | uin: int = proto_field(1) 6 | tips: str = proto_field(3) 7 | title: str = proto_field(4) 8 | -------------------------------------------------------------------------------- /lagrange/py.typed: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LagrangeDev/lagrange-python/974dfaa546a1fcfbd6461f284a950af4622ca09f/lagrange/py.typed -------------------------------------------------------------------------------- /lagrange/utils/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LagrangeDev/lagrange-python/974dfaa546a1fcfbd6461f284a950af4622ca09f/lagrange/utils/__init__.py -------------------------------------------------------------------------------- /lagrange/utils/audio/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LagrangeDev/lagrange-python/974dfaa546a1fcfbd6461f284a950af4622ca09f/lagrange/utils/audio/__init__.py -------------------------------------------------------------------------------- /lagrange/utils/audio/decoder.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass 2 | from typing import BinaryIO 3 | 4 | from .enum import AudioType 5 | 6 | 7 | @dataclass 8 | class AudioInfo: 9 | type: AudioType 10 | time: float 11 | 12 | @property 13 | def seconds(self) -> int: 14 | return int(self.time) 15 | 16 | 17 | def _decode(f: BinaryIO, *, _f=False) -> AudioInfo: 18 | buf = f.read(1) 19 | if buf != b"\x23": 20 | if not _f: 21 | return _decode(f, _f=True) 22 | else: 23 | raise ValueError("Unknown audio type") 24 | else: 25 | buf += f.read(5) 26 | 27 | if buf == b"#!AMR\n": 28 | size = len(f.read()) 29 | return AudioInfo(AudioType.amr, size / 1607.0) 30 | elif buf == b"#!SILK": 31 | ver = f.read(3) 32 | if ver != b"_V3": 33 | raise ValueError(f"Unsupported silk version: {ver}") 34 | data = f.read() 35 | size = len(data) 36 | 37 | if _f: # txsilk 38 | typ = AudioType.tx_silk 39 | else: 40 | typ = AudioType.silk_v3 41 | 42 | blks = 0 43 | pos = 0 44 | while pos + 2 < size: 45 | length = int.from_bytes(data[pos : pos + 2], byteorder="little") 46 | if length == 0xFFFF: 47 | break 48 | else: 49 | blks += 1 50 | pos += length + 2 51 | 52 | return AudioInfo(typ, blks * 0.02) 53 | else: 54 | raise ValueError(f"Unknown audio type: {buf!r}") 55 | 56 | 57 | def decode(f: BinaryIO) -> AudioInfo: 58 | try: 59 | return _decode(f) 60 | finally: 61 | f.seek(0) 62 | -------------------------------------------------------------------------------- /lagrange/utils/audio/enum.py: -------------------------------------------------------------------------------- 1 | from enum import IntEnum 2 | 3 | 4 | class AudioType(IntEnum): 5 | amr = 0 6 | tx_silk = 1 7 | silk_v3 = 2 8 | -------------------------------------------------------------------------------- /lagrange/utils/binary/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LagrangeDev/lagrange-python/974dfaa546a1fcfbd6461f284a950af4622ca09f/lagrange/utils/binary/__init__.py -------------------------------------------------------------------------------- /lagrange/utils/binary/builder.py: -------------------------------------------------------------------------------- 1 | import struct 2 | from typing import Union 3 | 4 | from typing_extensions import Self, TypeAlias 5 | from typing import Optional 6 | 7 | from lagrange.utils.crypto.tea import qqtea_encrypt 8 | 9 | BYTES_LIKE: TypeAlias = Union[bytes, bytearray, memoryview] 10 | 11 | 12 | class Builder: 13 | def __init__(self, encrypt_key: Optional[bytes] = None): 14 | self._buffer = bytearray() 15 | self._encrypt_key = encrypt_key 16 | 17 | def __iadd__(self, other): 18 | if isinstance(other, (bytes, bytearray, memoryview)): 19 | self._buffer += other 20 | else: 21 | raise TypeError(f"buffer must be bytes or bytearray, not {type(other)}") 22 | 23 | def __len__(self) -> int: 24 | return len(self._buffer) 25 | 26 | @property 27 | def buffer(self) -> bytearray: 28 | return self._buffer 29 | 30 | @property 31 | def data(self) -> bytes: 32 | if self._encrypt_key: 33 | return qqtea_encrypt(self._buffer, self._encrypt_key) 34 | return self.buffer 35 | 36 | def _pack(self, struct_fmt: str, *args) -> Self: 37 | self._buffer += struct.pack(f">{struct_fmt}", *args) 38 | return self 39 | 40 | def pack(self, typ: Optional[int] = None) -> bytes: 41 | if typ is not None: 42 | return struct.pack(">HH", typ, len(self.data)) + self.data 43 | return self.data 44 | 45 | def write_bool(self, v: bool) -> Self: 46 | return self._pack("?", v) 47 | 48 | def write_byte(self, v: int) -> Self: 49 | return self._pack("b", v) 50 | 51 | def write_bytes(self, v: BYTES_LIKE, *, with_length: bool = False) -> Self: 52 | if with_length: 53 | self.write_u16(len(v)) 54 | self._buffer += v 55 | return self 56 | 57 | def write_string(self, s: str) -> Self: 58 | return self.write_bytes(s.encode(), with_length=True) 59 | 60 | def write_struct(self, struct_fmt: str, *args) -> Self: 61 | return self._pack(struct_fmt, *args) 62 | 63 | def write_u8(self, v: int) -> Self: 64 | return self._pack("B", v) 65 | 66 | def write_u16(self, v: int) -> Self: 67 | return self._pack("H", v) 68 | 69 | def write_u32(self, v: int) -> Self: 70 | return self._pack("I", v) 71 | 72 | def write_u64(self, v: int) -> Self: 73 | return self._pack("Q", v) 74 | 75 | def write_i8(self, v: int) -> Self: 76 | return self._pack("b", v) 77 | 78 | def write_i16(self, v: int) -> Self: 79 | return self._pack("h", v) 80 | 81 | def write_i32(self, v: int) -> Self: 82 | return self._pack("i", v) 83 | 84 | def write_i64(self, v: int) -> Self: 85 | return self._pack("q", v) 86 | 87 | def write_float(self, v: float) -> Self: 88 | return self._pack("f", v) 89 | 90 | def write_double(self, v: float) -> Self: 91 | return self._pack("d", v) 92 | 93 | def write_tlv(self, *tlvs: bytes) -> Self: 94 | self.write_u16(len(tlvs)) 95 | for v in tlvs: 96 | self.write_bytes(v) 97 | return self 98 | -------------------------------------------------------------------------------- /lagrange/utils/binary/protobuf/__init__.py: -------------------------------------------------------------------------------- 1 | from .coder import proto_decode, proto_encode 2 | from .models import ProtoField, ProtoStruct, proto_field 3 | 4 | __all__ = ["proto_encode", "proto_decode", "ProtoStruct", "ProtoField", "proto_field"] 5 | -------------------------------------------------------------------------------- /lagrange/utils/binary/protobuf/coder.py: -------------------------------------------------------------------------------- 1 | from typing import Union, TypeVar, TYPE_CHECKING, cast 2 | from collections.abc import Mapping, Sequence 3 | from typing_extensions import Self, TypeAlias 4 | 5 | from lagrange.utils.binary.builder import Builder 6 | from lagrange.utils.binary.reader import Reader 7 | 8 | Proto: TypeAlias = dict[int, "ProtoEncodable"] 9 | LengthDelimited: TypeAlias = Union[str, "Proto", bytes] 10 | ProtoEncodable: TypeAlias = Union[ 11 | int, 12 | float, 13 | bool, 14 | LengthDelimited, 15 | Sequence["ProtoEncodable"], 16 | Mapping[int, "ProtoEncodable"], 17 | ] 18 | TProtoEncodable = TypeVar("TProtoEncodable", bound="ProtoEncodable") 19 | 20 | 21 | class ProtoDecoded: 22 | def __init__(self, proto: Proto): 23 | self.proto = proto 24 | 25 | def __getitem__(self, item: int) -> "ProtoEncodable": 26 | return self.proto[item] 27 | 28 | def into(self, field: Union[int, tuple[int, ...]], tp: type[TProtoEncodable]) -> TProtoEncodable: 29 | if isinstance(field, int): 30 | return self.proto[field] # type: ignore 31 | else: 32 | data = self.proto 33 | for f in field: 34 | data = data[f] # type: ignore 35 | return data # type: ignore 36 | 37 | 38 | class ProtoBuilder(Builder): 39 | def write_varint(self, v: int) -> Self: 40 | if v >= 127: 41 | buffer = bytearray() 42 | 43 | while v > 127: 44 | buffer.append((v & 127) | 128) 45 | v >>= 7 46 | 47 | buffer.append(v) 48 | self.write_bytes(buffer) 49 | else: 50 | self.write_u8(v) 51 | 52 | return self 53 | 54 | def write_length_delimited(self, v: LengthDelimited) -> Self: 55 | if isinstance(v, dict): 56 | v = proto_encode(v) 57 | elif isinstance(v, str): 58 | v = v.encode("utf-8") 59 | 60 | self.write_varint(len(v)).write_bytes(v) 61 | return self 62 | 63 | 64 | class ProtoReader(Reader): 65 | def read_varint(self) -> int: 66 | value = 0 67 | count = 0 68 | 69 | while True: 70 | byte = self.read_u8() 71 | value |= (byte & 127) << (count * 7) 72 | count += 1 73 | 74 | if (byte & 128) <= 0: 75 | break 76 | 77 | return value 78 | 79 | def read_length_delimited(self) -> bytes: 80 | length = self.read_varint() 81 | data = self.read_bytes(length) 82 | if len(data) != length: 83 | raise ValueError("length of data does not match") 84 | return data 85 | 86 | 87 | def _encode(builder: ProtoBuilder, tag: int, value: ProtoEncodable): 88 | if value is None: 89 | return 90 | 91 | if isinstance(value, int): 92 | wire_type = 0 93 | elif isinstance(value, bool): 94 | wire_type = 0 95 | elif isinstance(value, float): 96 | wire_type = 1 97 | elif isinstance(value, (str, bytes, bytearray, dict)): 98 | wire_type = 2 99 | else: 100 | raise Exception("Unsupported wire type in protobuf") 101 | 102 | head = int(tag) << 3 | wire_type 103 | builder.write_varint(head) 104 | 105 | if wire_type == 0: 106 | if isinstance(value, bool): 107 | value = 1 if value else 0 108 | if TYPE_CHECKING: 109 | assert isinstance(value, int) 110 | if value >= 0: 111 | builder.write_varint(value) 112 | else: 113 | raise NotImplementedError 114 | elif wire_type == 1: 115 | raise NotImplementedError 116 | elif wire_type == 2: 117 | if isinstance(value, dict): 118 | value = proto_encode(value) 119 | if TYPE_CHECKING: 120 | value = cast(LengthDelimited, value) 121 | builder.write_length_delimited(value) 122 | else: 123 | raise AssertionError 124 | 125 | 126 | def proto_decode(data: bytes, max_layer=-1) -> ProtoDecoded: 127 | reader = ProtoReader(data) 128 | proto = {} 129 | 130 | while reader.remain > 0: 131 | leaf = reader.read_varint() 132 | tag = leaf >> 3 133 | wire_type = leaf & 0b111 134 | 135 | assert tag > 0, f"Invalid tag: {tag}" 136 | 137 | if wire_type == 0: 138 | value = reader.read_varint() 139 | elif wire_type == 2: 140 | value = reader.read_length_delimited() 141 | 142 | if max_layer != 0 and len(value) > 1: 143 | try: # serialize nested 144 | value = proto_decode(value, max_layer - 1).proto 145 | except Exception: 146 | pass 147 | elif wire_type == 5: 148 | value = reader.read_u32() 149 | else: 150 | raise AssertionError(wire_type) 151 | 152 | if tag in proto: # repeated elem 153 | if not isinstance(proto[tag], list): 154 | proto[tag] = [proto[tag]] 155 | proto[tag].append(value) 156 | else: 157 | proto[tag] = value 158 | 159 | return ProtoDecoded(proto) 160 | 161 | 162 | def proto_encode(proto: Proto) -> bytes: 163 | builder = ProtoBuilder() 164 | 165 | for tag in proto: 166 | value = proto[tag] 167 | 168 | if isinstance(value, list): 169 | for i in value: 170 | _encode(builder, tag, i) 171 | else: 172 | _encode(builder, tag, value) 173 | 174 | return bytes(builder.data) 175 | -------------------------------------------------------------------------------- /lagrange/utils/binary/protobuf/util.py: -------------------------------------------------------------------------------- 1 | import collections.abc 2 | import functools 3 | import operator 4 | import sys 5 | import types 6 | import typing 7 | from typing import ForwardRef, List 8 | import typing_extensions 9 | from typing_extensions import ParamSpec 10 | 11 | _GenericAlias = type(List[int]) 12 | SpecialType = (_GenericAlias, types.GenericAlias) 13 | 14 | if sys.version_info >= (3, 10): 15 | SpecialType += (types.UnionType,) 16 | 17 | if sys.version_info >= (3, 11): 18 | eval_type = typing._eval_type # type: ignore 19 | else: 20 | def _is_param_expr(arg): 21 | return arg is ... or isinstance(arg, (tuple, list, ParamSpec, typing_extensions._ConcatenateGenericAlias)) # type: ignore 22 | 23 | 24 | def _should_unflatten_callable_args(typ, args): 25 | """Internal helper for munging collections.abc.Callable's __args__. 26 | 27 | The canonical representation for a Callable's __args__ flattens the 28 | argument types, see https://github.com/python/cpython/issues/86361. 29 | 30 | For example:: 31 | 32 | >>> import collections.abc 33 | >>> P = ParamSpec('P') 34 | >>> collections.abc.Callable[[int, int], str].__args__ == (int, int, str) 35 | True 36 | >>> collections.abc.Callable[P, str].__args__ == (P, str) 37 | True 38 | 39 | As a result, if we need to reconstruct the Callable from its __args__, 40 | we need to unflatten it. 41 | """ 42 | return ( 43 | typ.__origin__ is collections.abc.Callable 44 | and not (len(args) == 2 and _is_param_expr(args[0])) 45 | ) 46 | 47 | 48 | def eval_type(t, globalns, localns, recursive_guard=frozenset()): 49 | """Evaluate all forward references in the given type t. 50 | 51 | For use of globalns and localns see the docstring for get_type_hints(). 52 | recursive_guard is used to prevent infinite recursion with a recursive 53 | ForwardRef. 54 | """ 55 | if isinstance(t, ForwardRef): 56 | return t._evaluate(globalns, localns, recursive_guard) 57 | if isinstance(t, SpecialType): 58 | if isinstance(t, types.GenericAlias): 59 | args = tuple( 60 | ForwardRef(arg) if isinstance(arg, str) else arg 61 | for arg in t.__args__ 62 | ) 63 | is_unpacked = getattr(t, "__unpacked__", False) 64 | if _should_unflatten_callable_args(t, args): 65 | t = t.__origin__[(args[:-1], args[-1])] # type: ignore 66 | else: 67 | t = t.__origin__[args] # type: ignore 68 | if is_unpacked: 69 | t = typing_extensions.Unpack[t] 70 | ev_args = tuple(eval_type(a, globalns, localns, recursive_guard) for a in t.__args__) # type: ignore 71 | if ev_args == t.__args__: # type: ignore 72 | return t 73 | if isinstance(t, types.GenericAlias): 74 | return types.GenericAlias(t.__origin__, ev_args) 75 | if hasattr(types, "UnionType") and isinstance(t, types.UnionType): # type: ignore 76 | return functools.reduce(operator.or_, ev_args) 77 | return t.copy_with(ev_args) # type: ignore 78 | return t 79 | -------------------------------------------------------------------------------- /lagrange/utils/binary/reader.py: -------------------------------------------------------------------------------- 1 | import struct 2 | from typing import Any, Union 3 | 4 | from typing_extensions import TypeAlias, Literal 5 | 6 | LENGTH_PREFIX = Literal["u8", "u16", "u32", "u64"] 7 | BYTES_LIKE: TypeAlias = Union[bytes, bytearray, memoryview] 8 | 9 | 10 | class Reader: 11 | def __init__(self, buffer: BYTES_LIKE): 12 | if not isinstance(buffer, (bytes, bytearray, memoryview)): 13 | raise TypeError("Invalid data: " + str(buffer)) 14 | self._buffer = buffer 15 | self._pos = 0 16 | 17 | @property 18 | def remain(self) -> int: 19 | return len(self._buffer) - self._pos 20 | 21 | def read_u8(self) -> int: 22 | v = self._buffer[self._pos] 23 | self._pos += 1 24 | return v 25 | 26 | def read_u16(self) -> int: 27 | v = self._buffer[self._pos : self._pos + 2] 28 | self._pos += 2 29 | return struct.unpack(">H", v)[0] 30 | 31 | def read_u32(self) -> int: 32 | v = self._buffer[self._pos : self._pos + 4] 33 | self._pos += 4 34 | return struct.unpack(">I", v)[0] 35 | 36 | def read_u64(self) -> int: 37 | v = self._buffer[self._pos : self._pos + 8] 38 | self._pos += 8 39 | return struct.unpack(">Q", v)[0] 40 | 41 | def read_struct(self, format: str) -> tuple[Any, ...]: 42 | size = struct.calcsize(format) 43 | v = self._buffer[self._pos : self._pos + size] 44 | self._pos += size 45 | return struct.unpack(format, v) 46 | 47 | def read_bytes(self, length: int) -> bytes: 48 | v = self._buffer[self._pos : self._pos + length] 49 | self._pos += length 50 | return v 51 | 52 | def read_string(self, length: int) -> str: 53 | return self.read_bytes(length).decode("utf-8") 54 | 55 | def read_bytes_with_length(self, prefix: LENGTH_PREFIX, with_prefix=True) -> bytes: 56 | if with_prefix: 57 | if prefix == "u8": 58 | length = self.read_u8() - 1 59 | elif prefix == "u16": 60 | length = self.read_u16() - 2 61 | elif prefix == "u32": 62 | length = self.read_u32() - 4 63 | else: 64 | length = self.read_u64() - 8 65 | else: 66 | if prefix == "u8": 67 | length = self.read_u8() 68 | elif prefix == "u16": 69 | length = self.read_u16() 70 | elif prefix == "u32": 71 | length = self.read_u32() 72 | else: 73 | length = self.read_u64() 74 | v = self._buffer[self._pos : self._pos + length] 75 | self._pos += length 76 | return v 77 | 78 | def read_string_with_length(self, prefix: LENGTH_PREFIX, with_prefix=True) -> str: 79 | return self.read_bytes_with_length(prefix, with_prefix).decode("utf-8") 80 | 81 | def read_tlv(self) -> dict[int, bytes]: 82 | result = {} 83 | count = self.read_u16() 84 | 85 | for i in range(count): 86 | tag = self.read_u16() 87 | result[tag] = self.read_bytes(self.read_u16()) 88 | 89 | return result 90 | -------------------------------------------------------------------------------- /lagrange/utils/crypto/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LagrangeDev/lagrange-python/974dfaa546a1fcfbd6461f284a950af4622ca09f/lagrange/utils/crypto/__init__.py -------------------------------------------------------------------------------- /lagrange/utils/crypto/aes.py: -------------------------------------------------------------------------------- 1 | import secrets 2 | 3 | from cryptography.hazmat.primitives.ciphers.aead import AESGCM 4 | 5 | 6 | def aes_gcm_encrypt(data: bytes, key: bytes) -> bytes: 7 | nonce = secrets.token_bytes(12) 8 | 9 | cipher = AESGCM(key) 10 | return nonce + cipher.encrypt(nonce, data, None) 11 | 12 | 13 | def aes_gcm_decrypt(data: bytes, key: bytes) -> bytes: 14 | nonce = data[:12] 15 | cipher = AESGCM(key) 16 | return cipher.decrypt(nonce, data[12:], None) 17 | 18 | 19 | __all__ = ["aes_gcm_encrypt", "aes_gcm_decrypt"] 20 | -------------------------------------------------------------------------------- /lagrange/utils/crypto/ecdh/__init__.py: -------------------------------------------------------------------------------- 1 | from .impl import ecdh 2 | 3 | __all__ = ["ecdh"] 4 | -------------------------------------------------------------------------------- /lagrange/utils/crypto/ecdh/curve.py: -------------------------------------------------------------------------------- 1 | from .point import EllipticPoint 2 | 3 | 4 | class EllipticCurve: 5 | def __init__( 6 | self, 7 | P: int, 8 | A: int, 9 | B: int, 10 | G: EllipticPoint, 11 | N: int, 12 | H: int, 13 | size: int, 14 | pack_size: int, 15 | ): 16 | self._P = P 17 | self._A = A 18 | self._B = B 19 | self._G = G 20 | self._N = N 21 | self._H = H 22 | self._size = size 23 | self._pack_size = pack_size 24 | 25 | @property 26 | def P(self) -> int: 27 | return self._P 28 | 29 | @property 30 | def A(self) -> int: 31 | return self._A 32 | 33 | @property 34 | def B(self) -> int: 35 | return self._B 36 | 37 | @property 38 | def G(self) -> EllipticPoint: 39 | return self._G 40 | 41 | @property 42 | def N(self) -> int: 43 | return self._N 44 | 45 | @property 46 | def size(self) -> int: 47 | return self._size 48 | 49 | @property 50 | def pack_size(self) -> int: 51 | return self._pack_size 52 | 53 | def check_on(self, point: EllipticPoint) -> bool: 54 | return ( 55 | pow(point.y, 2) - pow(point.x, 3) - self._A * point.x - self._B 56 | ) % self._P == 0 57 | 58 | 59 | CURVE = { 60 | "secp192k1": EllipticCurve( 61 | 0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFEFFFFEE37, 62 | 0, 63 | 3, 64 | EllipticPoint( 65 | 0xDB4FF10EC057E9AE26B07D0280B7F4341DA5D1B1EAE06C7D, 66 | 0x9B2F2F6D9C5628A7844163D015BE86344082AA88D95E2F9D, 67 | ), 68 | 0xFFFFFFFFFFFFFFFFFFFFFFFE26F2FC170F69466A74DEFD8D, 69 | 1, 70 | 24, 71 | 24, 72 | ), 73 | "prime256v1": EllipticCurve( 74 | 0xFFFFFFFF00000001000000000000000000000000FFFFFFFFFFFFFFFFFFFFFFFF, 75 | 0xFFFFFFFF00000001000000000000000000000000FFFFFFFFFFFFFFFFFFFFFFFC, 76 | 0x5AC635D8AA3A93E7B3EBBD55769886BC651D06B0CC53B0F63BCE3C3E27D2604B, 77 | EllipticPoint( 78 | 0x6B17D1F2E12C4247F8BCE6E563A440F277037D812DEB33A0F4A13945D898C296, 79 | 0x4FE342E2FE1A7F9B8EE7EB4A7C0F9E162BCE33576B315ECECBB6406837BF51F5, 80 | ), 81 | 0xFFFFFFFF00000000FFFFFFFFFFFFFFFFBCE6FAADA7179E84F3B9CAC2FC632551, 82 | 1, 83 | 32, 84 | 16, 85 | ), 86 | } 87 | -------------------------------------------------------------------------------- /lagrange/utils/crypto/ecdh/ecdh.py: -------------------------------------------------------------------------------- 1 | import hashlib 2 | import math 3 | import random 4 | 5 | from .curve import EllipticCurve, EllipticPoint 6 | 7 | 8 | class ECDHProvider: 9 | def __init__(self, curve: EllipticCurve): 10 | self._curve = curve 11 | self._secret = self._create_secret() 12 | self._public = self._create_public(self._secret) 13 | 14 | def key_exchange(self, bob_pub: bytes, hashed: bool) -> bytes: 15 | unpacked = self.unpack_public(bob_pub) 16 | shared = self._create_shared(self._secret, unpacked) 17 | return self._pack_shared(shared, hashed) 18 | 19 | def unpack_public(self, pub: bytes) -> EllipticPoint: 20 | length = len(pub) 21 | if length != self._curve.size * 2 + 1 and length != self._curve.size + 1: 22 | raise AssertionError("Length of public key does not match") 23 | 24 | x = bytes(1) + pub[1 : self._curve.size + 1] 25 | if pub[0] == 0x04: # uncompressed 26 | y = bytes(1) + pub[self._curve.size + 1 : self._curve.size * 2 + 1] 27 | return EllipticPoint(int.from_bytes(x, "big"), int.from_bytes(y, "big")) 28 | 29 | px = int.from_bytes(x, "big") 30 | x_3 = pow(px, 3) % self._curve.P 31 | ax = px * self._curve.P 32 | right = (x_3 + ax + self._curve.B) % self._curve.P 33 | 34 | tmp = (self._curve.P + 1) >> 2 35 | py = pow(right, tmp, self._curve.P) 36 | 37 | if py % 2 == 0: 38 | tmp = self._curve.P 39 | tmp -= py 40 | py = tmp 41 | 42 | return EllipticPoint(px, py) 43 | 44 | def pack_public(self, compress: bool) -> bytes: 45 | if compress: 46 | result = bytearray(self._public.x.to_bytes(self._curve.size, "big")) 47 | result = bytearray(1) + result 48 | result[0] = ( 49 | 0x02 50 | if (((self._public.y % 2) == 0) ^ ((self._public.y > 0) < 0)) 51 | else 0x03 52 | ) 53 | return result 54 | 55 | x = self._public.x.to_bytes(self._curve.size, "big") 56 | y = self._public.y.to_bytes(self._curve.size, "big") 57 | result = bytearray(1) + x + y 58 | result[0] = 0x04 59 | return bytes(result) 60 | 61 | def _pack_shared(self, shared: EllipticPoint, hashed: bool) -> bytes: 62 | x = shared.x.to_bytes(self._curve.size, "big") 63 | if hashed: 64 | x = hashlib.md5(x[0 : self._curve.pack_size]).digest() 65 | return x 66 | 67 | def _create_public(self, sec: int) -> EllipticPoint: 68 | return self._create_shared(sec, self._curve.G) 69 | 70 | def _create_secret(self) -> int: 71 | result = 0 72 | 73 | while result < 1 or result >= self._curve.N: 74 | buffer = bytearray(random.Random().randbytes(self._curve.size + 1)) 75 | buffer[self._curve.size] = 0 76 | result = int.from_bytes(buffer, "little") 77 | 78 | return result 79 | 80 | def _create_shared(self, sec: int, pub: EllipticPoint) -> EllipticPoint: 81 | if sec % self._curve.N == 0 or pub.is_default: 82 | return EllipticPoint(0, 0) # default 83 | if sec < 0: 84 | self._create_shared(-sec, -pub) 85 | 86 | if not self._curve.check_on(pub): 87 | raise AssertionError("Incorrect public key") 88 | 89 | pr = EllipticPoint(0, 0) 90 | pa = pub 91 | while sec > 0: 92 | if (sec & 1) > 0: 93 | pr = _point_add(self._curve, pr, pa) 94 | 95 | pa = _point_add(self._curve, pa, pa) 96 | sec >>= 1 97 | 98 | if not self._curve.check_on(pr): 99 | raise AssertionError("Incorrect result assertion") 100 | return pr 101 | 102 | 103 | def _point_add( 104 | curve: EllipticCurve, p1: EllipticPoint, p2: EllipticPoint 105 | ) -> EllipticPoint: 106 | if p1.is_default: 107 | return p2 108 | if p2.is_default: 109 | return p1 110 | if not (curve.check_on(p1) and curve.check_on(p2)): 111 | raise AssertionError("Points is not on the curve") 112 | 113 | if p1.x == p2.x: 114 | if p1.y == p2.y: 115 | m = (3 * p1.x * p1.x + curve.A) * _mod_inverse(p1.y << 1, curve.P) 116 | else: 117 | return EllipticPoint(0, 0) # default 118 | else: 119 | m = (p1.y - p2.y) * _mod_inverse(p1.x - p2.x, curve.P) 120 | 121 | xr = _mod(m * m - p1.x - p2.x, curve.P) 122 | yr = _mod(m * (p1.x - xr) - p1.y, curve.P) 123 | pr = EllipticPoint(xr, yr) 124 | 125 | if not curve.check_on(pr): 126 | raise AssertionError("Result point is not on the curve") 127 | return pr 128 | 129 | 130 | def _mod(a: int, b: int) -> int: 131 | result = a % b 132 | if result < 0: 133 | result += b 134 | return result 135 | 136 | 137 | def _mod_inverse(a: int, p: int) -> int: 138 | if a < 0: 139 | return p - _mod_inverse(-a, p) 140 | 141 | g = math.gcd(a, p) 142 | if g != 1: 143 | raise AssertionError("Inverse does not exist.") 144 | 145 | return pow(a, p - 2, p) 146 | -------------------------------------------------------------------------------- /lagrange/utils/crypto/ecdh/impl.py: -------------------------------------------------------------------------------- 1 | from .curve import CURVE 2 | from .ecdh import ECDHProvider 3 | 4 | ECDH_PRIME_PUBLIC = bytes.fromhex( 5 | "04" 6 | "9D1423332735980EDABE7E9EA451B3395B6F35250DB8FC56F25889F628CBAE3E" 7 | "8E73077914071EEEBC108F4E0170057792BB17AA303AF652313D17C1AC815E79" 8 | ) 9 | ECDH_SECP_PUBLIC = bytes.fromhex( 10 | "04" 11 | "928D8850673088B343264E0C6BACB8496D697799F37211DE" 12 | "B25BB73906CB089FEA9639B4E0260498B51A992D50813DA8" 13 | ) 14 | 15 | 16 | class BaseECDH: 17 | _provider: ECDHProvider 18 | _public_key: bytes 19 | _share_key: bytes 20 | _compress_key: bool 21 | 22 | @property 23 | def public_key(self) -> bytes: 24 | return self._public_key 25 | 26 | @property 27 | def share_key(self) -> bytes: 28 | return self._share_key 29 | 30 | def exchange(self, new_key: bytes): 31 | return self._provider.key_exchange(new_key, self._compress_key) 32 | 33 | 34 | class ECDHPrime(BaseECDH): # exchange key 35 | def __init__(self): 36 | self._provider = ECDHProvider(CURVE["prime256v1"]) 37 | self._public_key = self._provider.pack_public(False) 38 | self._share_key = self._provider.key_exchange(ECDH_PRIME_PUBLIC, False) 39 | self._compress_key = False 40 | 41 | 42 | class ECDHSecp(BaseECDH): # login and others 43 | def __init__(self): 44 | self._provider = ECDHProvider(CURVE["secp192k1"]) 45 | self._public_key = self._provider.pack_public(True) 46 | self._share_key = self._provider.key_exchange(ECDH_SECP_PUBLIC, True) 47 | self._compress_key = True 48 | 49 | 50 | ecdh = {"secp192k1": ECDHSecp(), "prime256v1": ECDHPrime()} 51 | 52 | __all__ = ["ecdh"] 53 | -------------------------------------------------------------------------------- /lagrange/utils/crypto/ecdh/point.py: -------------------------------------------------------------------------------- 1 | class EllipticPoint: 2 | def __init__(self, x: int, y: int): 3 | self._x = x 4 | self._y = y 5 | 6 | def __eq__(self, other) -> bool: 7 | return isinstance(other, EllipticPoint) and self._x == other._x and self._y == other._y 8 | 9 | def __neg__(self): 10 | return EllipticPoint(-self._x, -self._y) 11 | 12 | @property 13 | def x(self) -> int: 14 | return self._x 15 | 16 | @property 17 | def y(self) -> int: 18 | return self._y 19 | 20 | @property 21 | def is_default(self) -> bool: 22 | return self._x == 0 and self._y == 0 23 | -------------------------------------------------------------------------------- /lagrange/utils/crypto/tea.py: -------------------------------------------------------------------------------- 1 | import struct 2 | from typing import Optional 3 | 4 | __all__ = ["qqtea_encrypt", "qqtea_decrypt"] 5 | 6 | 7 | def _xor(a, b): 8 | op = 0xFFFFFFFF 9 | a1, a2 = struct.unpack(b">LL", a[0:8]) 10 | b1, b2 = struct.unpack(b">LL", b[0:8]) 11 | return struct.pack(b">LL", (a1 ^ b1) & op, (a2 ^ b2) & op) 12 | 13 | 14 | def _tea_code(v, k) -> bytes: # 传入8字节数据 16字节key 15 | n = 16 16 | op = 0xFFFFFFFF 17 | delta = 0x9E3779B9 18 | k = struct.unpack(b">LLLL", k[0:16]) 19 | v0, v1 = struct.unpack(b">LL", v[0:8]) 20 | sum_ = 0 21 | for i in range(n): 22 | sum_ += delta 23 | v0 += (op & (v1 << 4)) + k[0] ^ v1 + sum_ ^ (op & (v1 >> 5)) + k[1] 24 | v0 &= op 25 | v1 += (op & (v0 << 4)) + k[2] ^ v0 + sum_ ^ (op & (v0 >> 5)) + k[3] 26 | v1 &= op 27 | r = struct.pack(b">LL", v0, v1) 28 | return r 29 | 30 | 31 | def _tea_decipher(v: bytes, k: bytes) -> bytes: 32 | n = 16 33 | op = 0xFFFFFFFF 34 | v0, v1 = struct.unpack(">LL", v[0:8]) 35 | k0, k1, k2, k3 = struct.unpack(b">LLLL", k[0:16]) 36 | delta = 0x9E3779B9 37 | sum_ = (delta << 4) & op # 左移4位 就是x16 38 | for i in range(n): 39 | v1 -= ((v0 << 4) + k2) ^ (v0 + sum_) ^ ((v0 >> 5) + k3) 40 | v1 &= op 41 | v0 -= ((v1 << 4) + k0) ^ (v1 + sum_) ^ ((v1 >> 5) + k1) 42 | v0 &= op 43 | sum_ -= delta 44 | sum_ &= op 45 | return struct.pack(b">LL", v0, v1) 46 | 47 | 48 | class _TEA: 49 | """ 50 | QQ TEA 加解密, 64比特明码, 128比特密钥 51 | 这是一个确认线程安全的独立加密模块,使用时必须要有一个全局变量secret_key,要求大于等于16位 52 | """ 53 | 54 | def __init__(self, secret_key: bytes): 55 | self.secret_key = secret_key 56 | 57 | @staticmethod 58 | def _preprocess(data: bytes) -> bytes: 59 | data_len = len(data) 60 | filln = (8 - (data_len + 2)) % 8 + 2 61 | fills = bytearray() 62 | for i in range(filln): 63 | fills.append(220) 64 | return bytes([(filln - 2) | 0xF8]) + fills + data + b"\x00" * 7 65 | 66 | def encrypt(self, data: bytes) -> bytes: 67 | data = self._preprocess(data) 68 | tr = b"\0" * 8 69 | to = b"\0" * 8 70 | result = bytearray() 71 | for i in range(0, len(data), 8): 72 | o = _xor(data[i : i + 8], tr) 73 | tr = _xor(_tea_code(o, self.secret_key), to) 74 | to = o 75 | result += tr 76 | return bytes(result) 77 | 78 | def decrypt(self, text: bytes) -> Optional[bytes]: # v不可变 79 | data_len = len(text) 80 | plain = _tea_decipher(text, self.secret_key) 81 | pos = (plain[0] & 0x07) + 2 82 | ret = plain 83 | precrypt = text[0:8] 84 | for i in range(8, data_len, 8): 85 | x = _xor( 86 | _tea_decipher(_xor(text[i : i + 8], plain), self.secret_key), precrypt 87 | ) # 跳过了前8个字节 88 | plain = _xor(x, precrypt) 89 | precrypt = text[i : i + 8] 90 | ret += x 91 | if ret[-7:] != b"\0" * 7: 92 | return None 93 | return ret[pos + 1 : -7] 94 | 95 | 96 | def qqtea_encrypt(data: bytes, key: bytes) -> bytes: 97 | return _TEA(key).encrypt(data) 98 | 99 | 100 | def qqtea_decrypt(data: bytes, key: bytes) -> bytes: 101 | return _TEA(key).decrypt(data) 102 | 103 | 104 | try: 105 | from ftea import TEA as FTEA 106 | 107 | def qqtea_encrypt(data: bytes, key: bytes) -> bytes: 108 | return FTEA(key).encrypt_qq(data) 109 | 110 | def qqtea_decrypt(data: bytes, key: bytes) -> bytes: 111 | return FTEA(key).decrypt_qq(data) 112 | 113 | except ImportError: 114 | # Leave the pure Python version in place. 115 | pass 116 | -------------------------------------------------------------------------------- /lagrange/utils/httpcat.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import gzip 3 | import json 4 | import zlib 5 | from dataclasses import dataclass 6 | from typing import Optional, overload, Literal 7 | from urllib import parse 8 | 9 | from .log import log 10 | 11 | _logger = log.fork("utils.httpcat") 12 | 13 | 14 | @dataclass 15 | class HttpResponse: 16 | code: int 17 | status: str 18 | header: dict[str, str] 19 | body: bytes 20 | cookies: dict[str, str] 21 | 22 | @property 23 | def decompressed_body(self) -> bytes: 24 | if "Content-Encoding" in self.header: 25 | if self.header["Content-Encoding"] == "gzip": 26 | return gzip.decompress(self.body) 27 | elif self.header["Content-Encoding"] == "deflate": 28 | return zlib.decompress(self.body) 29 | else: 30 | raise TypeError( 31 | "Unsuppoted compress type:", self.header["Content-Encoding"] 32 | ) 33 | else: 34 | return self.body 35 | 36 | def json(self, verify_type=True): 37 | if ( 38 | "Content-Type" in self.header 39 | and self.header["Content-Type"].find("application/json") == -1 40 | and verify_type 41 | ): 42 | raise TypeError(self.header.get("Content-Type", "NotSet")) 43 | return json.loads(self.decompressed_body) 44 | 45 | def text(self, encoding="utf-8", errors="strict") -> str: 46 | return self.decompressed_body.decode(encoding, errors) 47 | 48 | 49 | class HttpCat: 50 | def __init__( 51 | self, 52 | host: str, 53 | port: int, 54 | headers: Optional[dict[str, str]] = None, 55 | cookies: Optional[dict[str, str]] = None, 56 | ssl=False, 57 | timeout=5, 58 | ): 59 | self.host = host 60 | self.port = port 61 | self.ssl = ssl 62 | self.header: dict[str, str] = headers or {} 63 | self.cookie: dict[str, str] = cookies or {} 64 | self._reader: Optional[asyncio.StreamReader] = None 65 | self._writer: Optional[asyncio.StreamWriter] = None 66 | self._stop_flag = True 67 | self._timeout = timeout 68 | self.header["Connection"] = "keep-alive" 69 | 70 | @classmethod 71 | def _encode_header( 72 | cls, method: str, path: str, header: dict[str, str], *, protocol="HTTP/1.1" 73 | ) -> bytearray: 74 | ret = bytearray() 75 | ret += f"{method.upper()} {path} {protocol}\r\n".encode() 76 | for k, v in header.items(): 77 | ret += f"{k}: {v}\r\n".encode() 78 | ret += b"\r\n" 79 | return ret 80 | 81 | @staticmethod 82 | async def _read_line(reader: asyncio.StreamReader) -> str: 83 | return (await reader.readline()).rstrip(b"\r\n").decode() 84 | 85 | @staticmethod 86 | def _parse_url(url: str) -> tuple[tuple[str, int], str, bool]: 87 | purl = parse.urlparse(url) 88 | if purl.scheme not in ("http", "https"): 89 | raise ValueError("unsupported scheme:", purl.scheme) 90 | if purl.netloc.find(":") != -1: 91 | host, port = purl.netloc.split(":") 92 | else: 93 | host = purl.netloc 94 | if purl.scheme == "https": 95 | port = 443 96 | else: 97 | port = 80 98 | return ( 99 | (host, int(port)), 100 | parse.quote(purl.path) + ("?" + purl.query if purl.query else ""), 101 | purl.scheme == "https", 102 | ) 103 | 104 | @classmethod 105 | async def _read_all(cls, header: dict, reader: asyncio.StreamReader) -> bytes: 106 | if header.get("Transfer-Encoding") == "chunked": 107 | bs = bytearray() 108 | while True: 109 | len_hex = await cls._read_line(reader) 110 | if len_hex: 111 | length = int(len_hex, 16) 112 | if length: 113 | bs += await reader.readexactly(length) 114 | else: 115 | break 116 | else: 117 | if header.get("Connection") == "close": # cloudflare? 118 | break 119 | raise ConnectionResetError("Connection reset by peer") 120 | return bytes(bs) 121 | elif "Content-Length" in header: 122 | return await reader.readexactly(int(header["Content-Length"])) 123 | else: 124 | return await reader.read() 125 | 126 | @classmethod 127 | async def _parse_response(cls, reader: asyncio.StreamReader) -> HttpResponse: 128 | stat = await cls._read_line(reader) 129 | if not stat: 130 | raise ConnectionResetError 131 | _, code, status = stat.split(" ", 2) 132 | header = {} 133 | cookies = {} 134 | while True: 135 | head_block = await cls._read_line(reader) 136 | if head_block: 137 | k, v = head_block.split(": ") 138 | if k.title() == "Set-Cookie": 139 | name, value = v[: v.find(";")].split("=", 1) 140 | cookies[name] = value 141 | else: 142 | header[k.title()] = v 143 | else: 144 | break 145 | return HttpResponse( 146 | int(code), status, header, await cls._read_all(header, reader), cookies 147 | ) 148 | 149 | @classmethod 150 | @overload 151 | async def _request( 152 | cls, 153 | host: str, 154 | reader: asyncio.StreamReader, 155 | writer: asyncio.StreamWriter, 156 | method: str, 157 | path: str, 158 | header: Optional[dict[str, str]] = None, 159 | body: Optional[bytes] = None, 160 | cookies: Optional[dict[str, str]] = None, 161 | wait_rsp: Literal[True] = True, 162 | loop: Optional[asyncio.AbstractEventLoop] = None, 163 | ) -> HttpResponse: 164 | ... 165 | 166 | @classmethod 167 | @overload 168 | async def _request( 169 | cls, 170 | host: str, 171 | reader: asyncio.StreamReader, 172 | writer: asyncio.StreamWriter, 173 | method: str, 174 | path: str, 175 | header: Optional[dict[str, str]] = None, 176 | body: Optional[bytes] = None, 177 | cookies: Optional[dict[str, str]] = None, 178 | wait_rsp: Literal[False] = False, 179 | loop: Optional[asyncio.AbstractEventLoop] = None, 180 | ) -> None: 181 | ... 182 | 183 | @classmethod 184 | async def _request( 185 | cls, 186 | host: str, 187 | reader: asyncio.StreamReader, 188 | writer: asyncio.StreamWriter, 189 | method: str, 190 | path: str, 191 | header: Optional[dict[str, str]] = None, 192 | body: Optional[bytes] = None, 193 | cookies: Optional[dict[str, str]] = None, 194 | wait_rsp: bool = True, 195 | loop: Optional[asyncio.AbstractEventLoop] = None, 196 | ) -> Optional[HttpResponse]: 197 | if not loop: 198 | loop = asyncio.get_running_loop() 199 | header = { 200 | "Host": host, 201 | "Connection": "close", 202 | "User-Agent": "HttpCat/1.1", 203 | "Accept-Encoding": "gzip, deflate", 204 | "Content-Length": "0" if not body else str(len(body)), 205 | **(header if header else {}), 206 | } 207 | if cookies: 208 | header["Cookie"] = "; ".join([f"{k}={v}" for k, v in cookies.items()]) 209 | 210 | writer.write(cls._encode_header(method, path, header)) 211 | if body: 212 | writer.write(body) 213 | await writer.drain() 214 | 215 | if wait_rsp: 216 | try: 217 | return await cls._parse_response(reader) 218 | finally: 219 | if header["Connection"] == "close": 220 | loop.call_soon(writer.close) 221 | 222 | @classmethod 223 | async def request( 224 | cls, 225 | method: str, 226 | url: str, 227 | header: Optional[dict[str, str]] = None, 228 | body: Optional[bytes] = None, 229 | cookies: Optional[dict[str, str]] = None, 230 | follow_redirect=True, 231 | max_redirect=10, 232 | conn_timeout=0, 233 | loop: Optional[asyncio.AbstractEventLoop] = None, 234 | ) -> HttpResponse: 235 | address, path, ssl = cls._parse_url(url) 236 | if conn_timeout: 237 | reader, writer = await asyncio.wait_for( 238 | asyncio.open_connection(*address, ssl=ssl), conn_timeout 239 | ) 240 | else: 241 | reader, writer = await asyncio.open_connection(*address, ssl=ssl) 242 | resp = await cls._request( 243 | address[0], reader, writer, method, path, header, body, cookies, True, loop 244 | ) 245 | _logger.debug(f"request({method})[{resp.code}]: {url}") 246 | if resp.code // 100 == 3 and follow_redirect and max_redirect > 0: 247 | return await cls.request( 248 | method, resp.header["Location"], header, body, cookies, follow_redirect, max_redirect - 1 249 | ) 250 | else: 251 | return resp 252 | 253 | async def send_request( 254 | self, method: str, path: str, body=None, follow_redirect=True, conn_timeout=0 255 | ) -> HttpResponse: 256 | if self._stop_flag: 257 | raise AssertionError("connection stopped") 258 | if not (self._reader and self._writer): 259 | if conn_timeout: 260 | reader, writer = await asyncio.wait_for( 261 | asyncio.open_connection(self.host, self.port, ssl=self.ssl), 262 | conn_timeout, 263 | ) 264 | else: 265 | reader, writer = await asyncio.open_connection( 266 | self.host, self.port, ssl=self.ssl 267 | ) 268 | self._reader = reader 269 | self._writer = writer 270 | _logger.debug(f"connected to {self.host}:{self.port}") 271 | 272 | resp = await self._request( 273 | self.host, 274 | self._reader, 275 | self._writer, 276 | method, 277 | path, 278 | self.header, 279 | body, 280 | self.cookie, 281 | True, 282 | ) 283 | _logger.debug( 284 | f"send_request({method})[{resp.code}]: http{'s' if self.ssl else ''}://{self.host}:{self.port}{path}" 285 | ) 286 | if resp.cookies: 287 | self.cookie = resp.cookies 288 | if resp.code // 100 == 3 and follow_redirect: 289 | return await self.send_request(method, resp.header["Location"], body) 290 | else: 291 | return resp 292 | 293 | async def __aenter__(self): 294 | self._stop_flag = False 295 | return self 296 | 297 | async def __aexit__(self, exc_type, exc_val, exc_tb): 298 | self._stop_flag = True 299 | if self._reader and self._writer: 300 | self._writer.close() 301 | await self._writer.wait_closed() 302 | self._reader, self._writer = None, None 303 | -------------------------------------------------------------------------------- /lagrange/utils/image/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LagrangeDev/lagrange-python/974dfaa546a1fcfbd6461f284a950af4622ca09f/lagrange/utils/image/__init__.py -------------------------------------------------------------------------------- /lagrange/utils/image/decoder.py: -------------------------------------------------------------------------------- 1 | import binascii 2 | import struct 3 | from dataclasses import dataclass 4 | from typing import BinaryIO 5 | 6 | from .enum import ImageType 7 | 8 | 9 | @dataclass 10 | class ImageInfo: 11 | name: str 12 | width: int 13 | height: int 14 | depth: int 15 | 16 | @property 17 | def pic_type(self) -> ImageType: 18 | return getattr(ImageType, self.name) 19 | 20 | 21 | class BaseDecoder: 22 | @classmethod 23 | def decode(cls, fio: BinaryIO) -> ImageInfo: 24 | raise NotImplementedError 25 | 26 | 27 | class JPEGDecoder(BaseDecoder): 28 | @classmethod 29 | def decode(cls, fio: BinaryIO) -> ImageInfo: 30 | if fio.read(2) != b"\xff\xd8": 31 | raise TypeError("not a valid jpeg file") 32 | while True: 33 | if fio.read(1) != b"\xff": 34 | raise ValueError("decoder fail") 35 | btype = fio.read(1) 36 | data = fio.read(int.from_bytes(fio.read(2), "big") - 2) 37 | if btype[0] in (192, 193, 194): # C0-C2 38 | depth, height, width, _ = struct.unpack("!BHHB", data[:6]) 39 | return ImageInfo("jpg", width, height, depth) 40 | 41 | 42 | class PNGDecoder(BaseDecoder): 43 | @classmethod 44 | def decode(cls, fio: BinaryIO) -> ImageInfo: 45 | if fio.read(8).hex() != "89504e470d0a1a0a": 46 | raise TypeError("not a valid png file") 47 | while True: 48 | raw_head = fio.read(8) 49 | if not raw_head: 50 | break 51 | elif len(raw_head) != 8: 52 | raise ValueError("decoder fail") 53 | length, btype = struct.unpack("!I4s", raw_head) 54 | data = fio.read(length) 55 | if binascii.crc32(raw_head[4:] + data) != int.from_bytes( 56 | fio.read(4), "big" 57 | ): 58 | raise ValueError("CRC not match") 59 | if btype == b"IHDR": 60 | width, height, depth, *_ = struct.unpack("!IIBBBBB", data) 61 | return ImageInfo("png", width, height, depth) 62 | raise ValueError("decoder fail") 63 | 64 | 65 | class GIFDecoder(BaseDecoder): 66 | @classmethod 67 | def decode(cls, fio: BinaryIO) -> ImageInfo: 68 | if fio.read(6) != b"GIF89a": 69 | raise TypeError("not a valid gif file") 70 | width, height, flag, *_ = struct.unpack("> 4) + 2) 72 | 73 | 74 | class BMPDecoder(BaseDecoder): 75 | @classmethod 76 | def decode(cls, fio: BinaryIO) -> ImageInfo: 77 | if fio.read(2) != b"BM": 78 | raise TypeError("not a valid bmp file") 79 | fio.read(12) # offset 80 | data = fio.read(16) 81 | _, width, height, _, depth = struct.unpack(" ImageInfo: 86 | head = f.read(3) 87 | f.seek(0) 88 | try: 89 | if head[:-1] == b"\xff\xd8": 90 | return JPEGDecoder.decode(f) 91 | elif head.hex() == "89504e": 92 | return PNGDecoder.decode(f) 93 | elif head == b"GIF": 94 | return GIFDecoder.decode(f) 95 | elif head[:-1] == b"BM": 96 | return BMPDecoder.decode(f) 97 | else: 98 | raise NotImplementedError 99 | finally: 100 | f.seek(0) 101 | -------------------------------------------------------------------------------- /lagrange/utils/image/enum.py: -------------------------------------------------------------------------------- 1 | from enum import IntEnum 2 | 3 | 4 | class ImageType(IntEnum): 5 | jpg = 1000 6 | png = 1001 7 | webp = 1002 8 | bmp = 1005 9 | gif = 2000 10 | apng = 2001 11 | 12 | @classmethod 13 | def _missing_(cls, value: object): 14 | return cls.jpg 15 | -------------------------------------------------------------------------------- /lagrange/utils/log.py: -------------------------------------------------------------------------------- 1 | import inspect 2 | import logging 3 | import sys 4 | from typing import ClassVar, Callable, Optional, Protocol, Union 5 | 6 | __all__ = ["log", "install_loguru"] 7 | 8 | 9 | class Logger(Protocol): 10 | def info(self, __message: str, *args, **kwargs): ... 11 | 12 | def debug(self, __message: str, *args, **kwargs): ... 13 | 14 | def success(self, __message: str, *args, **kwargs): ... 15 | 16 | def warning(self, __message: str, *args, **kwargs): ... 17 | 18 | def error(self, __message: str, *args, **kwargs): ... 19 | 20 | def critical(self, __message: str, *args, **kwargs): ... 21 | 22 | def exception(self, __message: str, *args, **kwargs): ... 23 | 24 | def set_level(self, level: Union[str, int]): ... 25 | 26 | 27 | class LoggingLoggerProxy: 28 | def __init__(self, logger: logging.Logger): 29 | self._logger = logger 30 | self.info = logger.info 31 | self.debug = logger.debug 32 | self.success = logger.info 33 | self.warning = logger.warning 34 | self.error = logger.error 35 | self.critical = logger.critical 36 | self.exception = logger.exception 37 | self.set_level = logger.setLevel 38 | 39 | 40 | class _Logger: 41 | get_logger: ClassVar[Callable[["_Logger"], Logger]] 42 | 43 | def __init__(self, root, context: Optional[str] = None): 44 | self._root = root 45 | self.context = context or root.name 46 | 47 | def info(self, msg: str, *args, **kwargs): 48 | _Logger.get_logger(self).info(msg, *args, **kwargs) 49 | 50 | def debug(self, msg: str, *args, **kwargs): 51 | _Logger.get_logger(self).debug(msg, *args, **kwargs) 52 | 53 | def success(self, msg: str, *args, **kwargs): 54 | _Logger.get_logger(self).success(msg, *args, **kwargs) 55 | 56 | def warning(self, msg: str, *args, **kwargs): 57 | _Logger.get_logger(self).warning(msg, *args, **kwargs) 58 | 59 | def error(self, msg: str, *args, **kwargs): 60 | _Logger.get_logger(self).error(msg, *args, **kwargs) 61 | 62 | def critical(self, msg: str, *args, **kwargs): 63 | _Logger.get_logger(self).critical(msg, *args, **kwargs) 64 | 65 | def exception(self, msg: str, *args, **kwargs): 66 | _Logger.get_logger(self).exception(msg, *args, **kwargs) 67 | 68 | def set_level(self, level: Union[str, int]): 69 | _Logger.get_logger(self).set_level(level) 70 | 71 | 72 | _Logger.get_logger = lambda self: LoggingLoggerProxy(self._root) 73 | 74 | 75 | class LoggerProvider: 76 | def __init__(self): 77 | logging.basicConfig( 78 | level="INFO", format="%(asctime)s | %(name)s[%(levelname)s]: %(message)s" 79 | ) 80 | self._root = logging.getLogger("lagrange") 81 | self.loggers: dict[str, _Logger] = { 82 | "lagrange": _Logger(self._root), 83 | } 84 | self.fork("login") 85 | self.fork("network") 86 | self.fork("utils") 87 | 88 | def set_level(self, level: Union[str, int]): 89 | logging.basicConfig( 90 | level=level, format="%(asctime)s | %(name)s[%(levelname)s]: %(message)s" 91 | ) 92 | for _, logger in self.loggers.items(): 93 | logger.set_level(level) 94 | 95 | def fork(self, child_name: str): 96 | logger = _Logger(self._root.getChild(child_name)) 97 | self.loggers[logger.context] = logger 98 | return logger 99 | 100 | @property 101 | def root(self) -> _Logger: 102 | return self.loggers["lagrange"] 103 | 104 | @property 105 | def network(self) -> _Logger: 106 | return self.loggers["lagrange.network"] 107 | 108 | @property 109 | def utils(self) -> _Logger: 110 | return self.loggers["lagrange.utils"] 111 | 112 | @property 113 | def login(self) -> _Logger: 114 | return self.loggers["lagrange.login"] 115 | 116 | 117 | log = LoggerProvider() 118 | 119 | 120 | def install_loguru(): 121 | from loguru import logger 122 | 123 | class LoguruHandler(logging.Handler): # pragma: no cover 124 | """logging 与 loguru 之间的桥梁,将 logging 的日志转发到 loguru。""" 125 | 126 | def emit(self, record: logging.LogRecord): 127 | try: 128 | level = logger.level(record.levelname).name 129 | except ValueError: 130 | level = record.levelno 131 | 132 | frame, depth = inspect.currentframe(), 0 133 | while frame and ( 134 | depth == 0 or frame.f_code.co_filename == logging.__file__ 135 | ): 136 | frame = frame.f_back 137 | depth += 1 138 | 139 | logger.opt(depth=depth, exception=record.exc_info).log( 140 | level, record.getMessage() 141 | ) 142 | 143 | logging.basicConfig( 144 | handlers=[LoguruHandler()], 145 | level="INFO", 146 | format="%(asctime)s | %(name)s[%(levelname)s]: %(message)s", 147 | ) 148 | 149 | def default_filter(record): 150 | log_level = record["extra"].get("lagrange_log_level", "INFO") 151 | levelno = ( 152 | logger.level(log_level).no if isinstance(log_level, str) else log_level 153 | ) 154 | return record["level"].no >= levelno 155 | 156 | logger.remove() 157 | logger.add( 158 | sys.stderr, 159 | level=0, 160 | diagnose=True, 161 | backtrace=True, 162 | colorize=True, 163 | filter=default_filter, 164 | format="{time:MM-DD HH:mm:ss} | {level: <8} | {name} | {message}", 165 | ) 166 | 167 | def _config(level: Union[str, int]): 168 | logging.basicConfig( 169 | handlers=[LoguruHandler()], 170 | level=level, 171 | format="%(asctime)s | %(name)s[%(levelname)s]: %(message)s", 172 | ) 173 | logger.configure(extra={"lagrange_log_level": level}) 174 | 175 | log.set_level = _config 176 | 177 | _Logger.get_logger = lambda self: logger.patch( 178 | lambda r: r.update(name=self.context) 179 | ) 180 | -------------------------------------------------------------------------------- /lagrange/utils/network.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import traceback 3 | from typing import Optional 4 | 5 | from .log import log 6 | 7 | _logger = log.fork("network") 8 | 9 | 10 | class Connection: 11 | def __init__( 12 | self, 13 | host: str, 14 | port: int, 15 | ssl: bool = False, 16 | timeout: Optional[float] = 10, 17 | ) -> None: 18 | self._host = host 19 | self._port = port 20 | self._ssl = ssl 21 | self._stop_flag = False 22 | self._stop_ev = asyncio.Event() 23 | self.timeout = timeout 24 | 25 | self._reader: Optional[asyncio.StreamReader] = None 26 | self._writer: Optional[asyncio.StreamWriter] = None 27 | 28 | @property 29 | def host(self) -> str: 30 | return self._host 31 | 32 | @property 33 | def port(self) -> int: 34 | return self._port 35 | 36 | @property 37 | def ssl(self) -> bool: 38 | return self._ssl 39 | 40 | @property 41 | def writer(self) -> asyncio.StreamWriter: 42 | if not self._writer: 43 | raise RuntimeError("Connection closed!") 44 | return self._writer 45 | 46 | @property 47 | def reader(self) -> asyncio.StreamReader: 48 | if not self._reader: 49 | raise RuntimeError("Connection closed!") 50 | return self._reader 51 | 52 | @property 53 | def closed(self) -> bool: 54 | return not (self._reader or self._writer) or self._stop_flag 55 | 56 | async def wait_closed(self) -> None: 57 | await self._stop_ev.wait() 58 | 59 | async def connect(self) -> None: 60 | if self._stop_flag: 61 | raise RuntimeError("Connection already stopped") 62 | self._reader, self._writer = await asyncio.wait_for( 63 | asyncio.open_connection(self.host, self.port, ssl=self.ssl), self.timeout 64 | ) 65 | 66 | async def close(self, force: bool = False): 67 | if self._stop_flag and not force: 68 | return 69 | _logger.debug("Closing connection") 70 | await self.on_close() 71 | self._writer.close() # type: ignore 72 | await self.writer.wait_closed() 73 | self._reader = None 74 | self._writer = None 75 | 76 | async def stop(self): 77 | if self._stop_flag: 78 | return 79 | self._stop_flag = True 80 | await self.close(force=True) 81 | self._stop_ev.set() 82 | 83 | async def _read_loop(self): 84 | try: 85 | while not self.closed: 86 | length = ( 87 | int.from_bytes(await self.reader.readexactly(4), byteorder="big") 88 | - 4 89 | ) 90 | if length: 91 | await self.on_message(length) 92 | else: 93 | await self.close() 94 | except asyncio.CancelledError: 95 | await self.stop() 96 | except Exception as e: 97 | if not await self.on_error(): 98 | raise e 99 | 100 | async def loop(self): 101 | fail = False 102 | while not self._stop_flag: 103 | try: 104 | await self.connect() 105 | fail = False 106 | except (OSError, ConnectionError) as e: 107 | if fail: 108 | _logger.debug(f"connect retry fail: {repr(e)}") 109 | else: 110 | _logger.error("Connect fail, retrying...") 111 | fail = True 112 | await asyncio.sleep(1 if not fail else 5) 113 | continue 114 | await self.on_connected() 115 | await self._read_loop() 116 | 117 | async def on_connected(self): ... 118 | 119 | async def on_close(self): ... 120 | 121 | async def on_message(self, message_length: int): ... 122 | 123 | async def on_error(self) -> bool: 124 | """use sys.exc_info() to catch exceptions""" 125 | traceback.print_exc() 126 | return True 127 | -------------------------------------------------------------------------------- /lagrange/utils/operator.py: -------------------------------------------------------------------------------- 1 | import time 2 | from typing import Any, TypeVar, Union, overload 3 | 4 | T = TypeVar("T") 5 | 6 | 7 | @overload 8 | def unpack_dict(pd: dict, rule: str) -> Any: 9 | ... 10 | 11 | 12 | @overload 13 | def unpack_dict(pd: dict, rule: str, default: T) -> Union[Any, T]: 14 | ... 15 | 16 | 17 | def unpack_dict(pd: dict, rule: str, default: Union[T, None] = None) -> Union[Any, T]: 18 | _pd: Any = pd 19 | for r in rule.split("."): 20 | if isinstance(_pd, list) or (isinstance(_pd, dict) and int(r) in _pd): 21 | _pd = _pd[int(r)] 22 | elif default is not None: 23 | return default 24 | else: 25 | raise KeyError(r) 26 | return _pd 27 | 28 | 29 | def timestamp() -> int: 30 | return int(time.time()) 31 | -------------------------------------------------------------------------------- /lagrange/utils/sign.py: -------------------------------------------------------------------------------- 1 | import time 2 | import json 3 | 4 | from .httpcat import HttpCat 5 | from .log import log 6 | 7 | _logger = log.fork("sign_provider") 8 | 9 | SIGN_PKG_LIST = [ 10 | "trpc.o3.ecdh_access.EcdhAccess.SsoEstablishShareKey", 11 | "trpc.o3.ecdh_access.EcdhAccess.SsoSecureAccess", 12 | "trpc.o3.report.Report.SsoReport", 13 | "MessageSvc.PbSendMsg", 14 | "wtlogin.trans_emp", 15 | "wtlogin.login", 16 | # "trpc.login.ecdh.EcdhService.SsoKeyExchange", 17 | "trpc.login.ecdh.EcdhService.SsoNTLoginPasswordLogin", 18 | "trpc.login.ecdh.EcdhService.SsoNTLoginEasyLogin", 19 | "trpc.login.ecdh.EcdhService.SsoNTLoginPasswordLoginNewDevice", 20 | "trpc.login.ecdh.EcdhService.SsoNTLoginEasyLoginUnusualDevice", 21 | "trpc.login.ecdh.EcdhService.SsoNTLoginPasswordLoginUnusualDevice", 22 | "OidbSvcTrpcTcp.0x11ec_1", 23 | "OidbSvcTrpcTcp.0x758_1", 24 | "OidbSvcTrpcTcp.0x7c2_5", 25 | "OidbSvcTrpcTcp.0x10db_1", 26 | "OidbSvcTrpcTcp.0x8a1_7", 27 | "OidbSvcTrpcTcp.0x89a_0", 28 | "OidbSvcTrpcTcp.0x89a_15", 29 | "OidbSvcTrpcTcp.0x88d_0", 30 | "OidbSvcTrpcTcp.0x88d_14", 31 | "OidbSvcTrpcTcp.0x112a_1", 32 | "OidbSvcTrpcTcp.0x587_74", 33 | "OidbSvcTrpcTcp.0x1100_1", 34 | "OidbSvcTrpcTcp.0x1102_1", 35 | "OidbSvcTrpcTcp.0x1103_1", 36 | "OidbSvcTrpcTcp.0x1107_1", 37 | "OidbSvcTrpcTcp.0x1105_1", 38 | "OidbSvcTrpcTcp.0xf88_1", 39 | "OidbSvcTrpcTcp.0xf89_1", 40 | "OidbSvcTrpcTcp.0xf57_1", 41 | "OidbSvcTrpcTcp.0xf57_106", 42 | "OidbSvcTrpcTcp.0xf57_9", 43 | "OidbSvcTrpcTcp.0xf55_1", 44 | "OidbSvcTrpcTcp.0xf67_1", 45 | "OidbSvcTrpcTcp.0xf67_5", 46 | ] 47 | 48 | 49 | def sign_provider(upstream_url: str): 50 | async def get_sign(cmd: str, seq: int, buf: bytes) -> dict: 51 | if cmd not in SIGN_PKG_LIST: 52 | return {} 53 | 54 | params = {"cmd": cmd, "seq": seq, "src": buf.hex()} 55 | body = json.dumps(params).encode("utf-8") 56 | headers = { 57 | "Content-Type": "application/json" 58 | } 59 | for _ in range(3): 60 | try: 61 | start_time = time.time() 62 | ret = await HttpCat.request("POST", upstream_url, body=body, header=headers) 63 | if ret.code != 200: 64 | raise ConnectionAbortedError(ret.code, ret.body) 65 | _logger.debug( 66 | f"signed for [{cmd}:{seq}]({(time.time() - start_time) * 1000:.2f}ms)" 67 | ) 68 | except Exception: 69 | _logger.exception("Unexpected error on sign request:") 70 | continue 71 | break 72 | else: 73 | raise ConnectionError("Max retries exceeded") 74 | 75 | return ret.json()["value"] 76 | 77 | return get_sign 78 | -------------------------------------------------------------------------------- /lagrange/version.py: -------------------------------------------------------------------------------- 1 | # auto generate, do not modify 2 | 3 | __version__ = "dev" 4 | -------------------------------------------------------------------------------- /main.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import os 3 | 4 | from lagrange import Lagrange, install_loguru 5 | from lagrange.client.client import Client 6 | from lagrange.client.events.group import GroupMessage, GroupSign, GroupReaction 7 | from lagrange.client.events.service import ServerKick 8 | from lagrange.client.message.elems import At, Text, Quote, Emoji 9 | 10 | 11 | async def msg_handler(client: Client, event: GroupMessage): 12 | # print(event) 13 | if event.msg.startswith("114514"): 14 | msg_seq = await client.send_grp_msg( 15 | [At.build(event), Text("1919810")], event.grp_id 16 | ) 17 | await asyncio.sleep(5) 18 | await client.recall_grp_msg(event.grp_id, msg_seq) 19 | elif event.msg.startswith("imgs"): 20 | await client.send_grp_msg( 21 | [ 22 | await client.upload_grp_image( 23 | open("98416427_p0.jpg", "rb"), event.grp_id 24 | ) 25 | ], 26 | event.grp_id, 27 | ) 28 | print(f"{event.nickname}({event.grp_name}): {event.msg}") 29 | 30 | 31 | async def handle_kick(client: "Client", event: "ServerKick"): 32 | print(f"被服务器踢出:[{event.title}] {event.tips}") 33 | await client.stop() 34 | 35 | 36 | async def handle_grp_sign(client: "Client", event: "GroupSign"): 37 | a = "闲着没事爱打卡,可以去找个班上" 38 | k = None 39 | uid = None 40 | while True: 41 | kk = await client.get_grp_members(event.grp_id, k) 42 | for m in kk.body: 43 | if m.account.uin == event.uin: 44 | uid = m.account.uid 45 | break 46 | if uid: 47 | break 48 | if kk.next_key: 49 | k = kk.next_key.decode() 50 | else: 51 | raise ValueError(f"cannot find member: {event.uin}") 52 | 53 | await client.send_grp_msg( 54 | [At(f"@{event.nickname} ", event.uin, uid), Text(a)], event.grp_id 55 | ) 56 | 57 | 58 | async def handle_group_reaction(client: "Client", event: "GroupReaction"): 59 | msg = (await client.get_grp_msg(event.grp_id, event.seq))[0] 60 | mi = (await client.get_grp_member_info(event.grp_id, event.uid)).body[0] 61 | if event.is_emoji: 62 | e = Text(chr(event.emoji_id)) 63 | else: 64 | e = Emoji(event.emoji_id) 65 | if event.is_increase: 66 | m = "给你点了" 67 | else: 68 | m = "取消了" 69 | await client.send_grp_msg( 70 | [Quote.build(msg), Text(f"{mi.name.string if mi.name else mi.nickname}{m}"), e], 71 | event.grp_id, 72 | ) 73 | 74 | 75 | lag = Lagrange( 76 | int(os.environ.get("LAGRANGE_UIN", "0")), 77 | "linux", 78 | os.environ.get("LAGRANGE_SIGN_URL", "") 79 | ) 80 | install_loguru() # optional, for better logging 81 | lag.log.set_level("DEBUG") 82 | 83 | lag.subscribe(GroupMessage, msg_handler) 84 | lag.subscribe(ServerKick, handle_kick) 85 | lag.subscribe(GroupSign, handle_grp_sign) 86 | lag.subscribe(GroupReaction, handle_group_reaction) 87 | 88 | 89 | lag.launch() 90 | -------------------------------------------------------------------------------- /pdm_build.py: -------------------------------------------------------------------------------- 1 | """ 2 | PDM Build Script 3 | auto bump version to lagrange/version.py 4 | 2024/3/18 5 | """ 6 | 7 | import subprocess 8 | import shutil 9 | 10 | from pdm.backend.hooks import Context 11 | 12 | git = shutil.which("git") 13 | rev = "" 14 | 15 | 16 | def pdm_build_hook_enabled(_context: Context) -> bool: 17 | global rev 18 | if not git: 19 | return False 20 | with subprocess.Popen( 21 | [git, "rev-parse", "--short", "HEAD"], 22 | stdout=subprocess.PIPE, 23 | ) as proc: 24 | rev = proc.stdout.read().strip().decode() 25 | if rev is None: 26 | return False 27 | return True 28 | 29 | 30 | def pdm_build_initialize(context: Context): 31 | ver = context.config.metadata.get("version") 32 | with open("lagrange/version.py", "w") as f: 33 | if ver is None: 34 | ver = "0.0.0" 35 | f.write(f"__version__ = '{ver}{'-' if rev else ''}{rev}'\n") 36 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [project] 2 | name = "lagrange-python" 3 | version = "0.1.7" 4 | description = "An Python Implementation of NTQQ PC Protocol" 5 | authors = [ 6 | {name="linwenxuan05"}, 7 | {name="wyapx"}, 8 | ] 9 | dependencies = [ 10 | "typing-extensions>=4.7.0", 11 | "cryptography>=40.0.0", 12 | "qrcode>=7.4.2", 13 | ] 14 | keywords = ["QQ", "Tencent", "NTQQ", "Framework", "Bot", "asyncio"] 15 | classifiers = [ 16 | "Development Status :: 3 - Alpha", 17 | "Framework :: AsyncIO", 18 | "Operating System :: OS Independent", 19 | "License :: OSI Approved :: GNU Lesser General Public License v2 or later (LGPLv2+)", 20 | "Programming Language :: Python", 21 | "Programming Language :: Python :: 3.9", 22 | "Programming Language :: Python :: 3.10", 23 | "Programming Language :: Python :: 3.11", 24 | "Programming Language :: Python :: 3.12", 25 | "Programming Language :: Python :: Implementation :: CPython", 26 | ] 27 | requires-python = ">=3.9" 28 | 29 | [project.optional-dependencies] 30 | faster = ["ftea>=0.1.8"] 31 | loguru = [ 32 | "loguru>=0.7.2", 33 | ] 34 | 35 | [tool.pdm] 36 | distribution = true 37 | 38 | [tool.pdm.build] 39 | includes = ["lagrange"] 40 | 41 | [tool.pdm.dev-dependencies] 42 | dev = [ 43 | "loguru>=0.7.2", 44 | ] 45 | [build-system] 46 | requires = ["pdm-backend"] 47 | build-backend = "pdm.backend" 48 | 49 | [tool.ruff] 50 | line-length = 120 51 | target-version = "py39" 52 | exclude = ["pdm_build.py"] 53 | 54 | [tool.ruff.lint] 55 | select = ["E", "W", "F", "UP", "C", "T", "Q"] 56 | ignore = ["E402", "F403", "F405", "C901", "UP037"] 57 | 58 | [tool.pyright] 59 | pythonPlatform = "All" 60 | pythonVersion = "3.9" 61 | typeCheckingMode = "basic" 62 | reportShadowedImports = false 63 | disableBytesTypePromotions = true 64 | --------------------------------------------------------------------------------