├── .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 |
--------------------------------------------------------------------------------