loopbot-discord-sdk 1.0.0__py3-none-any.whl → 1.0.6__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
loopbot/__init__.py CHANGED
@@ -5,6 +5,7 @@ Build Discord bots without websockets using HTTP Interactions
5
5
 
6
6
  from .bot import Bot
7
7
  from .client import Client
8
+ from .database import Database
8
9
  from .builders import (
9
10
  EmbedBuilder,
10
11
  ButtonBuilder,
@@ -35,6 +36,7 @@ __all__ = [
35
36
  # Core
36
37
  "Bot",
37
38
  "Client",
39
+ "Database",
38
40
  # Builders
39
41
  "EmbedBuilder",
40
42
  "ButtonBuilder",
loopbot/bot.py CHANGED
@@ -7,6 +7,7 @@ import signal
7
7
  from typing import Any, Callable, Dict, List, Optional, Union
8
8
 
9
9
  from .client import Client
10
+ from .database import Database
10
11
  from .context.command import CommandContext
11
12
  from .context.button import ButtonContext
12
13
  from .context.modal import ModalContext
@@ -49,7 +50,7 @@ class Bot:
49
50
  def __init__(
50
51
  self,
51
52
  token: str,
52
- api_url: str = "https://api.loopbot.app",
53
+ api_url: str = "https://gatewayloop.squareweb.app",
53
54
  ):
54
55
  self.token = token
55
56
  self.api_url = api_url
@@ -60,6 +61,7 @@ class Bot:
60
61
  self._select_handlers: Dict[str, SelectHandler] = {}
61
62
  self._application_id: str = ""
62
63
  self._running = False
64
+ self.db = Database(self._client)
63
65
 
64
66
  def command(
65
67
  self,
loopbot/client.py CHANGED
@@ -1,5 +1,5 @@
1
1
  """
2
- HTTP/SSE Client for Loop Discord SDK
2
+ WebSocket Client for Loop Discord SDK (Lightweight version)
3
3
  """
4
4
 
5
5
  import asyncio
@@ -7,18 +7,23 @@ import json
7
7
  from typing import Any, Callable, Dict, List, Optional
8
8
 
9
9
  import aiohttp
10
- from aiohttp_sse_client import client as sse_client
11
10
 
12
11
 
13
12
  class Client:
14
- """HTTP client for communicating with the Loop API"""
13
+ """HTTP/WebSocket client for communicating with the Loop API"""
15
14
 
16
- def __init__(self, token: str, base_url: str = "https://gatewayloop.discloud.app"):
15
+ def __init__(self, token: str, base_url: str = "https://api.loopbot.app"):
17
16
  self.token = token
18
17
  self.base_url = base_url.rstrip("/")
19
18
  self._session: Optional[aiohttp.ClientSession] = None
20
- self._sse_task: Optional[asyncio.Task] = None
19
+ self._ws: Optional[aiohttp.ClientWebSocketResponse] = None
20
+ self._ws_task: Optional[asyncio.Task] = None
21
21
  self._connected = False
22
+ self._reconnect_attempts = 0
23
+ self._max_reconnect_attempts = 10
24
+ self._reconnect_delay = 1.0
25
+ self._on_interaction: Optional[Callable[[Dict[str, Any]], None]] = None
26
+ self._heartbeat_task: Optional[asyncio.Task] = None
22
27
 
23
28
  @property
24
29
  def headers(self) -> Dict[str, str]:
@@ -27,6 +32,11 @@ class Client:
27
32
  "Content-Type": "application/json",
28
33
  }
29
34
 
35
+ @property
36
+ def ws_url(self) -> str:
37
+ """Convert HTTP URL to WebSocket URL"""
38
+ return self.base_url.replace("https://", "wss://").replace("http://", "ws://") + "/api/sdk/ws"
39
+
30
40
  async def _get_session(self) -> aiohttp.ClientSession:
31
41
  if self._session is None or self._session.closed:
32
42
  self._session = aiohttp.ClientSession()
@@ -63,25 +73,94 @@ class Client:
63
73
  self._connected = True
64
74
  return result
65
75
 
66
- async def connect_sse(self, on_interaction: Callable[[Dict[str, Any]], None]) -> None:
67
- """Connect to SSE for receiving interactions"""
68
- url = f"{self.base_url}/api/sdk/events"
76
+ async def connect_ws(self, on_interaction: Callable[[Dict[str, Any]], None]) -> None:
77
+ """Connect to WebSocket for receiving interactions"""
78
+ self._on_interaction = on_interaction
79
+ await self._create_websocket()
69
80
 
70
- async with sse_client.EventSource(
71
- url,
72
- headers=self.headers,
73
- ) as event_source:
74
- async for event in event_source:
75
- if event.type == "connected":
76
- print("[Loop SDK] SSE connected")
77
- elif event.type == "interaction":
81
+ async def _create_websocket(self) -> None:
82
+ """Create and manage WebSocket connection"""
83
+ session = await self._get_session()
84
+
85
+ try:
86
+ self._ws = await session.ws_connect(
87
+ self.ws_url,
88
+ headers=self.headers,
89
+ heartbeat=30.0,
90
+ )
91
+ print("[Loop SDK] WebSocket connected")
92
+ self._reconnect_attempts = 0
93
+
94
+ # Start heartbeat
95
+ self._heartbeat_task = asyncio.create_task(self._heartbeat_loop())
96
+
97
+ # Message loop
98
+ async for msg in self._ws:
99
+ if msg.type == aiohttp.WSMsgType.TEXT:
78
100
  try:
79
- interaction = json.loads(event.data)
80
- on_interaction(interaction)
101
+ data = json.loads(msg.data)
102
+ if data.get("type") == "interaction" and self._on_interaction:
103
+ self._on_interaction(data.get("data", {}))
104
+ elif data.get("type") == "connected":
105
+ print("[Loop SDK] Authenticated with API")
106
+ elif data.get("type") == "pong":
107
+ pass # Heartbeat response
81
108
  except json.JSONDecodeError as e:
82
- print(f"[Loop SDK] Failed to parse interaction: {e}")
83
- elif event.type == "ping":
84
- pass # Heartbeat
109
+ print(f"[Loop SDK] Failed to parse message: {e}")
110
+ elif msg.type == aiohttp.WSMsgType.ERROR:
111
+ print(f"[Loop SDK] WebSocket error: {self._ws.exception()}")
112
+ break
113
+ elif msg.type == aiohttp.WSMsgType.CLOSED:
114
+ break
115
+
116
+ except Exception as e:
117
+ print(f"[Loop SDK] WebSocket connection error: {e}")
118
+ finally:
119
+ if self._heartbeat_task:
120
+ self._heartbeat_task.cancel()
121
+ self._heartbeat_task = None
122
+ await self._attempt_reconnect()
123
+
124
+ async def _heartbeat_loop(self) -> None:
125
+ """Send periodic heartbeats"""
126
+ try:
127
+ while True:
128
+ await asyncio.sleep(30)
129
+ if self._ws and not self._ws.closed:
130
+ await self._ws.send_json({"type": "ping"})
131
+ except asyncio.CancelledError:
132
+ pass
133
+
134
+ async def _attempt_reconnect(self) -> None:
135
+ """Attempt to reconnect to WebSocket"""
136
+ if self._reconnect_attempts >= self._max_reconnect_attempts:
137
+ print("[Loop SDK] Max reconnect attempts reached")
138
+ return
139
+
140
+ self._reconnect_attempts += 1
141
+ delay = self._reconnect_delay * (2 ** (self._reconnect_attempts - 1))
142
+
143
+ print(f"[Loop SDK] Reconnecting in {delay}s (attempt {self._reconnect_attempts}/{self._max_reconnect_attempts})")
144
+
145
+ await asyncio.sleep(delay)
146
+
147
+ if self._on_interaction:
148
+ await self._create_websocket()
149
+
150
+ async def connect_sse(self, on_interaction: Callable[[Dict[str, Any]], None]) -> None:
151
+ """Legacy SSE method - now uses WebSocket internally"""
152
+ await self.connect_ws(on_interaction)
153
+
154
+ async def disconnect_ws(self) -> None:
155
+ """Disconnect WebSocket"""
156
+ if self._heartbeat_task:
157
+ self._heartbeat_task.cancel()
158
+ self._heartbeat_task = None
159
+
160
+ if self._ws and not self._ws.closed:
161
+ await self._ws.close()
162
+ self._ws = None
163
+ self._on_interaction = None
85
164
 
86
165
  async def respond(self, interaction_id: str, response: Dict[str, Any]) -> None:
87
166
  """Send interaction response"""
@@ -118,6 +197,8 @@ class Client:
118
197
 
119
198
  async def disconnect(self) -> None:
120
199
  """Disconnect from API"""
200
+ await self.disconnect_ws()
201
+
121
202
  if self._connected:
122
203
  try:
123
204
  await self.request("POST", "/sdk/disconnect", {})
@@ -802,4 +883,3 @@ class Client:
802
883
  "webhookToken": webhook_token,
803
884
  "messageId": message_id,
804
885
  })
