Compare commits

...

38 Commits

Author SHA1 Message Date
Swifty
1b9c6af302 Merge branch 'dev' into swiftyos/secrt-1045-ayrshare-integration 2025-07-25 08:57:38 +02:00
Swifty
4d9bd00cc1 re-update generated files 2025-07-24 15:18:11 +02:00
Swifty
6cc758a895 PR Comments 2025-07-24 15:14:37 +02:00
Swifty
a7c8096b5b added a toast 2025-07-24 15:13:44 +02:00
Swifty
5bd5452855 Frontend PR Comment Changes 2025-07-24 14:51:26 +02:00
Swifty
95c0335332 updated generated types 2025-07-24 14:36:15 +02:00
Swifty
b9bb1df6db Merge branch 'dev' into swiftyos/secrt-1045-ayrshare-integration 2025-07-24 14:17:03 +02:00
Swifty
b83cf67780 Merge branch 'dev' into swiftyos/secrt-1045-ayrshare-integration 2025-07-21 09:58:21 +02:00
Swifty
277e2a3c66 Merge branch 'dev' into swiftyos/secrt-1045-ayrshare-integration 2025-07-14 12:19:30 +02:00
SwiftyOS
bb1825e959 removed unrelated change 2025-07-11 10:50:05 +02:00
SwiftyOS
4d30483250 commented out all but twitter block 2025-07-11 10:45:40 +02:00
SwiftyOS
379b5e1021 updated openapi.json 2025-07-11 10:45:24 +02:00
SwiftyOS
cb3aeecb39 revert test skip 2025-07-11 09:30:42 +02:00
SwiftyOS
fbc36e3138 Merge remote-tracking branch 'origin/dev' into swiftyos/secrt-1045-ayrshare-integration 2025-07-11 09:28:48 +02:00
SwiftyOS
0cb7172167 flattened input data, updated snapshots 2025-06-16 17:03:08 +02:00
Swifty
e412a08222 Merge branch 'dev' into swiftyos/secrt-1045-ayrshare-integration 2025-06-16 12:01:00 +02:00
Swifty
a61c1addcf Merge branch 'dev' into swiftyos/secrt-1045-ayrshare-integration 2025-06-10 12:07:14 +02:00
Swifty
0ef9a2403c Merge branch 'dev' into swiftyos/secrt-1045-ayrshare-integration 2025-06-06 16:29:46 +02:00
SwiftyOS
f8d56dce3a dont provide a null provider 2025-06-06 14:28:37 +02:00
SwiftyOS
267b83460a added platform specific options and newly supported platforms 2025-06-06 14:10:07 +02:00
Swifty
444a1a9ed2 Merge branch 'dev' into swiftyos/secrt-1045-ayrshare-integration 2025-06-06 10:14:41 +02:00
SwiftyOS
ca703fa1f0 fixed type on name 2025-05-16 16:15:13 +02:00
Swifty
3c3311c506 Merge branch 'dev' into swiftyos/secrt-1045-ayrshare-integration 2025-05-16 16:11:50 +02:00
SwiftyOS
7e62ce3159 final checks 2025-05-16 16:02:17 +02:00
SwiftyOS
9509762a94 Posting now working 2025-05-16 15:19:11 +02:00
SwiftyOS
3eff0a8df2 added hidden repsonse field 2025-05-16 14:58:47 +02:00
SwiftyOS
d53da6f572 added sso flow 2025-05-16 13:01:18 +02:00
SwiftyOS
fdf4785ae2 removed credentials fixed blocks 2025-05-16 12:46:37 +02:00
SwiftyOS
f1b5128715 add to all credentials 2025-05-16 11:42:51 +02:00
SwiftyOS
d26edd8dac add getAyrshareSSOUrl api call 2025-05-16 11:41:43 +02:00
Swifty
15bc5cf228 Merge branch 'dev' into swiftyos/secrt-1045-ayrshare-integration 2025-05-16 10:28:29 +02:00
SwiftyOS
2d3842bdd1 updated blocks and cred store access 2025-05-16 10:13:40 +02:00
SwiftyOS
cae4a6e145 updating to using block 2025-05-15 16:42:15 +02:00
SwiftyOS
e71a422173 formatting 2025-05-15 16:27:24 +02:00
SwiftyOS
6912e3ade3 added ayrshare integrations 2025-05-15 16:20:17 +02:00
Reinier van der Leer
7d90376eb2 simplify code 2025-05-15 14:57:56 +02:00
Reinier van der Leer
993e123f1b improve code 2025-05-15 14:06:31 +02:00
Reinier van der Leer
863a9e98ec feat(backend): Managed credentials store 2025-05-15 13:18:23 +02:00
19 changed files with 3648 additions and 11 deletions

View File

@@ -197,6 +197,10 @@ SMARTLEAD_API_KEY=
# ZeroBounce
ZEROBOUNCE_API_KEY=
# Ayrshare
AYRSHARE_API_KEY=
AYRSHARE_JWT_KEY=
## ===== OPTIONAL API KEYS END ===== ##
# Block Error Rate Monitoring

File diff suppressed because it is too large Load Diff

View File

@@ -55,6 +55,7 @@ class BlockType(Enum):
WEBHOOK_MANUAL = "Webhook (manual)"
AGENT = "Agent"
AI = "AI"
AYRSHARE = "Ayrshare"
class BlockCategory(Enum):

View File

