agim 0.0.1__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- agim-0.0.1/.gitignore +24 -0
- agim-0.0.1/PKG-INFO +64 -0
- agim-0.0.1/README.md +53 -0
- agim-0.0.1/pyproject.toml +44 -0
- agim-0.0.1/src/agim/__init__.py +45 -0
- agim-0.0.1/src/agim/_compat.py +32 -0
- agim-0.0.1/src/agim/client.py +36 -0
- agim-0.0.1/src/agim/errors.py +13 -0
- agim-0.0.1/src/agim/log.py +19 -0
- agim-0.0.1/src/agim/models.py +146 -0
- agim-0.0.1/src/agim/wechat/__init__.py +17 -0
- agim-0.0.1/src/agim/wechat/auth.py +141 -0
- agim-0.0.1/src/agim/wechat/client.py +318 -0
- agim-0.0.1/src/agim/wechat/media.py +288 -0
- agim-0.0.1/tests/test_wechat.py +508 -0
- agim-0.0.1/uv.lock +611 -0
agim-0.0.1/.gitignore
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
**/.venv/
|
|
2
|
+
backend/__pycache__/
|
|
3
|
+
**/__pycache__/
|
|
4
|
+
*.py[cod]
|
|
5
|
+
frontend/node_modules/
|
|
6
|
+
docs/node_modules/
|
|
7
|
+
# VitePress build scratch space.
|
|
8
|
+
docs/.vitepress/cache/
|
|
9
|
+
docs/.vitepress/.temp/
|
|
10
|
+
zett3rd/chrome-extension/node_modules/
|
|
11
|
+
zett3rd/zettelekasten-chatgpt/node_modules/
|
|
12
|
+
zett3rd/zettelekasten-chatgpt/dist/
|
|
13
|
+
frontend/dist/
|
|
14
|
+
dist/
|
|
15
|
+
backend/zett/static/
|
|
16
|
+
site/
|
|
17
|
+
data/*.db
|
|
18
|
+
.env
|
|
19
|
+
.vscode/settings.json
|
|
20
|
+
backend/.zett-tool-output/
|
|
21
|
+
# Profiling output written next to the package, e.g. zett/profile/result.json.
|
|
22
|
+
backend/zett/profile/
|
|
23
|
+
**/.pytest_cache/
|
|
24
|
+
**/.ruff_cache/
|
agim-0.0.1/PKG-INFO
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: agim
|
|
3
|
+
Version: 0.0.1
|
|
4
|
+
Summary: Standalone plugin toolkit for IM platform login, receive, and send
|
|
5
|
+
Requires-Python: >=3.10
|
|
6
|
+
Requires-Dist: cryptography<45,>=43
|
|
7
|
+
Requires-Dist: httpx<1,>=0.27
|
|
8
|
+
Requires-Dist: pydantic<2.13,>=2.12
|
|
9
|
+
Requires-Dist: typing-extensions<5,>=4.12; python_version < '3.12'
|
|
10
|
+
Description-Content-Type: text/markdown
|
|
11
|
+
|
|
12
|
+
# agim
|
|
13
|
+
|
|
14
|
+
One stateless interface for IM login, receive, and send. agim is a plain SDK:
|
|
15
|
+
it owns no storage, no plugin registry, and no host concepts. Callers persist
|
|
16
|
+
the login handshake and receive cursor themselves and hand them back on the
|
|
17
|
+
next call.
|
|
18
|
+
|
|
19
|
+
## Interface
|
|
20
|
+
|
|
21
|
+
```python
|
|
22
|
+
class IMClient(ABC):
|
|
23
|
+
async def login(self) -> LoginHandshake: ...
|
|
24
|
+
async def is_login(self, handshake: LoginHandshake) -> LoginState: ...
|
|
25
|
+
async def receive(self, cursor: str | None = None) -> ReceiveResult: ...
|
|
26
|
+
async def send(self, chat_id: str, text: str, *, context_token: str | None = None) -> None: ...
|
|
27
|
+
async def aclose(self) -> None: ...
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
Because nothing is stored inside agim:
|
|
31
|
+
|
|
32
|
+
- `login()` returns the QR payload **and** the provider state needed to poll it.
|
|
33
|
+
Persist the whole `LoginHandshake`, then pass it back to `is_login()`.
|
|
34
|
+
- `receive()` returns the message **and** the next cursor. Persist the cursor,
|
|
35
|
+
then pass it to the next `receive()`.
|
|
36
|
+
- `send()` takes the context token the caller stored from the inbound message.
|
|
37
|
+
|
|
38
|
+
One `getupdates` poll can carry several messages. `receive()` still hands over
|
|
39
|
+
exactly one message per call: the rest of the batch waits in memory (oldest
|
|
40
|
+
first), and the returned cursor only advances past the batch once its last
|
|
41
|
+
message has been handed over, so a restart mid-batch re-delivers that batch
|
|
42
|
+
instead of dropping the messages behind the first one.
|
|
43
|
+
|
|
44
|
+
## Platform clients
|
|
45
|
+
|
|
46
|
+
`WeChatClient` implements the Tencent personal WeChat iLink/ClawBot protocol
|
|
47
|
+
used by Tencent's official `openclaw-weixin` project: QR login, `getupdates`
|
|
48
|
+
long polling, and `sendmessage`. More platforms (for example Discord)
|
|
49
|
+
implement the same `IMClient` surface.
|
|
50
|
+
|
|
51
|
+
Inbound messages carry their attachments on `InboundMessage.media`, one
|
|
52
|
+
`InboundMedia` per image, voice note, file, or video. The client downloads the
|
|
53
|
+
CDN reference and removes the AES-128-ECB encryption before returning, so a
|
|
54
|
+
caller receives usable bytes; whether a given consumer reads an image, plays a
|
|
55
|
+
voice note, or ignores both is the caller's decision. An item that cannot be
|
|
56
|
+
downloaded or decrypted is logged and skipped: the message keeps the
|
|
57
|
+
`[image]`/`[voice]`/`[file]`/`[video]` placeholder in its text. An item over
|
|
58
|
+
`MAX_MEDIA_BYTES` raises `MediaTooLargeError` from `fetch_media` and is reported
|
|
59
|
+
on `InboundMessage.rejected_media`, so a caller can tell the sender that the
|
|
60
|
+
attachment was refused instead of losing it quietly.
|
|
61
|
+
|
|
62
|
+
## Storage
|
|
63
|
+
|
|
64
|
+
None. Whoever calls agim owns persistence.
|
agim-0.0.1/README.md
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
# agim
|
|
2
|
+
|
|
3
|
+
One stateless interface for IM login, receive, and send. agim is a plain SDK:
|
|
4
|
+
it owns no storage, no plugin registry, and no host concepts. Callers persist
|
|
5
|
+
the login handshake and receive cursor themselves and hand them back on the
|
|
6
|
+
next call.
|
|
7
|
+
|
|
8
|
+
## Interface
|
|
9
|
+
|
|
10
|
+
```python
|
|
11
|
+
class IMClient(ABC):
|
|
12
|
+
async def login(self) -> LoginHandshake: ...
|
|
13
|
+
async def is_login(self, handshake: LoginHandshake) -> LoginState: ...
|
|
14
|
+
async def receive(self, cursor: str | None = None) -> ReceiveResult: ...
|
|
15
|
+
async def send(self, chat_id: str, text: str, *, context_token: str | None = None) -> None: ...
|
|
16
|
+
async def aclose(self) -> None: ...
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
Because nothing is stored inside agim:
|
|
20
|
+
|
|
21
|
+
- `login()` returns the QR payload **and** the provider state needed to poll it.
|
|
22
|
+
Persist the whole `LoginHandshake`, then pass it back to `is_login()`.
|
|
23
|
+
- `receive()` returns the message **and** the next cursor. Persist the cursor,
|
|
24
|
+
then pass it to the next `receive()`.
|
|
25
|
+
- `send()` takes the context token the caller stored from the inbound message.
|
|
26
|
+
|
|
27
|
+
One `getupdates` poll can carry several messages. `receive()` still hands over
|
|
28
|
+
exactly one message per call: the rest of the batch waits in memory (oldest
|
|
29
|
+
first), and the returned cursor only advances past the batch once its last
|
|
30
|
+
message has been handed over, so a restart mid-batch re-delivers that batch
|
|
31
|
+
instead of dropping the messages behind the first one.
|
|
32
|
+
|
|
33
|
+
## Platform clients
|
|
34
|
+
|
|
35
|
+
`WeChatClient` implements the Tencent personal WeChat iLink/ClawBot protocol
|
|
36
|
+
used by Tencent's official `openclaw-weixin` project: QR login, `getupdates`
|
|
37
|
+
long polling, and `sendmessage`. More platforms (for example Discord)
|
|
38
|
+
implement the same `IMClient` surface.
|
|
39
|
+
|
|
40
|
+
Inbound messages carry their attachments on `InboundMessage.media`, one
|
|
41
|
+
`InboundMedia` per image, voice note, file, or video. The client downloads the
|
|
42
|
+
CDN reference and removes the AES-128-ECB encryption before returning, so a
|
|
43
|
+
caller receives usable bytes; whether a given consumer reads an image, plays a
|
|
44
|
+
voice note, or ignores both is the caller's decision. An item that cannot be
|
|
45
|
+
downloaded or decrypted is logged and skipped: the message keeps the
|
|
46
|
+
`[image]`/`[voice]`/`[file]`/`[video]` placeholder in its text. An item over
|
|
47
|
+
`MAX_MEDIA_BYTES` raises `MediaTooLargeError` from `fetch_media` and is reported
|
|
48
|
+
on `InboundMessage.rejected_media`, so a caller can tell the sender that the
|
|
49
|
+
attachment was refused instead of losing it quietly.
|
|
50
|
+
|
|
51
|
+
## Storage
|
|
52
|
+
|
|
53
|
+
None. Whoever calls agim owns persistence.
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "agim"
|
|
3
|
+
version = "0.0.1"
|
|
4
|
+
description = "Standalone plugin toolkit for IM platform login, receive, and send"
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
requires-python = ">=3.10"
|
|
7
|
+
dependencies = [
|
|
8
|
+
"cryptography>=43,<45",
|
|
9
|
+
"httpx>=0.27,<1",
|
|
10
|
+
"pydantic>=2.12,<2.13",
|
|
11
|
+
"typing_extensions>=4.12,<5; python_version < '3.12'",
|
|
12
|
+
]
|
|
13
|
+
|
|
14
|
+
[build-system]
|
|
15
|
+
requires = ["hatchling"]
|
|
16
|
+
build-backend = "hatchling.build"
|
|
17
|
+
|
|
18
|
+
[tool.hatch.build.targets.wheel]
|
|
19
|
+
packages = ["src/agim"]
|
|
20
|
+
|
|
21
|
+
[dependency-groups]
|
|
22
|
+
dev = [
|
|
23
|
+
"pytest>=8,<9",
|
|
24
|
+
"pytest-asyncio>=0.26,<2",
|
|
25
|
+
"ruff>=0.11,<1",
|
|
26
|
+
]
|
|
27
|
+
|
|
28
|
+
[tool.ruff]
|
|
29
|
+
line-length = 120
|
|
30
|
+
target-version = "py310"
|
|
31
|
+
src = ["src", "tests"]
|
|
32
|
+
|
|
33
|
+
[tool.ruff.lint]
|
|
34
|
+
select = ["E", "F", "I", "UP", "B"]
|
|
35
|
+
ignore = ["E501"]
|
|
36
|
+
|
|
37
|
+
[tool.ruff.format]
|
|
38
|
+
quote-style = "double"
|
|
39
|
+
indent-style = "space"
|
|
40
|
+
line-ending = "lf"
|
|
41
|
+
|
|
42
|
+
[tool.pytest.ini_options]
|
|
43
|
+
testpaths = ["tests"]
|
|
44
|
+
asyncio_mode = "auto"
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
"""agim: one stateless interface for IM login, receive, and send.
|
|
2
|
+
|
|
3
|
+
The package is a plain SDK. It owns no storage, no plugin registry, and no
|
|
4
|
+
host concepts: callers persist the login handshake and receive cursor
|
|
5
|
+
themselves and hand them back on the next call.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from .client import IMClient
|
|
9
|
+
from .errors import MediaTooLargeError
|
|
10
|
+
from .models import (
|
|
11
|
+
MAX_MEDIA_BYTES,
|
|
12
|
+
MAX_MEDIA_ITEMS,
|
|
13
|
+
InboundMedia,
|
|
14
|
+
InboundMessage,
|
|
15
|
+
JsonValue,
|
|
16
|
+
LoginCredentials,
|
|
17
|
+
LoginHandshake,
|
|
18
|
+
LoginState,
|
|
19
|
+
LoginStatus,
|
|
20
|
+
MediaKind,
|
|
21
|
+
MediaRejectionReason,
|
|
22
|
+
ReceiveResult,
|
|
23
|
+
RejectedMedia,
|
|
24
|
+
)
|
|
25
|
+
from .wechat import WeChatAuthClient, WeChatClient
|
|
26
|
+
|
|
27
|
+
__all__ = [
|
|
28
|
+
"IMClient",
|
|
29
|
+
"InboundMessage",
|
|
30
|
+
"InboundMedia",
|
|
31
|
+
"JsonValue",
|
|
32
|
+
"LoginCredentials",
|
|
33
|
+
"LoginHandshake",
|
|
34
|
+
"LoginState",
|
|
35
|
+
"LoginStatus",
|
|
36
|
+
"MAX_MEDIA_BYTES",
|
|
37
|
+
"MAX_MEDIA_ITEMS",
|
|
38
|
+
"MediaKind",
|
|
39
|
+
"MediaRejectionReason",
|
|
40
|
+
"MediaTooLargeError",
|
|
41
|
+
"RejectedMedia",
|
|
42
|
+
"ReceiveResult",
|
|
43
|
+
"WeChatAuthClient",
|
|
44
|
+
"WeChatClient",
|
|
45
|
+
]
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
"""Shims for standard-library features that are newer than Python 3.10.
|
|
2
|
+
|
|
3
|
+
``agim`` supports Python 3.10 and newer, but a couple of pieces build on
|
|
4
|
+
features that only exist from 3.11 onward. Collecting them here keeps the
|
|
5
|
+
version checks in one place.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import sys
|
|
11
|
+
|
|
12
|
+
if sys.version_info >= (3, 11): # pragma: no cover - selected by the interpreter
|
|
13
|
+
from enum import StrEnum
|
|
14
|
+
else: # pragma: no cover - selected by the interpreter
|
|
15
|
+
from enum import Enum
|
|
16
|
+
|
|
17
|
+
class StrEnum(str, Enum):
|
|
18
|
+
"""``enum.StrEnum`` for Python 3.10: members are their own string value."""
|
|
19
|
+
|
|
20
|
+
def __str__(self) -> str:
|
|
21
|
+
return str(self.value)
|
|
22
|
+
|
|
23
|
+
def __format__(self, format_spec: str) -> str:
|
|
24
|
+
return str.__format__(str(self), format_spec)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
if sys.version_info >= (3, 12): # pragma: no cover - selected by the interpreter
|
|
28
|
+
from typing import TypeAliasType
|
|
29
|
+
else: # pragma: no cover - selected by the interpreter
|
|
30
|
+
from typing_extensions import TypeAliasType
|
|
31
|
+
|
|
32
|
+
__all__ = ["StrEnum", "TypeAliasType"]
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
"""The one interface every agim platform client implements."""
|
|
2
|
+
|
|
3
|
+
from abc import ABC, abstractmethod
|
|
4
|
+
|
|
5
|
+
from .models import LoginHandshake, LoginState, ReceiveResult
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class IMClient(ABC):
|
|
9
|
+
"""Unified login, receive, and send surface for one IM platform.
|
|
10
|
+
|
|
11
|
+
agim is stateless by design: callers own and persist the login handshake
|
|
12
|
+
and the receive cursor, then hand them back on the next call.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
@abstractmethod
|
|
16
|
+
async def login(self) -> LoginHandshake:
|
|
17
|
+
"""Begin a login flow and return the handshake the caller must store."""
|
|
18
|
+
|
|
19
|
+
@abstractmethod
|
|
20
|
+
async def is_login(self, handshake: LoginHandshake) -> LoginState:
|
|
21
|
+
"""Poll one stored handshake and return the current login state."""
|
|
22
|
+
|
|
23
|
+
@abstractmethod
|
|
24
|
+
async def receive(self, cursor: str | None = None) -> ReceiveResult:
|
|
25
|
+
"""Await the next inbound message and return its next cursor."""
|
|
26
|
+
|
|
27
|
+
@abstractmethod
|
|
28
|
+
async def send(self, chat_id: str, text: str, *, context_token: str | None = None) -> None:
|
|
29
|
+
"""Send one outbound message, reusing the caller's stored token."""
|
|
30
|
+
|
|
31
|
+
async def aclose(self) -> None:
|
|
32
|
+
"""Release transport resources; platforms without one keep the default."""
|
|
33
|
+
return None
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
__all__ = ["IMClient"]
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
"""Errors agim raises for platform payloads it refuses to hand back."""
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class MediaTooLargeError(ValueError):
|
|
5
|
+
"""One attachment exceeded the payload size agim is willing to hold.
|
|
6
|
+
|
|
7
|
+
Callers decide what an oversized attachment means for their own surface: an
|
|
8
|
+
SDK caller may log it, and a chat host may answer the sender instead of
|
|
9
|
+
running a turn over bytes it never received.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
__all__ = ["MediaTooLargeError"]
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
"""Logging helpers for the standalone IM gateway."""
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def configure_logging() -> None:
|
|
7
|
+
"""Configure process-wide gateway logging once."""
|
|
8
|
+
logging.basicConfig(
|
|
9
|
+
level=logging.INFO,
|
|
10
|
+
format="%(asctime)s %(levelname)s %(name)s %(message)s",
|
|
11
|
+
)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def get_logger(name: str) -> logging.Logger:
|
|
15
|
+
"""Return one gateway logger."""
|
|
16
|
+
return logging.getLogger(name)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
__all__ = ["configure_logging", "get_logger"]
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
"""Typed models shared by every agim client."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pydantic import BaseModel, ConfigDict, Field, model_validator
|
|
6
|
+
|
|
7
|
+
from ._compat import StrEnum, TypeAliasType
|
|
8
|
+
|
|
9
|
+
JsonValue = TypeAliasType("JsonValue", "str | int | float | bool | None | list[JsonValue] | dict[str, JsonValue]")
|
|
10
|
+
|
|
11
|
+
#: Largest single attachment a platform client keeps in memory.
|
|
12
|
+
MAX_MEDIA_BYTES = 256 * 1024 * 1024
|
|
13
|
+
|
|
14
|
+
#: Most attachments one inbound message may carry.
|
|
15
|
+
MAX_MEDIA_ITEMS = 16
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class LoginStatus(StrEnum):
|
|
19
|
+
"""Lifecycle of one QR-assisted login flow."""
|
|
20
|
+
|
|
21
|
+
PENDING = "pending"
|
|
22
|
+
SCANNED = "scanned"
|
|
23
|
+
VERIFY_REQUIRED = "verify_required"
|
|
24
|
+
CONNECTED = "connected"
|
|
25
|
+
FAILED = "failed"
|
|
26
|
+
EXPIRED = "expired"
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class LoginHandshake(BaseModel):
|
|
30
|
+
"""Opaque login state the caller must persist between login and is_login.
|
|
31
|
+
|
|
32
|
+
agim never stores anything itself, so the QR payload and whatever provider
|
|
33
|
+
state keeps the poll alive travel back to the caller in this object.
|
|
34
|
+
"""
|
|
35
|
+
|
|
36
|
+
model_config = ConfigDict(extra="forbid")
|
|
37
|
+
|
|
38
|
+
qr_content: str = Field(min_length=1, max_length=10_000)
|
|
39
|
+
qr_url: str | None = Field(default=None, max_length=10_000)
|
|
40
|
+
state: dict[str, JsonValue] = Field(default_factory=dict)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class LoginCredentials(BaseModel):
|
|
44
|
+
"""Provider config and secrets returned once a login succeeds."""
|
|
45
|
+
|
|
46
|
+
model_config = ConfigDict(extra="forbid")
|
|
47
|
+
|
|
48
|
+
config: dict[str, JsonValue] = Field(default_factory=dict)
|
|
49
|
+
secrets: dict[str, str] = Field(default_factory=dict)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class LoginState(BaseModel):
|
|
53
|
+
"""Current login status; ``is_login`` returns this to its caller."""
|
|
54
|
+
|
|
55
|
+
model_config = ConfigDict(extra="forbid")
|
|
56
|
+
|
|
57
|
+
status: LoginStatus
|
|
58
|
+
message: str = Field(min_length=1, max_length=2_000)
|
|
59
|
+
credentials: LoginCredentials | None = None
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
class MediaKind(StrEnum):
|
|
63
|
+
"""Attachment kinds an IM platform can deliver."""
|
|
64
|
+
|
|
65
|
+
IMAGE = "image"
|
|
66
|
+
VOICE = "voice"
|
|
67
|
+
FILE = "file"
|
|
68
|
+
VIDEO = "video"
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
class MediaRejectionReason(StrEnum):
|
|
72
|
+
"""Why an attachment did not arrive with its message."""
|
|
73
|
+
|
|
74
|
+
TOO_LARGE = "too_large"
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
class RejectedMedia(BaseModel):
|
|
78
|
+
"""One attachment the platform announced but this client refused to carry."""
|
|
79
|
+
|
|
80
|
+
model_config = ConfigDict(extra="forbid")
|
|
81
|
+
|
|
82
|
+
kind: MediaKind
|
|
83
|
+
reason: MediaRejectionReason
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
class InboundMedia(BaseModel):
|
|
87
|
+
"""One attachment of an inbound message, already downloaded and decrypted.
|
|
88
|
+
|
|
89
|
+
Callers receive the bytes so a host can store, forward, or hand them to a
|
|
90
|
+
model; whether a given model can read them is the caller's decision, not
|
|
91
|
+
agim's.
|
|
92
|
+
"""
|
|
93
|
+
|
|
94
|
+
model_config = ConfigDict(extra="forbid")
|
|
95
|
+
|
|
96
|
+
kind: MediaKind
|
|
97
|
+
media_type: str = Field(min_length=1, max_length=100)
|
|
98
|
+
name: str | None = Field(default=None, max_length=255)
|
|
99
|
+
data: bytes = Field(min_length=1, max_length=MAX_MEDIA_BYTES)
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
class InboundMessage(BaseModel):
|
|
103
|
+
"""One normalized inbound message: its text plus any attached media."""
|
|
104
|
+
|
|
105
|
+
model_config = ConfigDict(extra="forbid")
|
|
106
|
+
|
|
107
|
+
event_id: str = Field(min_length=1, max_length=200)
|
|
108
|
+
chat_id: str = Field(min_length=1, max_length=500)
|
|
109
|
+
user_id: str = Field(min_length=1, max_length=500)
|
|
110
|
+
text: str = Field(default="", max_length=100_000)
|
|
111
|
+
media: list[InboundMedia] = Field(default_factory=list, max_length=MAX_MEDIA_ITEMS)
|
|
112
|
+
rejected_media: list[RejectedMedia] = Field(default_factory=list, max_length=MAX_MEDIA_ITEMS)
|
|
113
|
+
reply_token: str | None = Field(default=None, max_length=10_000)
|
|
114
|
+
|
|
115
|
+
@model_validator(mode="after")
|
|
116
|
+
def _require_text_or_media(self) -> InboundMessage:
|
|
117
|
+
"""A message carries something: text, a delivered attachment, or a refusal."""
|
|
118
|
+
if not self.text.strip() and not self.media and not self.rejected_media:
|
|
119
|
+
raise ValueError("Inbound message requires text, media, or a media rejection")
|
|
120
|
+
return self
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
class ReceiveResult(BaseModel):
|
|
124
|
+
"""One inbound message plus the cursor the caller must persist next."""
|
|
125
|
+
|
|
126
|
+
model_config = ConfigDict(extra="forbid")
|
|
127
|
+
|
|
128
|
+
message: InboundMessage
|
|
129
|
+
cursor: str = Field(default="", max_length=100_000)
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
__all__ = [
|
|
133
|
+
"InboundMessage",
|
|
134
|
+
"InboundMedia",
|
|
135
|
+
"JsonValue",
|
|
136
|
+
"LoginCredentials",
|
|
137
|
+
"LoginHandshake",
|
|
138
|
+
"LoginState",
|
|
139
|
+
"LoginStatus",
|
|
140
|
+
"MAX_MEDIA_BYTES",
|
|
141
|
+
"MAX_MEDIA_ITEMS",
|
|
142
|
+
"MediaKind",
|
|
143
|
+
"MediaRejectionReason",
|
|
144
|
+
"RejectedMedia",
|
|
145
|
+
"ReceiveResult",
|
|
146
|
+
]
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
"""Personal WeChat iLink/ClawBot implementation."""
|
|
2
|
+
|
|
3
|
+
from .auth import ILINK_BASE_URL, WeChatAuthClient
|
|
4
|
+
from .client import WeChatClient, attach_media, normalize_message
|
|
5
|
+
from .media import CDN_BASE_URL, MediaRef, fetch_media, media_refs
|
|
6
|
+
|
|
7
|
+
__all__ = [
|
|
8
|
+
"CDN_BASE_URL",
|
|
9
|
+
"ILINK_BASE_URL",
|
|
10
|
+
"MediaRef",
|
|
11
|
+
"WeChatAuthClient",
|
|
12
|
+
"WeChatClient",
|
|
13
|
+
"attach_media",
|
|
14
|
+
"fetch_media",
|
|
15
|
+
"media_refs",
|
|
16
|
+
"normalize_message",
|
|
17
|
+
]
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
"""Tencent iLink QR login for personal WeChat bots."""
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
import httpx
|
|
6
|
+
|
|
7
|
+
from ..models import LoginCredentials, LoginHandshake, LoginState, LoginStatus
|
|
8
|
+
|
|
9
|
+
ILINK_BASE_URL = "https://ilinkai.weixin.qq.com"
|
|
10
|
+
ILINK_APP_ID = "bot"
|
|
11
|
+
ILINK_APP_CLIENT_VERSION = str((2 << 16) | (4 << 8) | 9)
|
|
12
|
+
ILINK_BOT_TYPE = "3"
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _build_headers(*, token: str | None = None, json_content: bool = False) -> dict[str, str]:
|
|
16
|
+
headers = {
|
|
17
|
+
"iLink-App-Id": ILINK_APP_ID,
|
|
18
|
+
"iLink-App-ClientVersion": ILINK_APP_CLIENT_VERSION,
|
|
19
|
+
}
|
|
20
|
+
if json_content:
|
|
21
|
+
headers["Content-Type"] = "application/json"
|
|
22
|
+
if token:
|
|
23
|
+
headers["AuthorizationType"] = "ilink_bot_token"
|
|
24
|
+
headers["Authorization"] = f"Bearer {token}"
|
|
25
|
+
return headers
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class WeChatAuthClient:
|
|
29
|
+
"""Implement the official iLink QR-code login protocol."""
|
|
30
|
+
|
|
31
|
+
def __init__(
|
|
32
|
+
self,
|
|
33
|
+
*,
|
|
34
|
+
timeout_seconds: float = 40.0,
|
|
35
|
+
client: httpx.AsyncClient | None = None,
|
|
36
|
+
) -> None:
|
|
37
|
+
self._timeout_seconds = timeout_seconds
|
|
38
|
+
self._client = client
|
|
39
|
+
self._owns_client = client is None
|
|
40
|
+
|
|
41
|
+
async def aclose(self) -> None:
|
|
42
|
+
"""Close the HTTP client when this adapter created it."""
|
|
43
|
+
if self._owns_client and self._client is not None:
|
|
44
|
+
await self._client.aclose()
|
|
45
|
+
self._client = None
|
|
46
|
+
|
|
47
|
+
async def create_login(self) -> LoginHandshake:
|
|
48
|
+
"""Request one QR code and return the handshake the caller must store."""
|
|
49
|
+
response = await self._get_client().post(
|
|
50
|
+
f"{ILINK_BASE_URL}/ilink/bot/get_bot_qrcode",
|
|
51
|
+
params={"bot_type": ILINK_BOT_TYPE},
|
|
52
|
+
json={"local_token_list": []},
|
|
53
|
+
headers=_build_headers(json_content=True),
|
|
54
|
+
)
|
|
55
|
+
response.raise_for_status()
|
|
56
|
+
payload = response.json()
|
|
57
|
+
if not isinstance(payload, dict):
|
|
58
|
+
raise ValueError("WeChat QR login returned an invalid response")
|
|
59
|
+
qrcode = payload.get("qrcode")
|
|
60
|
+
qr_content = payload.get("qrcode_img_content")
|
|
61
|
+
if not isinstance(qrcode, str) or not qrcode:
|
|
62
|
+
raise ValueError("WeChat QR login response is missing qrcode")
|
|
63
|
+
if not isinstance(qr_content, str) or not qr_content:
|
|
64
|
+
raise ValueError("WeChat QR login response is missing qrcode_img_content")
|
|
65
|
+
return LoginHandshake(
|
|
66
|
+
qr_content=qr_content,
|
|
67
|
+
qr_url=qr_content if qr_content.startswith(("http://", "https://")) else None,
|
|
68
|
+
state={"qrcode": qrcode, "base_url": ILINK_BASE_URL},
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
async def poll_login(
|
|
72
|
+
self,
|
|
73
|
+
handshake: LoginHandshake,
|
|
74
|
+
*,
|
|
75
|
+
verify_code: str | None = None,
|
|
76
|
+
) -> LoginState:
|
|
77
|
+
"""Poll one stored handshake and normalize the platform status."""
|
|
78
|
+
qrcode = handshake.state.get("qrcode")
|
|
79
|
+
base_url = handshake.state.get("base_url") or ILINK_BASE_URL
|
|
80
|
+
if not isinstance(qrcode, str) or not qrcode or not isinstance(base_url, str):
|
|
81
|
+
return LoginState(status=LoginStatus.EXPIRED, message="Login session expired; request a new QR code.")
|
|
82
|
+
params: dict[str, str] = {"qrcode": qrcode}
|
|
83
|
+
if verify_code:
|
|
84
|
+
params["verify_code"] = verify_code
|
|
85
|
+
try:
|
|
86
|
+
response = await self._get_client().get(
|
|
87
|
+
f"{base_url.rstrip('/')}/ilink/bot/get_qrcode_status",
|
|
88
|
+
params=params,
|
|
89
|
+
headers=_build_headers(),
|
|
90
|
+
)
|
|
91
|
+
response.raise_for_status()
|
|
92
|
+
payload: Any = response.json()
|
|
93
|
+
except (httpx.TimeoutException, httpx.NetworkError):
|
|
94
|
+
return LoginState(status=LoginStatus.PENDING, message="Waiting for the WeChat QR code to be scanned.")
|
|
95
|
+
except httpx.HTTPError as error:
|
|
96
|
+
return LoginState(
|
|
97
|
+
status=LoginStatus.PENDING,
|
|
98
|
+
message=f"WeChat login service is temporarily unavailable: {error}",
|
|
99
|
+
)
|
|
100
|
+
if not isinstance(payload, dict):
|
|
101
|
+
raise ValueError("WeChat QR status returned an invalid response")
|
|
102
|
+
status = payload.get("status")
|
|
103
|
+
if status == "confirmed":
|
|
104
|
+
token = payload.get("bot_token")
|
|
105
|
+
if not isinstance(token, str) or not token:
|
|
106
|
+
raise ValueError("WeChat login confirmed without bot_token")
|
|
107
|
+
config: dict[str, Any] = {"base_url": base_url}
|
|
108
|
+
if isinstance(payload.get("ilink_bot_id"), str):
|
|
109
|
+
config["ilink_bot_id"] = payload["ilink_bot_id"]
|
|
110
|
+
if isinstance(payload.get("ilink_user_id"), str):
|
|
111
|
+
config["ilink_user_id"] = payload["ilink_user_id"]
|
|
112
|
+
return LoginState(
|
|
113
|
+
status=LoginStatus.CONNECTED,
|
|
114
|
+
message="WeChat bot connected.",
|
|
115
|
+
credentials=LoginCredentials(config=config, secrets={"bot_token": token}),
|
|
116
|
+
)
|
|
117
|
+
if status in {"scaned", "scaned_but_redirect"}:
|
|
118
|
+
return LoginState(status=LoginStatus.SCANNED, message="QR code scanned; confirm in WeChat.")
|
|
119
|
+
if status == "need_verifycode":
|
|
120
|
+
return LoginState(status=LoginStatus.VERIFY_REQUIRED, message="Enter the pairing code shown in WeChat.")
|
|
121
|
+
if status in {"expired", "verify_code_blocked"}:
|
|
122
|
+
return LoginState(status=LoginStatus.EXPIRED, message="QR code expired; request a new one.")
|
|
123
|
+
if status == "binded_redirect":
|
|
124
|
+
return LoginState(status=LoginStatus.CONNECTED, message="This WeChat bot is already linked.")
|
|
125
|
+
return LoginState(status=LoginStatus.PENDING, message="Scan the QR code with WeChat to finish signing in.")
|
|
126
|
+
|
|
127
|
+
def _get_client(self) -> httpx.AsyncClient:
|
|
128
|
+
if self._client is None:
|
|
129
|
+
self._client = httpx.AsyncClient(
|
|
130
|
+
timeout=self._timeout_seconds,
|
|
131
|
+
follow_redirects=True,
|
|
132
|
+
)
|
|
133
|
+
return self._client
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
__all__ = [
|
|
137
|
+
"ILINK_APP_CLIENT_VERSION",
|
|
138
|
+
"ILINK_APP_ID",
|
|
139
|
+
"ILINK_BASE_URL",
|
|
140
|
+
"WeChatAuthClient",
|
|
141
|
+
]
|