agim 0.0.1__py3-none-any.whl
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/__init__.py +45 -0
- agim/_compat.py +32 -0
- agim/client.py +36 -0
- agim/errors.py +13 -0
- agim/log.py +19 -0
- agim/models.py +146 -0
- agim/wechat/__init__.py +17 -0
- agim/wechat/auth.py +141 -0
- agim/wechat/client.py +318 -0
- agim/wechat/media.py +288 -0
- agim-0.0.1.dist-info/METADATA +64 -0
- agim-0.0.1.dist-info/RECORD +13 -0
- agim-0.0.1.dist-info/WHEEL +4 -0
agim/__init__.py
ADDED
|
@@ -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
|
+
]
|
agim/_compat.py
ADDED
|
@@ -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"]
|
agim/client.py
ADDED
|
@@ -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"]
|
agim/errors.py
ADDED
|
@@ -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"]
|
agim/log.py
ADDED
|
@@ -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"]
|
agim/models.py
ADDED
|
@@ -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
|
+
]
|
agim/wechat/__init__.py
ADDED
|
@@ -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
|
+
]
|
agim/wechat/auth.py
ADDED
|
@@ -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
|
+
]
|
agim/wechat/client.py
ADDED
|
@@ -0,0 +1,318 @@
|
|
|
1
|
+
"""Personal WeChat implementation of the unified agim client."""
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import base64
|
|
5
|
+
import random
|
|
6
|
+
from collections import deque
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
import httpx
|
|
10
|
+
|
|
11
|
+
from ..client import IMClient
|
|
12
|
+
from ..errors import MediaTooLargeError
|
|
13
|
+
from ..log import get_logger
|
|
14
|
+
from ..models import (
|
|
15
|
+
MAX_MEDIA_BYTES,
|
|
16
|
+
MAX_MEDIA_ITEMS,
|
|
17
|
+
InboundMedia,
|
|
18
|
+
InboundMessage,
|
|
19
|
+
LoginHandshake,
|
|
20
|
+
LoginState,
|
|
21
|
+
MediaRejectionReason,
|
|
22
|
+
ReceiveResult,
|
|
23
|
+
RejectedMedia,
|
|
24
|
+
)
|
|
25
|
+
from .auth import ILINK_APP_CLIENT_VERSION, ILINK_APP_ID, ILINK_BASE_URL, WeChatAuthClient
|
|
26
|
+
from .media import CDN_BASE_URL, MediaRef, fetch_media, media_refs
|
|
27
|
+
|
|
28
|
+
DEFAULT_LONG_POLL_TIMEOUT_MS = 35_000
|
|
29
|
+
MIN_LONG_POLL_TIMEOUT_MS = 5_000
|
|
30
|
+
MAX_LONG_POLL_TIMEOUT_MS = 60_000
|
|
31
|
+
MIN_EMPTY_RESPONSE_DELAY_SECONDS = 0.25
|
|
32
|
+
MAX_CONSECUTIVE_FAILURES = 3
|
|
33
|
+
BACKOFF_SECONDS = 30.0
|
|
34
|
+
|
|
35
|
+
logger = get_logger(__name__)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _headers(token: str) -> dict[str, str]:
|
|
39
|
+
random_uin = str(random.getrandbits(32)).encode()
|
|
40
|
+
return {
|
|
41
|
+
"Content-Type": "application/json",
|
|
42
|
+
"AuthorizationType": "ilink_bot_token",
|
|
43
|
+
"Authorization": f"Bearer {token}",
|
|
44
|
+
"X-WECHAT-UIN": base64.b64encode(random_uin).decode(),
|
|
45
|
+
"iLink-App-Id": ILINK_APP_ID,
|
|
46
|
+
"iLink-App-ClientVersion": ILINK_APP_CLIENT_VERSION,
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _base_info() -> dict[str, str]:
|
|
51
|
+
return {"channel_version": "2.4.9", "bot_agent": "Zett"}
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _next_timeout_ms(payload: dict[str, Any]) -> int:
|
|
55
|
+
"""Honor the server's suggested long-poll timeout within safe bounds."""
|
|
56
|
+
suggested = payload.get("longpolling_timeout_ms")
|
|
57
|
+
if not isinstance(suggested, int):
|
|
58
|
+
return DEFAULT_LONG_POLL_TIMEOUT_MS
|
|
59
|
+
return min(MAX_LONG_POLL_TIMEOUT_MS, max(MIN_LONG_POLL_TIMEOUT_MS, suggested))
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _text_from_message(message: dict[str, Any]) -> str:
|
|
63
|
+
"""Collect the text one update already carries.
|
|
64
|
+
|
|
65
|
+
Text items contribute their text, voice items contribute the platform's
|
|
66
|
+
speech-to-text result when it exists, and every other media item contributes
|
|
67
|
+
a placeholder so a message is never empty before its bytes are downloaded.
|
|
68
|
+
"""
|
|
69
|
+
pieces: list[str] = []
|
|
70
|
+
for item in message.get("item_list") or []:
|
|
71
|
+
if not isinstance(item, dict):
|
|
72
|
+
continue
|
|
73
|
+
if item.get("type") == 1:
|
|
74
|
+
text = item.get("text_item")
|
|
75
|
+
if isinstance(text, dict) and isinstance(text.get("text"), str):
|
|
76
|
+
pieces.append(text["text"])
|
|
77
|
+
continue
|
|
78
|
+
if item.get("type") == 3:
|
|
79
|
+
voice = item.get("voice_item")
|
|
80
|
+
if isinstance(voice, dict) and isinstance(voice.get("text"), str):
|
|
81
|
+
pieces.append(voice["text"])
|
|
82
|
+
continue
|
|
83
|
+
labels = {2: "[image]", 4: "[file]", 5: "[video]"}
|
|
84
|
+
if item.get("type") in labels:
|
|
85
|
+
pieces.append(labels[item["type"]])
|
|
86
|
+
return "\n".join(piece for piece in pieces if piece.strip())
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def normalize_message(message: dict[str, Any]) -> InboundMessage | None:
|
|
90
|
+
"""Normalize one iLink update into the package message model.
|
|
91
|
+
|
|
92
|
+
Media is represented by placeholder text here; :func:`attach_media` replaces
|
|
93
|
+
that placeholder with the downloaded attachment when its bytes arrive.
|
|
94
|
+
"""
|
|
95
|
+
text = _text_from_message(message)
|
|
96
|
+
if not text.strip():
|
|
97
|
+
return None
|
|
98
|
+
user_id = message.get("from_user_id")
|
|
99
|
+
event_id = message.get("message_id")
|
|
100
|
+
if not isinstance(user_id, str) or not user_id:
|
|
101
|
+
return None
|
|
102
|
+
if not isinstance(event_id, str) or not event_id:
|
|
103
|
+
event_id = str(message.get("seq") or "")
|
|
104
|
+
if not event_id:
|
|
105
|
+
return None
|
|
106
|
+
context_token = message.get("context_token")
|
|
107
|
+
return InboundMessage(
|
|
108
|
+
event_id=event_id,
|
|
109
|
+
chat_id=user_id,
|
|
110
|
+
user_id=user_id,
|
|
111
|
+
text=text.strip(),
|
|
112
|
+
reply_token=context_token if isinstance(context_token, str) else None,
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
async def attach_media(
|
|
117
|
+
client: httpx.AsyncClient,
|
|
118
|
+
message: InboundMessage,
|
|
119
|
+
raw: dict[str, Any],
|
|
120
|
+
*,
|
|
121
|
+
cdn_base_url: str = CDN_BASE_URL,
|
|
122
|
+
max_bytes: int = MAX_MEDIA_BYTES,
|
|
123
|
+
) -> InboundMessage:
|
|
124
|
+
"""Download every attachment of one update onto its normalized message.
|
|
125
|
+
|
|
126
|
+
An item the CDN cannot deliver is logged and skipped: the message keeps its
|
|
127
|
+
placeholder text, so a caller still sees that the sender sent something and
|
|
128
|
+
the conversation does not stall on one unreachable object. An item over
|
|
129
|
+
``max_bytes`` is instead reported as rejected, because a caller can only
|
|
130
|
+
answer the sender if it is told the attachment was refused rather than lost.
|
|
131
|
+
"""
|
|
132
|
+
refs: list[MediaRef] = media_refs(raw, limit=MAX_MEDIA_ITEMS)
|
|
133
|
+
if not refs:
|
|
134
|
+
return message
|
|
135
|
+
media: list[InboundMedia] = []
|
|
136
|
+
rejected: list[RejectedMedia] = []
|
|
137
|
+
for ref in refs:
|
|
138
|
+
try:
|
|
139
|
+
media.append(await fetch_media(client, ref, cdn_base_url=cdn_base_url, max_bytes=max_bytes))
|
|
140
|
+
except MediaTooLargeError as error:
|
|
141
|
+
logger.warning("WeChat inbound media is too large; kind=%s error=%s", ref.kind.value, error)
|
|
142
|
+
rejected.append(RejectedMedia(kind=ref.kind, reason=MediaRejectionReason.TOO_LARGE))
|
|
143
|
+
except asyncio.CancelledError:
|
|
144
|
+
raise
|
|
145
|
+
except Exception:
|
|
146
|
+
logger.exception("WeChat inbound media download failed; kind=%s", ref.kind.value)
|
|
147
|
+
if not media and not rejected:
|
|
148
|
+
return message
|
|
149
|
+
# ``model_copy`` skips validation, so the item cap is applied here instead of
|
|
150
|
+
# trusting the field constraint to catch a message with more attachments.
|
|
151
|
+
return message.model_copy(update={"media": media[:MAX_MEDIA_ITEMS], "rejected_media": rejected})
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
class WeChatClient(IMClient):
|
|
155
|
+
"""Login, long-poll, and send over the Tencent personal WeChat iLink API.
|
|
156
|
+
|
|
157
|
+
The client keeps no persistence: the caller stores the login handshake and
|
|
158
|
+
the ``get_updates_buf`` cursor, and passes the reply context token back on
|
|
159
|
+
each ``send``. One poll can carry several messages, so the rest of a batch
|
|
160
|
+
waits in memory and is handed over one :meth:`receive` call at a time.
|
|
161
|
+
"""
|
|
162
|
+
|
|
163
|
+
def __init__(
|
|
164
|
+
self,
|
|
165
|
+
*,
|
|
166
|
+
token: str | None = None,
|
|
167
|
+
base_url: str = ILINK_BASE_URL,
|
|
168
|
+
cdn_base_url: str = CDN_BASE_URL,
|
|
169
|
+
media_max_bytes: int = MAX_MEDIA_BYTES,
|
|
170
|
+
client: httpx.AsyncClient | None = None,
|
|
171
|
+
auth: WeChatAuthClient | None = None,
|
|
172
|
+
) -> None:
|
|
173
|
+
self._token = token
|
|
174
|
+
self._base_url = base_url.rstrip("/")
|
|
175
|
+
self._cdn_base_url = cdn_base_url
|
|
176
|
+
self._media_max_bytes = media_max_bytes
|
|
177
|
+
self._client = client
|
|
178
|
+
self._owns_client = client is None
|
|
179
|
+
self._auth = auth or WeChatAuthClient()
|
|
180
|
+
self._queued: deque[tuple[InboundMessage, dict[str, Any]]] = deque()
|
|
181
|
+
self._queued_cursor = ""
|
|
182
|
+
|
|
183
|
+
async def aclose(self) -> None:
|
|
184
|
+
"""Close the HTTP clients owned by this instance."""
|
|
185
|
+
self._queued.clear()
|
|
186
|
+
await self._auth.aclose()
|
|
187
|
+
if self._owns_client and self._client is not None:
|
|
188
|
+
await self._client.aclose()
|
|
189
|
+
self._client = None
|
|
190
|
+
|
|
191
|
+
async def login(self) -> LoginHandshake:
|
|
192
|
+
"""Request a QR code and return the handshake the caller must store."""
|
|
193
|
+
return await self._auth.create_login()
|
|
194
|
+
|
|
195
|
+
async def is_login(self, handshake: LoginHandshake, *, verify_code: str | None = None) -> LoginState:
|
|
196
|
+
"""Poll one stored handshake, optionally with a pairing code."""
|
|
197
|
+
return await self._auth.poll_login(handshake, verify_code=verify_code)
|
|
198
|
+
|
|
199
|
+
async def receive(self, cursor: str | None = None) -> ReceiveResult:
|
|
200
|
+
"""Long-poll until one normalized inbound message is available.
|
|
201
|
+
|
|
202
|
+
Only one message is returned per call, but a poll that carries a batch
|
|
203
|
+
hands over its remaining messages on the following calls; the cursor
|
|
204
|
+
only moves past the batch once its last message has been returned, so a
|
|
205
|
+
crash mid-batch re-delivers the batch instead of dropping its tail.
|
|
206
|
+
"""
|
|
207
|
+
previous = cursor or ""
|
|
208
|
+
if self._queued:
|
|
209
|
+
return await self._hand_over(previous)
|
|
210
|
+
current = previous
|
|
211
|
+
failures = 0
|
|
212
|
+
long_poll_timeout_ms = DEFAULT_LONG_POLL_TIMEOUT_MS
|
|
213
|
+
while True:
|
|
214
|
+
try:
|
|
215
|
+
response = await self._get_client().post(
|
|
216
|
+
f"{self._base_url}/ilink/bot/getupdates",
|
|
217
|
+
json={"get_updates_buf": current, "base_info": _base_info()},
|
|
218
|
+
headers=_headers(self._require_token()),
|
|
219
|
+
timeout=(long_poll_timeout_ms / 1000) + 5,
|
|
220
|
+
)
|
|
221
|
+
response.raise_for_status()
|
|
222
|
+
payload = response.json()
|
|
223
|
+
if not isinstance(payload, dict):
|
|
224
|
+
raise ValueError("WeChat getupdates returned an invalid response")
|
|
225
|
+
if payload.get("ret") not in (None, 0) or payload.get("errcode") not in (None, 0):
|
|
226
|
+
raise RuntimeError(f"WeChat getupdates failed: {payload.get('errmsg') or payload.get('errcode')}")
|
|
227
|
+
next_cursor = payload.get("get_updates_buf")
|
|
228
|
+
if isinstance(next_cursor, str):
|
|
229
|
+
current = next_cursor
|
|
230
|
+
long_poll_timeout_ms = _next_timeout_ms(payload)
|
|
231
|
+
messages = payload.get("msgs") or []
|
|
232
|
+
for raw in messages:
|
|
233
|
+
if not isinstance(raw, dict):
|
|
234
|
+
continue
|
|
235
|
+
message = normalize_message(raw)
|
|
236
|
+
if message is None:
|
|
237
|
+
continue
|
|
238
|
+
self._queued.append((message, raw))
|
|
239
|
+
if self._queued:
|
|
240
|
+
self._queued_cursor = current
|
|
241
|
+
return await self._hand_over(previous)
|
|
242
|
+
failures = 0
|
|
243
|
+
if not messages:
|
|
244
|
+
# Some gateways return immediately on transient upstream
|
|
245
|
+
# conditions. A tiny pause prevents a hot getupdates loop.
|
|
246
|
+
await asyncio.sleep(MIN_EMPTY_RESPONSE_DELAY_SECONDS)
|
|
247
|
+
except asyncio.CancelledError:
|
|
248
|
+
raise
|
|
249
|
+
except Exception:
|
|
250
|
+
failures += 1
|
|
251
|
+
logger.exception(
|
|
252
|
+
"WeChat getupdates failed; consecutive_failures=%d max=%d",
|
|
253
|
+
failures,
|
|
254
|
+
MAX_CONSECUTIVE_FAILURES,
|
|
255
|
+
)
|
|
256
|
+
if failures >= MAX_CONSECUTIVE_FAILURES:
|
|
257
|
+
failures = 0
|
|
258
|
+
await asyncio.sleep(BACKOFF_SECONDS)
|
|
259
|
+
else:
|
|
260
|
+
await asyncio.sleep(2)
|
|
261
|
+
|
|
262
|
+
async def _hand_over(self, previous_cursor: str) -> ReceiveResult:
|
|
263
|
+
"""Return the oldest queued message and the cursor the caller should keep."""
|
|
264
|
+
message, raw = self._queued.popleft()
|
|
265
|
+
attached = await attach_media(
|
|
266
|
+
self._get_client(),
|
|
267
|
+
message,
|
|
268
|
+
raw,
|
|
269
|
+
cdn_base_url=self._cdn_base_url,
|
|
270
|
+
max_bytes=self._media_max_bytes,
|
|
271
|
+
)
|
|
272
|
+
# Handing over the new cursor early would let a crash mid-batch skip the
|
|
273
|
+
# messages still queued behind this one.
|
|
274
|
+
cursor = self._queued_cursor if not self._queued else previous_cursor
|
|
275
|
+
return ReceiveResult(message=attached, cursor=cursor)
|
|
276
|
+
|
|
277
|
+
async def send(self, chat_id: str, text: str, *, context_token: str | None = None) -> None:
|
|
278
|
+
"""Send one text message, reusing the caller's stored context token."""
|
|
279
|
+
message: dict[str, Any] = {
|
|
280
|
+
"from_user_id": "",
|
|
281
|
+
"to_user_id": chat_id,
|
|
282
|
+
"client_id": f"agim-{random.getrandbits(64):x}",
|
|
283
|
+
"message_type": 2,
|
|
284
|
+
"message_state": 2,
|
|
285
|
+
"item_list": [{"type": 1, "text_item": {"text": text}}],
|
|
286
|
+
}
|
|
287
|
+
if context_token:
|
|
288
|
+
message["context_token"] = context_token
|
|
289
|
+
response = await self._get_client().post(
|
|
290
|
+
f"{self._base_url}/ilink/bot/sendmessage",
|
|
291
|
+
json={"msg": message, "base_info": _base_info()},
|
|
292
|
+
headers=_headers(self._require_token()),
|
|
293
|
+
)
|
|
294
|
+
response.raise_for_status()
|
|
295
|
+
payload = response.json()
|
|
296
|
+
if isinstance(payload, dict) and payload.get("ret") not in (None, 0):
|
|
297
|
+
raise RuntimeError(f"WeChat sendmessage failed: {payload.get('errmsg') or payload.get('ret')}")
|
|
298
|
+
|
|
299
|
+
def _require_token(self) -> str:
|
|
300
|
+
if not self._token:
|
|
301
|
+
raise RuntimeError("WeChat client has no bot token; complete login first")
|
|
302
|
+
return self._token
|
|
303
|
+
|
|
304
|
+
def _get_client(self) -> httpx.AsyncClient:
|
|
305
|
+
if self._client is None:
|
|
306
|
+
self._client = httpx.AsyncClient(timeout=20)
|
|
307
|
+
return self._client
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
__all__ = [
|
|
311
|
+
"CDN_BASE_URL",
|
|
312
|
+
"DEFAULT_LONG_POLL_TIMEOUT_MS",
|
|
313
|
+
"MAX_LONG_POLL_TIMEOUT_MS",
|
|
314
|
+
"MIN_LONG_POLL_TIMEOUT_MS",
|
|
315
|
+
"WeChatClient",
|
|
316
|
+
"attach_media",
|
|
317
|
+
"normalize_message",
|
|
318
|
+
]
|
agim/wechat/media.py
ADDED
|
@@ -0,0 +1,288 @@
|
|
|
1
|
+
"""Download, decrypt, and type the media items of one iLink update.
|
|
2
|
+
|
|
3
|
+
Media arrives as a CDN reference plus an AES-128 key. The wire format and the
|
|
4
|
+
decryption rules follow Tencent's ``openclaw-weixin`` channel plugin: fields are
|
|
5
|
+
read from ``media`` (``encrypt_query_param``, ``aes_key``, ``full_url``), images
|
|
6
|
+
may carry a hex ``aeskey`` instead, and the fetched payload is AES-128-ECB with
|
|
7
|
+
PKCS#7 padding.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
import base64
|
|
11
|
+
import binascii
|
|
12
|
+
from dataclasses import dataclass
|
|
13
|
+
from mimetypes import guess_type
|
|
14
|
+
from typing import Any
|
|
15
|
+
from urllib.parse import quote
|
|
16
|
+
|
|
17
|
+
import httpx
|
|
18
|
+
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
|
|
19
|
+
|
|
20
|
+
from ..errors import MediaTooLargeError
|
|
21
|
+
from ..log import get_logger
|
|
22
|
+
from ..models import MAX_MEDIA_BYTES, MAX_MEDIA_ITEMS, InboundMedia, MediaKind
|
|
23
|
+
|
|
24
|
+
logger = get_logger(__name__)
|
|
25
|
+
|
|
26
|
+
#: Where the iLink API serves encrypted media from.
|
|
27
|
+
CDN_BASE_URL = "https://novac2c.cdn.weixin.qq.com/c2c"
|
|
28
|
+
|
|
29
|
+
#: How long one CDN download may take before the item is skipped.
|
|
30
|
+
MEDIA_DOWNLOAD_TIMEOUT_SECONDS = 60.0
|
|
31
|
+
|
|
32
|
+
_AES_BLOCK_BYTES = 16
|
|
33
|
+
|
|
34
|
+
#: iLink item types that carry a download reference.
|
|
35
|
+
_IMAGE_ITEM = 2
|
|
36
|
+
_VOICE_ITEM = 3
|
|
37
|
+
_FILE_ITEM = 4
|
|
38
|
+
_VIDEO_ITEM = 5
|
|
39
|
+
|
|
40
|
+
#: Item type to the kind and payload field that carry its CDN reference.
|
|
41
|
+
_MEDIA_ITEM_FIELDS = {
|
|
42
|
+
_IMAGE_ITEM: (MediaKind.IMAGE, "image_item"),
|
|
43
|
+
_VOICE_ITEM: (MediaKind.VOICE, "voice_item"),
|
|
44
|
+
_FILE_ITEM: (MediaKind.FILE, "file_item"),
|
|
45
|
+
_VIDEO_ITEM: (MediaKind.VIDEO, "video_item"),
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
_DEFAULT_MEDIA_TYPES = {
|
|
49
|
+
MediaKind.IMAGE: "image/jpeg",
|
|
50
|
+
MediaKind.VOICE: "audio/silk",
|
|
51
|
+
MediaKind.FILE: "application/octet-stream",
|
|
52
|
+
MediaKind.VIDEO: "video/mp4",
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
#: Voice encodings named by iLink, keyed by ``voice_item.encode_type``.
|
|
56
|
+
_VOICE_MEDIA_TYPES = {
|
|
57
|
+
1: "audio/pcm",
|
|
58
|
+
2: "audio/adpcm",
|
|
59
|
+
3: "audio/feature",
|
|
60
|
+
4: "audio/speex",
|
|
61
|
+
5: "audio/amr",
|
|
62
|
+
6: "audio/silk",
|
|
63
|
+
7: "audio/mpeg",
|
|
64
|
+
8: "audio/ogg",
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
#: Leading bytes of the image formats iLink forwards.
|
|
68
|
+
_IMAGE_SIGNATURES: tuple[tuple[bytes, str], ...] = (
|
|
69
|
+
(b"\x89PNG\r\n\x1a\n", "image/png"),
|
|
70
|
+
(b"\xff\xd8\xff", "image/jpeg"),
|
|
71
|
+
(b"GIF87a", "image/gif"),
|
|
72
|
+
(b"GIF89a", "image/gif"),
|
|
73
|
+
(b"BM", "image/bmp"),
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
@dataclass(frozen=True, slots=True)
|
|
78
|
+
class MediaRef:
|
|
79
|
+
"""One CDN reference found in an inbound message item."""
|
|
80
|
+
|
|
81
|
+
kind: MediaKind
|
|
82
|
+
#: MIME type known before the download, refined from the bytes when possible.
|
|
83
|
+
media_type: str
|
|
84
|
+
encrypt_query_param: str | None = None
|
|
85
|
+
full_url: str | None = None
|
|
86
|
+
aes_key: str | None = None
|
|
87
|
+
name: str | None = None
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _media_ref(item: dict[str, Any], *, kind: MediaKind, field: str) -> MediaRef | None:
|
|
91
|
+
"""Read one item's media reference, or ``None`` when nothing is downloadable."""
|
|
92
|
+
payload = item.get(field)
|
|
93
|
+
if not isinstance(payload, dict):
|
|
94
|
+
return None
|
|
95
|
+
media = payload.get("media")
|
|
96
|
+
if not isinstance(media, dict):
|
|
97
|
+
return None
|
|
98
|
+
encrypted = media.get("encrypt_query_param")
|
|
99
|
+
full_url = media.get("full_url")
|
|
100
|
+
if not isinstance(encrypted, str) or not encrypted:
|
|
101
|
+
encrypted = None
|
|
102
|
+
if not isinstance(full_url, str) or not full_url:
|
|
103
|
+
full_url = None
|
|
104
|
+
if encrypted is None and full_url is None:
|
|
105
|
+
return None
|
|
106
|
+
aes_key = media.get("aes_key")
|
|
107
|
+
aes_key = aes_key if isinstance(aes_key, str) and aes_key else None
|
|
108
|
+
if kind is MediaKind.IMAGE:
|
|
109
|
+
# Images may publish the key as hex on the item itself; that form wins.
|
|
110
|
+
hex_key = payload.get("aeskey")
|
|
111
|
+
if isinstance(hex_key, str) and hex_key:
|
|
112
|
+
try:
|
|
113
|
+
aes_key = base64.b64encode(bytes.fromhex(hex_key)).decode("ascii")
|
|
114
|
+
except ValueError:
|
|
115
|
+
logger.warning("WeChat image item carries an invalid hex aeskey; ignoring it")
|
|
116
|
+
return MediaRef(
|
|
117
|
+
kind=kind,
|
|
118
|
+
media_type=_declared_media_type(item, kind=kind, field=field),
|
|
119
|
+
encrypt_query_param=encrypted,
|
|
120
|
+
full_url=full_url,
|
|
121
|
+
aes_key=aes_key,
|
|
122
|
+
name=_item_name(item, field=field),
|
|
123
|
+
)
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def _declared_media_type(item: dict[str, Any], *, kind: MediaKind, field: str) -> str:
|
|
127
|
+
"""Name the MIME type from the item's own metadata, before any bytes exist."""
|
|
128
|
+
payload = item.get(field)
|
|
129
|
+
if not isinstance(payload, dict):
|
|
130
|
+
return _DEFAULT_MEDIA_TYPES[kind]
|
|
131
|
+
if kind is MediaKind.VOICE:
|
|
132
|
+
encode_type = payload.get("encode_type")
|
|
133
|
+
if isinstance(encode_type, int):
|
|
134
|
+
named = _VOICE_MEDIA_TYPES.get(encode_type)
|
|
135
|
+
if named is not None:
|
|
136
|
+
return named
|
|
137
|
+
if kind is MediaKind.FILE:
|
|
138
|
+
guessed, _ = guess_type(str(payload.get("file_name") or ""))
|
|
139
|
+
if guessed:
|
|
140
|
+
return guessed
|
|
141
|
+
return _DEFAULT_MEDIA_TYPES[kind]
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def _item_name(item: dict[str, Any], *, field: str) -> str | None:
|
|
145
|
+
"""Return the sender-visible file name when the item carries one."""
|
|
146
|
+
payload = item.get(field)
|
|
147
|
+
if not isinstance(payload, dict):
|
|
148
|
+
return None
|
|
149
|
+
name = payload.get("file_name")
|
|
150
|
+
return name if isinstance(name, str) and name else None
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def media_refs(message: dict[str, Any], *, limit: int = MAX_MEDIA_ITEMS) -> list[MediaRef]:
|
|
154
|
+
"""Return the downloadable media references of one raw iLink update.
|
|
155
|
+
|
|
156
|
+
The list stops at ``limit``: an update is untrusted input, and downloading
|
|
157
|
+
every reference an oversized payload names would cost memory the receiver
|
|
158
|
+
never agreed to hold.
|
|
159
|
+
"""
|
|
160
|
+
refs: list[MediaRef] = []
|
|
161
|
+
for item in message.get("item_list") or []:
|
|
162
|
+
if not isinstance(item, dict):
|
|
163
|
+
continue
|
|
164
|
+
item_type = item.get("type")
|
|
165
|
+
fields = _MEDIA_ITEM_FIELDS.get(item_type) if isinstance(item_type, int) else None
|
|
166
|
+
ref = _media_ref(item, kind=fields[0], field=fields[1]) if fields is not None else None
|
|
167
|
+
if ref is not None:
|
|
168
|
+
refs.append(ref)
|
|
169
|
+
if len(refs) >= limit:
|
|
170
|
+
logger.warning("WeChat update names more than %d attachments; ignoring the rest", limit)
|
|
171
|
+
break
|
|
172
|
+
return refs
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def build_cdn_download_url(encrypt_query_param: str, cdn_base_url: str) -> str:
|
|
176
|
+
"""Build the CDN download URL used when the server sent no ``full_url``."""
|
|
177
|
+
return f"{cdn_base_url.rstrip('/')}/download?encrypted_query_param={quote(encrypt_query_param, safe='')}"
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def parse_aes_key(aes_key_base64: str) -> bytes:
|
|
181
|
+
"""Decode a wire ``aes_key`` into its raw 16 bytes.
|
|
182
|
+
|
|
183
|
+
Images publish ``base64(raw 16 bytes)``; files, voice, and video publish
|
|
184
|
+
``base64(hex string of 16 bytes)``. Both encodings are accepted.
|
|
185
|
+
"""
|
|
186
|
+
try:
|
|
187
|
+
decoded = base64.b64decode(aes_key_base64, validate=True)
|
|
188
|
+
except (binascii.Error, ValueError) as error:
|
|
189
|
+
raise ValueError("WeChat media aes_key is not valid base64") from error
|
|
190
|
+
if len(decoded) == _AES_BLOCK_BYTES:
|
|
191
|
+
return decoded
|
|
192
|
+
if len(decoded) == 2 * _AES_BLOCK_BYTES:
|
|
193
|
+
try:
|
|
194
|
+
return bytes.fromhex(decoded.decode("ascii"))
|
|
195
|
+
except (UnicodeDecodeError, ValueError) as error:
|
|
196
|
+
raise ValueError("WeChat media aes_key is neither 16 raw bytes nor 32 hex characters") from error
|
|
197
|
+
raise ValueError(f"WeChat media aes_key decodes to {len(decoded)} bytes instead of 16")
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def decrypt_aes_ecb(ciphertext: bytes, key: bytes) -> bytes:
|
|
201
|
+
"""Decrypt one AES-128-ECB payload and drop its PKCS#7 padding when present."""
|
|
202
|
+
if len(key) != _AES_BLOCK_BYTES:
|
|
203
|
+
raise ValueError("WeChat media AES key must be 16 bytes")
|
|
204
|
+
if not ciphertext or len(ciphertext) % _AES_BLOCK_BYTES:
|
|
205
|
+
raise ValueError("WeChat media payload is not a whole number of AES blocks")
|
|
206
|
+
decryptor = Cipher(algorithms.AES(key), modes.ECB()).decryptor()
|
|
207
|
+
plaintext = decryptor.update(ciphertext) + decryptor.finalize()
|
|
208
|
+
return _strip_pkcs7(plaintext)
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def _strip_pkcs7(plaintext: bytes) -> bytes:
|
|
212
|
+
"""Remove PKCS#7 padding, tolerating payloads that were never padded."""
|
|
213
|
+
padding = plaintext[-1]
|
|
214
|
+
if 1 <= padding <= _AES_BLOCK_BYTES and plaintext[-padding:] == bytes([padding]) * padding:
|
|
215
|
+
return plaintext[:-padding]
|
|
216
|
+
return plaintext
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def _image_media_type(data: bytes, fallback: str) -> str:
|
|
220
|
+
"""Prefer the image format the bytes actually are."""
|
|
221
|
+
for signature, media_type in _IMAGE_SIGNATURES:
|
|
222
|
+
if data.startswith(signature):
|
|
223
|
+
return media_type
|
|
224
|
+
if data[:4] == b"RIFF" and data[8:12] == b"WEBP":
|
|
225
|
+
return "image/webp"
|
|
226
|
+
return fallback
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
async def fetch_media(
|
|
230
|
+
client: httpx.AsyncClient,
|
|
231
|
+
ref: MediaRef,
|
|
232
|
+
*,
|
|
233
|
+
cdn_base_url: str = CDN_BASE_URL,
|
|
234
|
+
max_bytes: int = MAX_MEDIA_BYTES,
|
|
235
|
+
) -> InboundMedia:
|
|
236
|
+
"""Download, decrypt, and describe one referenced attachment.
|
|
237
|
+
|
|
238
|
+
Raises:
|
|
239
|
+
MediaTooLargeError: when the payload declares or delivers more than
|
|
240
|
+
``max_bytes``.
|
|
241
|
+
ValueError: when the reference carries no download URL or cannot be
|
|
242
|
+
decrypted with its declared key.
|
|
243
|
+
httpx.HTTPError: when the CDN request itself fails.
|
|
244
|
+
"""
|
|
245
|
+
if ref.full_url:
|
|
246
|
+
url = ref.full_url
|
|
247
|
+
elif ref.encrypt_query_param:
|
|
248
|
+
url = build_cdn_download_url(ref.encrypt_query_param, cdn_base_url)
|
|
249
|
+
else:
|
|
250
|
+
raise ValueError("WeChat media reference has neither full_url nor encrypt_query_param")
|
|
251
|
+
payload = await _download(client, url, max_bytes=max_bytes)
|
|
252
|
+
if ref.aes_key:
|
|
253
|
+
payload = decrypt_aes_ecb(payload, parse_aes_key(ref.aes_key))
|
|
254
|
+
if not payload:
|
|
255
|
+
raise ValueError("WeChat media payload is empty")
|
|
256
|
+
media_type = _image_media_type(payload, ref.media_type) if ref.kind is MediaKind.IMAGE else ref.media_type
|
|
257
|
+
return InboundMedia(kind=ref.kind, media_type=media_type, name=ref.name, data=payload)
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
async def _download(client: httpx.AsyncClient, url: str, *, max_bytes: int) -> bytes:
|
|
261
|
+
"""Read one CDN object, refusing to hold more than ``max_bytes`` of it.
|
|
262
|
+
|
|
263
|
+
The body is streamed, so a server that ignores the size contract cannot make
|
|
264
|
+
this process buffer an unbounded payload before the check runs.
|
|
265
|
+
"""
|
|
266
|
+
async with client.stream("GET", url, timeout=MEDIA_DOWNLOAD_TIMEOUT_SECONDS, follow_redirects=True) as response:
|
|
267
|
+
response.raise_for_status()
|
|
268
|
+
declared = response.headers.get("content-length")
|
|
269
|
+
if declared is not None and declared.isdigit() and int(declared) > max_bytes:
|
|
270
|
+
raise MediaTooLargeError(f"WeChat media payload declares {declared} bytes, over the {max_bytes} byte limit")
|
|
271
|
+
payload = bytearray()
|
|
272
|
+
async for chunk in response.aiter_bytes():
|
|
273
|
+
payload.extend(chunk)
|
|
274
|
+
if len(payload) > max_bytes:
|
|
275
|
+
raise MediaTooLargeError(f"WeChat media payload exceeds the {max_bytes} byte limit")
|
|
276
|
+
return bytes(payload)
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
__all__ = [
|
|
280
|
+
"CDN_BASE_URL",
|
|
281
|
+
"MEDIA_DOWNLOAD_TIMEOUT_SECONDS",
|
|
282
|
+
"MediaRef",
|
|
283
|
+
"build_cdn_download_url",
|
|
284
|
+
"decrypt_aes_ecb",
|
|
285
|
+
"fetch_media",
|
|
286
|
+
"media_refs",
|
|
287
|
+
"parse_aes_key",
|
|
288
|
+
]
|
|
@@ -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.
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
agim/__init__.py,sha256=R4lpeSr2bA88n-gzZNati6lZ9_1KItbD9OddE_8av7Q,1033
|
|
2
|
+
agim/_compat.py,sha256=_0xpKWQWjDaAJO_cDYLm_9KrDUwvEMIKRncpO5ILFSg,1059
|
|
3
|
+
agim/client.py,sha256=2hGlGh1voqC8bDhG9_s1UeT56S0uUViIpRdIfST8Ilo,1247
|
|
4
|
+
agim/errors.py,sha256=1U_3BAfPd1FCTx5YVA9eJu0CldaPyDFPYDU3Rp5Bcto,434
|
|
5
|
+
agim/log.py,sha256=GDkhUiRgkVi2FIcg-A3aardiS3IrObAiDZbskAKpmi8,450
|
|
6
|
+
agim/models.py,sha256=C9pwDc3wTI7qJKUX9gnKIBAo7uD3la7dBBucKs9Jras,4382
|
|
7
|
+
agim/wechat/__init__.py,sha256=bBhP5d8Ce-Pebn5A_N5i85YthUMDkcJ9bNT4ucBYIn4,436
|
|
8
|
+
agim/wechat/auth.py,sha256=TQrYS3F5mWy-PtWWfYEM74DaQqDz47hyUAxKWxpA1Fo,5934
|
|
9
|
+
agim/wechat/client.py,sha256=01EsbLfAhRvsKl2ztrsvDkJOP3U4Vst4cttJR9ZM5vk,12801
|
|
10
|
+
agim/wechat/media.py,sha256=w5vVoOIz-GqIMPWWcNVCByRYnNRPWSK-S_bLtyzfeYM,10885
|
|
11
|
+
agim-0.0.1.dist-info/METADATA,sha256=J1z3JpWWFwp7aqhnsk6o5CCIofhrvZJqui1tj4N4evQ,2837
|
|
12
|
+
agim-0.0.1.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
|
|
13
|
+
agim-0.0.1.dist-info/RECORD,,
|