File size: 15,304 Bytes
bc0b11f
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
"""
Anuma AI Client — Privy authentication and chat API SDK.
"""

import time
import json
import base64
import uuid
from typing import Dict, Any, List, Optional, Callable

import requests

def get_jwt_expiry(token: str) -> Optional[int]:
    try:
        payload_b64 = token.split('.')[1]
        payload_b64 += '=' * (4 - len(payload_b64) % 4)
        payload = json.loads(base64.b64decode(payload_b64))
        return payload.get("exp")
    except: return None

class TemporaryMailClient:
    def __init__(self, api_key: str, base_url: str = "https://maliapi.215.im/v1"):
        self.api_key = api_key
        self.base_url = base_url
        self.account_id, self.address, self.temp_token, self.proxies = None, None, None, None

    def create_account(self, local_part: str, domain: Optional[str] = None) -> Dict[str, Any]:
        payload = {"localPart": local_part}
        if domain: payload["domain"] = domain
        kwargs = {"headers": {"X-API-Key": self.api_key}, "json": payload}
        if self.proxies: kwargs["proxies"] = self.proxies
        res = requests.post(f"{self.base_url}/accounts", **kwargs).json()
        if res.get("success"):
            data = res.get("data", {})
            self.account_id = data.get("id")
            self.address = data.get("address")
            self.temp_token = data.get("token") or data.get("access_token") or data.get("tempToken") or res.get("token")
        return res

    def _mail_headers(self) -> Dict[str, str]:
        headers = {"X-API-Key": self.api_key}
        if self.temp_token:
            headers["Authorization"] = f"Bearer {self.temp_token}"
        return headers

    def get_messages_response(self) -> Dict[str, Any]:
        kwargs = {"headers": self._mail_headers(), "params": {"address": self.address}}
        if self.proxies: kwargs["proxies"] = self.proxies
        return requests.get(f"{self.base_url}/messages", **kwargs).json()

    def get_messages(self) -> List[Dict[str, Any]]:
        res = self.get_messages_response()
        data = res.get("data", {})
        if isinstance(data, dict):
            return data.get("messages", [])
        if isinstance(data, list):
            return data
        return []

    def get_message(self, message_id: str) -> Dict[str, Any]:
        kwargs = {"headers": self._mail_headers(), "params": {"address": self.address}}
        if self.proxies: kwargs["proxies"] = self.proxies
        return requests.get(f"{self.base_url}/messages/{message_id}", **kwargs).json()

    def wait_for_code(self, timeout: int = 120, progress: Optional[Callable[[str], None]] = None) -> str:
        import re
        start = time.time()
        last_log = 0
        while time.time() - start < timeout:
            elapsed = int(time.time() - start)
            try:
                res = self.get_messages_response()
            except Exception as e:
                if progress and elapsed - last_log >= 10:
                    progress(f"查询邮箱失败: {e}")
                    last_log = elapsed
                time.sleep(3)
                continue

            data = res.get("data", {}) if isinstance(res, dict) else {}
            if isinstance(data, dict):
                messages = data.get("messages", [])
            elif isinstance(data, list):
                messages = data
            else:
                messages = []

            if progress and elapsed - last_log >= 10:
                if isinstance(res, dict) and not res.get("success", True):
                    progress(f"邮箱接口错误: {res.get('error') or res.get('errorCode') or res}")
                else:
                    progress(f"等待邮箱验证码中... {elapsed}s,当前邮件 {len(messages)} 封")
                last_log = elapsed

            for msg in messages:
                message_id = msg.get("id") or msg.get("messageId")
                if not message_id:
                    continue
                detail = self.get_message(message_id).get("data", {})
                text = detail.get("text") or detail.get("body") or detail.get("html") or msg.get("text") or msg.get("subject") or ""
                code = re.search(r'\b(\d{6})\b', text)
                if code: return code.group(1)
            time.sleep(3)
        raise TimeoutError("Timeout")

