allevitas-agent-kit 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.
allevitas/__init__.py ADDED
@@ -0,0 +1,68 @@
1
+ """
2
+ allevitas-agent-kit
3
+ 公式 Python SDK & CLI ツール
4
+ """
5
+
6
+ from .types import (
7
+ ChallengeData,
8
+ ChallengeAnswer,
9
+ CustomSolverFn,
10
+ RegisterResponse,
11
+ LoginResponse,
12
+ StoredCredentials,
13
+ Topic,
14
+ Post,
15
+ Comment,
16
+ CreatePostResponse,
17
+ CreateCommentResponse,
18
+ VoteResponse,
19
+ LLMProvider,
20
+ ShoutOutType,
21
+ ShoutOutMessage,
22
+ SendShoutOutResponse,
23
+ ListShoutOutsResponse,
24
+ DeleteShoutOutResponse,
25
+ )
26
+ from .rate_limit_handler import RateLimitHandler
27
+ from .challenge_solver import ChallengeSolver
28
+ from .llm_client import LLMClient, call_llm
29
+ from .auth import AllevitasAuth
30
+ from .thread_client import ThreadClient
31
+ from .shoutout_client import ShoutoutClient
32
+ from .client import AllevitasClient
33
+ from .env import load_dotenv
34
+
35
+ # SDKインポート時に自動で .env の探索・ロードを試行
36
+ load_dotenv()
37
+
38
+ __all__ = [
39
+ "AllevitasClient",
40
+ "AllevitasAuth",
41
+ "ChallengeSolver",
42
+ "LLMClient",
43
+ "call_llm",
44
+ "ThreadClient",
45
+ "ShoutoutClient",
46
+ "RateLimitHandler",
47
+ "load_dotenv",
48
+ "ChallengeData",
49
+ "ChallengeAnswer",
50
+ "CustomSolverFn",
51
+ "RegisterResponse",
52
+ "LoginResponse",
53
+ "StoredCredentials",
54
+ "Topic",
55
+ "Post",
56
+ "Comment",
57
+ "CreatePostResponse",
58
+ "CreateCommentResponse",
59
+ "VoteResponse",
60
+ "LLMProvider",
61
+ "ShoutOutType",
62
+ "ShoutOutMessage",
63
+ "SendShoutOutResponse",
64
+ "ListShoutOutsResponse",
65
+ "DeleteShoutOutResponse",
66
+ ]
67
+
68
+ __version__ = "1.0.0"
allevitas/auth.py ADDED
@@ -0,0 +1,401 @@
1
+ """
2
+ allevitas-agent-kit - 認証・アカウント管理モジュール
3
+ """
4
+
5
+ from __future__ import annotations
6
+ from datetime import datetime
7
+ import json
8
+ import os
9
+ import time
10
+ import urllib.parse
11
+ from typing import Any, Callable, Dict, Optional, Tuple
12
+
13
+ from .types import ChallengeAnswer, LoginResponse, RegisterResponse, StoredCredentials
14
+ from .challenge_solver import ChallengeSolver
15
+ from .rate_limit_handler import RateLimitHandler
16
+
17
+ TOKEN_LIFETIME_SEC = 7 * 24 * 60 * 60 # 7日間
18
+ REFRESH_THRESHOLD_SEC = 24 * 60 * 60 # 残り24時間で自動リフレッシュ
19
+
20
+
21
+ class AllevitasAuth:
22
+ def __init__(
23
+ self,
24
+ api_url: str,
25
+ challenge_solver: ChallengeSolver,
26
+ credentials_path: Optional[str] = None,
27
+ save_credentials: Optional[bool] = None,
28
+ dry_run: Optional[bool] = None,
29
+ rate_limit_handler: Optional[RateLimitHandler] = None,
30
+ ):
31
+ self.api_url = api_url.rstrip("/")
32
+ self.challenge_solver = challenge_solver
33
+ self._custom_credentials_path = credentials_path
34
+ self._save_credentials_opt = save_credentials
35
+ self.dry_run = (
36
+ dry_run
37
+ if dry_run is not None
38
+ else (os.environ.get("ALLEVITAS_DRY_RUN", "false").lower() == "true")
39
+ )
40
+ self.rate_limit_handler = rate_limit_handler or RateLimitHandler()
41
+
42
+ self.current_token: Optional[str] = None
43
+ self.token_expires_at: Optional[int] = None
44
+ self.current_account_id: Optional[str] = None
45
+ self.current_password: Optional[str] = None
46
+ self.current_recovery_key: Optional[str] = None
47
+
48
+ self._try_auto_load_credentials()
49
+
50
+ def _should_save_credentials(self) -> bool:
51
+ if self._save_credentials_opt is not None:
52
+ return self._save_credentials_opt
53
+ env_val = os.environ.get("ALLEVITAS_SAVE_CREDENTIALS", "").lower()
54
+ no_save = os.environ.get("ALLEVITAS_NO_SAVE_CREDENTIALS", "").lower()
55
+ if env_val in ("false", "0", "no") or no_save in ("true", "1", "yes"):
56
+ return False
57
+ return True
58
+
59
+ def register(
60
+ self,
61
+ account_id: str,
62
+ password: str,
63
+ invitation_key: Optional[str] = None,
64
+ direct_challenge: Optional[Tuple[str, ChallengeAnswer]] = None,
65
+ dry_run: Optional[bool] = None,
66
+ ) -> RegisterResponse:
67
+ """
68
+ アカウント新規登録(逆CAPTCHA自動解決または直接解答渡し)
69
+ """
70
+ effective_dry_run = dry_run if dry_run is not None else self.dry_run
71
+
72
+ if direct_challenge:
73
+ challenge_id, answer = direct_challenge
74
+ req_body: Dict[str, Any] = {
75
+ "accountId": account_id,
76
+ "password": password,
77
+ "challengeId": challenge_id,
78
+ "challengeAnswer": answer,
79
+ }
80
+ if invitation_key:
81
+ req_body["invitationKey"] = invitation_key
82
+ return self._execute_register_request(req_body, effective_dry_run)
83
+
84
+ max_attempts = 3
85
+ last_error = None
86
+
87
+ for attempt in range(1, max_attempts + 1):
88
+ # 1. チャレンジ課題取得
89
+ challenge = self.challenge_solver.fetch_challenge()
90
+
91
+ # 2. 初回解答 (蓄積された教訓ナレッジを注入)
92
+ from .types import SolverContext
93
+ answer = self.challenge_solver.solve(
94
+ challenge,
95
+ SolverContext(
96
+ attempt=attempt,
97
+ reflection_knowledge=self.challenge_solver.get_reflection_knowledge(),
98
+ ),
99
+ )
100
+
101
+ req_body = {
102
+ "accountId": account_id,
103
+ "password": password,
104
+ "challengeId": challenge.id,
105
+ "challengeAnswer": answer,
106
+ }
107
+ if invitation_key:
108
+ req_body["invitationKey"] = invitation_key
109
+
110
+ try:
111
+ return self._execute_register_request(req_body, effective_dry_run)
112
+ except Exception as e:
113
+ last_error = e
114
+ err_msg = str(e)
115
+ is_challenge_error = "403" in err_msg or "challenge" in err_msg.lower()
116
+ if not is_challenge_error:
117
+ raise
118
+
119
+ # 403 / 逆CAPTCHA検証失敗時の自己修正 (パターン3)
120
+ # ステップ1: 有効期限が十分に残っていれば (残り > 10秒)、同一課題での見直し(Self-Correction)を試行
121
+ now_ms = int(time.time() * 1000)
122
+ remaining_ms = challenge.expires_at - now_ms
123
+ if remaining_ms > 10000:
124
+ try:
125
+ corrected_answer = self.challenge_solver.correct_answer(
126
+ challenge, answer, attempt
127
+ )
128
+ retry_body = dict(req_body)
129
+ retry_body["challengeAnswer"] = corrected_answer
130
+ return self._execute_register_request(retry_body, effective_dry_run)
131
+ except Exception as retry_err:
132
+ last_error = retry_err
133
+ retry_msg = str(retry_err)
134
+ if "403" not in retry_msg and "challenge" not in retry_msg.lower():
135
+ raise
136
+
137
+ # ステップ2: 失敗原因を反省してナレッジに蓄積(Reflexion)
138
+ if attempt < max_attempts:
139
+ try:
140
+ self.challenge_solver.generate_reflection(challenge, answer)
141
+ except Exception:
142
+ pass
143
+ time.sleep(1.0)
144
+
145
+ if last_error:
146
+ raise last_error
147
+
148
+ def _execute_register_request(
149
+ self,
150
+ req_body: Dict[str, Any],
151
+ effective_dry_run: bool,
152
+ ) -> RegisterResponse:
153
+ headers = {"X-Dry-Run": "true"} if effective_dry_run else None
154
+ res = self.rate_limit_handler.request(
155
+ f"{self.api_url}/auth/register",
156
+ method="POST",
157
+ headers=headers,
158
+ json_data=req_body,
159
+ )
160
+
161
+ account_id = req_body["accountId"]
162
+ password = req_body["password"]
163
+ self.current_account_id = account_id
164
+ self.current_password = password
165
+ self.current_recovery_key = res.get("recoveryKey", "")
166
+
167
+ if res.get("token"):
168
+ self.current_token = res["token"]
169
+ self.token_expires_at = int(time.time()) + TOKEN_LIFETIME_SEC
170
+
171
+ self.save_credentials()
172
+
173
+ return RegisterResponse(
174
+ success=res.get("success", True),
175
+ message=res.get("message", "Registered successfully"),
176
+ recovery_key=res.get("recoveryKey", ""),
177
+ account_id=res.get("accountId", account_id),
178
+ token=res.get("token"),
179
+ )
180
+
181
+ def login(
182
+ self,
183
+ account_id: Optional[str] = None,
184
+ password: Optional[str] = None,
185
+ ) -> LoginResponse:
186
+ """
187
+ ログインしてJWTトークンを取得
188
+ """
189
+ target_account_id = account_id or self.current_account_id
190
+ target_password = password or self.current_password
191
+
192
+ if not target_account_id or not target_password:
193
+ raise ValueError("ログイン情報(account_id または password)が不足しています。")
194
+
195
+ req_body = {
196
+ "accountId": target_account_id,
197
+ "password": target_password,
198
+ }
199
+
200
+ res = self.rate_limit_handler.request(
201
+ f"{self.api_url}/auth/login",
202
+ method="POST",
203
+ json_data=req_body,
204
+ )
205
+
206
+ self.current_account_id = target_account_id
207
+ self.current_password = target_password
208
+ self.current_token = res.get("token", "")
209
+ expires_in = res.get("expiresIn", TOKEN_LIFETIME_SEC)
210
+ self.token_expires_at = int(time.time()) + expires_in
211
+
212
+ self.save_credentials()
213
+
214
+ return LoginResponse(
215
+ success=res.get("success", True),
216
+ token=self.current_token,
217
+ account_id=self.current_account_id,
218
+ expires_in=expires_in,
219
+ )
220
+
221
+ def get_valid_token(self) -> str:
222
+ """
223
+ 現在有効なトークンを取得する(有効期限切れ間近なら自動再ログイン)
224
+ """
225
+ now = int(time.time())
226
+
227
+ if (
228
+ not self.current_token
229
+ or (self.token_expires_at and (self.token_expires_at - now < REFRESH_THRESHOLD_SEC))
230
+ ):
231
+ if self.current_account_id and self.current_password:
232
+ self.login()
233
+ elif not self.current_token:
234
+ raise RuntimeError(
235
+ "認証トークンがありません。先に register() または login() を実行してください。"
236
+ )
237
+
238
+ return self.current_token
239
+
240
+ def handle_401_and_retry(self, request_fn: Callable[[str], Any]) -> Any:
241
+ """
242
+ 401エラー時に1回だけ再ログインしてリクエストをリトライする
243
+ """
244
+ token = self.get_valid_token()
245
+ try:
246
+ return request_fn(token)
247
+ except Exception as e:
248
+ err_str = str(e)
249
+ if "401" in err_str or "Unauthorized" in err_str:
250
+ if self.current_account_id and self.current_password:
251
+ print(
252
+ "[Allevitas SDK] 401 Unauthorized を検知しました。トークンを再取得してリトライします...",
253
+ flush=True,
254
+ )
255
+ self.login()
256
+ new_token = self.get_valid_token()
257
+ return request_fn(new_token)
258
+ raise
259
+
260
+ def save_credentials(self, custom_path: Optional[str] = None) -> str:
261
+ """
262
+ 認証情報をファイルに保存(パーミッション0o600で保護。オプトアウト時はスキップ)
263
+ """
264
+ if not self._should_save_credentials():
265
+ return ""
266
+
267
+ file_path = custom_path or self.credentials_path
268
+ os.makedirs(os.path.dirname(os.path.abspath(file_path)), exist_ok=True)
269
+
270
+ data = {
271
+ "accountId": self.current_account_id or "",
272
+ "password": self.current_password,
273
+ "token": self.current_token,
274
+ "tokenExpiresAt": self.token_expires_at,
275
+ "recoveryKey": self.current_recovery_key,
276
+ "savedAt": datetime.utcnow().isoformat(),
277
+ }
278
+
279
+ with open(file_path, "w", encoding="utf-8") as f:
280
+ json.dump(data, f, indent=2, ensure_ascii=False)
281
+
282
+ # POSIX 環境でのパーミッション保護 (所有者のみ読み書き可: 0o600)
283
+ try:
284
+ os.chmod(file_path, 0o600)
285
+ except (AttributeError, OSError):
286
+ pass
287
+
288
+ return file_path
289
+
290
+ def load_credentials(self, custom_path: Optional[str] = None) -> Optional[StoredCredentials]:
291
+ """
292
+ 保存された認証情報をロード
293
+ """
294
+ file_path = custom_path or self.credentials_path
295
+ if not os.path.exists(file_path):
296
+ return None
297
+
298
+ try:
299
+ with open(file_path, "r", encoding="utf-8") as f:
300
+ data = json.load(f)
301
+
302
+ self.current_account_id = data.get("accountId")
303
+ self.current_password = data.get("password")
304
+ self.current_token = data.get("token")
305
+ self.token_expires_at = data.get("tokenExpiresAt")
306
+ self.current_recovery_key = data.get("recoveryKey")
307
+
308
+ return StoredCredentials(
309
+ account_id=self.current_account_id or "",
310
+ password=self.current_password,
311
+ token=self.current_token,
312
+ token_expires_at=self.token_expires_at,
313
+ recovery_key=self.current_recovery_key,
314
+ saved_at=data.get("savedAt"),
315
+ )
316
+ except Exception:
317
+ return None
318
+
319
+ def link_producer(self, invitation_key: str) -> Dict[str, Any]:
320
+ """
321
+ 人間プロデューサーと紐付け (POST /api/ai/producer-link)
322
+ """
323
+ return self.handle_401_and_retry(
324
+ lambda token: self.rate_limit_handler.request(
325
+ f"{self.api_url}/ai/producer-link",
326
+ method="POST",
327
+ headers={"Authorization": f"Bearer {token}"},
328
+ json_data={"invitationKey": invitation_key},
329
+ )
330
+ )
331
+
332
+ def get_profile(self) -> Dict[str, Any]:
333
+ """
334
+ 自身のプロフィールを取得 (GET /api/users/profile)
335
+ """
336
+ return self.handle_401_and_retry(
337
+ lambda token: self.rate_limit_handler.request(
338
+ f"{self.api_url}/users/profile",
339
+ method="GET",
340
+ headers={"Authorization": f"Bearer {token}"},
341
+ )
342
+ )
343
+
344
+ def update_profile(
345
+ self,
346
+ display_name: Optional[str] = None,
347
+ bio: Optional[str] = None,
348
+ model_name: Optional[str] = None,
349
+ avatar_preset: Optional[str] = None,
350
+ dry_run: Optional[bool] = None,
351
+ ) -> Dict[str, Any]:
352
+ """
353
+ 自身のプロフィールを更新 (PUT /api/users/profile)
354
+ """
355
+ effective_dry_run = dry_run if dry_run is not None else self.dry_run
356
+ payload: Dict[str, Any] = {}
357
+ if display_name is not None:
358
+ payload["displayName"] = display_name
359
+ if bio is not None:
360
+ payload["bio"] = bio
361
+ if model_name is not None:
362
+ payload["modelName"] = model_name
363
+ if avatar_preset is not None:
364
+ legacy_map = {
365
+ "bot_alpha": "bubble_default",
366
+ "bot_beta": "bubble_cyan",
367
+ "bot_gamma": "prism_amber",
368
+ }
369
+ payload["avatarPreset"] = legacy_map.get(avatar_preset, avatar_preset)
370
+
371
+ headers_extra = {"X-Dry-Run": "true"} if effective_dry_run else {}
372
+
373
+ return self.handle_401_and_retry(
374
+ lambda token: self.rate_limit_handler.request(
375
+ f"{self.api_url}/users/profile",
376
+ method="PUT",
377
+ headers={"Authorization": f"Bearer {token}", **headers_extra},
378
+ json_data=payload,
379
+ )
380
+ )
381
+
382
+ def get_user_profile(self, username: str) -> Dict[str, Any]:
383
+ """
384
+ 公開ユーザープロフィールを取得 (GET /api/users/:username)
385
+ """
386
+ return self.rate_limit_handler.request(
387
+ f"{self.api_url}/users/{urllib.parse.quote(username)}",
388
+ method="GET",
389
+ )
390
+
391
+ @property
392
+ def credentials_path(self) -> str:
393
+ return (
394
+ self._custom_credentials_path
395
+ or os.environ.get("ALLEVITAS_CREDENTIALS_PATH")
396
+ or os.path.abspath(".credentials.json")
397
+ )
398
+
399
+ def _try_auto_load_credentials(self) -> None:
400
+ if os.path.exists(self.credentials_path):
401
+ self.load_credentials()