diff --git a/examples/auth/auth_example.py b/examples/auth/auth_example.py new file mode 100644 index 0000000..3e687f3 --- /dev/null +++ b/examples/auth/auth_example.py @@ -0,0 +1,101 @@ +#!/usr/bin/env python3 +""" +Interactive example: pick how to start authentication. + +Both flows are unauthenticated (no api_key / jwt_token required): + + - Email: sends a magic sign-in link via ``UserService.start_email_verification``. + - X (Twitter): returns the OAuth URL via ``UserService.get_x_oauth_url``. + +Copy ``mate.yaml.example`` to ``mate.yaml`` if you want a custom ``base_url``; +credentials in that file are not needed for this script. +""" + +from __future__ import annotations + +import os + +from wyseos.mate import Client, ClientOptions +from wyseos.mate.config import load_config +from wyseos.mate.errors import APIError + + +def create_client() -> Client: + """Client without credentials; optional mate.yaml for base_url.""" + script_dir = os.path.dirname(os.path.abspath(__file__)) + config_path = os.path.join(script_dir, "mate.yaml") + + if os.path.exists(config_path): + print(f"Loading config: {config_path}") + return Client(load_config(config_path)) + + print("No mate.yaml found, using default base_url without credentials.") + return Client(ClientOptions()) + + +def run_email_flow(client: Client) -> None: + email = input("Email address: ").strip() + if not email: + print("Email is required.") + return + + invite = input("Invite code (optional, press Enter to skip): ").strip() + invite_code = invite or None + + try: + resp = client.user.start_email_verification( + email=email, + invite_code=invite_code, + ) + except APIError as exc: + print(f"Request failed: {exc}") + return + + print("\nSign-in link request accepted. Response:") + print(f" sign_type: {resp.sign_type}") + print(f" pre_auth_id: {resp.pre_auth_id}") + print( + "\nThe user should open the link in the email to finish sign-in." + ) + + +def run_twitter_oauth_flow(client: Client) -> None: + try: + resp = client.user.get_x_oauth_url() + except APIError as exc: + print(f"Request failed: {exc}") + return + + print("\nX (Twitter) OAuth authorization URL:") + print(resp.auth_url) + print( + "\nOpen the URL in a browser to finish login. The backend redirects to " + "the configured webapp host." + ) + + +def print_menu() -> None: + print("\n=== Choose auth method ===") + print("1) Email — send verify link for sign-in / sign-up") + print("2) X (Twitter) — get OAuth login URL") + print("q) Quit") + + +def main() -> None: + client = create_client() + + while True: + print_menu() + choice = input("Choice: ").strip().lower() + if choice == "q": + break + if choice == "1": + run_email_flow(client) + elif choice == "2": + run_twitter_oauth_flow(client) + else: + print("Unknown option.") + + +if __name__ == "__main__": + main() diff --git a/examples/auth/connectors_example.py b/examples/auth/connectors_example.py new file mode 100644 index 0000000..24cc90b --- /dev/null +++ b/examples/auth/connectors_example.py @@ -0,0 +1,117 @@ +#!/usr/bin/env python3 +""" +Example: manage X (Twitter) connector accounts. + +Demonstrates the three X-connector endpoints on UserService: + - list_x_accounts() -> GET /connectors/v1/x/accounts + - authorize_x_account() -> POST /connectors/v1/x/accounts/authorize + - delete_x_account(id) -> DELETE /connectors/v1/x/accounts/{id} + +Requires a valid api_key (or jwt_token) in mate.yaml. +""" + +import os +from typing import Optional + +from wyseos.mate import Client +from wyseos.mate.config import load_config +from wyseos.mate.errors import APIError + + +def create_client() -> Optional[Client]: + script_dir = os.path.dirname(os.path.abspath(__file__)) + config_path = os.path.join(script_dir, "mate.yaml") + try: + print(f"Loading config: {config_path}") + return Client(load_config(config_path)) + except Exception as exc: + print(f"Failed to load config: {exc}") + print("Please configure mate.yaml with a valid api_key or jwt_token.") + return None + + +def print_menu() -> None: + print("\n=== X Connector Menu ===") + print("1) List connected X accounts") + print("2) Authorize a new / existing X account") + print("3) Delete a connected X account") + print("q) Quit") + + +def do_list(client: Client) -> None: + try: + result = client.user.list_x_accounts() + except APIError as exc: + print(f"List failed: {exc}") + return + + if not result.items: + print("No X accounts are currently connected.") + return + + print(f"Connected X accounts ({len(result.items)}):") + for idx, account in enumerate(result.items, start=1): + print( + f" {idx}. connector_id={account.connector_id} " + f"username=@{account.external_username} " + f"status={account.status} " + f"expires_at={account.expires_at}" + ) + + +def do_authorize(client: Client) -> None: + target = input( + "Target connector_id (leave empty to create a new connector): " + ).strip() + try: + resp = client.user.authorize_x_account( + target_connector_id=target or None, + ) + except APIError as exc: + print(f"Authorize failed: {exc}") + return + + print("Open this URL in a browser to complete the binding:") + print(resp.auth_url) + + +def do_delete(client: Client) -> None: + connector_id = input("connector_id to delete: ").strip() + if not connector_id: + print("connector_id is required.") + return + + try: + client.user.delete_x_account(connector_id) + except APIError as exc: + print(f"Delete failed: {exc}") + return + + print(f"Deleted X connector {connector_id}.") + + +def main() -> None: + client = create_client() + if not client: + return + + actions = { + "1": do_list, + "2": do_authorize, + "3": do_delete, + } + + while True: + print_menu() + choice = input("Choose an action: ").strip().lower() + if choice == "q": + break + action = actions.get(choice) + if action is None: + print("Unknown option.") + continue + action(client) + + +if __name__ == "__main__": + main() diff --git a/examples/auth/mate.yaml.example b/examples/auth/mate.yaml.example new file mode 100644 index 0000000..8cebc9f --- /dev/null +++ b/examples/auth/mate.yaml.example @@ -0,0 +1,9 @@ +mate: + # API base URL (override only when targeting a non-default environment). + base_url: "https://api.wyseos.com" + timeout: 30 + + # Required for connectors_example.py (list / authorize / delete X accounts). + # Not required for auth_example.py — those flows are public. + # Uncomment and fill in to use connector endpoints: + # api_key: "your-api-key" diff --git a/examples/quickstart.md b/examples/quickstart.md index 6f65b0b..c6bd0ad 100644 --- a/examples/quickstart.md +++ b/examples/quickstart.md @@ -43,7 +43,49 @@ from wyseos.mate.config import load_config client = Client(load_config("mate.yaml")) ``` -## 3. Choose One Workflow +## 3. Register an Account (Two Ways) + +Sign-in and sign-up both start without `api_key` or `jwt_token` on the client (these endpoints use `skip_auth=True`). You can use `Client(ClientOptions())` for the default `base_url`, or `load_config("mate.yaml")` if you only need a custom `base_url`. + +1. **Email magic link** — send a link to the address; the user opens it in a browser to finish sign-in or sign-up: + +```python +from wyseos.mate import Client, ClientOptions + +client = Client(ClientOptions()) # or load_config("mate.yaml") without credentials + +resp = client.user.start_email_verification( + email="you@example.com", + invite_code=None, # optional invite code +) +# resp.sign_type, resp.pre_auth_id — use after the user follows the link per product flow +``` + +2. **X (Twitter) OAuth** — get a login URL; open it in a browser to complete sign-in or sign-up with X: + +```python +url_resp = client.user.get_x_oauth_url() +print(url_resp.auth_url) # open in browser +``` + +Runnable reference: `examples/auth/auth_example.py`. + +## 4. X (Twitter) Authorization: Functions + +Distinguish **account login/registration** (no API key) from **binding X to an existing account** (requires `api_key` or `jwt_token` after you are signed in). + +| Purpose | Call | Auth required | +| ------- | ---- | ------------- | +| Sign in / sign up with X | `client.user.get_x_oauth_url()` | No | +| List connected X accounts | `client.user.list_x_accounts()` | Yes | +| Bind or re-bind an X account (connector) | `client.user.authorize_x_account(target_connector_id=None)` | Yes; optional `target_connector_id` selects the credential slot | +| Remove a connected X account | `client.user.delete_x_account(connector_id)` | Yes | + +`authorize_x_account` returns an `OAuthURLResponse` with `auth_url` — open that URL in a browser to complete the connector flow. + +Runnable reference: `examples/auth/connectors_example.py`. + +## 5. Choose One Workflow | Workflow | Entry Point | Transport | Main Output | | ----------------- | -------------------------------------------------------- | ------------ | ------------------------------------------ | @@ -187,7 +229,7 @@ Runnable example: `examples/product_analysis/example.py`. --- -## 4. Error Handling +## 6. Error Handling ```python from wyseos.mate.errors import APIError, NetworkError, ConfigError, WebSocketError @@ -205,7 +247,18 @@ except ConfigError as e: print("ConfigError:", e) ``` -## 5. Related APIs +## 7. Related APIs + +Account registration (no `api_key` / `jwt_token` required): + +- `client.user.start_email_verification(email, invite_code=None)` +- `client.user.get_x_oauth_url()` + +X connector (after sign-in, with `api_key` or `jwt_token`): + +- `client.user.list_x_accounts()` +- `client.user.authorize_x_account(target_connector_id=None)` +- `client.user.delete_x_account(connector_id)` Marketing: diff --git a/examples/quickstart_cn.md b/examples/quickstart_cn.md index 14d5a7c..d02759d 100644 --- a/examples/quickstart_cn.md +++ b/examples/quickstart_cn.md @@ -42,7 +42,49 @@ from wyseos.mate.config import load_config client = Client(load_config("mate.yaml")) ``` -## 3. 选择一个工作流 +## 3. 注册账户(两种方式) + +登录与注册都可在**不提供** `api_key`、`jwt_token` 的情况下发起(这些接口在 SDK 中走 `skip_auth`)。无密钥时可用 `Client(ClientOptions())` 使用默认 `base_url`,或仅用 `load_config("mate.yaml")` 配置自定义 `base_url`。 + +1. **邮箱 magic link** — 向邮箱发送登录/注册链接,用户在浏览器中打开完成验证: + +```python +from wyseos.mate import Client, ClientOptions + +client = Client(ClientOptions()) # 或使用不含密钥的 mate.yaml + +resp = client.user.start_email_verification( + email="you@example.com", + invite_code=None, # 可选填邀请码 +) +# resp.sign_type、resp.pre_auth_id — 用户点击邮件内链接后按产品流程续接 +``` + +2. **X(Twitter)OAuth** — 获取用于登录/注册的授权 URL,在浏览器中打开完成: + +```python +url_resp = client.user.get_x_oauth_url() +print(url_resp.auth_url) # 在浏览器中打开 +``` + +可运行参考:`examples/auth/auth_example.py`。 + +## 4. 授权与绑定 X(相关函数) + +区分两类场景:**账户登录/注册**(无 API 密钥)与**将 X 账号绑定到已有账户**(登录后使用 `api_key` 或 `jwt_token`)。 + +| 用途 | 调用 | 是否需要已登录 | +| ---- | ---- | -------------- | +| 使用 X 登录或注册 | `client.user.get_x_oauth_url()` | 否 | +| 列出已绑定的 X 账号 | `client.user.list_x_accounts()` | 是 | +| 发起绑定或换绑 X(连接器) | `client.user.authorize_x_account(target_connector_id=None)` | 是;可选 `target_connector_id` 指定要写入的凭据位 | +| 解绑已连接的 X 账号 | `client.user.delete_x_account(connector_id)` | 是 | + +`authorize_x_account` 返回 `OAuthURLResponse`,通过 `auth_url` 在浏览器中完成授权。 + +可运行参考:`examples/auth/connectors_example.py`。 + +## 5. 选择一个工作流 | 工作流 | 入口 | 传输方式 | 主要输出 | | -------- | -------------------------------------------------------- | --------- | ----------------------- | @@ -186,7 +228,7 @@ if info.analysis_result and info.analysis_result.report_id: --- -## 4. 错误处理 +## 6. 错误处理 ```python from wyseos.mate.errors import APIError, NetworkError, ConfigError, WebSocketError @@ -204,7 +246,18 @@ except ConfigError as e: print("ConfigError:", e) ``` -## 5. 相关 API +## 7. 相关 API + +账户注册(不需要 `api_key` / `jwt_token`): + +- `client.user.start_email_verification(email, invite_code=None)` +- `client.user.get_x_oauth_url()` + +X 连接器(登录后、带 `api_key` 或 `jwt_token`): + +- `client.user.list_x_accounts()` +- `client.user.authorize_x_account(target_connector_id=None)` +- `client.user.delete_x_account(connector_id)` 营销: diff --git a/wyseos/mate/client.py b/wyseos/mate/client.py index 7c0c488..8f9579a 100644 --- a/wyseos/mate/client.py +++ b/wyseos/mate/client.py @@ -58,7 +58,11 @@ def __init__(self, options: Optional[ClientOptions] = None): self.product = ProductService(self) def _do_request( - self, method: str, endpoint: str, body: Optional[Dict] = None + self, + method: str, + endpoint: str, + body: Optional[Dict] = None, + skip_auth: bool = False, ) -> requests.Response: url = urljoin(self.base_url, endpoint) @@ -68,10 +72,11 @@ def _do_request( HEADER_ACCEPT: CONTENT_TYPE_JSON, } - if self.jwt_token: - headers[HEADER_AUTHORIZATION] = self.jwt_token - elif self.api_key: - headers[HEADER_API_KEY] = self.api_key + if not skip_auth: + if self.jwt_token: + headers[HEADER_AUTHORIZATION] = self.jwt_token + elif self.api_key: + headers[HEADER_API_KEY] = self.api_key try: response = self.http_client.request( @@ -97,10 +102,11 @@ def get( endpoint: str, result_model: Type[T], params: Optional[Dict[str, str]] = None, + skip_auth: bool = False, ) -> T: if params: endpoint = self._build_url(endpoint, params) - response = self._do_request("GET", endpoint) + response = self._do_request("GET", endpoint, skip_auth=skip_auth) if result_model is dict: return response.json() return result_model.model_validate(response.json()) @@ -133,8 +139,9 @@ def post( endpoint: str, body: Optional[Dict] = None, result_model: Optional[Type[T]] = None, + skip_auth: bool = False, ) -> Optional[T]: - response = self._do_request("POST", endpoint, body) + response = self._do_request("POST", endpoint, body, skip_auth=skip_auth) if result_model and response.content: if result_model is dict: return response.json() diff --git a/wyseos/mate/config.py b/wyseos/mate/config.py index 917fb63..d683abe 100644 --- a/wyseos/mate/config.py +++ b/wyseos/mate/config.py @@ -94,6 +94,14 @@ def load_config(config_path: Optional[Union[str, Path]] = None) -> ClientOptions if "mate" in config_data and isinstance(config_data["mate"], dict): config_data = config_data["mate"] + # Treat blank credential placeholders (e.g. `api_key: ""` in a template + # YAML) as "not set" — Pydantic's min_length=1 would otherwise reject + # them and force users to remove the keys entirely. + for key in ("api_key", "jwt_token"): + value = config_data.get(key) + if isinstance(value, str) and value.strip() == "": + config_data.pop(key) + return ClientOptions(**config_data) except IOError as e: diff --git a/wyseos/mate/constants.py b/wyseos/mate/constants.py index 39f2181..85e4c3a 100644 --- a/wyseos/mate/constants.py +++ b/wyseos/mate/constants.py @@ -27,6 +27,13 @@ # API Endpoints # User endpoints ENDPOINT_API_KEY_LIST = "/user/apikey/lists" +ENDPOINT_AUTH_URL = "/auth/url" +ENDPOINT_AUTH_EMAIL_LINK_SIGNINUP = "/auth/otp/signinup" + +# X (Twitter) connector endpoints +ENDPOINT_X_CONNECTOR_ACCOUNTS = "/connectors/v1/x/accounts" +ENDPOINT_X_CONNECTOR_AUTHORIZE = "/connectors/v1/x/accounts/authorize" +ENDPOINT_X_CONNECTOR_DELETE = "/connectors/v1/x/accounts/{connector_id}" # Team endpoints ENDPOINT_TEAM_LIST = "/team/lists" diff --git a/wyseos/mate/models.py b/wyseos/mate/models.py index d519087..1fb9456 100644 --- a/wyseos/mate/models.py +++ b/wyseos/mate/models.py @@ -3,7 +3,7 @@ """ from datetime import datetime -from typing import Annotated, Any, Dict, Generic, List, Optional, TypeVar +from typing import Annotated, Any, Dict, Generic, List, Literal, Optional, TypeVar from pydantic import BaseModel, Field @@ -312,6 +312,52 @@ class CreateAPIKeyResponse(BaseModel): ) # Full key value returned only on creation +class OAuthURLResponse(BaseModel): + """Response for retrieving an OAuth authorization URL.""" + + auth_url: str + + +class XConnectorAccount(BaseModel): + """X (Twitter) connector account information.""" + + connector_id: str + platform: Literal["x"] + external_user_id: str + external_username: str + expires_at: str + updated_at: str + status: Literal["connected", "expired"] + + +class ListXAccountsResponse(BaseModel): + """Response for listing X connector accounts.""" + + items: List[XConnectorAccount] + + +class AuthorizeXAccountRequest(BaseModel): + """Request body for authorizing an X connector account.""" + + redirect_url: str + target_credential_id: Optional[str] = None + + +class EmailLinkVerifyRequest(BaseModel): + """Request body for starting email magic-link sign in / sign up.""" + + email: str + invite_code: Optional[str] = None + credential_type: Literal["api_key"] = "api_key" + + +class EmailLinkVerifyResponse(BaseModel): + """Response after requesting a sign-in / sign-up link sent to email.""" + + sign_type: str + pre_auth_id: str + + class ListBrowsersResponse(BaseModel): """Response for listing browsers.""" diff --git a/wyseos/mate/services/user.py b/wyseos/mate/services/user.py index 57fe740..f332ea9 100644 --- a/wyseos/mate/services/user.py +++ b/wyseos/mate/services/user.py @@ -6,10 +6,22 @@ from ..constants import ( ENDPOINT_API_KEY_LIST, + ENDPOINT_AUTH_EMAIL_LINK_SIGNINUP, + ENDPOINT_AUTH_URL, + ENDPOINT_X_CONNECTOR_ACCOUNTS, + ENDPOINT_X_CONNECTOR_AUTHORIZE, + ENDPOINT_X_CONNECTOR_DELETE, ) +from ..extension_host import resolve_extension_webapp_host from ..models import ( APIKey, + APIResponse, + AuthorizeXAccountRequest, ListOptions, + ListXAccountsResponse, + OAuthURLResponse, + EmailLinkVerifyRequest, + EmailLinkVerifyResponse, PaginatedResponse, ) @@ -66,3 +78,100 @@ def list_api_keys( result_model=PaginatedResponse[APIKey], params=params, ) + + def start_email_verification( + self, email: str, invite_code: Optional[str] = None + ) -> EmailLinkVerifyResponse: + """ + Start email magic-link sign in / sign up (link sent to the given address). + + Args: + email: The email address to send the sign-in link to + invite_code: Optional invite code + + Returns: + EmailLinkVerifyResponse: Contains sign_type and pre_auth_id for completing + verification after the user follows the link. + """ + payload = EmailLinkVerifyRequest(email=email, invite_code=invite_code) + + resp = self.client.post( + endpoint=ENDPOINT_AUTH_EMAIL_LINK_SIGNINUP, + body=payload.model_dump(exclude_none=True), + result_model=APIResponse[EmailLinkVerifyResponse], + skip_auth=True, + ) + return resp.data + + def get_x_oauth_url(self) -> OAuthURLResponse: + """ + Get an OAuth authorization URL for Twitter login. + + Returns: + OAuthURLResponse: Response containing the OAuth authorization URL + """ + params = { + "type": "login", + "platform": "twitter", + "credential_type": "api_key", + "redirect_url": f"{resolve_extension_webapp_host()}/oauth/twitter", + } + + resp = self.client.get( + endpoint=ENDPOINT_AUTH_URL, + result_model=APIResponse[OAuthURLResponse], + params=params, + skip_auth=True, + ) + return resp.data + + def list_x_accounts(self) -> ListXAccountsResponse: + """ + List the current user's connected X (Twitter) accounts. + + Returns: + ListXAccountsResponse: Response containing the list of X connector accounts + """ + resp = self.client.get( + endpoint=ENDPOINT_X_CONNECTOR_ACCOUNTS, + result_model=APIResponse[ListXAccountsResponse], + ) + return resp.data + + def authorize_x_account( + self, target_connector_id: Optional[str] = None + ) -> OAuthURLResponse: + """ + Start the OAuth authorization flow to bind an X (Twitter) account. + + Args: + target_connector_id: Optional ID of the credential slot to bind the X + account to. When omitted, the backend creates a new credential. + + Returns: + OAuthURLResponse: Response containing the authorization URL + """ + payload = AuthorizeXAccountRequest( + target_credential_id=target_connector_id, + redirect_url=( + f"{resolve_extension_webapp_host()}" + "/settings/integrations/x/callback?scene=connector_x_bind" + ), + ) + + resp = self.client.post( + endpoint=ENDPOINT_X_CONNECTOR_AUTHORIZE, + body=payload.model_dump(exclude_none=True), + result_model=APIResponse[OAuthURLResponse], + ) + return resp.data + + def delete_x_account(self, connector_id: str) -> None: + """ + Delete a connected X (Twitter) account. + + Args: + connector_id: ID of the X connector account to delete + """ + endpoint = ENDPOINT_X_CONNECTOR_DELETE.format(connector_id=connector_id) + self.client.delete(endpoint)