@@ -14,7 +14,6 @@ from typing import (
Generic,
Literal,
Optional,
TypedDict,
TypeVar,
cast,
get_args,
@@ -38,6 +37,7 @@ from pydantic_core import (
ValidationError,
core_schema,
)
from typing_extensions import TypedDict
from backend.integrations.providers import ProviderName
from backend.util.settings import Secrets
@@ -316,15 +316,32 @@ class OAuthState(BaseModel):
class UserMetadata(BaseModel):
integration_credentials: list[Credentials] = Field(default_factory=list)
"""⚠️ Deprecated; use `UserIntegrations.credentials` instead"""
integration_oauth_states: list[OAuthState] = Field(default_factory=list)
"""⚠️ Deprecated; use `UserIntegrations.oauth_states` instead"""
class UserMetadataRaw(TypedDict, total=False):
integration_credentials: list[dict]
"""⚠️ Deprecated; use `UserIntegrations.credentials` instead"""
integration_oauth_states: list[dict]
"""⚠️ Deprecated; use `UserIntegrations.oauth_states` instead"""
class UserIntegrations(BaseModel):
class ManagedCredentials(BaseModel):
"""Integration credentials managed by us, rather than by the user"""
ayrshare_profile_key: Optional[SecretStr] = None
@field_serializer("*")
def dump_secret_strings(value: Any, _info):
if isinstance(value, SecretStr):
return value.get_secret_value()
return value
managed_credentials: ManagedCredentials = Field(default_factory=ManagedCredentials)
credentials: list[Credentials] = Field(default_factory=list)
oauth_states: list[OAuthState] = Field(default_factory=list)

View File

@@ -35,6 +35,7 @@ from autogpt_libs.utils.cache import thread_cached
from prometheus_client import Gauge, start_http_server
from backend.blocks.agent import AgentExecutorBlock
from backend.blocks.ayrshare.post import AYRSHARE_BLOCK_IDS
from backend.data import redis_client as redis
from backend.data.block import (
BlockData,
@@ -182,6 +183,10 @@ async def execute_node(
)
extra_exec_kwargs[field_name] = credentials
if node_block.id in AYRSHARE_BLOCK_IDS:
profile_key = creds_manager.store.get_ayrshare_profile_key(user_id)
extra_exec_kwargs["profile_key"] = profile_key
output_size = 0
try:
async for output_name, output_data in node_block.execute(

View File

@@ -0,0 +1,474 @@
from __future__ import annotations
import json
import logging
from enum import Enum
from typing import Any, Optional
from pydantic import BaseModel
from backend.util.exceptions import MissingConfigError
from backend.util.request import Requests
from backend.util.settings import Settings
logger = logging.getLogger(__name__)
settings = Settings()
class AyrshareAPIException(Exception):
def __init__(self, message: str, status_code: int):
super().__init__(message)
self.status_code = status_code
class SocialPlatform(str, Enum):
BLUESKY = "bluesky"
FACEBOOK = "facebook"
TWITTER = "twitter"
LINKEDIN = "linkedin"
INSTAGRAM = "instagram"
YOUTUBE = "youtube"
REDDIT = "reddit"
TELEGRAM = "telegram"
GOOGLE_MY_BUSINESS = "gmb"
PINTEREST = "pinterest"
TIKTOK = "tiktok"
SNAPCHAT = "snapchat"
THREADS = "threads"
class EmailConfig(BaseModel):
to: str
subject: Optional[str] = None
body: Optional[str] = None
from_name: Optional[str] = None
from_email: Optional[str] = None
class JWTResponse(BaseModel):
status: str
title: str
token: str
url: str
emailSent: Optional[bool] = None
expiresIn: Optional[str] = None
class ProfileResponse(BaseModel):
status: str
title: str
refId: str
profileKey: str
messagingActive: Optional[bool] = None
class PostResponse(BaseModel):
status: str
id: str
refId: str
profileTitle: str
post: str
postIds: Optional[list[dict[str, Any]]] = None
scheduleDate: Optional[str] = None
errors: Optional[list[str]] = None
class AutoHashtag(BaseModel):
max: Optional[int] = None
position: Optional[str] = None
class FirstComment(BaseModel):
text: str
platforms: Optional[list[SocialPlatform]] = None
class AutoSchedule(BaseModel):
interval: str
platforms: Optional[list[SocialPlatform]] = None
startDate: Optional[str] = None
endDate: Optional[str] = None
class AutoRepost(BaseModel):
interval: str
platforms: Optional[list[SocialPlatform]] = None
startDate: Optional[str] = None
endDate: Optional[str] = None
class AyrshareClient:
"""Client for the Ayrshare Social Media Post API"""
API_URL = "https://api.ayrshare.com/api"
POST_ENDPOINT = f"{API_URL}/post"
PROFILES_ENDPOINT = f"{API_URL}/profiles"
JWT_ENDPOINT = f"{PROFILES_ENDPOINT}/generateJWT"
def __init__(
self,
custom_requests: Optional[Requests] = None,
):
if not settings.secrets.ayrshare_api_key:
raise MissingConfigError("AYRSHARE_API_KEY is not configured")
headers: dict[str, str] = {
"Content-Type": "application/json",
"Authorization": f"Bearer {settings.secrets.ayrshare_api_key}",
}
self.headers = headers
if custom_requests:
self._requests = custom_requests
else:
self._requests = Requests(
extra_headers=headers,
trusted_origins=["https://api.ayrshare.com"],
raise_for_status=False,
)
async def generate_jwt(
self,
private_key: str,
profile_key: str,
logout: Optional[bool] = None,
redirect: Optional[str] = None,
allowed_social: Optional[list[SocialPlatform]] = None,
verify: Optional[bool] = None,
base64: Optional[bool] = None,
expires_in: Optional[int] = None,
email: Optional[EmailConfig] = None,
) -> JWTResponse:
"""
Generate a JSON Web Token (JWT) for use with single sign on.
Args:
domain: Domain of app. Must match the domain given during onboarding.
private_key: Private Key used for encryption.
profile_key: User Profile Key (not the API Key).
logout: Automatically logout the current session.
redirect: URL to redirect to when the "Done" button or logo is clicked.
allowed_social: List of social networks to display in the linking page.
verify: Verify that the generated token is valid (recommended for non-production).
base64: Whether the private key is base64 encoded.
expires_in: Token longevity in minutes (1-2880).
email: Configuration for sending Connect Accounts email.
Returns:
JWTResponse object containing the JWT token and URL.
Raises:
AyrshareAPIException: If the API request fails or private key is invalid.
"""
payload: dict[str, Any] = {
"domain": "id-pojeg",
"privateKey": private_key,
"profileKey": profile_key,
}
headers = self.headers
headers["Profile-Key"] = profile_key
if logout is not None:
payload["logout"] = logout
if redirect is not None:
payload["redirect"] = redirect
if allowed_social is not None:
payload["allowedSocial"] = [p.value for p in allowed_social]
if verify is not None:
payload["verify"] = verify
if base64 is not None:
payload["base64"] = base64
if expires_in is not None:
payload["expiresIn"] = expires_in
if email is not None:
payload["email"] = email.model_dump(exclude_none=True)
response = await self._requests.post(
self.JWT_ENDPOINT, json=payload, headers=headers
)
if not response.ok:
try:
error_data = response.json()
error_message = error_data.get("message", "Unknown error")
except json.JSONDecodeError:
error_message = response.text()
raise AyrshareAPIException(
f"Ayrshare API request failed ({response.status}): {error_message}",
response.status,
)
response_data = response.json()
if response_data.get("status") != "success":
raise AyrshareAPIException(
f"Ayrshare API returned error: {response_data.get('message', 'Unknown error')}",
response.status,
)
return JWTResponse(**response_data)
async def create_profile(
self,
title: str,
messaging_active: Optional[bool] = None,
hide_top_header: Optional[bool] = None,
top_header: Optional[str] = None,
disable_social: Optional[list[SocialPlatform]] = None,
team: Optional[bool] = None,
email: Optional[str] = None,
sub_header: Optional[str] = None,
tags: Optional[list[str]] = None,
) -> ProfileResponse:
"""
Create a new User Profile under your Primary Profile.
Args:
title: Title of the new profile. Must be unique.
messaging_active: Set to true to activate messaging for this user profile.
hide_top_header: Hide the top header on the social accounts linkage page.
top_header: Change the header on the social accounts linkage page.
disable_social: Array of social networks that are disabled for this user's profile.
team: Create a new user profile as a team member.
email: Email address for team member invite (required if team is true).
sub_header: Change the sub header on the social accounts linkage page.
tags: Array of strings to tag user profiles.
Returns:
ProfileResponse object containing the profile details and profile key.
Raises:
AyrshareAPIException: If the API request fails or profile title already exists.
"""
payload: dict[str, Any] = {
"title": title,
}
if messaging_active is not None:
payload["messagingActive"] = messaging_active
if hide_top_header is not None:
payload["hideTopHeader"] = hide_top_header
if top_header is not None:
payload["topHeader"] = top_header
if disable_social is not None:
payload["disableSocial"] = [p.value for p in disable_social]
if team is not None:
payload["team"] = team
if email is not None:
payload["email"] = email
if sub_header is not None:
payload["subHeader"] = sub_header
if tags is not None:
payload["tags"] = tags
response = await self._requests.post(self.PROFILES_ENDPOINT, json=payload)
if not response.ok:
try:
error_data = response.json()
error_message = error_data.get("message", "Unknown error")
except json.JSONDecodeError:
error_message = response.text()
raise AyrshareAPIException(
f"Ayrshare API request failed ({response.status}): {error_message}",
response.status,
)
response_data = response.json()
if response_data.get("status") != "success":
raise AyrshareAPIException(
f"Ayrshare API returned error: {response_data.get('message', 'Unknown error')}",
response.status,
)
return ProfileResponse(**response_data)
async def create_post(
self,
post: str,
platforms: list[SocialPlatform],
*,
media_urls: Optional[list[str]] = None,
is_video: Optional[bool] = None,
schedule_date: Optional[str] = None,
first_comment: Optional[FirstComment] = None,
disable_comments: Optional[bool] = None,
shorten_links: Optional[bool] = None,
auto_schedule: Optional[AutoSchedule] = None,
auto_repost: Optional[AutoRepost] = None,
auto_hashtag: Optional[AutoHashtag | bool] = None,
unsplash: Optional[str] = None,
bluesky_options: Optional[dict[str, Any]] = None,
facebook_options: Optional[dict[str, Any]] = None,
gmb_options: Optional[dict[str, Any]] = None,
instagram_options: Optional[dict[str, Any]] = None,
linkedin_options: Optional[dict[str, Any]] = None,
pinterest_options: Optional[dict[str, Any]] = None,
reddit_options: Optional[dict[str, Any]] = None,
snapchat_options: Optional[dict[str, Any]] = None,
telegram_options: Optional[dict[str, Any]] = None,
threads_options: Optional[dict[str, Any]] = None,
tiktok_options: Optional[dict[str, Any]] = None,
twitter_options: Optional[dict[str, Any]] = None,
youtube_options: Optional[dict[str, Any]] = None,
requires_approval: Optional[bool] = None,
random_post: Optional[bool] = None,
random_media_url: Optional[bool] = None,
idempotency_key: Optional[str] = None,
notes: Optional[str] = None,
profile_key: Optional[str] = None,
) -> PostResponse:
"""
Create a post across multiple social media platforms.
Args:
post: The post text to be published
platforms: List of platforms to post to (e.g. [SocialPlatform.TWITTER, SocialPlatform.FACEBOOK])
media_urls: Optional list of media URLs to include
is_video: Whether the media is a video
schedule_date: UTC datetime for scheduling (YYYY-MM-DDThh:mm:ssZ)
first_comment: Configuration for first comment
disable_comments: Whether to disable comments
shorten_links: Whether to shorten links
auto_schedule: Configuration for automatic scheduling
auto_repost: Configuration for automatic reposting
auto_hashtag: Configuration for automatic hashtags
unsplash: Unsplash image configuration
bluesky_options: Bluesky-specific options
facebook_options: Facebook-specific options
gmb_options: Google Business Profile options
instagram_options: Instagram-specific options
linkedin_options: LinkedIn-specific options
pinterest_options: Pinterest-specific options
reddit_options: Reddit-specific options
snapchat_options: Snapchat-specific options
telegram_options: Telegram-specific options
threads_options: Threads-specific options
tiktok_options: TikTok-specific options
twitter_options: Twitter-specific options
youtube_options: YouTube-specific options
requires_approval: Whether to enable approval workflow
random_post: Whether to generate random post text
random_media_url: Whether to generate random media
idempotency_key: Unique ID for the post
notes: Additional notes for the post
Returns:
PostResponse object containing the post details and status
Raises:
AyrshareAPIException: If the API request fails
"""
payload: dict[str, Any] = {
"post": post,
"platforms": [p.value for p in platforms],
}
# Add optional parameters if provided
if media_urls:
payload["mediaUrls"] = media_urls
if is_video is not None:
payload["isVideo"] = is_video
if schedule_date:
payload["scheduleDate"] = schedule_date
if first_comment:
first_comment_dict = first_comment.model_dump(exclude_none=True)
if first_comment.platforms:
first_comment_dict["platforms"] = [
p.value for p in first_comment.platforms
]
payload["firstComment"] = first_comment_dict
if disable_comments is not None:
payload["disableComments"] = disable_comments
if shorten_links is not None:
payload["shortenLinks"] = shorten_links
if auto_schedule:
auto_schedule_dict = auto_schedule.model_dump(exclude_none=True)
if auto_schedule.platforms:
auto_schedule_dict["platforms"] = [
p.value for p in auto_schedule.platforms
]
payload["autoSchedule"] = auto_schedule_dict
if auto_repost:
auto_repost_dict = auto_repost.model_dump(exclude_none=True)
if auto_repost.platforms:
auto_repost_dict["platforms"] = [p.value for p in auto_repost.platforms]
payload["autoRepost"] = auto_repost_dict
if auto_hashtag:
payload["autoHashtag"] = (
auto_hashtag.model_dump(exclude_none=True)
if isinstance(auto_hashtag, AutoHashtag)
else auto_hashtag
)
if unsplash:
payload["unsplash"] = unsplash
if bluesky_options:
payload["blueskyOptions"] = bluesky_options
if facebook_options:
payload["faceBookOptions"] = facebook_options
if gmb_options:
payload["gmbOptions"] = gmb_options
if instagram_options:
payload["instagramOptions"] = instagram_options
if linkedin_options:
payload["linkedInOptions"] = linkedin_options
if pinterest_options:
payload["pinterestOptions"] = pinterest_options
if reddit_options:
payload["redditOptions"] = reddit_options
if snapchat_options:
payload["snapchatOptions"] = snapchat_options
if telegram_options:
payload["telegramOptions"] = telegram_options
if threads_options:
payload["threadsOptions"] = threads_options
if tiktok_options:
payload["tikTokOptions"] = tiktok_options
if twitter_options:
payload["twitterOptions"] = twitter_options
if youtube_options:
payload["youTubeOptions"] = youtube_options
if requires_approval is not None:
payload["requiresApproval"] = requires_approval
if random_post is not None:
payload["randomPost"] = random_post
if random_media_url is not None:
payload["randomMediaUrl"] = random_media_url
if idempotency_key:
payload["idempotencyKey"] = idempotency_key
if notes:
payload["notes"] = notes
headers = self.headers
if profile_key:
headers["Profile-Key"] = profile_key
response = await self._requests.post(
self.POST_ENDPOINT, json=payload, headers=headers
)
if not response.ok:
try:
error_data = response.json()
error_message = error_data.get("message", "Unknown error")
except json.JSONDecodeError:
error_message = response.text()
raise AyrshareAPIException(
f"Ayrshare API request failed ({response.status}): {error_message}",
response.status,
)
response_data = response.json()
if response_data.get("status") != "success":
raise AyrshareAPIException(
f"Ayrshare API returned error: {response_data.get('message', 'Unknown error')}",
response.status,
)
# Return the first post from the response
# This is because Ayrshare returns an array of posts even for single posts
return PostResponse(**response_data["posts"][0])

View File

@@ -1,6 +1,7 @@
import base64
import hashlib
import secrets
from contextlib import asynccontextmanager
from datetime import datetime, timedelta, timezone
from typing import Optional
@@ -182,6 +183,7 @@ zerobounce_credentials = APIKeyCredentials(
expires_at=None,
)
llama_api_credentials = APIKeyCredentials(
id="d44045af-1c33-4833-9e19-752313214de2",
provider="llama_api",
@@ -240,6 +242,7 @@ class IntegrationCredentialsStore:
return get_service_client(DatabaseManagerAsyncClient)
# =============== USER-MANAGED CREDENTIALS =============== #
async def add_creds(self, user_id: str, credentials: Credentials) -> None:
async with await self.locked_user_integrations(user_id):
if await self.get_creds_by_id(user_id, credentials.id):
@@ -359,6 +362,19 @@ class IntegrationCredentialsStore:
]
await self._set_user_integration_creds(user_id, filtered_credentials)
# ============== SYSTEM-MANAGED CREDENTIALS ============== #
async def get_ayrshare_profile_key(self, user_id: str) -> SecretStr | None:
user_integrations = await self._get_user_integrations(user_id)
return user_integrations.managed_credentials.ayrshare_profile_key
async def set_ayrshare_profile_key(self, user_id: str, profile_key: str) -> None:
_profile_key = SecretStr(profile_key)
async with self.edit_user_integrations(user_id) as user_integrations:
user_integrations.managed_credentials.ayrshare_profile_key = _profile_key
# ===================== OAUTH STATES ===================== #
async def store_state_token(
self, user_id: str, provider: str, scopes: list[str], use_pkce: bool = False
) -> tuple[str, str]:
@@ -375,6 +391,9 @@ class IntegrationCredentialsStore:
scopes=scopes,
)
async with self.edit_user_integrations(user_id) as user_integrations:
user_integrations.oauth_states.append(state)
async with await self.locked_user_integrations(user_id):
user_integrations = await self._get_user_integrations(user_id)
@@ -428,6 +447,17 @@ class IntegrationCredentialsStore:
return None
# =================== GET/SET HELPERS =================== #
@asynccontextmanager
async def edit_user_integrations(self, user_id: str):
async with await self.locked_user_integrations(user_id):
user_integrations = await self._get_user_integrations(user_id)
yield user_integrations # yield to allow edits
await self.db_manager.update_user_integrations(
user_id=user_id, data=user_integrations
)
async def _set_user_integration_creds(
self, user_id: str, credentials: list[Credentials]
) -> None:

View File

@@ -1,5 +1,6 @@
import asyncio
import logging
from datetime import datetime, timedelta, timezone
from typing import TYPE_CHECKING, Annotated, Awaitable, List, Literal
from fastapi import (
@@ -12,7 +13,8 @@ from fastapi import (
Request,
status,
)
from pydantic import BaseModel, Field
from pydantic import BaseModel, Field, SecretStr
from starlette.status import HTTP_500_INTERNAL_SERVER_ERROR, HTTP_502_BAD_GATEWAY
from backend.data.graph import get_graph, set_node_webhook
from backend.data.integrations import (
@@ -29,6 +31,7 @@ from backend.data.model import (
OAuth2Credentials,
)
from backend.executor.utils import add_graph_execution
from backend.integrations.ayrshare import AyrshareClient, SocialPlatform
from backend.integrations.creds_manager import IntegrationCredentialsManager
from backend.integrations.oauth import CREDENTIALS_BY_PROVIDER, HANDLERS_BY_NAME
from backend.integrations.providers import ProviderName
@@ -39,7 +42,7 @@ from backend.server.integrations.models import (
get_all_provider_names,
)
from backend.server.v2.library.db import set_preset_webhook, update_preset
from backend.util.exceptions import NeedConfirmation, NotFoundError
from backend.util.exceptions import MissingConfigError, NeedConfirmation, NotFoundError
from backend.util.settings import Settings
if TYPE_CHECKING:
@@ -271,6 +274,11 @@ class CredentialsDeletionNeedsConfirmationResponse(BaseModel):
message: str
class AyrshareSSOResponse(BaseModel):
sso_url: str = Field(..., description="The SSO URL for Ayrshare integration")
expires_at: str = Field(..., description="ISO timestamp when the URL expires")
@router.delete("/{provider}/credentials/{cred_id}")
async def delete_credentials(
request: Request,
@@ -548,9 +556,90 @@ def _get_provider_oauth_handler(
)
@router.get("/ayrshare/sso_url")
async def get_ayrshare_sso_url(
user_id: Annotated[str, Depends(get_user_id)],
) -> AyrshareSSOResponse:
"""
Generate an SSO URL for Ayrshare social media integration.
Returns:
dict: Contains the SSO URL for Ayrshare integration
"""
# Generate JWT and get SSO URL
try:
client = AyrshareClient()
except MissingConfigError:
raise HTTPException(
status_code=HTTP_500_INTERNAL_SERVER_ERROR,
detail="Ayrshare integration is not configured",
)
# Get or create profile key
profile_key = await creds_manager.store.get_ayrshare_profile_key(user_id)
if not profile_key:
logger.debug(f"Creating new Ayrshare profile for user {user_id}")
# Create new profile if none exists
try:
profile = await client.create_profile(
title=f"User {user_id}", messaging_active=True
)
profile_key = profile.profileKey
await creds_manager.store.set_ayrshare_profile_key(user_id, profile_key)
except Exception as e:
logger.error(f"Error creating Ayrshare profile for user {user_id}: {e}")
raise HTTPException(
status_code=HTTP_502_BAD_GATEWAY,
detail="Failed to create Ayrshare profile",
)
else:
logger.debug(f"Using existing Ayrshare profile for user {user_id}")
# Convert SecretStr to string if needed
profile_key_str = (
profile_key.get_secret_value()
if isinstance(profile_key, SecretStr)
else str(profile_key)
)
private_key = settings.secrets.ayrshare_jwt_key
expiry_minutes = 2880
try:
logger.debug(f"Generating Ayrshare JWT for user {user_id}")
jwt_response = await client.generate_jwt(
private_key=private_key,
profile_key=profile_key_str,
allowed_social=[
SocialPlatform.FACEBOOK,
SocialPlatform.TWITTER,
SocialPlatform.LINKEDIN,
SocialPlatform.INSTAGRAM,
SocialPlatform.YOUTUBE,
SocialPlatform.REDDIT,
SocialPlatform.TELEGRAM,
SocialPlatform.GOOGLE_MY_BUSINESS,
SocialPlatform.PINTEREST,
SocialPlatform.TIKTOK,
SocialPlatform.BLUESKY,
SocialPlatform.SNAPCHAT,
SocialPlatform.THREADS,
],
expires_in=expiry_minutes,
verify=True,
)
except Exception as e:
logger.error(f"Error generating Ayrshare JWT for user {user_id}: {e}")
raise HTTPException(
status_code=HTTP_502_BAD_GATEWAY, detail="Failed to generate JWT"
)
expires_at = datetime.now(timezone.utc) + timedelta(minutes=expiry_minutes)
return AyrshareSSOResponse(
sso_url=jwt_response.url, expires_at=expires_at.isoformat()
)
# === PROVIDER DISCOVERY ENDPOINTS ===
@router.get("/providers", response_model=List[str])
async def list_providers() -> List[str]:
"""

View File

@@ -495,7 +495,8 @@ class Secrets(UpdateTrackingModel["Secrets"], BaseSettings):
apollo_api_key: str = Field(default="", description="Apollo API Key")
smartlead_api_key: str = Field(default="", description="SmartLead API Key")
zerobounce_api_key: str = Field(default="", description="ZeroBounce API Key")
ayrshare_api_key: str = Field(default="", description="Ayrshare API Key")
ayrshare_jwt_key: str = Field(default="", description="Ayrshare private Key")
# Add more secret fields as needed
model_config = SettingsConfigDict(

View File

@@ -0,0 +1,184 @@
/**
* Generated by orval v7.10.0 🍺
* Do not edit manually.
* AutoGPT Agent Server
* This server is used to execute agents that are created by the AutoGPT system.
* OpenAPI spec version: 0.1
*/
import { useMutation } from "@tanstack/react-query";
import type {
MutationFunction,
QueryClient,
UseMutationOptions,
UseMutationResult,
} from "@tanstack/react-query";
import type { BodyPostV1UploadFileToCloudStorage } from "../../models/bodyPostV1UploadFileToCloudStorage";
import type { HTTPValidationError } from "../../models/hTTPValidationError";
import type { PostV1UploadFileToCloudStorageParams } from "../../models/postV1UploadFileToCloudStorageParams";
import type { UploadFileResponse } from "../../models/uploadFileResponse";
import { customMutator } from "../../../mutators/custom-mutator";
type SecondParameter<T extends (...args: never) => unknown> = Parameters<T>[1];
/**
* Upload a file to cloud storage and return a storage key that can be used
with FileStoreBlock and AgentFileInputBlock.
Args:
file: The file to upload
user_id: The user ID
provider: Cloud storage provider ("gcs", "s3", "azure")
expiration_hours: Hours until file expires (1-48)
Returns:
Dict containing the cloud storage path and signed URL
* @summary Upload file to cloud storage
*/
export type postV1UploadFileToCloudStorageResponse200 = {
data: UploadFileResponse;
status: 200;
};
export type postV1UploadFileToCloudStorageResponse422 = {
data: HTTPValidationError;
status: 422;
};
export type postV1UploadFileToCloudStorageResponseComposite =
| postV1UploadFileToCloudStorageResponse200
| postV1UploadFileToCloudStorageResponse422;
export type postV1UploadFileToCloudStorageResponse =
postV1UploadFileToCloudStorageResponseComposite & {
headers: Headers;
};
export const getPostV1UploadFileToCloudStorageUrl = (
params?: PostV1UploadFileToCloudStorageParams,
) => {
const normalizedParams = new URLSearchParams();
Object.entries(params || {}).forEach(([key, value]) => {
if (value !== undefined) {
normalizedParams.append(key, value === null ? "null" : value.toString());
}
});
const stringifiedParams = normalizedParams.toString();
return stringifiedParams.length > 0
? `/api/files/upload?${stringifiedParams}`
: `/api/files/upload`;
};
export const postV1UploadFileToCloudStorage = async (
bodyPostV1UploadFileToCloudStorage: BodyPostV1UploadFileToCloudStorage,
params?: PostV1UploadFileToCloudStorageParams,
options?: RequestInit,
): Promise<postV1UploadFileToCloudStorageResponse> => {
const formData = new FormData();
formData.append(`file`, bodyPostV1UploadFileToCloudStorage.file);
return customMutator<postV1UploadFileToCloudStorageResponse>(
getPostV1UploadFileToCloudStorageUrl(params),
{
...options,
method: "POST",
body: formData,
},
);
};
export const getPostV1UploadFileToCloudStorageMutationOptions = <
TError = HTTPValidationError,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof postV1UploadFileToCloudStorage>>,
TError,
{
data: BodyPostV1UploadFileToCloudStorage;
params?: PostV1UploadFileToCloudStorageParams;
},
TContext
>;
request?: SecondParameter<typeof customMutator>;
}): UseMutationOptions<
Awaited<ReturnType<typeof postV1UploadFileToCloudStorage>>,
TError,
{
data: BodyPostV1UploadFileToCloudStorage;
params?: PostV1UploadFileToCloudStorageParams;
},
TContext
> => {
const mutationKey = ["postV1UploadFileToCloudStorage"];
const { mutation: mutationOptions, request: requestOptions } = options
? options.mutation &&
"mutationKey" in options.mutation &&
options.mutation.mutationKey
? options
: { ...options, mutation: { ...options.mutation, mutationKey } }
: { mutation: { mutationKey }, request: undefined };
const mutationFn: MutationFunction<
Awaited<ReturnType<typeof postV1UploadFileToCloudStorage>>,
{
data: BodyPostV1UploadFileToCloudStorage;
params?: PostV1UploadFileToCloudStorageParams;
}
> = (props) => {
const { data, params } = props ?? {};
return postV1UploadFileToCloudStorage(data, params, requestOptions);
};
return { mutationFn, ...mutationOptions };
};
export type PostV1UploadFileToCloudStorageMutationResult = NonNullable<
Awaited<ReturnType<typeof postV1UploadFileToCloudStorage>>
>;
export type PostV1UploadFileToCloudStorageMutationBody =
BodyPostV1UploadFileToCloudStorage;
export type PostV1UploadFileToCloudStorageMutationError = HTTPValidationError;
/**
* @summary Upload file to cloud storage
*/
export const usePostV1UploadFileToCloudStorage = <
TError = HTTPValidationError,
TContext = unknown,
>(
options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof postV1UploadFileToCloudStorage>>,
TError,
{
data: BodyPostV1UploadFileToCloudStorage;
params?: PostV1UploadFileToCloudStorageParams;
},
TContext
>;
request?: SecondParameter<typeof customMutator>;
},
queryClient?: QueryClient,
): UseMutationResult<
Awaited<ReturnType<typeof postV1UploadFileToCloudStorage>>,
TError,
{
data: BodyPostV1UploadFileToCloudStorage;
params?: PostV1UploadFileToCloudStorageParams;
},
TContext
> => {
const mutationOptions =
getPostV1UploadFileToCloudStorageMutationOptions(options);
return useMutation(mutationOptions, queryClient);
};

View File

@@ -21,6 +21,8 @@ import type {
UseQueryResult,
} from "@tanstack/react-query";
import type { AyrshareSSOResponse } from "../../models/ayrshareSSOResponse";
import type { BodyPostV1Callback } from "../../models/bodyPostV1Callback";
import type { CredentialsMetaResponse } from "../../models/credentialsMetaResponse";
@@ -1530,6 +1532,210 @@ export const usePostV1WebhookPing = <
return useMutation(mutationOptions, queryClient);
};
/**
* Generate an SSO URL for Ayrshare social media integration.
Returns:
dict: Contains the SSO URL for Ayrshare integration
* @summary Get Ayrshare Sso Url
*/
export type getV1GetAyrshareSsoUrlResponse200 = {
data: AyrshareSSOResponse;
status: 200;
};
export type getV1GetAyrshareSsoUrlResponseComposite =
getV1GetAyrshareSsoUrlResponse200;
export type getV1GetAyrshareSsoUrlResponse =
getV1GetAyrshareSsoUrlResponseComposite & {
headers: Headers;
};
export const getGetV1GetAyrshareSsoUrlUrl = () => {
return `/api/integrations/ayrshare/sso_url`;
};
export const getV1GetAyrshareSsoUrl = async (
options?: RequestInit,
): Promise<getV1GetAyrshareSsoUrlResponse> => {
return customMutator<getV1GetAyrshareSsoUrlResponse>(
getGetV1GetAyrshareSsoUrlUrl(),
{
...options,
method: "GET",
},
);
};
export const getGetV1GetAyrshareSsoUrlQueryKey = () => {
return [`/api/integrations/ayrshare/sso_url`] as const;
};
export const getGetV1GetAyrshareSsoUrlQueryOptions = <
TData = Awaited<ReturnType<typeof getV1GetAyrshareSsoUrl>>,
TError = unknown,
>(options?: {
query?: Partial<
UseQueryOptions<
Awaited<ReturnType<typeof getV1GetAyrshareSsoUrl>>,
TError,
TData
>
>;
request?: SecondParameter<typeof customMutator>;
}) => {
const { query: queryOptions, request: requestOptions } = options ?? {};
const queryKey =
queryOptions?.queryKey ?? getGetV1GetAyrshareSsoUrlQueryKey();
const queryFn: QueryFunction<
Awaited<ReturnType<typeof getV1GetAyrshareSsoUrl>>
> = ({ signal }) => getV1GetAyrshareSsoUrl({ signal, ...requestOptions });
return { queryKey, queryFn, ...queryOptions } as UseQueryOptions<
Awaited<ReturnType<typeof getV1GetAyrshareSsoUrl>>,
TError,
TData
> & { queryKey: DataTag<QueryKey, TData, TError> };
};
export type GetV1GetAyrshareSsoUrlQueryResult = NonNullable<
Awaited<ReturnType<typeof getV1GetAyrshareSsoUrl>>
>;
export type GetV1GetAyrshareSsoUrlQueryError = unknown;
export function useGetV1GetAyrshareSsoUrl<
TData = Awaited<ReturnType<typeof getV1GetAyrshareSsoUrl>>,
TError = unknown,
>(
options: {
query: Partial<
UseQueryOptions<
Awaited<ReturnType<typeof getV1GetAyrshareSsoUrl>>,
TError,
TData
>
> &
Pick<
DefinedInitialDataOptions<
Awaited<ReturnType<typeof getV1GetAyrshareSsoUrl>>,
TError,
Awaited<ReturnType<typeof getV1GetAyrshareSsoUrl>>
>,
"initialData"
>;
request?: SecondParameter<typeof customMutator>;
},
queryClient?: QueryClient,
): DefinedUseQueryResult<TData, TError> & {
queryKey: DataTag<QueryKey, TData, TError>;
};
export function useGetV1GetAyrshareSsoUrl<
TData = Awaited<ReturnType<typeof getV1GetAyrshareSsoUrl>>,
TError = unknown,
>(
options?: {
query?: Partial<
UseQueryOptions<
Awaited<ReturnType<typeof getV1GetAyrshareSsoUrl>>,
TError,
TData
>
> &
Pick<
UndefinedInitialDataOptions<
Awaited<ReturnType<typeof getV1GetAyrshareSsoUrl>>,
TError,
Awaited<ReturnType<typeof getV1GetAyrshareSsoUrl>>
>,
"initialData"
>;
request?: SecondParameter<typeof customMutator>;
},
queryClient?: QueryClient,
): UseQueryResult<TData, TError> & {
queryKey: DataTag<QueryKey, TData, TError>;
};
export function useGetV1GetAyrshareSsoUrl<
TData = Awaited<ReturnType<typeof getV1GetAyrshareSsoUrl>>,
TError = unknown,
>(
options?: {
query?: Partial<
UseQueryOptions<
Awaited<ReturnType<typeof getV1GetAyrshareSsoUrl>>,
TError,
TData
>
>;
request?: SecondParameter<typeof customMutator>;
},
queryClient?: QueryClient,
): UseQueryResult<TData, TError> & {
queryKey: DataTag<QueryKey, TData, TError>;
};
/**
* @summary Get Ayrshare Sso Url
*/
export function useGetV1GetAyrshareSsoUrl<
TData = Awaited<ReturnType<typeof getV1GetAyrshareSsoUrl>>,
TError = unknown,
>(
options?: {
query?: Partial<
UseQueryOptions<
Awaited<ReturnType<typeof getV1GetAyrshareSsoUrl>>,
TError,
TData
>
>;
request?: SecondParameter<typeof customMutator>;
},
queryClient?: QueryClient,
): UseQueryResult<TData, TError> & {
queryKey: DataTag<QueryKey, TData, TError>;
} {
const queryOptions = getGetV1GetAyrshareSsoUrlQueryOptions(options);
const query = useQuery(queryOptions, queryClient) as UseQueryResult<
TData,
TError
> & { queryKey: DataTag<QueryKey, TData, TError> };
query.queryKey = queryOptions.queryKey;
return query;
}
/**
* @summary Get Ayrshare Sso Url
*/
export const prefetchGetV1GetAyrshareSsoUrlQuery = async <
TData = Awaited<ReturnType<typeof getV1GetAyrshareSsoUrl>>,
TError = unknown,
>(
queryClient: QueryClient,
options?: {
query?: Partial<
UseQueryOptions<
Awaited<ReturnType<typeof getV1GetAyrshareSsoUrl>>,
TError,
TData
>
>;
request?: SecondParameter<typeof customMutator>;
},
): Promise<QueryClient> => {
const queryOptions = getGetV1GetAyrshareSsoUrlQueryOptions(options);
await queryClient.prefetchQuery(queryOptions);
return queryClient;
};
/**
* Get a list of all available provider names.

View File

@@ -0,0 +1,14 @@
/**
* Generated by orval v7.10.0 🍺
* Do not edit manually.
* AutoGPT Agent Server
* This server is used to execute agents that are created by the AutoGPT system.
* OpenAPI spec version: 0.1
*/
export interface AyrshareSSOResponse {
/** The SSO URL for Ayrshare integration */
sso_url: string;
/** ISO timestamp when the URL expires */
expires_at: string;
}

View File

@@ -0,0 +1,11 @@
/**
* Generated by orval v7.10.0 🍺
* Do not edit manually.
* AutoGPT Agent Server
* This server is used to execute agents that are created by the AutoGPT system.
* OpenAPI spec version: 0.1
*/
export interface BodyPostV1UploadFileToCloudStorage {
file: Blob;
}

View File

@@ -0,0 +1,12 @@
/**
* Generated by orval v7.10.0 🍺
* Do not edit manually.
* AutoGPT Agent Server
* This server is used to execute agents that are created by the AutoGPT system.
* OpenAPI spec version: 0.1
*/
export type PostV1UploadFileToCloudStorageParams = {
provider?: string;
expiration_hours?: number;
};

View File

@@ -0,0 +1,15 @@
/**
* Generated by orval v7.10.0 🍺
* Do not edit manually.
* AutoGPT Agent Server
* This server is used to execute agents that are created by the AutoGPT system.
* OpenAPI spec version: 0.1
*/
export interface UploadFileResponse {
file_uri: string;
file_name: string;
size: number;
content_type: string;
expires_in_hours: number;
}

View File

@@ -443,6 +443,24 @@
}
}
},
"/api/integrations/ayrshare/sso_url": {
"get": {
"tags": ["v1", "integrations"],
"summary": "Get Ayrshare Sso Url",
"description": "Generate an SSO URL for Ayrshare social media integration.\n\nReturns:\n dict: Contains the SSO URL for Ayrshare integration",
"operationId": "getV1GetAyrshareSsoUrl",
"responses": {
"200": {
"description": "Successful Response",
"content": {
"application/json": {
"schema": { "$ref": "#/components/schemas/AyrshareSSOResponse" }
}
}
}
}
}
},
"/api/integrations/providers": {
"get": {
"tags": ["v1", "integrations"],
@@ -823,6 +841,64 @@
}
}
},
"/api/files/upload": {
"post": {
"tags": ["v1", "files"],
"summary": "Upload file to cloud storage",
"description": "Upload a file to cloud storage and return a storage key that can be used\nwith FileStoreBlock and AgentFileInputBlock.\n\nArgs:\n file: The file to upload\n user_id: The user ID\n provider: Cloud storage provider (\"gcs\", \"s3\", \"azure\")\n expiration_hours: Hours until file expires (1-48)\n\nReturns:\n Dict containing the cloud storage path and signed URL",
"operationId": "postV1Upload file to cloud storage",
"parameters": [
{
"name": "provider",
"in": "query",
"required": false,
"schema": {
"type": "string",
"default": "gcs",
"title": "Provider"
}
},
{
"name": "expiration_hours",
"in": "query",
"required": false,
"schema": {
"type": "integer",
"default": 24,
"title": "Expiration Hours"
}
}
],
"requestBody": {
"required": true,
"content": {
"multipart/form-data": {
"schema": {
"$ref": "#/components/schemas/Body_postV1Upload_file_to_cloud_storage"
}
}
}
},
"responses": {
"200": {
"description": "Successful Response",
"content": {
"application/json": {
"schema": { "$ref": "#/components/schemas/UploadFileResponse" }
}
}
},
"422": {
"description": "Validation Error",
"content": {
"application/json": {
"schema": { "$ref": "#/components/schemas/HTTPValidationError" }
}
}
}
}
}
},
"/api/credits": {
"get": {
"tags": ["v1", "credits"],
@@ -3790,6 +3866,23 @@
"required": ["amount", "threshold"],
"title": "AutoTopUpConfig"
},
"AyrshareSSOResponse": {
"properties": {
"sso_url": {
"type": "string",
"title": "Sso Url",
"description": "The SSO URL for Ayrshare integration"
},
"expires_at": {
"type": "string",
"title": "Expires At",
"description": "ISO timestamp when the URL expires"
}
},
"type": "object",
"required": ["sso_url", "expires_at"],
"title": "AyrshareSSOResponse"
},
"BaseGraph-Input": {
"properties": {
"id": { "type": "string", "title": "Id" },
@@ -3934,6 +4027,14 @@
"required": ["type", "data", "data_index"],
"title": "Body_postV1LogRawAnalytics"
},
"Body_postV1Upload_file_to_cloud_storage": {
"properties": {
"file": { "type": "string", "format": "binary", "title": "File" }
},
"type": "object",
"required": ["file"],
"title": "Body_postV1Upload file to cloud storage"
},
"Body_postV2Add_credits_to_user": {
"properties": {
"user_id": { "type": "string", "title": "User Id" },
@@ -6348,6 +6449,24 @@
"required": ["permissions"],
"title": "UpdatePermissionsRequest"
},
"UploadFileResponse": {
"properties": {
"file_uri": { "type": "string", "title": "File Uri" },
"file_name": { "type": "string", "title": "File Name" },
"size": { "type": "integer", "title": "Size" },
"content_type": { "type": "string", "title": "Content Type" },
"expires_in_hours": { "type": "integer", "title": "Expires In Hours" }
},
"type": "object",
"required": [
"file_uri",
"file_name",
"size",
"content_type",
"expires_in_hours"
],
"title": "UploadFileResponse"
},
"UserHistoryResponse": {
"properties": {
"history": {

View File

@@ -31,7 +31,7 @@ import {
parseKeys,
setNestedProperty,
} from "@/lib/utils";
import { Button } from "@/components/ui/button";
import { Button } from "@/components/atoms/Button/Button";
import { Switch } from "@/components/ui/switch";
import { TextRenderer } from "@/components/ui/render";
import { history } from "./history";
@@ -54,8 +54,10 @@ import {
CopyIcon,
ExitIcon,
} from "@radix-ui/react-icons";
import { Key } from "@phosphor-icons/react";
import useCredits from "@/hooks/useCredits";
import { getV1GetAyrshareSsoUrl } from "@/app/api/__generated__/endpoints/integrations/integrations";
import { toast } from "@/components/molecules/Toast/use-toast";
export type ConnectionData = Array<{
edge_id: string;
@@ -112,6 +114,8 @@ export const CustomNode = React.memo(
const flowContext = useContext(FlowContext);
const api = useBackendAPI();
const { formatCredits } = useCredits();
const [isLoading, setIsLoading] = useState(false);
let nodeFlowId = "";
if (data.uiType === BlockUIType.AGENT) {
@@ -241,6 +245,59 @@ export const CustomNode = React.memo(
return renderHandles(schema.properties);
};
const generateAyrshareSSOHandles = () => {
const handleSSOLogin = async () => {
setIsLoading(true);
try {
const {
data: { sso_url },
} = await getV1GetAyrshareSsoUrl();
const popup = window.open(sso_url, "_blank", "popup=true");
if (!popup) {
throw new Error(
"Please allow popups for this site to be able to login with Ayrshare",
);
}
} catch (error) {
toast({
title: "Error",
description: `Error getting SSO URL: ${error}`,
variant: "destructive",
});
} finally {
setIsLoading(false);
}
};
return (
<div className="flex flex-col gap-2">
<Button
type="button"
variant="outline"
className="w-full"
onClick={handleSSOLogin}
disabled={isLoading}
>
{isLoading ? (
"Loading..."
) : (
<>
<Key className="mr-2 h-4 w-4" />
Connect Social Media Accounts
</>
)}
</Button>
<NodeHandle
title="SSO Token"
keyName="sso_token"
isConnected={false}
schema={{ type: "string" }}
side="right"
/>
</div>
);
};
const generateInputHandles = (
schema: BlockIORootSchema,
nodeType: BlockUIType,
@@ -827,8 +884,18 @@ export const CustomNode = React.memo(
(A Webhook URL will be generated when you save the agent)
</p>
))}
{data.inputSchema &&
generateInputHandles(data.inputSchema, data.uiType)}
{data.uiType === BlockUIType.AYRSHARE ? (
<>
{generateAyrshareSSOHandles()}
{generateInputHandles(
data.inputSchema,
BlockUIType.STANDARD,
)}
</>
) : (
data.inputSchema &&
generateInputHandles(data.inputSchema, data.uiType)
)}
</div>
</div>
) : (

View File

@@ -230,7 +230,7 @@ export default function CredentialsProvider({
useEffect(() => {
if (!isLoggedIn || providerNames.length === 0) {
if (isLoggedIn == false) setProviders(null);
if (isLoggedIn == false) setProviders({});
return;
}

View File

@@ -604,6 +604,7 @@ export enum BlockUIType {
WEBHOOK_MANUAL = "Webhook (manual)",
AGENT = "Agent",
AI = "AI",
AYRSHARE = "Ayrshare",
}
export enum SpecialBlockID {