805
-
loopbot/context/base.py CHANGED
@@ -5,6 +5,7 @@ Base Context for all interaction types
5
5
  from typing import Any, Dict, List, Optional, Union
6
6
 
7
7
  from ..types import Interaction, InteractionResponseType, DiscordUser, DiscordMember
8
+ from ..database import Database
8
9
  from ..builders.embed import EmbedBuilder
9
10
  from ..builders.action_row import ActionRowBuilder
10
11
  from ..builders.modal import ModalBuilder
@@ -24,6 +25,7 @@ class BaseContext:
24
25
  self._application_id = application_id
25
26
  self._response: Optional[Dict[str, Any]] = None
26
27
  self._deferred = False
28
+ self.db = Database(client)
27
29
 
28
30
  @property
29
31
  def interaction_id(self) -> str:
loopbot/database.py ADDED
@@ -0,0 +1,70 @@
1
+ """
2
+ Database class for persistent storage
3
+ Makes HTTP calls to the Loop API to store/retrieve data
4
+ """
5
+
6
+ from typing import Any, Dict, Optional
7
+ import aiohttp
8
+
9
+
10
+ class Database:
11
+ """Database for persistent storage via Loop API"""
12
+
13
+ def __init__(self, client: Any):
14
+ self._client = client
15
+
16
+ async def get(self, key: str) -> Optional[Any]:
17
+ """Get a value by key"""
18
+ try:
19
+ response = await self._client.request(
20
+ "GET",
21
+ f"/sdk/db/{key}",
22
+ )
23
+ return response.get("value")
24
+ except Exception:
25
+ return None
26
+
27
+ async def set(self, key: str, value: Any) -> bool:
28
+ """Set a value for a key"""
29
+ try:
30
+ response = await self._client.request(
31
+ "POST",
32
+ f"/sdk/db/{key}",
33
+ {"value": value},
34
+ )
35
+ return response.get("success", False)
36
+ except Exception:
37
+ return False
38
+
39
+ async def delete(self, key: str) -> bool:
40
+ """Delete a key"""
41
+ try:
42
+ response = await self._client.request(
43
+ "DELETE",
44
+ f"/sdk/db/{key}",
45
+ )
46
+ return response.get("success", False)
47
+ except Exception:
48
+ return False
49
+
50
+ async def get_all(self) -> Dict[str, Any]:
51
+ """Get all data"""
52
+ try:
53
+ response = await self._client.request(
54
+ "GET",
55
+ "/sdk/db",
56
+ )
57
+ return response.get("data", {})
58
+ except Exception:
59
+ return {}
60
+
61
+ async def clear(self) -> bool:
62
+ """Clear all data"""
63
+ try:
64
+ response = await self._client.request(
65
+ "DELETE",
66
+ "/sdk/db",
67
+ )
68
+ return response.get("success", False)
69
+ except Exception:
70
+ return False
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: loopbot-discord-sdk
3
- Version: 1.0.0
3
+ Version: 1.0.6
4
4
  Summary: Official Loop Discord SDK for Python - Build Discord bots without websockets
