mirror of
https://github.com/eigent-ai/eigent.git
synced 2026-05-19 16:31:36 +00:00
feat: Add LinkedIn OAuth integration with CAMEL-AI toolkit (#1104)
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com> Co-authored-by: Wendong-Fan <w3ndong.fan@gmail.com> Co-authored-by: Wendong-Fan <133094783+Wendong-Fan@users.noreply.github.com>
This commit is contained in:
parent
4b8394d084
commit
ebb4ca00ac
6 changed files with 622 additions and 30 deletions
|
|
@ -12,18 +12,35 @@
|
|||
# limitations under the License.
|
||||
# ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. =========
|
||||
|
||||
from typing import Optional
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from pydantic import BaseModel
|
||||
from app.utils.toolkit.notion_mcp_toolkit import NotionMCPToolkit
|
||||
from app.utils.toolkit.google_calendar_toolkit import GoogleCalendarToolkit
|
||||
from app.utils.toolkit.linkedin_toolkit import LinkedInToolkit
|
||||
from app.utils.oauth_state_manager import oauth_state_manager
|
||||
import logging
|
||||
|
||||
|
||||
from camel.toolkits.hybrid_browser_toolkit.hybrid_browser_toolkit_ts import (
|
||||
HybridBrowserToolkit as BaseHybridBrowserToolkit,
|
||||
)
|
||||
from app.utils.cookie_manager import CookieManager
|
||||
|
||||
|
||||
import os
|
||||
import time
|
||||
import uuid
|
||||
|
||||
|
||||
class LinkedInTokenRequest(BaseModel):
|
||||
r"""Request model for saving LinkedIn OAuth token."""
|
||||
access_token: str
|
||||
refresh_token: Optional[str] = None
|
||||
expires_in: Optional[int] = None
|
||||
scope: Optional[str] = None
|
||||
|
||||
|
||||
logger = logging.getLogger("tool_controller")
|
||||
router = APIRouter()
|
||||
|
||||
|
|
@ -118,10 +135,81 @@ async def install_tool(tool: str):
|
|||
status_code=500,
|
||||
detail=f"Failed to install {tool}: {str(e)}"
|
||||
)
|
||||
elif tool == "linkedin":
|
||||
try:
|
||||
# Check if LinkedIn is already authenticated
|
||||
if LinkedInToolkit.is_authenticated():
|
||||
# Check if token is expired
|
||||
if LinkedInToolkit.is_token_expired():
|
||||
logger.info("LinkedIn token has expired")
|
||||
return {
|
||||
"success": False,
|
||||
"status": "token_expired",
|
||||
"message": "LinkedIn token has expired. Please re-authenticate via OAuth.",
|
||||
"toolkit_name": "LinkedInToolkit",
|
||||
"requires_auth": True,
|
||||
"oauth_url": "/api/oauth/linkedin/login"
|
||||
}
|
||||
|
||||
try:
|
||||
toolkit = LinkedInToolkit("install_auth")
|
||||
tools = [tool_func.func.__name__ for tool_func in toolkit.get_tools()]
|
||||
|
||||
# Try to get profile to verify token is valid
|
||||
profile = toolkit.get_profile_safe()
|
||||
|
||||
# Check if token is expiring soon
|
||||
token_warning = None
|
||||
if LinkedInToolkit.is_token_expiring_soon():
|
||||
token_info = LinkedInToolkit.get_token_info()
|
||||
if token_info and token_info.get("expires_at"):
|
||||
days_remaining = (token_info["expires_at"] - int(time.time())) // (24 * 60 * 60)
|
||||
token_warning = f"Token expires in {days_remaining} days. Consider re-authenticating soon."
|
||||
|
||||
logger.info(f"Successfully initialized LinkedIn toolkit with {len(tools)} tools")
|
||||
result = {
|
||||
"success": True,
|
||||
"tools": tools,
|
||||
"message": f"Successfully installed {tool} toolkit",
|
||||
"count": len(tools),
|
||||
"toolkit_name": "LinkedInToolkit",
|
||||
"profile": profile if "error" not in profile else None
|
||||
}
|
||||
if token_warning:
|
||||
result["warning"] = token_warning
|
||||
return result
|
||||
except Exception as e:
|
||||
logger.warning(f"LinkedIn token may be invalid: {e}")
|
||||
# Token exists but may be expired/invalid
|
||||
return {
|
||||
"success": False,
|
||||
"status": "token_invalid",
|
||||
"message": "LinkedIn token may be expired or invalid. Please re-authenticate via OAuth.",
|
||||
"toolkit_name": "LinkedInToolkit",
|
||||
"requires_auth": True,
|
||||
"oauth_url": "/api/oauth/linkedin/login"
|
||||
}
|
||||
else:
|
||||
# No credentials - need OAuth authorization
|
||||
logger.info("No LinkedIn credentials found, OAuth required")
|
||||
return {
|
||||
"success": False,
|
||||
"status": "not_configured",
|
||||
"message": "LinkedIn OAuth required. Redirect user to OAuth login.",
|
||||
"toolkit_name": "LinkedInToolkit",
|
||||
"requires_auth": True,
|
||||
"oauth_url": "/api/oauth/linkedin/login"
|
||||
}
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to install {tool} toolkit: {e}")
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail=f"Failed to install {tool}: {str(e)}"
|
||||
)
|
||||
else:
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
detail=f"Tool '{tool}' not found. Available tools: ['notion', 'google_calendar']"
|
||||
detail=f"Tool '{tool}' not found. Available tools: ['notion', 'google_calendar', 'linkedin']"
|
||||
)
|
||||
|
||||
|
||||
|
|
@ -148,6 +236,14 @@ async def list_available_tools():
|
|||
"description": "Google Calendar integration for managing events and schedules",
|
||||
"toolkit_class": "GoogleCalendarToolkit",
|
||||
"requires_auth": True
|
||||
},
|
||||
{
|
||||
"name": "linkedin",
|
||||
"display_name": "LinkedIn",
|
||||
"description": "LinkedIn integration for creating posts, managing profile, and social media automation",
|
||||
"toolkit_class": "LinkedInToolkit",
|
||||
"requires_auth": True,
|
||||
"oauth_url": "/api/oauth/linkedin/login"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -309,10 +405,140 @@ async def uninstall_tool(tool: str):
|
|||
status_code=500,
|
||||
detail=f"Failed to uninstall {tool}: {str(e)}"
|
||||
)
|
||||
elif tool == "linkedin":
|
||||
try:
|
||||
# Clear LinkedIn token
|
||||
success = LinkedInToolkit.clear_token()
|
||||
|
||||
if success:
|
||||
return {
|
||||
"success": True,
|
||||
"message": f"Successfully uninstalled {tool} and cleaned up authentication tokens"
|
||||
}
|
||||
else:
|
||||
return {
|
||||
"success": True,
|
||||
"message": f"Uninstalled {tool} (no tokens found to clean up)"
|
||||
}
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to uninstall {tool}: {e}")
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail=f"Failed to uninstall {tool}: {str(e)}"
|
||||
)
|
||||
else:
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
detail=f"Tool '{tool}' not found. Available tools: ['notion', 'google_calendar']"
|
||||
detail=f"Tool '{tool}' not found. Available tools: ['notion', 'google_calendar', 'linkedin']"
|
||||
)
|
||||
|
||||
|
||||
@router.post("/linkedin/save-token", name="save linkedin token")
|
||||
async def save_linkedin_token(token_request: LinkedInTokenRequest):
|
||||
r"""Save LinkedIn OAuth token after successful authorization.
|
||||
|
||||
Args:
|
||||
token_request: Token data containing access_token and optionally refresh_token
|
||||
|
||||
Returns:
|
||||
Save result with tool information
|
||||
"""
|
||||
try:
|
||||
token_data = token_request.model_dump(exclude_none=True)
|
||||
|
||||
# Save the token
|
||||
success = LinkedInToolkit.save_token(token_data)
|
||||
|
||||
if success:
|
||||
# Verify the token works by initializing toolkit
|
||||
try:
|
||||
toolkit = LinkedInToolkit("install_auth")
|
||||
tools = [tool_func.func.__name__ for tool_func in toolkit.get_tools()]
|
||||
profile = toolkit.get_profile_safe()
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"message": "LinkedIn token saved successfully",
|
||||
"tools": tools,
|
||||
"count": len(tools),
|
||||
"profile": profile if "error" not in profile else None
|
||||
}
|
||||
except Exception as e:
|
||||
logger.warning(f"Token saved but verification failed: {e}")
|
||||
return {
|
||||
"success": True,
|
||||
"message": "LinkedIn token saved (verification pending)",
|
||||
"warning": str(e)
|
||||
}
|
||||
else:
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail="Failed to save LinkedIn token"
|
||||
)
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to save LinkedIn token: {e}")
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail=f"Failed to save token: {str(e)}"
|
||||
)
|
||||
|
||||
|
||||
@router.get("/linkedin/status", name="get linkedin status")
|
||||
async def get_linkedin_status():
|
||||
r"""Get current LinkedIn authentication status and token info.
|
||||
|
||||
Returns:
|
||||
Status information including authentication state and token expiry
|
||||
"""
|
||||
try:
|
||||
is_authenticated = LinkedInToolkit.is_authenticated()
|
||||
|
||||
if not is_authenticated:
|
||||
return {
|
||||
"authenticated": False,
|
||||
"status": "not_configured",
|
||||
"message": "LinkedIn not configured. OAuth required.",
|
||||
"oauth_url": "/api/oauth/linkedin/login"
|
||||
}
|
||||
|
||||
token_info = LinkedInToolkit.get_token_info()
|
||||
is_expired = LinkedInToolkit.is_token_expired()
|
||||
is_expiring_soon = LinkedInToolkit.is_token_expiring_soon()
|
||||
|
||||
result = {
|
||||
"authenticated": True,
|
||||
"status": "expired" if is_expired else ("expiring_soon" if is_expiring_soon else "valid"),
|
||||
}
|
||||
|
||||
if token_info:
|
||||
if token_info.get("expires_at"):
|
||||
current_time = int(time.time())
|
||||
expires_at = token_info["expires_at"]
|
||||
seconds_remaining = max(0, expires_at - current_time)
|
||||
days_remaining = seconds_remaining // (24 * 60 * 60)
|
||||
result["expires_at"] = expires_at
|
||||
result["days_remaining"] = days_remaining
|
||||
|
||||
if token_info.get("saved_at"):
|
||||
result["saved_at"] = token_info["saved_at"]
|
||||
|
||||
if is_expired:
|
||||
result["message"] = "Token has expired. Please re-authenticate."
|
||||
result["oauth_url"] = "/api/oauth/linkedin/login"
|
||||
elif is_expiring_soon:
|
||||
result["message"] = f"Token expires in {result.get('days_remaining', 'unknown')} days. Consider re-authenticating."
|
||||
result["oauth_url"] = "/api/oauth/linkedin/login"
|
||||
else:
|
||||
result["message"] = "LinkedIn is connected and token is valid."
|
||||
|
||||
return result
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to get LinkedIn status: {e}")
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail=f"Failed to get status: {str(e)}"
|
||||
)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -12,6 +12,12 @@
|
|||
# limitations under the License.
|
||||
# ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. =========
|
||||
|
||||
import json
|
||||
import os
|
||||
import logging
|
||||
import time
|
||||
from typing import Optional
|
||||
|
||||
from camel.toolkits import LinkedInToolkit as BaseLinkedInToolkit
|
||||
from camel.toolkits.function_tool import FunctionTool
|
||||
from app.component.environment import env
|
||||
|
|
@ -19,18 +25,258 @@ from app.service.task import Agents
|
|||
from app.utils.listen.toolkit_listen import auto_listen_toolkit
|
||||
from app.utils.toolkit.abstract_toolkit import AbstractToolkit
|
||||
|
||||
logger = logging.getLogger("linkedin_toolkit")
|
||||
|
||||
# LinkedIn access tokens expire after 60 days (in seconds)
|
||||
LINKEDIN_TOKEN_LIFETIME_SECONDS = 60 * 24 * 60 * 60
|
||||
# Refresh token when less than 7 days remaining
|
||||
LINKEDIN_TOKEN_REFRESH_THRESHOLD_SECONDS = 7 * 24 * 60 * 60
|
||||
|
||||
|
||||
@auto_listen_toolkit(BaseLinkedInToolkit)
|
||||
class LinkedInToolkit(BaseLinkedInToolkit, AbstractToolkit):
|
||||
r"""LinkedIn toolkit for social media automation.
|
||||
|
||||
This toolkit wraps CAMEL-AI's LinkedInToolkit and provides:
|
||||
- Post creation and deletion
|
||||
- Profile information retrieval
|
||||
- Token file persistence
|
||||
- Environment variable fallback
|
||||
"""
|
||||
|
||||
agent_name: str = Agents.social_medium_agent
|
||||
|
||||
def __init__(self, api_task_id: str, timeout: float | None = None):
|
||||
super().__init__(timeout)
|
||||
self.api_task_id = api_task_id
|
||||
self._token_path = self._build_canonical_token_path()
|
||||
self._load_credentials()
|
||||
super().__init__(timeout)
|
||||
|
||||
@classmethod
|
||||
def _build_canonical_token_path(cls) -> str:
|
||||
r"""Build the canonical path for storing LinkedIn tokens."""
|
||||
return env("LINKEDIN_TOKEN_PATH") or os.path.join(
|
||||
os.path.expanduser("~"),
|
||||
".eigent",
|
||||
"tokens",
|
||||
"linkedin",
|
||||
"linkedin_token.json",
|
||||
)
|
||||
|
||||
def _load_credentials(self):
|
||||
r"""Load credentials from token file or environment variables."""
|
||||
from dotenv import load_dotenv
|
||||
|
||||
# Force reload environment variables from default .env file
|
||||
default_env_path = os.path.join(os.path.expanduser("~"), ".eigent", ".env")
|
||||
if os.path.exists(default_env_path):
|
||||
load_dotenv(dotenv_path=default_env_path, override=True)
|
||||
|
||||
# Try to load from token file first
|
||||
if os.path.exists(self._token_path):
|
||||
try:
|
||||
with open(self._token_path, "r") as f:
|
||||
token_data = json.load(f)
|
||||
access_token = token_data.get("access_token")
|
||||
if access_token:
|
||||
# Check if token is expired before loading
|
||||
expires_at = token_data.get("expires_at")
|
||||
if expires_at and int(time.time()) >= expires_at:
|
||||
logger.warning("LinkedIn token file contains expired token, skipping load")
|
||||
else:
|
||||
os.environ["LINKEDIN_ACCESS_TOKEN"] = access_token
|
||||
logger.info(f"Loaded LinkedIn credentials from token file: {self._token_path}")
|
||||
except Exception as e:
|
||||
logger.warning(f"Could not load LinkedIn token file: {e}")
|
||||
|
||||
@classmethod
|
||||
def get_can_use_tools(cls, api_task_id: str) -> list[FunctionTool]:
|
||||
r"""Return tools only if LinkedIn credentials are configured."""
|
||||
from dotenv import load_dotenv
|
||||
|
||||
# Force reload environment variables
|
||||
default_env_path = os.path.join(os.path.expanduser("~"), ".eigent", ".env")
|
||||
if os.path.exists(default_env_path):
|
||||
load_dotenv(dotenv_path=default_env_path, override=True)
|
||||
|
||||
# Check for token file
|
||||
token_path = cls._build_canonical_token_path()
|
||||
if os.path.exists(token_path):
|
||||
try:
|
||||
with open(token_path, "r") as f:
|
||||
token_data = json.load(f)
|
||||
access_token = token_data.get("access_token")
|
||||
if access_token:
|
||||
# Check if token is expired before loading
|
||||
expires_at = token_data.get("expires_at")
|
||||
if expires_at and int(time.time()) >= expires_at:
|
||||
logger.warning("LinkedIn token file contains expired token, skipping load")
|
||||
else:
|
||||
os.environ["LINKEDIN_ACCESS_TOKEN"] = access_token
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if env("LINKEDIN_ACCESS_TOKEN"):
|
||||
return LinkedInToolkit(api_task_id).get_tools()
|
||||
else:
|
||||
return []
|
||||
|
||||
@classmethod
|
||||
def save_token(cls, token_data: dict) -> bool:
|
||||
r"""Save OAuth token to file.
|
||||
|
||||
Args:
|
||||
token_data: Dictionary containing access_token and optionally refresh_token
|
||||
|
||||
Returns:
|
||||
True if saved successfully, False otherwise
|
||||
"""
|
||||
token_path = cls._build_canonical_token_path()
|
||||
try:
|
||||
# Add timestamp for expiration tracking if not present
|
||||
if "saved_at" not in token_data:
|
||||
token_data["saved_at"] = int(time.time())
|
||||
|
||||
# Calculate expiration time if expires_in is provided
|
||||
if "expires_in" in token_data and "expires_at" not in token_data:
|
||||
token_data["expires_at"] = token_data["saved_at"] + token_data["expires_in"]
|
||||
elif "expires_at" not in token_data:
|
||||
# Default to 60 days if no expiration info provided
|
||||
token_data["expires_at"] = token_data["saved_at"] + LINKEDIN_TOKEN_LIFETIME_SECONDS
|
||||
|
||||
os.makedirs(os.path.dirname(token_path), exist_ok=True)
|
||||
with open(token_path, "w") as f:
|
||||
json.dump(token_data, f, indent=2)
|
||||
logger.info(f"Saved LinkedIn token to {token_path}")
|
||||
|
||||
# Also update environment variable
|
||||
if token_data.get("access_token"):
|
||||
os.environ["LINKEDIN_ACCESS_TOKEN"] = token_data["access_token"]
|
||||
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to save LinkedIn token: {e}")
|
||||
return False
|
||||
|
||||
@classmethod
|
||||
def clear_token(cls) -> bool:
|
||||
r"""Remove stored token file and clear environment variable.
|
||||
|
||||
Returns:
|
||||
True if cleared successfully, False otherwise
|
||||
"""
|
||||
token_path = cls._build_canonical_token_path()
|
||||
try:
|
||||
if os.path.exists(token_path):
|
||||
os.remove(token_path)
|
||||
logger.info(f"Removed LinkedIn token file: {token_path}")
|
||||
|
||||
# Also try to remove the parent directory if empty
|
||||
token_dir = os.path.dirname(token_path)
|
||||
if os.path.exists(token_dir) and not os.listdir(token_dir):
|
||||
os.rmdir(token_dir)
|
||||
logger.info(f"Removed empty LinkedIn token directory: {token_dir}")
|
||||
|
||||
# Clear environment variable
|
||||
if "LINKEDIN_ACCESS_TOKEN" in os.environ:
|
||||
del os.environ["LINKEDIN_ACCESS_TOKEN"]
|
||||
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to clear LinkedIn token: {e}")
|
||||
return False
|
||||
|
||||
@classmethod
|
||||
def is_authenticated(cls) -> bool:
|
||||
r"""Check if user has valid LinkedIn credentials.
|
||||
|
||||
Returns:
|
||||
True if credentials are available, False otherwise
|
||||
"""
|
||||
from dotenv import load_dotenv
|
||||
|
||||
# Force reload environment variables
|
||||
default_env_path = os.path.join(os.path.expanduser("~"), ".eigent", ".env")
|
||||
if os.path.exists(default_env_path):
|
||||
load_dotenv(dotenv_path=default_env_path, override=True)
|
||||
|
||||
# Check token file
|
||||
token_path = cls._build_canonical_token_path()
|
||||
if os.path.exists(token_path):
|
||||
try:
|
||||
with open(token_path, "r") as f:
|
||||
token_data = json.load(f)
|
||||
if token_data.get("access_token"):
|
||||
return True
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Check environment variable
|
||||
return bool(env("LINKEDIN_ACCESS_TOKEN"))
|
||||
|
||||
@classmethod
|
||||
def get_token_info(cls) -> Optional[dict]:
|
||||
r"""Get stored token information including expiration.
|
||||
|
||||
Returns:
|
||||
Token data dictionary or None if not available
|
||||
"""
|
||||
token_path = cls._build_canonical_token_path()
|
||||
if os.path.exists(token_path):
|
||||
try:
|
||||
with open(token_path, "r") as f:
|
||||
return json.load(f)
|
||||
except Exception:
|
||||
pass
|
||||
return None
|
||||
|
||||
@classmethod
|
||||
def is_token_expiring_soon(cls) -> bool:
|
||||
r"""Check if token is expiring within the refresh threshold.
|
||||
|
||||
Returns:
|
||||
True if token is expiring soon or already expired, False otherwise.
|
||||
Returns False if no token file exists (e.g. env var auth only).
|
||||
"""
|
||||
token_info = cls.get_token_info()
|
||||
if not token_info:
|
||||
return False # No token file; cannot determine expiry, assume valid
|
||||
|
||||
expires_at = token_info.get("expires_at")
|
||||
if not expires_at:
|
||||
return False # No expiration info, assume valid
|
||||
|
||||
current_time = int(time.time())
|
||||
time_remaining = expires_at - current_time
|
||||
|
||||
return time_remaining < LINKEDIN_TOKEN_REFRESH_THRESHOLD_SECONDS
|
||||
|
||||
@classmethod
|
||||
def is_token_expired(cls) -> bool:
|
||||
r"""Check if token has expired.
|
||||
|
||||
Returns:
|
||||
True if token is expired, False otherwise.
|
||||
Returns False if no token file exists (e.g. env var auth only).
|
||||
"""
|
||||
token_info = cls.get_token_info()
|
||||
if not token_info:
|
||||
return False # No token file; cannot determine expiry, assume valid
|
||||
|
||||
expires_at = token_info.get("expires_at")
|
||||
if not expires_at:
|
||||
return False # No expiration info, assume valid
|
||||
|
||||
return int(time.time()) >= expires_at
|
||||
|
||||
def get_profile_safe(self) -> dict:
|
||||
r"""Get LinkedIn profile with error handling.
|
||||
|
||||
Returns:
|
||||
Profile dictionary or error information
|
||||
"""
|
||||
try:
|
||||
return self.get_profile(include_id=True)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to get LinkedIn profile: {e}")
|
||||
return {"error": str(e)}
|
||||
|
|
|
|||
|
|
@ -1,19 +1,20 @@
|
|||
# ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. =========
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
# ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. =========
|
||||
|
||||
# ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. =========
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
# ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. =========
|
||||
|
||||
from abc import ABC, abstractmethod
|
||||
from typing import Dict, Any, Optional
|
||||
from urllib.parse import urlencode
|
||||
import os
|
||||
import httpx
|
||||
from pydantic import BaseModel
|
||||
|
|
@ -191,6 +192,60 @@ class EXAOAuthAdapter(OAuthAdapter):
|
|||
return None
|
||||
|
||||
|
||||
class LinkedInOAuthAdapter(OAuthAdapter):
|
||||
r"""LinkedIn OAuth 2.0 adapter for 3-legged OAuth flow."""
|
||||
|
||||
AUTHORIZATION_URL = "https://www.linkedin.com/oauth/v2/authorization"
|
||||
TOKEN_URL = "https://www.linkedin.com/oauth/v2/accessToken"
|
||||
SCOPES = "openid profile email w_member_social"
|
||||
|
||||
def __init__(self, redirect_uri: Optional[str] = None):
|
||||
self.client_id = env("LINKEDIN_CLIENT_ID", "")
|
||||
self.client_secret = env("LINKEDIN_CLIENT_SECRET", "")
|
||||
self.redirect_uri = redirect_uri or env(
|
||||
"LINKEDIN_REDIRECT_URI", "https://localhost/api/oauth/linkedin/callback"
|
||||
)
|
||||
|
||||
def get_authorize_url(self, state: Optional[str] = None) -> Optional[str]:
|
||||
params = {
|
||||
"response_type": "code",
|
||||
"client_id": self.client_id,
|
||||
"redirect_uri": self.redirect_uri,
|
||||
"scope": self.SCOPES,
|
||||
}
|
||||
if state:
|
||||
params["state"] = state
|
||||
return f"{self.AUTHORIZATION_URL}?{urlencode(params)}"
|
||||
|
||||
def fetch_token(self, code: Optional[str]) -> Optional[Dict[str, Any]]:
|
||||
if not code:
|
||||
return None
|
||||
headers = {"Content-Type": "application/x-www-form-urlencoded"}
|
||||
data = {
|
||||
"grant_type": "authorization_code",
|
||||
"code": code,
|
||||
"client_id": self.client_id,
|
||||
"client_secret": self.client_secret,
|
||||
"redirect_uri": self.redirect_uri,
|
||||
}
|
||||
with httpx.Client() as client:
|
||||
resp = client.post(self.TOKEN_URL, headers=headers, data=data)
|
||||
return resp.json()
|
||||
|
||||
def refresh_token(self, refresh_token: str) -> Optional[Dict[str, Any]]:
|
||||
r"""Refresh an expired access token."""
|
||||
headers = {"Content-Type": "application/x-www-form-urlencoded"}
|
||||
data = {
|
||||
"grant_type": "refresh_token",
|
||||
"refresh_token": refresh_token,
|
||||
"client_id": self.client_id,
|
||||
"client_secret": self.client_secret,
|
||||
}
|
||||
with httpx.Client() as client:
|
||||
resp = client.post(self.TOKEN_URL, headers=headers, data=data)
|
||||
return resp.json()
|
||||
|
||||
|
||||
# 工厂方法
|
||||
OAUTH_ADAPTERS = {
|
||||
"slack": SlackOAuthAdapter,
|
||||
|
|
@ -198,20 +253,16 @@ OAUTH_ADAPTERS = {
|
|||
"x": XOAuthAdapter,
|
||||
"twitter": XOAuthAdapter,
|
||||
"googlesuite": GoogleSuiteOAuthAdapter,
|
||||
"linkedin": LinkedInOAuthAdapter,
|
||||
}
|
||||
|
||||
|
||||
def get_oauth_adapter(app_name: str, redirect_uri: Optional[str] = None) -> OAuthAdapter:
|
||||
adapter_cls = OAUTH_ADAPTERS.get(app_name.lower())
|
||||
if not adapter_cls:
|
||||
raise ValueError(f"不支持的OAuth应用: {app_name}")
|
||||
if app_name.lower() == "slack":
|
||||
return adapter_cls(redirect_uri=redirect_uri)
|
||||
if app_name.lower() == "notion":
|
||||
return adapter_cls(redirect_uri=redirect_uri)
|
||||
if app_name.lower() == "x" or app_name.lower() == "twitter":
|
||||
return adapter_cls(redirect_uri=redirect_uri)
|
||||
return adapter_cls()
|
||||
raise ValueError(f"Unsupported OAuth application: {app_name}")
|
||||
# All adapters support redirect_uri parameter
|
||||
return adapter_cls(redirect_uri=redirect_uri)
|
||||
|
||||
|
||||
class OauthCallbackPayload(BaseModel):
|
||||
|
|
|
|||
|
|
@ -79,7 +79,12 @@ class ConfigInfo:
|
|||
"toolkit": "whatsapp_toolkit",
|
||||
},
|
||||
ConfigGroup.LINKEDIN.value: {
|
||||
"env_vars": ["LINKEDIN_ACCESS_TOKEN"],
|
||||
"env_vars": [
|
||||
"LINKEDIN_CLIENT_ID",
|
||||
"LINKEDIN_CLIENT_SECRET",
|
||||
"LINKEDIN_ACCESS_TOKEN",
|
||||
"LINKEDIN_REFRESH_TOKEN",
|
||||
],
|
||||
"toolkit": "linkedin_toolkit",
|
||||
},
|
||||
ConfigGroup.REDDIT.value: {
|
||||
|
|
|
|||
|
|
@ -38,6 +38,7 @@ import {
|
|||
useIntegrationManagement,
|
||||
type IntegrationItem,
|
||||
} from "@/hooks/useIntegrationManagement";
|
||||
import { getProxyBaseURL } from "@/lib";
|
||||
|
||||
type IntegrationListVariant = "select" | "manage";
|
||||
|
||||
|
|
@ -124,6 +125,15 @@ export default function IntegrationList({
|
|||
return;
|
||||
}
|
||||
|
||||
// LinkedIn uses server-side OAuth flow
|
||||
if (item.key === "LinkedIn") {
|
||||
// Open LinkedIn OAuth login via the remote server (same pattern as other OAuth providers)
|
||||
const baseUrl = getProxyBaseURL();
|
||||
const oauthUrl = `${baseUrl}/api/oauth/linkedin/login`;
|
||||
window.open(oauthUrl, "_blank", "width=600,height=700");
|
||||
return;
|
||||
}
|
||||
|
||||
if (installed[item.key]) return;
|
||||
await item.onInstall();
|
||||
// Only refresh in select mode
|
||||
|
|
@ -256,7 +266,7 @@ export default function IntegrationList({
|
|||
"Slack",
|
||||
"X(Twitter)",
|
||||
"WhatsApp",
|
||||
"LinkedIn",
|
||||
// "LinkedIn", // LinkedIn OAuth is now supported
|
||||
"Reddit",
|
||||
"Github",
|
||||
];
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ import {
|
|||
proxyFetchPut,
|
||||
proxyFetchDelete,
|
||||
fetchDelete,
|
||||
fetchPost,
|
||||
} from "@/api/http";
|
||||
import { capitalizeFirstLetter } from "@/lib";
|
||||
import { useAuthStore } from "@/store/authStore";
|
||||
|
|
@ -75,7 +76,7 @@ export function useIntegrationManagement(items: IntegrationItem[]) {
|
|||
// Recalculate installed status when items or configs change
|
||||
useEffect(() => {
|
||||
const map: { [key: string]: boolean } = {};
|
||||
|
||||
|
||||
items.forEach((item) => {
|
||||
if (item.key === "Google Calendar") {
|
||||
// Only mark installed when refresh token is present (auth completed)
|
||||
|
|
@ -86,6 +87,15 @@ export function useIntegrationManagement(items: IntegrationItem[]) {
|
|||
c.config_value && String(c.config_value).length > 0
|
||||
);
|
||||
map[item.key] = hasRefreshToken;
|
||||
} else if (item.key === "LinkedIn") {
|
||||
// LinkedIn: check if access token is present
|
||||
const hasAccessToken = configs.some(
|
||||
(c: any) =>
|
||||
c.config_group?.toLowerCase() === "linkedin" &&
|
||||
c.config_name === "LINKEDIN_ACCESS_TOKEN" &&
|
||||
c.config_value && String(c.config_value).length > 0
|
||||
);
|
||||
map[item.key] = hasAccessToken;
|
||||
} else {
|
||||
// For other integrations, use config_group presence
|
||||
const hasConfig = configs.some(
|
||||
|
|
@ -94,7 +104,7 @@ export function useIntegrationManagement(items: IntegrationItem[]) {
|
|||
map[item.key] = hasConfig;
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
setInstalled(map);
|
||||
}, [items, configs]);
|
||||
|
||||
|
|
@ -187,6 +197,43 @@ export function useIntegrationManagement(items: IntegrationItem[]) {
|
|||
"Slack authorization successful, but access_token not found or env configuration not found"
|
||||
);
|
||||
}
|
||||
} else if (provider === "linkedin") {
|
||||
// LinkedIn OAuth: save token via local backend endpoint and config
|
||||
if (tokenResult.access_token) {
|
||||
try {
|
||||
// Save token to local backend toolkit (token file is stored locally)
|
||||
await fetchPost("/linkedin/save-token", {
|
||||
access_token: tokenResult.access_token,
|
||||
refresh_token: tokenResult.refresh_token,
|
||||
expires_in: tokenResult.expires_in,
|
||||
});
|
||||
|
||||
// Also save to config for UI status tracking
|
||||
await saveEnvAndConfig(
|
||||
"LinkedIn",
|
||||
"LINKEDIN_ACCESS_TOKEN",
|
||||
tokenResult.access_token
|
||||
);
|
||||
if (tokenResult.refresh_token) {
|
||||
await saveEnvAndConfig(
|
||||
"LinkedIn",
|
||||
"LINKEDIN_REFRESH_TOKEN",
|
||||
tokenResult.refresh_token
|
||||
);
|
||||
}
|
||||
|
||||
await fetchInstalled();
|
||||
console.log(
|
||||
"LinkedIn authorization successful and configuration saved!"
|
||||
);
|
||||
} catch (e) {
|
||||
console.error("Failed to save LinkedIn token:", e);
|
||||
}
|
||||
} else {
|
||||
console.log(
|
||||
"LinkedIn authorization successful, but access_token not found"
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch (e: any) {
|
||||
console.log(`${data.provider} authorization failed: ${e.message || e}`);
|
||||
|
|
@ -254,7 +301,7 @@ export function useIntegrationManagement(items: IntegrationItem[]) {
|
|||
}
|
||||
}
|
||||
|
||||
// Clean up authentication tokens for Google Calendar and Notion
|
||||
// Clean up authentication tokens for Google Calendar, Notion, and LinkedIn
|
||||
if (item.key === "Google Calendar") {
|
||||
try {
|
||||
await fetchDelete("/uninstall/tool/google_calendar");
|
||||
|
|
@ -269,6 +316,13 @@ export function useIntegrationManagement(items: IntegrationItem[]) {
|
|||
} catch (e) {
|
||||
console.log("Failed to clean up Notion tokens:", e);
|
||||
}
|
||||
} else if (item.key === "LinkedIn") {
|
||||
try {
|
||||
await fetchDelete("/uninstall/tool/linkedin");
|
||||
console.log("Cleaned up LinkedIn authentication tokens");
|
||||
} catch (e) {
|
||||
console.log("Failed to clean up LinkedIn tokens:", e);
|
||||
}
|
||||
}
|
||||
|
||||
// Update configs after deletion
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue