agim 0.0.1__tar.gz

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