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 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
+ ]
@@ -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,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.32.4
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any