5
5
  Author-email: Loop <contact@loopbot.app>
6
6
  License: MIT
@@ -25,7 +25,6 @@ Classifier: Typing :: Typed
25
25
  Requires-Python: >=3.8
26
26
  Description-Content-Type: text/markdown
27
27
  Requires-Dist: aiohttp>=3.8.0
28
- Requires-Dist: aiohttp-sse-client>=0.2.1
29
28
  Provides-Extra: dev
30
29
  Requires-Dist: pytest>=7.0.0; extra == "dev"
31
30
  Requires-Dist: pytest-asyncio>=0.21.0; extra == "dev"
@@ -1,6 +1,7 @@
1
- loopbot/__init__.py,sha256=oIkHZq8f8ygeHGLMiYgTd8cB_PmrNFDcNZnkccES7ws,1197
2
- loopbot/bot.py,sha256=1KXrUJwlplc0cwrrLN_svJMSO41ToVdbGfPVvt39_m8,20615
3
- loopbot/client.py,sha256=oCevrQZLGXJnaIZ87KY-VkzsZGEYJiCfFpYTE5s2nnA,26790
1
+ loopbot/__init__.py,sha256=GiSEnH3mfcy7-ez01TXQnpiOOc_vPIbdN3mMqVo37Og,1246
2
+ loopbot/bot.py,sha256=oVknClm_tOmocrsWBGP8P7VTBIeWHx8dsB9iJPI2dNY,20699
3
+ loopbot/client.py,sha256=s7UG3Fd_GUA6qpil5fbH3d3VFXJMDPjUX513_1Og4Fs,30124
4
+ loopbot/database.py,sha256=LxNMDQO0wi4YxJbLEdOpHSwOs7tibvSAA-BFcN15SFA,1948
4
5
  loopbot/types.py,sha256=MZcQgVvVDSs60UqAF5lWkHNM3yo9KLKDxZaU6tKndZQ,1645
