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.
- wechat_openclaw/__init__.py +50 -0
- wechat_openclaw/agent.py +223 -0
- wechat_openclaw/api.py +222 -0
- wechat_openclaw/client.py +424 -0
- wechat_openclaw/config.py +20 -0
- wechat_openclaw/connection.py +174 -0
- wechat_openclaw/coordinator.py +183 -0
- wechat_openclaw/models.py +290 -0
- wechat_openclaw/qrlogin.py +207 -0
- wechat_openclaw/utils/__init__.py +1 -0
- wechat_openclaw/utils/crypto.py +73 -0
- wechat_openclaw/utils/ids.py +42 -0
- wechat_openclaw/utils/markdown.py +412 -0
- wechat_openclaw/utils/retry.py +50 -0
- wechat_openclaw_sdk-0.1.0.dist-info/METADATA +199 -0
- wechat_openclaw_sdk-0.1.0.dist-info/RECORD +19 -0
- wechat_openclaw_sdk-0.1.0.dist-info/WHEEL +5 -0
- wechat_openclaw_sdk-0.1.0.dist-info/licenses/LICENSE +21 -0
- wechat_openclaw_sdk-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -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
|
+
]
|
wechat_openclaw/agent.py
ADDED
|
@@ -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
|