commandless-relay 0.1.0__tar.gz

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.
@@ -0,0 +1,78 @@
1
+ Metadata-Version: 2.4
2
+ Name: commandless-relay
3
+ Version: 0.1.0
4
+ Summary: Python SDK for Commandless relay API
5
+ Author: Commandless
6
+ License: MIT
7
+ Requires-Python: >=3.9
8
+ Description-Content-Type: text/markdown
9
+ Requires-Dist: requests>=2.31.0
10
+ Provides-Extra: discord
11
+ Requires-Dist: discord.py>=2.4.0; extra == "discord"
12
+
13
+ # commandless-relay (Python)
14
+
15
+ Official Python SDK for the Commandless relay API.
16
+
17
+ This package gives Python bots a simple client and a ready-to-use `discord.py` adapter.
18
+
19
+ ## Install
20
+
21
+ Core client:
22
+
23
+ ```bash
24
+ pip install commandless-relay
25
+ ```
26
+
27
+ With `discord.py` adapter support:
28
+
29
+ ```bash
30
+ pip install "commandless-relay[discord]"
31
+ ```
32
+
33
+ ## Quickstart (`discord.py`)
34
+
35
+ ```python
36
+ import os
37
+ import discord
38
+ from commandless_relay import RelayClient, use_discord_adapter
39
+
40
+ TOKEN = os.getenv("BOT_TOKEN")
41
+ API_KEY = os.getenv("COMMANDLESS_API_KEY")
42
+ BASE_URL = os.getenv("COMMANDLESS_SERVICE_URL")
43
+
44
+ intents = discord.Intents.default()
45
+ intents.message_content = True
46
+ intents.messages = True
47
+ intents.guilds = True
48
+
49
+ client = discord.Client(intents=intents)
50
+ relay = RelayClient(api_key=API_KEY, base_url=BASE_URL)
51
+
52
+ use_discord_adapter(client, relay, mention_required=True)
53
+
54
+ @client.event
55
+ async def on_ready():
56
+ print(f"Logged in as {client.user}")
57
+
58
+ client.run(TOKEN)
59
+ ```
60
+
61
+ ## Environment variables
62
+
63
+ - `BOT_TOKEN` - your Discord bot token
64
+ - `COMMANDLESS_API_KEY` - API key created in Commandless dashboard
65
+ - `COMMANDLESS_SERVICE_URL` - your Commandless backend URL
66
+ - `COMMANDLESS_HMAC_SECRET` - optional HMAC secret
67
+
68
+ ## Included components
69
+
70
+ - `RelayClient`
71
+ - `send_event(event)` -> Decision dict or `None`
72
+ - `register_bot(...)` -> botId (optional flow)
73
+ - `heartbeat()` (optional flow)
74
+ - `use_discord_adapter(client, relay, mention_required=True, execute=None)`
75
+ - binds an `on_message` listener
76
+ - sends events to relay
77
+ - executes reply actions by default
78
+ - sends a clear message on billing rejection (402 / no subscription or credits)
@@ -0,0 +1,66 @@
1
+ # commandless-relay (Python)
2
+
3
+ Official Python SDK for the Commandless relay API.
4
+
5
+ This package gives Python bots a simple client and a ready-to-use `discord.py` adapter.
6
+
7
+ ## Install
8
+
9
+ Core client:
10
+
11
+ ```bash
12
+ pip install commandless-relay
13
+ ```
14
+
15
+ With `discord.py` adapter support:
16
+
17
+ ```bash
18
+ pip install "commandless-relay[discord]"
19
+ ```
20
+
21
+ ## Quickstart (`discord.py`)
22
+
23
+ ```python
24
+ import os
25
+ import discord
26
+ from commandless_relay import RelayClient, use_discord_adapter
27
+
28
+ TOKEN = os.getenv("BOT_TOKEN")
29
+ API_KEY = os.getenv("COMMANDLESS_API_KEY")
30
+ BASE_URL = os.getenv("COMMANDLESS_SERVICE_URL")
31
+
32
+ intents = discord.Intents.default()
33
+ intents.message_content = True
34
+ intents.messages = True
35
+ intents.guilds = True
36
+
37
+ client = discord.Client(intents=intents)
38
+ relay = RelayClient(api_key=API_KEY, base_url=BASE_URL)
39
+
40
+ use_discord_adapter(client, relay, mention_required=True)
41
+
42
+ @client.event
43
+ async def on_ready():
44
+ print(f"Logged in as {client.user}")
45
+
46
+ client.run(TOKEN)
47
+ ```
48
+
49
+ ## Environment variables
50
+
51
+ - `BOT_TOKEN` - your Discord bot token
52
+ - `COMMANDLESS_API_KEY` - API key created in Commandless dashboard
53
+ - `COMMANDLESS_SERVICE_URL` - your Commandless backend URL
54
+ - `COMMANDLESS_HMAC_SECRET` - optional HMAC secret
55
+
56
+ ## Included components
57
+
58
+ - `RelayClient`
59
+ - `send_event(event)` -> Decision dict or `None`
60
+ - `register_bot(...)` -> botId (optional flow)
61
+ - `heartbeat()` (optional flow)
62
+ - `use_discord_adapter(client, relay, mention_required=True, execute=None)`
63
+ - binds an `on_message` listener
64
+ - sends events to relay
65
+ - executes reply actions by default
66
+ - sends a clear message on billing rejection (402 / no subscription or credits)
@@ -0,0 +1,26 @@
1
+ [build-system]
2
+ requires = ["setuptools>=68", "wheel"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "commandless-relay"
7
+ version = "0.1.0"
8
+ description = "Python SDK for Commandless relay API"
9
+ readme = "README.md"
10
+ requires-python = ">=3.9"
11
+ license = { text = "MIT" }
12
+ authors = [
13
+ { name = "Commandless" }
14
+ ]
15
+ dependencies = [
16
+ "requests>=2.31.0"
17
+ ]
18
+
19
+ [project.optional-dependencies]
20
+ discord = ["discord.py>=2.4.0"]
21
+
22
+ [tool.setuptools]
23
+ package-dir = { "" = "src" }
24
+
25
+ [tool.setuptools.packages.find]
26
+ where = ["src"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,9 @@
1
+ from .client import RelayClient, RelayError
2
+ from .discord_adapter import DiscordAdapter, use_discord_adapter
3
+
4
+ __all__ = [
5
+ "RelayClient",
6
+ "RelayError",
7
+ "DiscordAdapter",
8
+ "use_discord_adapter",
9
+ ]
@@ -0,0 +1,143 @@
1
+ import base64
2
+ import hashlib
3
+ import hmac
4
+ import json
5
+ import time
6
+ from dataclasses import dataclass
7
+ from typing import Any, Dict, Optional
8
+
9
+ import requests
10
+
11
+ DEFAULT_BASE = "https://commandless-app-production.up.railway.app"
12
+
13
+
14
+ @dataclass
15
+ class RelayError(Exception):
16
+ status: int
17
+ message: str
18
+ raw_error: Optional[str] = None
19
+
20
+ def __str__(self) -> str:
21
+ return f"Commandless error ({self.status}): {self.message}"
22
+
23
+
24
+ class RelayClient:
25
+ def __init__(
26
+ self,
27
+ api_key: str,
28
+ base_url: Optional[str] = None,
29
+ hmac_secret: Optional[str] = None,
30
+ timeout_ms: int = 15000,
31
+ max_retries: int = 3,
32
+ ) -> None:
33
+ if not api_key:
34
+ raise ValueError("api_key is required")
35
+ self.api_key = api_key
36
+ base = base_url or DEFAULT_BASE
37
+ if not base.startswith(("http://", "https://")):
38
+ base = f"https://{base}"
39
+ self.base_url = base.rstrip("/")
40
+ self.hmac_secret = hmac_secret
41
+ self.timeout = max(timeout_ms / 1000.0, 1)
42
+ self.max_retries = max_retries
43
+ self.bot_id: Optional[str] = None
44
+
45
+ def register_bot(
46
+ self,
47
+ platform: str = "discord",
48
+ name: Optional[str] = None,
49
+ client_id: Optional[str] = None,
50
+ bot_id: Optional[int] = None,
51
+ ) -> Optional[str]:
52
+ payload: Dict[str, Any] = {
53
+ "platform": platform,
54
+ "name": name,
55
+ "clientId": client_id,
56
+ "botId": bot_id,
57
+ }
58
+ res = self._post_json("/v1/relay/register", payload)
59
+ if res.get("ok") and isinstance(res.get("data"), dict) and res["data"].get("botId"):
60
+ self.bot_id = str(res["data"]["botId"])
61
+ return self.bot_id
62
+ return None
63
+
64
+ def heartbeat(self) -> Optional[Dict[str, Any]]:
65
+ res = self._post_json("/v1/relay/heartbeat", {"botId": self.bot_id})
66
+ if res.get("ok"):
67
+ return res.get("data")
68
+ return None
69
+
70
+ def send_event(self, event: Dict[str, Any]) -> Optional[Dict[str, Any]]:
71
+ payload = dict(event)
72
+ if self.bot_id and "botId" not in payload:
73
+ payload["botId"] = self.bot_id
74
+ idem = self._make_idempotency_key(payload)
75
+
76
+ last_error: Optional[RelayError] = None
77
+ for attempt in range(self.max_retries + 1):
78
+ res = self._post_json("/v1/relay/events", payload, idempotency_key=idem)
79
+ if res.get("ok"):
80
+ data = res.get("data") or {}
81
+ if isinstance(data, dict):
82
+ return data.get("decision")
83
+ return None
84
+
85
+ status = int(res.get("status", 0))
86
+ err_text = str(res.get("error") or "unknown")
87
+ parsed_message = self._extract_error_message(err_text) or err_text
88
+ last_error = RelayError(status=status, message=parsed_message, raw_error=err_text)
89
+ time.sleep(0.2 * (attempt + 1))
90
+
91
+ if last_error:
92
+ raise last_error
93
+ raise RelayError(status=0, message="Unknown relay failure")
94
+
95
+ def _make_idempotency_key(self, event: Dict[str, Any]) -> str:
96
+ base = f"{event.get('type')}:{event.get('id')}:{int(int(event.get('timestamp', int(time.time() * 1000))) / 1000)}"
97
+ return base64.urlsafe_b64encode(base.encode("utf-8")).decode("utf-8").rstrip("=")
98
+
99
+ def _extract_error_message(self, text: str) -> Optional[str]:
100
+ try:
101
+ data = json.loads(text)
102
+ if isinstance(data, dict):
103
+ return str(data.get("message") or data.get("error") or "")
104
+ except Exception:
105
+ pass
106
+ return None
107
+
108
+ def _post_json(
109
+ self,
110
+ path: str,
111
+ body: Dict[str, Any],
112
+ idempotency_key: Optional[str] = None,
113
+ ) -> Dict[str, Any]:
114
+ url = f"{self.base_url}{path}"
115
+ json_body = json.dumps(body, separators=(",", ":"))
116
+ headers = {
117
+ "content-type": "application/json",
118
+ "x-api-key": self.api_key,
119
+ "x-commandless-key": self.api_key,
120
+ "x-timestamp": str(int(time.time() * 1000)),
121
+ }
122
+ if idempotency_key:
123
+ headers["x-idempotency-key"] = idempotency_key
124
+ if self.hmac_secret:
125
+ signature = hmac.new(
126
+ self.hmac_secret.encode("utf-8"),
127
+ json_body.encode("utf-8"),
128
+ hashlib.sha256,
129
+ ).hexdigest()
130
+ headers["x-signature"] = signature
131
+
132
+ try:
133
+ response = requests.post(
134
+ url,
135
+ data=json_body,
136
+ headers=headers,
137
+ timeout=self.timeout,
138
+ )
139
+ if not response.ok:
140
+ return {"ok": False, "status": response.status_code, "error": response.text}
141
+ return {"ok": True, "status": response.status_code, "data": response.json()}
142
+ except Exception as exc:
143
+ return {"ok": False, "status": 0, "error": str(exc)}
@@ -0,0 +1,113 @@
1
+ import asyncio
2
+ import time
3
+ from typing import Any, Awaitable, Callable, Dict, Optional
4
+
5
+ from .client import RelayClient, RelayError
6
+
7
+ DecisionHandler = Callable[[Dict[str, Any], Any], Awaitable[None]]
8
+
9
+
10
+ class DiscordAdapter:
11
+ def __init__(
12
+ self,
13
+ client: Any,
14
+ relay: RelayClient,
15
+ mention_required: bool = True,
16
+ execute: Optional[DecisionHandler] = None,
17
+ ) -> None:
18
+ self.client = client
19
+ self.relay = relay
20
+ self.mention_required = mention_required
21
+ self.execute = execute
22
+
23
+ def bind(self) -> None:
24
+ self.client.add_listener(self._on_message, "on_message")
25
+
26
+ async def _on_message(self, message: Any) -> None:
27
+ if not message or not getattr(message, "author", None):
28
+ return
29
+ if getattr(message.author, "bot", False):
30
+ return
31
+
32
+ mentioned = False
33
+ try:
34
+ mentioned = bool(self.client.user and self.client.user in message.mentions)
35
+ except Exception:
36
+ mentioned = False
37
+
38
+ is_reply_to_bot = False
39
+ try:
40
+ if getattr(message, "reference", None) and getattr(message.reference, "resolved", None):
41
+ ref = message.reference.resolved
42
+ if getattr(ref, "author", None) and self.client.user:
43
+ is_reply_to_bot = ref.author.id == self.client.user.id
44
+ except Exception:
45
+ is_reply_to_bot = False
46
+
47
+ if self.mention_required and not mentioned and not is_reply_to_bot:
48
+ return
49
+
50
+ event = {
51
+ "type": "messageCreate",
52
+ "id": str(message.id),
53
+ "guildId": str(message.guild.id) if getattr(message, "guild", None) else None,
54
+ "channelId": str(message.channel.id),
55
+ "authorId": str(message.author.id),
56
+ "content": str(message.content or ""),
57
+ "timestamp": int((getattr(message, "created_at", None) or time.time()).timestamp() * 1000)
58
+ if getattr(message, "created_at", None)
59
+ else int(time.time() * 1000),
60
+ "botClientId": str(self.client.user.id) if getattr(self.client, "user", None) else None,
61
+ "isReplyToBot": is_reply_to_bot,
62
+ "referencedMessageId": str(message.reference.message_id) if getattr(message, "reference", None) and getattr(message.reference, "message_id", None) else None,
63
+ }
64
+
65
+ try:
66
+ async with message.channel.typing():
67
+ decision = await asyncio.to_thread(self.relay.send_event, event)
68
+
69
+ if not decision:
70
+ return
71
+
72
+ if self.execute:
73
+ await self.execute(decision, message)
74
+ else:
75
+ await self._default_execute(decision, message)
76
+ except RelayError as err:
77
+ msg = str(err)
78
+ if err.status == 402 or "SUBSCRIPTION_REQUIRED" in msg or "Payment Required" in msg:
79
+ try:
80
+ await message.reply(
81
+ "This bot's Commandless subscription is inactive or out of free credits. "
82
+ "Please ask the bot owner to update billing."
83
+ )
84
+ except Exception:
85
+ pass
86
+ except Exception:
87
+ # Keep adapter resilient by default
88
+ pass
89
+
90
+ async def _default_execute(self, decision: Dict[str, Any], message: Any) -> None:
91
+ actions = decision.get("actions") or []
92
+ for action in actions:
93
+ if action.get("kind") == "reply":
94
+ content = action.get("content")
95
+ if content:
96
+ await message.reply(str(content))
97
+ return
98
+
99
+
100
+ def use_discord_adapter(
101
+ client: Any,
102
+ relay: RelayClient,
103
+ mention_required: bool = True,
104
+ execute: Optional[DecisionHandler] = None,
105
+ ) -> DiscordAdapter:
106
+ adapter = DiscordAdapter(
107
+ client=client,
108
+ relay=relay,
109
+ mention_required=mention_required,
110
+ execute=execute,
111
+ )
112
+ adapter.bind()
113
+ return adapter
@@ -0,0 +1,78 @@
1
+ Metadata-Version: 2.4
2
+ Name: commandless-relay
3
+ Version: 0.1.0
4
+ Summary: Python SDK for Commandless relay API
5
+ Author: Commandless
6
+ License: MIT
7
+ Requires-Python: >=3.9
8
+ Description-Content-Type: text/markdown
9
+ Requires-Dist: requests>=2.31.0
10
+ Provides-Extra: discord
11
+ Requires-Dist: discord.py>=2.4.0; extra == "discord"
12
+
13
+ # commandless-relay (Python)
14
+
15
+ Official Python SDK for the Commandless relay API.
16
+
17
+ This package gives Python bots a simple client and a ready-to-use `discord.py` adapter.
18
+
19
+ ## Install
20
+
21
+ Core client:
22
+
23
+ ```bash
24
+ pip install commandless-relay
25
+ ```
26
+
27
+ With `discord.py` adapter support:
28
+
29
+ ```bash
30
+ pip install "commandless-relay[discord]"
31
+ ```
32
+
33
+ ## Quickstart (`discord.py`)
34
+
35
+ ```python
36
+ import os
37
+ import discord
38
+ from commandless_relay import RelayClient, use_discord_adapter
39
+
40
+ TOKEN = os.getenv("BOT_TOKEN")
41
+ API_KEY = os.getenv("COMMANDLESS_API_KEY")
42
+ BASE_URL = os.getenv("COMMANDLESS_SERVICE_URL")
43
+
44
+ intents = discord.Intents.default()
45
+ intents.message_content = True
46
+ intents.messages = True
47
+ intents.guilds = True
48
+
49
+ client = discord.Client(intents=intents)
50
+ relay = RelayClient(api_key=API_KEY, base_url=BASE_URL)
51
+
52
+ use_discord_adapter(client, relay, mention_required=True)
53
+
54
+ @client.event
55
+ async def on_ready():
56
+ print(f"Logged in as {client.user}")
57
+
58
+ client.run(TOKEN)
59
+ ```
60
+
61
+ ## Environment variables
62
+
63
+ - `BOT_TOKEN` - your Discord bot token
64
+ - `COMMANDLESS_API_KEY` - API key created in Commandless dashboard
65
+ - `COMMANDLESS_SERVICE_URL` - your Commandless backend URL
66
+ - `COMMANDLESS_HMAC_SECRET` - optional HMAC secret
67
+
68
+ ## Included components
69
+
70
+ - `RelayClient`
71
+ - `send_event(event)` -> Decision dict or `None`
72
+ - `register_bot(...)` -> botId (optional flow)
73
+ - `heartbeat()` (optional flow)
74
+ - `use_discord_adapter(client, relay, mention_required=True, execute=None)`
75
+ - binds an `on_message` listener
76
+ - sends events to relay
77
+ - executes reply actions by default
78
+ - sends a clear message on billing rejection (402 / no subscription or credits)
@@ -0,0 +1,10 @@
1
+ README.md
2
+ pyproject.toml
3
+ src/commandless_relay/__init__.py
4
+ src/commandless_relay/client.py
5
+ src/commandless_relay/discord_adapter.py
6
+ src/commandless_relay.egg-info/PKG-INFO
7
+ src/commandless_relay.egg-info/SOURCES.txt
8
+ src/commandless_relay.egg-info/dependency_links.txt
9
+ src/commandless_relay.egg-info/requires.txt
10
+ src/commandless_relay.egg-info/top_level.txt
@@ -0,0 +1,4 @@
1
+ requests>=2.31.0
2
+
3
+ [discord]
4
+ discord.py>=2.4.0
@@ -0,0 +1 @@
1
+ commandless_relay