5
6
  loopbot/builders/__init__.py,sha256=STrS9XOMPsVDzx9VAvbOZW2f8fp6mITbZWzdkgmqNe4,790
6
7
  loopbot/builders/action_row.py,sha256=ouQKGpcb9KIWwUWJzUT6kH7Ov60UCZi5FjWlXC6sRVw,1225
@@ -15,12 +16,12 @@ loopbot/builders/select_menu.py,sha256=Ay5-7mCnyt4Q9Diyd50T79nZrcWoWD6BITgaWCldt
15
16
  loopbot/builders/separator.py,sha256=RWy04jS6k67wWULoSI_wlTNbV0D8L0P4XFSQgjwWe_U,882
16
17
  loopbot/builders/text_display.py,sha256=vtU9NJ_QL0Kilp-nkub6mwREXcTaEzl-OFiWWlfwmEE,653
17
18
  loopbot/context/__init__.py,sha256=Z3-t2n-LcL4FT-rrmPPB131JBL3t6kbC2RSXopvOqKU,352
18
- loopbot/context/base.py,sha256=7GUiEBuo12KoJ1e2K0xR_u8idZr-mnO5OSxXTmAWgzI,8104
19
+ loopbot/context/base.py,sha256=XDH8PbtymzjEEO66_4LvdIX-ixATu3QoDOOYwM7m6SI,8173
19
20
  loopbot/context/button.py,sha256=5-XO6mYNMx2FPHhv8ijiUrsALOkxzSal-NKvVvXvMnM,756
20
21
  loopbot/context/command.py,sha256=i7Z8o3F5mLvQDDSVdio8LMB5-zPzMKtSiPsK1WgNg4c,3076
21
22
  loopbot/context/modal.py,sha256=QTgKDRZP7g43S3ScR5dMGKzZolUDWMtVmYqEip4GJXI,1476
22
23
  loopbot/context/select.py,sha256=8ozLkBNdlZBdQdeCbJ2545j3YkwsCBUnFpHWQPW5VIw,954
23
- loopbot_discord_sdk-1.0.0.dist-info/METADATA,sha256=2ooM5EqAJQXzhCZdAcnwpLnhbis9Duwwuyy2lBgyG0Y,12504
24
- loopbot_discord_sdk-1.0.0.dist-info/WHEEL,sha256=wUyA8OaulRlbfwMtmQsvNngGrxQHAvkKcvRmdizlJi0,92
25
- loopbot_discord_sdk-1.0.0.dist-info/top_level.txt,sha256=4sZzEMpMwz6lUz7RJzVFFh7LY-vr5W1-RAfn_bF6tks,8
26
- loopbot_discord_sdk-1.0.0.dist-info/RECORD,,
24
+ loopbot_discord_sdk-1.0.6.dist-info/METADATA,sha256=7Dp39kOpthgVKx30H0OeoznNrJsIHtvh-moxhXDXUYM,12462
25
+ loopbot_discord_sdk-1.0.6.dist-info/WHEEL,sha256=wUyA8OaulRlbfwMtmQsvNngGrxQHAvkKcvRmdizlJi0,92
26
+ loopbot_discord_sdk-1.0.6.dist-info/top_level.txt,sha256=4sZzEMpMwz6lUz7RJzVFFh7LY-vr5W1-RAfn_bF6tks,8
27
+ loopbot_discord_sdk-1.0.6.dist-info/RECORD,,