datadid-sdk-python 1.0.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.
src/data/client.py ADDED
@@ -0,0 +1,473 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any, Optional
4
+
5
+ import httpx
6
+
7
+ from ..errors import DataDIDApiError
8
+ from .types import (
9
+ ActionRecord,
10
+ AuthMeResponse,
11
+ AuthTokens,
12
+ DataClientOptions,
13
+ DiscordInfo,
14
+ EvmLoginResult,
15
+ PiInfo,
16
+ TelegramInfo,
17
+ TwitterInfo,
18
+ UserInfo,
19
+ )
20
+
21
+ _PRODUCTION_URL = "https://data-be.metamemo.one"
22
+ _TEST_URL = "https://test-data-be-v2.memolabs.net"
23
+
24
+
25
+ class DataClient:
26
+ """
27
+ Async REST client for the DataDID platform API (data-be).
28
+
29
+ Wraps authentication, user info, and action record endpoints.
30
+ """
31
+
32
+ def __init__(self, options: DataClientOptions) -> None:
33
+ self._base_url: str = options.base_url.rstrip("/")
34
+ self._access_token: Optional[str] = None
35
+ self._disable_auto_token: bool = options.disable_auto_token
36
+
37
+ @classmethod
38
+ def production(cls) -> DataClient:
39
+ """Create a client pointing to production (https://data-be.metamemo.one)."""
40
+ return cls(DataClientOptions(base_url=_PRODUCTION_URL))
41
+
42
+ @classmethod
43
+ def testnet(cls) -> DataClient:
44
+ """Create a client pointing to the test server (https://test-data-be-v2.memolabs.net)."""
45
+ return cls(DataClientOptions(base_url=_TEST_URL))
46
+
47
+ def set_access_token(self, token: str) -> None:
48
+ """Manually set the access token for authenticated requests."""
49
+ self._access_token = token
50
+
51
+ def get_access_token(self) -> Optional[str]:
52
+ """Return the currently stored access token, or None."""
53
+ return self._access_token
54
+
55
+ # ── Auth endpoints ───────────────────────────────────────────
56
+
57
+ async def send_email_code(self, email: str) -> None:
58
+ """
59
+ Send a login verification code to the given email.
60
+
61
+ POST /v2/login/email/code
62
+ """
63
+ await self._request("POST", "/v2/login/email/code", {"email": email})
64
+
65
+ async def login_with_email(
66
+ self,
67
+ email: str,
68
+ code: str,
69
+ source: str,
70
+ useragent: Optional[str] = None,
71
+ ) -> AuthTokens:
72
+ """
73
+ Log in with email and verification code.
74
+
75
+ POST /v2/login/email
76
+ """
77
+ body: dict[str, Any] = {"email": email, "code": code, "source": source}
78
+ if useragent is not None:
79
+ body["useragent"] = useragent
80
+ data = await self._request("POST", "/v2/login/email", body)
81
+ tokens = self._extract_tokens(data)
82
+ self._auto_set_token(tokens.access_token)
83
+ return tokens
84
+
85
+ async def register_with_email(
86
+ self,
87
+ email: str,
88
+ code: str,
89
+ password: str,
90
+ source: str,
91
+ useragent: Optional[str] = None,
92
+ ) -> AuthTokens:
93
+ """
94
+ Register a new user with email, verification code, and password.
95
+
96
+ POST /v2/login/email/register
97
+ """
98
+ body: dict[str, Any] = {
99
+ "email": email,
100
+ "code": code,
101
+ "password": password,
102
+ "source": source,
103
+ }
104
+ if useragent is not None:
105
+ body["useragent"] = useragent
106
+ data = await self._request("POST", "/v2/login/email/register", body)
107
+ tokens = self._extract_tokens(data)
108
+ self._auto_set_token(tokens.access_token)
109
+ return tokens
110
+
111
+ async def login_with_email_password(self, email: str, password: str) -> AuthTokens:
112
+ """
113
+ Log in with email and password.
114
+
115
+ POST /v2/login/email/password
116
+ """
117
+ data = await self._request(
118
+ "POST",
119
+ "/v2/login/email/password",
120
+ {"email": email, "password": password},
121
+ )
122
+ tokens = self._extract_tokens(data)
123
+ self._auto_set_token(tokens.access_token)
124
+ return tokens
125
+
126
+ async def reset_password(self, email: str, code: str, new_password: str) -> None:
127
+ """
128
+ Reset password with email and verification code.
129
+
130
+ POST /v2/login/email/password/reset
131
+ """
132
+ await self._request(
133
+ "POST",
134
+ "/v2/login/email/password/reset",
135
+ {"email": email, "code": code, "new_password": new_password},
136
+ )
137
+
138
+ async def login_with_telegram(
139
+ self,
140
+ initdata: str,
141
+ source: str,
142
+ useragent: Optional[str] = None,
143
+ ) -> AuthTokens:
144
+ """
145
+ Log in with Telegram init data.
146
+
147
+ POST /v2/login/telegram
148
+ """
149
+ body: dict[str, Any] = {"initdata": initdata, "source": source}
150
+ if useragent is not None:
151
+ body["useragent"] = useragent
152
+ data = await self._request("POST", "/v2/login/telegram", body)
153
+ tokens = self._extract_tokens(data)
154
+ self._auto_set_token(tokens.access_token)
155
+ return tokens
156
+
157
+ async def login_with_pi(
158
+ self,
159
+ pi_access_token: str,
160
+ source: str,
161
+ useragent: Optional[str] = None,
162
+ ) -> AuthTokens:
163
+ """
164
+ Log in with Pi browser access token.
165
+
166
+ POST /v2/login/pi
167
+ """
168
+ body: dict[str, Any] = {"source": source}
169
+ if useragent is not None:
170
+ body["useragent"] = useragent
171
+ data = await self._request(
172
+ "POST",
173
+ "/v2/login/pi",
174
+ body,
175
+ extra_headers={"Authorization": pi_access_token},
176
+ )
177
+ tokens = self._extract_tokens(data)
178
+ self._auto_set_token(tokens.access_token)
179
+ return tokens
180
+
181
+ async def get_evm_challenge(
182
+ self,
183
+ address: str,
184
+ chain_id: Optional[int] = None,
185
+ origin: Optional[str] = None,
186
+ ) -> str:
187
+ """
188
+ Get the sign-in challenge message for EVM wallet login.
189
+
190
+ GET /v2/login/evm/challenge
191
+ """
192
+ params: dict[str, str] = {"address": address}
193
+ if chain_id is not None:
194
+ params["chainid"] = str(chain_id)
195
+ query = "&".join(f"{k}={v}" for k, v in params.items())
196
+ headers: dict[str, str] = {}
197
+ if origin is not None:
198
+ headers["Origin"] = origin
199
+ data = await self._request(
200
+ "GET",
201
+ f"/v2/login/evm/challenge?{query}",
202
+ extra_headers=headers or None,
203
+ )
204
+ return str(data)
205
+
206
+ async def login_with_evm(
207
+ self,
208
+ message: str,
209
+ signature: str,
210
+ source: str,
211
+ useragent: Optional[str] = None,
212
+ ) -> EvmLoginResult:
213
+ """
214
+ Log in with EVM wallet signature.
215
+
216
+ POST /v2/login/evm
217
+ """
218
+ body: dict[str, Any] = {
219
+ "message": message,
220
+ "signature": signature,
221
+ "source": source,
222
+ }
223
+ if useragent is not None:
224
+ body["useragent"] = useragent
225
+ data = await self._request("POST", "/v2/login/evm", body)
226
+ result = EvmLoginResult(
227
+ access_token=(
228
+ data.get("access_token")
229
+ or data.get("accessToken")
230
+ or data.get("AccessToken")
231
+ or ""
232
+ ),
233
+ refresh_token=(
234
+ data.get("refresh_token")
235
+ or data.get("refreshToken")
236
+ or data.get("RefreshToken")
237
+ or ""
238
+ ),
239
+ did=data.get("did", ""),
240
+ number=data.get("number", ""),
241
+ )
242
+ self._auto_set_token(result.access_token)
243
+ return result
244
+
245
+ async def refresh_token(self, refresh_token: str) -> str:
246
+ """
247
+ Get a new access token using the refresh token.
248
+
249
+ POST /v2/login/refresh
250
+ """
251
+ data = await self._request(
252
+ "POST",
253
+ "/v2/login/refresh",
254
+ extra_headers={"Authorization": refresh_token},
255
+ )
256
+ new_token = (
257
+ data.get("access_token")
258
+ or data.get("accessToken")
259
+ or data.get("AccessToken")
260
+ or ""
261
+ )
262
+ self._auto_set_token(new_token)
263
+ return new_token
264
+
265
+ # ── User endpoints ───────────────────────────────────────────
266
+
267
+ async def get_me(self) -> AuthMeResponse:
268
+ """
269
+ Validate access token and get current user's basic info.
270
+
271
+ GET /v2/auth/me
272
+ """
273
+ data = await self._authenticated_request("GET", "/v2/auth/me")
274
+ return AuthMeResponse(
275
+ uid=data.get("uid", ""),
276
+ email=data.get("email", ""),
277
+ username=data.get("username", ""),
278
+ role=data.get("role", ""),
279
+ )
280
+
281
+ async def get_user_info(self) -> UserInfo:
282
+ """
283
+ Get the current logged-in user's full info.
284
+
285
+ GET /v2/user/info
286
+ """
287
+ data = await self._authenticated_request("GET", "/v2/user/info")
288
+
289
+ pi_raw = data.get("pi_info")
290
+ pi_info = (
291
+ PiInfo(
292
+ pi_id=pi_raw.get("pi_id", ""),
293
+ pi_user_name=pi_raw.get("pi_user_name", ""),
294
+ pi_address=pi_raw.get("pi_address", ""),
295
+ )
296
+ if pi_raw
297
+ else None
298
+ )
299
+
300
+ tw_raw = data.get("twitter_info")
301
+ twitter_info = (
302
+ TwitterInfo(
303
+ twitter_id=tw_raw.get("twitter_id", ""),
304
+ twitter_name=tw_raw.get("twitter_name", ""),
305
+ twitter_user_name=tw_raw.get("twitter_user_name", ""),
306
+ )
307
+ if tw_raw
308
+ else None
309
+ )
310
+
311
+ tg_raw = data.get("telegram_info")
312
+ telegram_info = (
313
+ TelegramInfo(
314
+ telegram_id=tg_raw.get("telegram_id", 0),
315
+ telegram_first_name=tg_raw.get("telegram_first_name", ""),
316
+ telegram_last_name=tg_raw.get("telegram_last_name", ""),
317
+ telegram_user_name=tg_raw.get("telegram_user_name", ""),
318
+ telegram_photo=tg_raw.get("telegram_photo", ""),
319
+ )
320
+ if tg_raw
321
+ else None
322
+ )
323
+
324
+ dc_raw = data.get("discord_info")
325
+ discord_info = (
326
+ DiscordInfo(
327
+ discord_id=dc_raw.get("discord_id", ""),
328
+ discord_name=dc_raw.get("discord_name", ""),
329
+ discord_user_name=dc_raw.get("discord_user_name", ""),
330
+ discord_email=dc_raw.get("discord_email", ""),
331
+ )
332
+ if dc_raw
333
+ else None
334
+ )
335
+
336
+ return UserInfo(
337
+ name=data.get("name", ""),
338
+ icon=data.get("icon", ""),
339
+ address=data.get("address", ""),
340
+ did=data.get("did", ""),
341
+ email=data.get("email"),
342
+ pi_info=pi_info,
343
+ twitter_info=twitter_info,
344
+ telegram_info=telegram_info,
345
+ discord_info=discord_info,
346
+ wechat_info=data.get("wechat_info"),
347
+ )
348
+
349
+ # ── Action record endpoints ───────────────────────────────────
350
+
351
+ async def get_action_record(self, action_id: int) -> Optional[ActionRecord]:
352
+ """
353
+ Get the current user's completion record for the given action ID.
354
+ Returns None if no record exists.
355
+
356
+ GET /v2/data/record/:actionID
357
+ """
358
+ data = await self._authenticated_request("GET", f"/v2/data/record/{action_id}")
359
+ if not data:
360
+ return None
361
+ return ActionRecord(
362
+ action=data.get("action", 0),
363
+ points=data.get("points", 0),
364
+ time=data.get("time", 0),
365
+ )
366
+
367
+ async def add_action_record(self, action_id: int, opts: Any = None) -> None:
368
+ """
369
+ Add one action completion record for the current user.
370
+
371
+ POST /v2/data/record/add
372
+ """
373
+ body: dict[str, Any] = {"actionid": action_id}
374
+ if opts is not None:
375
+ body["opts"] = opts
376
+ await self._authenticated_request("POST", "/v2/data/record/add", body)
377
+
378
+ # ── Internal helpers ─────────────────────────────────────────
379
+
380
+ def _auto_set_token(self, token: str) -> None:
381
+ if not self._disable_auto_token and token:
382
+ self._access_token = token
383
+
384
+ @staticmethod
385
+ def _extract_tokens(data: Any) -> AuthTokens:
386
+ return AuthTokens(
387
+ access_token=(
388
+ data.get("access_token")
389
+ or data.get("accessToken")
390
+ or data.get("AccessToken")
391
+ or ""
392
+ ),
393
+ refresh_token=(
394
+ data.get("refresh_token")
395
+ or data.get("refreshToken")
396
+ or data.get("RefreshToken")
397
+ or ""
398
+ ),
399
+ )
400
+
401
+ async def _authenticated_request(
402
+ self,
403
+ method: str,
404
+ path: str,
405
+ body: Optional[dict[str, Any]] = None,
406
+ ) -> Any:
407
+ if not self._access_token:
408
+ raise RuntimeError(
409
+ "No access token set. "
410
+ "Call a login method first, or use set_access_token()."
411
+ )
412
+ return await self._request(
413
+ method,
414
+ path,
415
+ body,
416
+ extra_headers={"Authorization": f"Bearer {self._access_token}"},
417
+ )
418
+
419
+ async def _request(
420
+ self,
421
+ method: str,
422
+ path: str,
423
+ body: Optional[dict[str, Any]] = None,
424
+ extra_headers: Optional[dict[str, str]] = None,
425
+ ) -> Any:
426
+ url = f"{self._base_url}{path}"
427
+ headers: dict[str, str] = dict(extra_headers or {})
428
+ if body is not None:
429
+ headers["Content-Type"] = "application/json"
430
+
431
+ async with httpx.AsyncClient() as http:
432
+ response = await http.request(method, url, headers=headers, json=body)
433
+
434
+ try:
435
+ json_body = response.json()
436
+ except Exception:
437
+ if not response.is_success:
438
+ raise DataDIDApiError(
439
+ f"HTTP {response.status_code} {response.reason_phrase}",
440
+ response.status_code,
441
+ )
442
+ return None
443
+
444
+ # Two response formats:
445
+ # 1. { "result": 1, "data": ... } — most endpoints
446
+ # 2. { "success": true, "data": ... } — /v2/auth/me
447
+ if json_body.get("success") is False:
448
+ raise DataDIDApiError(
449
+ json_body.get("error") or "Request failed",
450
+ response.status_code,
451
+ json_body,
452
+ )
453
+ result_val = json_body.get("result")
454
+ if result_val is not None and result_val != 1:
455
+ raise DataDIDApiError(
456
+ json_body.get("message") or f"API returned result={result_val}",
457
+ response.status_code,
458
+ json_body,
459
+ )
460
+ if (
461
+ not response.is_success
462
+ and result_val is None
463
+ and json_body.get("success") is None
464
+ ):
465
+ raise DataDIDApiError(
466
+ json_body.get("message")
467
+ or json_body.get("error")
468
+ or f"HTTP {response.status_code}",
469
+ response.status_code,
470
+ json_body,
471
+ )
472
+
473
+ return json_body.get("data")
src/data/types.py ADDED
@@ -0,0 +1,103 @@
1
+ from dataclasses import dataclass
2
+ from typing import Optional
3
+
4
+
5
+ @dataclass
6
+ class DataClientOptions:
7
+ """Options for creating a DataClient."""
8
+
9
+ base_url: str
10
+ """Base URL of the Data-BE server."""
11
+
12
+ disable_auto_token: bool = False
13
+ """If True, login methods will NOT auto-set the access token on the client."""
14
+
15
+
16
+ @dataclass
17
+ class AuthTokens:
18
+ """Tokens returned by login endpoints."""
19
+
20
+ access_token: str
21
+ refresh_token: str
22
+
23
+
24
+ @dataclass
25
+ class EvmLoginResult(AuthTokens):
26
+ """EVM login returns tokens + DID info."""
27
+
28
+ did: str = ""
29
+ number: str = ""
30
+
31
+
32
+ @dataclass
33
+ class AuthMeResponse:
34
+ """User info from GET /v2/auth/me."""
35
+
36
+ uid: str
37
+ email: str
38
+ username: str
39
+ role: str
40
+
41
+
42
+ @dataclass
43
+ class PiInfo:
44
+ """Pi account info."""
45
+
46
+ pi_id: str
47
+ pi_user_name: str
48
+ pi_address: str
49
+
50
+
51
+ @dataclass
52
+ class TwitterInfo:
53
+ """Twitter account info."""
54
+
55
+ twitter_id: str
56
+ twitter_name: str
57
+ twitter_user_name: str
58
+
59
+
60
+ @dataclass
61
+ class TelegramInfo:
62
+ """Telegram account info."""
63
+
64
+ telegram_id: int
65
+ telegram_first_name: str
66
+ telegram_last_name: str
67
+ telegram_user_name: str
68
+ telegram_photo: str
69
+
70
+
71
+ @dataclass
72
+ class DiscordInfo:
73
+ """Discord account info."""
74
+
75
+ discord_id: str
76
+ discord_name: str
77
+ discord_user_name: str
78
+ discord_email: str
79
+
80
+
81
+ @dataclass
82
+ class UserInfo:
83
+ """Full user info from GET /v2/user/info."""
84
+
85
+ name: str
86
+ icon: str
87
+ address: str
88
+ did: str
89
+ email: Optional[str] = None
90
+ pi_info: Optional[PiInfo] = None
91
+ twitter_info: Optional[TwitterInfo] = None
92
+ telegram_info: Optional[TelegramInfo] = None
93
+ discord_info: Optional[DiscordInfo] = None
94
+ wechat_info: Optional[bool] = None
95
+
96
+
97
+ @dataclass
98
+ class ActionRecord:
99
+ """Action record from GET /v2/data/record/:actionID."""
100
+
101
+ action: int
102
+ points: int
103
+ time: int
src/did/__init__.py ADDED
File without changes