class AnumaClient:
    def __init__(self, mail_api_key: str, proxies: Optional[Dict[str, str]] = None,
                 mail_proxies: Optional[Dict[str, str]] = None,
                 mail_base_url: str = "https://maliapi.215.im/v1"):
        self.api_key = mail_api_key
        self.mail_client = TemporaryMailClient(mail_api_key, base_url=mail_base_url)
        self.mail_client.proxies = mail_proxies
        self.proxies = proxies or {}
        self.session = requests.Session()
        if proxies: self.session.proxies.update(proxies)
        self.privy_auth_url = "https://auth.privy.io/api/v1"
        self.portal_url = "https://portal.anuma.ai/api/v1"
        self.chat_url = "https://chat.anuma.ai/api"
        self.identity_token, self.privy_access_token, self.refresh_token = None, None, None
        self.wallet_address = None

    def _get_headers(self, with_auth: bool = True) -> Dict[str, str]:
        # 完全模仿成功 curl 的 Headers
        headers = {
            "accept": "application/json",
            "accept-language": "zh-CN,zh;q=0.9",
            "cache-control": "no-cache",
            "content-type": "application/json",
            "origin": "https://chat.anuma.ai",
            "pragma": "no-cache",
            "priority": "u=1, i",
            "referer": "https://chat.anuma.ai/",
            "sec-ch-ua": '"Google Chrome";v="147", "Not.A/Brand";v="8", "Chromium";v="147"',
            "sec-ch-ua-mobile": "?0",
            "sec-ch-ua-platform": '"macOS"',
            "sec-fetch-dest": "empty",
            "sec-fetch-mode": "cors",
            "sec-fetch-site": "cross-site",
            "sec-fetch-storage-access": "active",
            "user-agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/147.0.0.0 Safari/537.36",
            "x-privacy-mode": "standard" # 改为作为 Header
        }
        headers["privy-app-id"] = "cmjrfihuc03h8l10ca0bi9o2y"
        headers["privy-client"] = "react-auth:3.14.1"
        headers["privy-ca-id"] = str(uuid.uuid4())
        if with_auth and self.identity_token:
            headers["authorization"] = f"Bearer {self.identity_token}"
        return headers

    def _json_or_error(self, resp, label: str) -> Dict[str, Any]:
        try:
            data = resp.json()
        except Exception:
            data = {"text": resp.text[:500]}
        if resp.status_code != 200:
            raise Exception(f"{label} failed: HTTP {resp.status_code}, {data}")
        if isinstance(data, dict) and data.get("error"):
            raise Exception(f"{label} failed: {data}")
        return data

    def signup(self, mailbox_local_part: Optional[str] = None, domain: Optional[str] = None, progress: Optional[Callable[[str], None]] = None) -> Dict[str, Any]:
        def log_step(message: str):
            if progress:
                progress(message)

        if not mailbox_local_part: mailbox_local_part = f"anuma_{int(time.time())}"
        log_step(f"创建临时邮箱: {mailbox_local_part}{'@' + domain if domain else ''}")
        mail_res = self.mail_client.create_account(mailbox_local_part, domain=domain)
        if not mail_res.get("success"):
            if not domain:
                log_step("默认邮箱域名创建失败,切换到 0m0.app")
                mail_res = self.mail_client.create_account(mailbox_local_part, domain="0m0.app")
            if not mail_res.get("success"): raise Exception(f"Mail Error: {mail_res}")
        email = self.mail_client.address
        log_step(f"邮箱创建成功: {email}")
        s = self.session
        log_step("初始化 Privy 会话")
        s.post(f"{self.privy_auth_url}/analytics_events", headers=self._get_headers(False), json={"event_name": "sdk_initialize", "client_id": "582a28dc-edef-4b36-b2e1-cd1662bd3494", "payload": {"embeddedWallets": {"ethereum": {"createOnLogin": "all-users"}}, "supportedChains": [7001], "clientTimestamp": time.strftime("%Y-%m-%dT%H:%M:%S.000Z")}} )
        log_step("发送邮箱验证码")
        init_resp = s.post(f"{self.privy_auth_url}/passwordless/init", headers=self._get_headers(False), json={"email": email})
        try:
            init_data = init_resp.json()
        except Exception:
            init_data = {"text": init_resp.text[:300]}
        if init_resp.status_code != 200:
            raise Exception(f"Passwordless init failed: HTTP {init_resp.status_code}, {init_data}")
        if isinstance(init_data, dict) and (init_data.get("error") or init_data.get("message")):
            log_step(f"验证码发送响应: {init_data.get('error') or init_data.get('message')}")
        else:
            log_step("验证码发送请求成功")
        code = self.mail_client.wait_for_code(progress=log_step)
        log_step("收到邮箱验证码,开始登录")
        auth_res = self._json_or_error(
            s.post(f"{self.privy_auth_url}/passwordless/authenticate", headers=self._get_headers(False), json={"email": email, "code": code, "mode": "login-or-sign-up"}),
            "Passwordless authenticate"
        )
        if "refresh_token" not in auth_res or "privy_access_token" not in auth_res:
            raise Exception(f"Passwordless authenticate failed: {auth_res}")
        self.refresh_token = auth_res["refresh_token"]
        self.privy_access_token = auth_res["privy_access_token"]
        log_step("刷新 identity token")
        sess_res = self._json_or_error(
            s.post(f"{self.privy_auth_url}/sessions", headers={"Authorization": f"Bearer {self.privy_access_token}", **self._get_headers(False)}, json={"refresh_token": self.refresh_token}),
            "Create session"
        )
        if "identity_token" not in sess_res:
            raise Exception(f"Create session failed: {sess_res}")
        self.identity_token = sess_res["identity_token"]
        self.privy_access_token = sess_res.get("privy_access_token", self.privy_access_token)
        log_step("创建钱包")
        wallet_res = self._json_or_error(
            s.post(f"{self.privy_auth_url}/wallets", headers={"Authorization": f"Bearer {sess_res['privy_access_token']}", **self._get_headers(False)}, json={"chain_type": "ethereum"}),
            "Create wallet"
        )
        self.wallet_address = wallet_res.get("address") or wallet_res.get("data", {}).get("address")
        log_step("同步钱包信息")
        final_sess = self._json_or_error(
            s.post(f"{self.privy_auth_url}/sessions", headers={"Authorization": f"Bearer {sess_res['privy_access_token']}", **self._get_headers(False)}, json={"refresh_token": self.refresh_token}),
            "Refresh session"
        )
        if "identity_token" not in final_sess:
            raise Exception(f"Refresh session failed: {final_sess}")
        self.identity_token = final_sess["identity_token"]
        self.privy_access_token = final_sess.get("privy_access_token", self.privy_access_token)
        self.refresh_token = final_sess.get("refresh_token", self.refresh_token)
        log_step("查询额度")
        return {"email": email, "credits": self.get_balance(), "access_token": self.privy_access_token, "identity_token": self.identity_token}

    def refresh_id_token(self, refresh_token: str, access_token: str) -> str:
        headers = self._get_headers(False)
        headers.update({
            "authorization": f"Bearer {access_token}",
            "accept": "application/json",
            "privy-app-id": "cmjrfihuc03h8l10ca0bi9o2y",
            "privy-client": "react-auth:3.14.1",
            "privy-ca-id": str(uuid.uuid4()),
            "sec-fetch-site": "cross-site",
            "sec-fetch-storage-access": "active",
        })
        resp = self.session.post(f"{self.privy_auth_url}/sessions", headers=headers, json={"refresh_token": refresh_token})
        try:
            res = resp.json()
        except Exception:
            raise Exception(f"Refresh Error: HTTP {resp.status_code}, {resp.text[:500]}")

        if resp.status_code != 200 or "identity_token" not in res:
            err = res.get("error") or res.get("message") or res.get("code") or res
            raise Exception(f"Refresh Error: HTTP {resp.status_code}, {err}")

        self.identity_token = res["identity_token"]
        self.privy_access_token = res.get("privy_access_token", access_token)
        self.refresh_token = res.get("refresh_token", refresh_token)
        return self.identity_token

    def _prepare_payload(self, messages, model, max_tokens, stream, tools):
        anuma_input = []
        for msg in messages:
            content = msg["content"]
            if isinstance(content, str): c_block = [{"type": "text", "text": content}]
            else:
                c_block = []
                for b in content:
                    if b.get("type") == "text": c_block.append({"type": "text", "text": b.get("text", "")})
                    elif b.get("type") == "tool_use": c_block.append({"type": "tool_call", "id": b.get("id"), "name": b.get("name"), "parameters": b.get("input", {})})
                    elif b.get("type") == "tool_result": c_block.append({"type": "tool_result", "tool_use_id": b.get("tool_use_id"), "content": b.get("content") if isinstance(b.get("content"), str) else json.dumps(b.get("content"))})
            anuma_input.append({"role": msg["role"], "content": c_block})

        payload = {
            "input": anuma_input,
            "model": model,
            "stream": stream,
            "max_output_tokens": max_tokens,
            "conversation_id": str(uuid.uuid4())
        }
        # if tools: payload["tools"] = tools # 这里在 api_server 会过滤掉,但这层保留兼容性
        return payload

    def chat_stream(self, messages, model, max_tokens=32000, tools=None):
        payload = self._prepare_payload(messages, model, max_tokens, True, tools)
        res = self.session.post(f"{self.portal_url}/responses", headers=self._get_headers(), json=payload, stream=True)
        if res.status_code != 200: raise Exception(f"Chat Error: {res.text}")
        for line in res.iter_lines():
            if line:
                line_s = line.decode('utf-8')
                if line_s.startswith('data: '):
                    data = line_s[6:]
                    if data.strip() and data.strip() != '[DONE]':
                        try: yield json.loads(data)
                        except: continue

    def chat(self, messages, model, stream=False, max_tokens=32000, tools=None):
        payload = self._prepare_payload(messages, model, max_tokens, False, tools)
        res = self.session.post(f"{self.portal_url}/responses", headers=self._get_headers(), json=payload)
        if res.status_code != 200: raise Exception(f"Chat Error: {res.text}")
        return res.json()

    def get_balance(self):
        res = self.session.get(f"{self.portal_url}/credits/balance", headers=self._get_headers())
        if res.status_code != 200: raise Exception(f"Balance Error: {res.text}")
        return res.json()