wechat-openclaw-sdk 0.1.0__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.
@@ -0,0 +1,50 @@
1
+ """WeChat OpenClaw SDK —— Python 版微信 iLink OpenClaw 接入 SDK。
2
+
3
+ 核心能力:任意 Agent 对接、单机/分布式部署、主动发消息、二维码登录。
4
+ """
5
+ from .agent import (
6
+ AgentEvent,
7
+ AgentEventType,
8
+ AgentRunRequest,
9
+ Attachment,
10
+ BaseAgent,
11
+ Dispatcher,
12
+ EchoAgent,
13
+ OpenAICompatAgent,
14
+ )
15
+ from .api import OpenClawAPI, OpenClawApiError, SessionExpiredError
16
+ from .client import OpenClawClient, SDKConfig
17
+ from .config import DEFAULT_BASE_URL, DEFAULT_CDN_BASE_URL, ENV_BASE_URL, get_base_url
18
+ from .connection import ConnectionManager, LocalDeduplicator, RedisDeduplicator
19
+ from .coordinator import BaseCoordinator, NoopCoordinator, RedisCoordinator
20
+ from .models import (
21
+ BaseInfo,
22
+ CdnMedia,
23
+ MessageItem,
24
+ TextItem,
25
+ TypingStatus,
26
+ UploadMediaType,
27
+ WeChatMessage,
28
+ WeChatMessageItemType,
29
+ WeChatMessageState,
30
+ WeChatMessageType,
31
+ )
32
+ from .qrlogin import QrLoginError, QrLoginModule, QrSession, WeChatCredentials
33
+ from .utils.markdown import StreamingMarkdownFilter, markdown_to_plain
34
+
35
+ __version__ = "0.1.0"
36
+
37
+ __all__ = [
38
+ "AgentEvent", "AgentEventType", "AgentRunRequest", "Attachment",
39
+ "BaseAgent", "Dispatcher", "EchoAgent", "OpenAICompatAgent",
40
+ "OpenClawAPI", "OpenClawApiError", "SessionExpiredError",
41
+ "OpenClawClient", "SDKConfig",
42
+ "DEFAULT_BASE_URL", "DEFAULT_CDN_BASE_URL", "ENV_BASE_URL", "get_base_url",
43
+ "ConnectionManager", "LocalDeduplicator", "RedisDeduplicator",
44
+ "BaseCoordinator", "NoopCoordinator", "RedisCoordinator",
45
+ "BaseInfo", "CdnMedia", "MessageItem", "TextItem", "TypingStatus",
46
+ "UploadMediaType", "WeChatMessage", "WeChatMessageItemType",
47
+ "WeChatMessageState", "WeChatMessageType",
48
+ "QrLoginError", "QrLoginModule", "QrSession", "WeChatCredentials",
49
+ "StreamingMarkdownFilter", "markdown_to_plain",
50
+ ]
@@ -0,0 +1,223 @@
1
+ """Agent 对接抽象 —— BaseAgent + AgentEvent + Dispatcher。
2
+
3
+ 编排行为:收到消息后先发占位语,随后消费 Agent 事件流并分段发送回复。
4
+ """
5
+ from __future__ import annotations
6
+
7
+ import json
8
+ import logging
9
+ from abc import ABC, abstractmethod
10
+ from dataclasses import dataclass, field
11
+ from enum import Enum
12
+ from typing import TYPE_CHECKING, AsyncIterator, Optional
13
+
14
+ from .models import CdnMedia, TypingStatus, WeChatMessage, WeChatMessageItemType
15
+
16
+ if TYPE_CHECKING:
17
+ from .client import OpenClawClient
18
+
19
+ log = logging.getLogger(__name__)
20
+
21
+ # 收到消息后的占位语与兜底错误文案
22
+ THINKING_PLACEHOLDER = "嗯,我想想🤔。"
23
+ FALLBACK_ERROR_TEXT = "消息处理错误。"
24
+
25
+
26
+ class AgentEventType(Enum):
27
+ """Agent 事件类型"""
28
+
29
+ REASONING_START = "REASONING_START"
30
+ REASONING_DELTA = "REASONING_DELTA"
31
+ REASONING_END = "REASONING_END"
32
+ TOOL_RUNNING = "TOOL_RUNNING"
33
+ TOOL_COMPLETED = "TOOL_COMPLETED"
34
+ TOOL_FAILED = "TOOL_FAILED"
35
+ TEXT_DELTA = "TEXT_DELTA"
36
+ MESSAGE_COMPLETED = "MESSAGE_COMPLETED"
37
+ MESSAGE_FAILED = "MESSAGE_FAILED"
38
+
39
+
40
+ # 步骤完成事件:触发 typing 翻转 / 全文发送
41
+ STEP_COMPLETE_EVENTS = {AgentEventType.REASONING_END,
42
+ AgentEventType.TOOL_COMPLETED,
43
+ AgentEventType.MESSAGE_COMPLETED}
44
+
45
+
46
+ @dataclass
47
+ class AgentEvent:
48
+ """Agent 事件"""
49
+
50
+ event_type: AgentEventType
51
+ data: dict = field(default_factory=dict)
52
+
53
+ def to_markdown(self) -> str:
54
+ """渲染为可累积的文本片段:TEXT_DELTA 返回增量文本,其余事件默认空串"""
55
+ return self.data.get("text", "")
56
+
57
+
58
+ @dataclass
59
+ class Attachment:
60
+ """入站附件(图片/文件等,含 CdnMedia 引用)"""
61
+
62
+ item_type: int
63
+ media: Optional[CdnMedia] = None
64
+ file_name: Optional[str] = None
65
+
66
+
67
+ @dataclass
68
+ class AgentRunRequest:
69
+ """Agent 运行请求"""
70
+
71
+ user_input: str
72
+ user_id: str
73
+ user_nickname: Optional[str] = None
74
+ # 会话标识,用 WeChatMessage.session_id 填充
75
+ session_id: str = ""
76
+ attachments: list[Attachment] = field(default_factory=list)
77
+ # 扩展上下文(原始消息、context_token 等透传数据)
78
+ context: dict = field(default_factory=dict)
79
+
80
+
81
+ class BaseAgent(ABC):
82
+ """智能体抽象:任何后端实现 run 返回异步事件流即可插拔接入"""
83
+
84
+ @abstractmethod
85
+ def run(self, request: AgentRunRequest) -> AsyncIterator[AgentEvent]:
86
+ """运行智能体,返回异步事件流。实现方通常写成 async generator。"""
87
+ ...
88
+
89
+
90
+ def build_agent_request(msg: WeChatMessage) -> AgentRunRequest:
91
+ """从入站消息构造 AgentRunRequest:提取文本/语音 ASR 文本与媒体附件"""
92
+ texts: list[str] = []
93
+ attachments: list[Attachment] = []
94
+ for item in msg.item_list or []:
95
+ if item.type == WeChatMessageItemType.TEXT and item.text_item:
96
+ if item.text_item.text:
97
+ texts.append(item.text_item.text)
98
+ elif item.type == WeChatMessageItemType.VOICE and item.voice_item:
99
+ # 语音消息不下载音频:直接使用微信 ASR 转文字结果
100
+ if item.voice_item.text:
101
+ texts.append(item.voice_item.text)
102
+ elif item.type == WeChatMessageItemType.IMAGE and item.image_item:
103
+ attachments.append(Attachment(item_type=item.type,
104
+ media=item.image_item.media))
105
+ elif item.type == WeChatMessageItemType.FILE and item.file_item:
106
+ attachments.append(Attachment(item_type=item.type,
107
+ media=item.file_item.media,
108
+ file_name=item.file_item.file_name))
109
+ return AgentRunRequest(
110
+ user_input="\n".join(texts),
111
+ user_id=msg.from_user_id or "",
112
+ session_id=msg.session_id or "",
113
+ attachments=attachments,
114
+ context={"message": msg, "context_token": msg.context_token})
115
+
116
+
117
+ class Dispatcher:
118
+ """消息 -> Agent 编排(行为序列转录自 WeChatMessageListener.onMessage)。
119
+
120
+ typing 翻转规则:步骤完成事件先关 typing;MESSAGE_COMPLETED 时发送缓冲全文,
121
+ 其余完成事件重开 typing 让"输入中"重新闪烁;文本增量只缓冲不逐条发送。
122
+ """
123
+
124
+ def __init__(self, client: "OpenClawClient", agent: BaseAgent,
125
+ send_placeholder: bool = True) -> None:
126
+ self._client = client
127
+ self._agent = agent
128
+ self._send_placeholder = send_placeholder
129
+
130
+ async def on_message(self, msg: WeChatMessage) -> None:
131
+ request = build_agent_request(msg)
132
+ user_id = request.user_id
133
+ token = msg.context_token
134
+ buffer: list[str] = []
135
+ is_typing = True
136
+ await self._client.send_typing(user_id, token, TypingStatus.TYPING)
137
+ if self._send_placeholder:
138
+ try:
139
+ await self._client.send_text(user_id, THINKING_PLACEHOLDER, token)
140
+ except Exception:
141
+ log.warning("failed to send thinking placeholder, message_id=%s",
142
+ msg.message_id)
143
+ try:
144
+ async for event in self._agent.run(request):
145
+ if event.event_type in STEP_COMPLETE_EVENTS:
146
+ if is_typing:
147
+ await self._client.send_typing(user_id, token,
148
+ TypingStatus.CANCEL)
149
+ is_typing = False
150
+ if event.event_type is AgentEventType.MESSAGE_COMPLETED:
151
+ await self._client.send_text(user_id, "".join(buffer), token)
152
+ else:
153
+ await self._client.send_typing(user_id, token,
154
+ TypingStatus.TYPING)
155
+ is_typing = True
156
+ buffer.clear()
157
+ else:
158
+ if not is_typing:
159
+ await self._client.send_typing(user_id, token,
160
+ TypingStatus.TYPING)
161
+ is_typing = True
162
+ buffer.append(event.to_markdown())
163
+ except Exception:
164
+ log.exception("agent stream error, message_id=%s", msg.message_id)
165
+ try:
166
+ await self._client.send_text(user_id, FALLBACK_ERROR_TEXT, token)
167
+ except Exception:
168
+ log.exception("failed to send fallback error text, message_id=%s",
169
+ msg.message_id)
170
+
171
+
172
+ class EchoAgent(BaseAgent):
173
+ """参考实现一:回声机器人"""
174
+
175
+ async def run(self, request: AgentRunRequest) -> AsyncIterator[AgentEvent]:
176
+ yield AgentEvent(AgentEventType.TEXT_DELTA,
177
+ {"text": f"你说的是:{request.user_input}"})
178
+ yield AgentEvent(AgentEventType.MESSAGE_COMPLETED)
179
+
180
+
181
+ class OpenAICompatAgent(BaseAgent):
182
+ """参考实现二:对接任意 OpenAI 兼容 /chat/completions 流式接口。
183
+
184
+ 映射:SSE delta.content -> TEXT_DELTA,流结束 -> MESSAGE_COMPLETED;
185
+ 若接口带 reasoning_content 可映射为 REASONING_DELTA / REASONING_END。
186
+ """
187
+
188
+ def __init__(self, base_url: str, api_key: str, model: str) -> None:
189
+ import httpx
190
+ self._http = httpx.AsyncClient(
191
+ base_url=base_url,
192
+ headers={"Authorization": f"Bearer {api_key}"},
193
+ timeout=60.0)
194
+ self._model = model
195
+
196
+ async def run(self, request: AgentRunRequest) -> AsyncIterator[AgentEvent]:
197
+ messages = [{"role": "user", "content": request.user_input}]
198
+ async for event in self.stream_chat(messages):
199
+ yield event
200
+ yield AgentEvent(AgentEventType.MESSAGE_COMPLETED)
201
+
202
+ async def stream_chat(self, messages: list[dict]) -> AsyncIterator[AgentEvent]:
203
+ """按 OpenAI 格式 messages 流式调用(支持图文混合 content),
204
+ 产出 TEXT_DELTA 事件;末尾 MESSAGE_COMPLETED 由调用方自行补发。
205
+ """
206
+ payload = {"model": self._model, "stream": True, "messages": messages}
207
+ async with self._http.stream("POST", "/chat/completions",
208
+ json=payload) as resp:
209
+ if resp.is_error:
210
+ # 流式响应默认不读 body,出错时先读出来让异常信息带上服务端原文
211
+ await resp.aread()
212
+ resp.raise_for_status()
213
+ async for line in resp.aiter_lines():
214
+ if not line.startswith("data: ") or line == "data: [DONE]":
215
+ continue
216
+ chunk = json.loads(line[6:])
217
+ # usage 统计块等场景 choices 为空数组,跳过以免下标越界
218
+ choices = chunk.get("choices") or []
219
+ if not choices:
220
+ continue
221
+ delta = (choices[0].get("delta") or {}).get("content")
222
+ if delta:
223
+ yield AgentEvent(AgentEventType.TEXT_DELTA, {"text": delta})
wechat_openclaw/api.py ADDED
@@ -0,0 +1,222 @@
1
+ """OpenClawAPI —— OpenClaw HTTP 协议封装。
2
+
3
+ 5 个 POST JSON 端点 + CDN octet-stream 上传:
4
+ - /ilink/bot/getupdates 长轮询拉取消息,超时 35s(客户端超时视为正常空响应)
5
+ - /ilink/bot/sendmessage 发送消息,超时 15s
6
+ - /ilink/bot/getuploadurl 获取 CDN 上传 URL,超时 15s
7
+ - /ilink/bot/getconfig 获取配置(typing_ticket),超时 10s
8
+ - /ilink/bot/sendtyping 发送输入状态,超时 10s
9
+ """
10
+ from __future__ import annotations
11
+
12
+ import json
13
+ import logging
14
+ from typing import Any, Optional
15
+
16
+ import httpx
17
+
18
+ from .config import get_base_url
19
+ from .models import (
20
+ GetConfigRequest,
21
+ GetConfigResponse,
22
+ GetUpdatesRequest,
23
+ GetUpdatesResponse,
24
+ GetUploadUrlRequest,
25
+ GetUploadUrlResponse,
26
+ SendMessageRequest,
27
+ SendTypingRequest,
28
+ )
29
+ from .utils.ids import random_wechat_uin, redact_url
30
+ from .utils.retry import retry_async
31
+
32
+ log = logging.getLogger(__name__)
33
+
34
+ # SDK 内部异常分类标识
35
+ ERR_API_FAILURE = "ERR_API_FAILURE"
36
+ ERR_SESSION_EXPIRED = "ERR_SESSION_EXPIRED"
37
+
38
+
39
+ class OpenClawApiError(RuntimeError):
40
+ """一般 API 失败(业务错误,不重试)"""
41
+
42
+ def __init__(self, error_code: str, message: str,
43
+ api_errcode: Optional[int] = None,
44
+ raw_text: Optional[str] = None) -> None:
45
+ super().__init__(message)
46
+ self.error_code = error_code
47
+ self.api_errcode = api_errcode
48
+ self.raw_text = raw_text
49
+
50
+
51
+ class SessionExpiredError(OpenClawApiError):
52
+ """errcode == -14 会话过期(bot_token 失效),不可重试,需重新扫码登录"""
53
+
54
+ def __init__(self, message: str, raw_text: Optional[str] = None) -> None:
55
+ super().__init__(ERR_SESSION_EXPIRED, message,
56
+ api_errcode=OpenClawAPI.SESSION_EXPIRED_ERRCODE,
57
+ raw_text=raw_text)
58
+
59
+
60
+ class OpenClawAPI:
61
+ """OpenClaw HTTP 协议客户端,与单个 bot_token 绑定"""
62
+
63
+ LONG_POLL_TIMEOUT = 35.0
64
+ API_TIMEOUT = 15.0
65
+ CONFIG_TIMEOUT = 10.0
66
+ SESSION_EXPIRED_ERRCODE = -14
67
+ UPLOAD_MAX_RETRIES = 3
68
+
69
+ def __init__(self, bot_token: str,
70
+ base_url: str | None = None,
71
+ http: httpx.AsyncClient | None = None,
72
+ verify_ssl: bool = True) -> None:
73
+ self._bot_token = bot_token
74
+ self._base_url = (base_url or get_base_url()).rstrip("/")
75
+ # 默认超时按普通 API 配置,长轮询请求单独覆盖 read 超时
76
+ # 跟随重定向以兼容网关侧 302;verify_ssl=False 供裸 IP 部署(证书 hostname
77
+ # 不匹配)使用,仅作用于自持有客户端,外部传入的 http 不受影响
78
+ self._http = http or httpx.AsyncClient(
79
+ timeout=httpx.Timeout(connect=10.0, read=self.API_TIMEOUT,
80
+ write=15.0, pool=10.0),
81
+ follow_redirects=True, verify=verify_ssl)
82
+ self._own_http = http is None
83
+
84
+ async def aclose(self) -> None:
85
+ """关闭自持有的 HTTP 连接池"""
86
+ if self._own_http:
87
+ await self._http.aclose()
88
+
89
+ def _headers(self) -> dict[str, str]:
90
+ """公共请求头;X-WECHAT-UIN 每次请求随机生成"""
91
+ headers = {
92
+ "Content-Type": "application/json",
93
+ "AuthorizationType": "ilink_bot_token",
94
+ "X-WECHAT-UIN": random_wechat_uin(),
95
+ }
96
+ # bot_token 为空时不携带 Authorization 头
97
+ if self._bot_token:
98
+ headers["Authorization"] = f"Bearer {self._bot_token}"
99
+ return headers
100
+
101
+ @staticmethod
102
+ def _ensure_api_success(label: str, text: str) -> None:
103
+ """统一响应校验。
104
+
105
+ 空体视为成功;ret != 0 或 errcode != 0 即失败;errcode == -14 会话过期。
106
+ """
107
+ if not text:
108
+ return
109
+ try:
110
+ body: dict[str, Any] = json.loads(text)
111
+ except (json.JSONDecodeError, ValueError) as e:
112
+ raise OpenClawApiError(
113
+ ERR_API_FAILURE, f"{label} returned non-JSON response",
114
+ raw_text=text[:200]) from e
115
+ ret = body.get("ret") or 0
116
+ errcode = body.get("errcode") or 0
117
+ if ret == 0 and errcode == 0:
118
+ return
119
+ errmsg = body.get("errmsg") or f"{label} failed with ret={ret}, errcode={errcode}"
120
+ if errcode == OpenClawAPI.SESSION_EXPIRED_ERRCODE:
121
+ raise SessionExpiredError(errmsg, raw_text=text[:200])
122
+ raise OpenClawApiError(ERR_API_FAILURE, errmsg,
123
+ api_errcode=errcode, raw_text=text[:200])
124
+
125
+ async def _post(self, path: str, payload: dict, timeout: float, label: str) -> str:
126
+ """发送 POST JSON 请求并做统一校验,返回原始响应体文本"""
127
+ url = self._base_url + path
128
+ resp = await self._http.post(url, json=payload,
129
+ headers=self._headers(), timeout=timeout)
130
+ resp.raise_for_status()
131
+ text = resp.text
132
+ if log.isEnabledFor(logging.DEBUG):
133
+ log.debug("%s response: %s", label, text[:200])
134
+ self._ensure_api_success(label, text)
135
+ return text
136
+
137
+ async def _post_with_retry(self, path: str, payload: dict,
138
+ timeout: float, label: str) -> str:
139
+ """带重试的 POST:会话过期与业务错误不重试,仅网络层异常重试"""
140
+ return await retry_async(
141
+ lambda: self._post(path, payload, timeout, label),
142
+ no_retry=(OpenClawApiError,), label=label)
143
+
144
+ async def get_updates(self, req: GetUpdatesRequest) -> GetUpdatesResponse:
145
+ """长轮询拉取消息。客户端超时属正常现象(服务端无消息时持有请求),
146
+ 此时合成空响应(ret=0, msgs=[], buf 原样),不抛异常。
147
+ """
148
+ try:
149
+ text = await self._post("/ilink/bot/getupdates", req.to_payload(),
150
+ self.LONG_POLL_TIMEOUT, "getupdates")
151
+ except httpx.TimeoutException:
152
+ log.debug("getupdates client timeout, treat as empty response")
153
+ return GetUpdatesResponse(ret=0, errcode=0, msgs=[],
154
+ get_updates_buf=req.get_updates_buf)
155
+ if not text:
156
+ return GetUpdatesResponse(ret=0, errcode=0, msgs=[],
157
+ get_updates_buf=req.get_updates_buf)
158
+ return GetUpdatesResponse.model_validate_json(text)
159
+
160
+ async def send_message(self, req: SendMessageRequest) -> None:
161
+ """发送消息(文本/媒体统一入口)"""
162
+ await self._post_with_retry("/ilink/bot/sendmessage", req.to_payload(),
163
+ self.API_TIMEOUT, "sendmessage")
164
+
165
+ async def get_upload_url(self, req: GetUploadUrlRequest) -> GetUploadUrlResponse:
166
+ """获取 CDN 上传 URL"""
167
+ text = await self._post_with_retry("/ilink/bot/getuploadurl", req.to_payload(),
168
+ self.API_TIMEOUT, "getuploadurl")
169
+ return GetUploadUrlResponse.model_validate_json(text) if text \
170
+ else GetUploadUrlResponse()
171
+
172
+ async def get_config(self, ilink_user_id: str, context_token: str) -> GetConfigResponse:
173
+ """获取配置(typing_ticket)"""
174
+ req = GetConfigRequest(ilink_user_id=ilink_user_id, context_token=context_token)
175
+ text = await self._post_with_retry("/ilink/bot/getconfig", req.to_payload(),
176
+ self.CONFIG_TIMEOUT, "getconfig")
177
+ return GetConfigResponse.model_validate_json(text) if text \
178
+ else GetConfigResponse()
179
+
180
+ async def send_typing(self, req: SendTypingRequest) -> None:
181
+ """发送输入状态"""
182
+ await self._post_with_retry("/ilink/bot/sendtyping", req.to_payload(),
183
+ self.CONFIG_TIMEOUT, "sendtyping")
184
+
185
+ async def upload_to_cdn(self, cdn_url: str, encrypted_data: bytes,
186
+ file_key: str) -> str:
187
+ """CDN 上传:POST octet-stream 密文,返回响应头 x-encrypted-param。
188
+
189
+ 只携带 Content-Type: application/octet-stream,不携带任何公共认证头;
190
+ 4xx 客户端错误立即失败不重试,5xx / 网络异常最多重试 3 次,每次超时 15s。
191
+ """
192
+ last_exc: Exception | None = None
193
+ for attempt in range(1, self.UPLOAD_MAX_RETRIES + 1):
194
+ try:
195
+ resp = await self._http.post(
196
+ cdn_url, content=encrypted_data,
197
+ headers={"Content-Type": "application/octet-stream"},
198
+ timeout=self.API_TIMEOUT)
199
+ if 400 <= resp.status_code < 500:
200
+ err = resp.headers.get("x-error-message", "")
201
+ raise OpenClawApiError(
202
+ ERR_API_FAILURE,
203
+ f"cdn upload client error, status={resp.status_code}, "
204
+ f"error={err}, file_key={file_key}")
205
+ resp.raise_for_status()
206
+ param = resp.headers.get("x-encrypted-param")
207
+ if not param:
208
+ err = resp.headers.get("x-error-message", "")
209
+ raise httpx.HTTPError(
210
+ f"cdn upload missing x-encrypted-param, error={err}")
211
+ log.info("cdn upload succeeded, file_key=%s, url=%s",
212
+ file_key, redact_url(cdn_url))
213
+ return param
214
+ except OpenClawApiError:
215
+ raise
216
+ except Exception as e:
217
+ last_exc = e
218
+ log.warning("cdn upload failed (attempt %d/%d), url=%s: %s",
219
+ attempt, self.UPLOAD_MAX_RETRIES,
220
+ redact_url(cdn_url), type(e).__name__)
221
+ assert last_exc is not None
222
+ raise last_exc