bot-cmder 0.2.0__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.
Files changed (79) hide show
  1. bot_cmder/__init__.py +10 -0
  2. bot_cmder/__main__.py +15 -0
  3. bot_cmder/adapters/__init__.py +0 -0
  4. bot_cmder/adapters/base.py +23 -0
  5. bot_cmder/adapters/discord/__init__.py +5 -0
  6. bot_cmder/adapters/discord/adapter.py +203 -0
  7. bot_cmder/adapters/discord/client.py +126 -0
  8. bot_cmder/adapters/discord/gateway.py +483 -0
  9. bot_cmder/adapters/discord/router.py +122 -0
  10. bot_cmder/adapters/discord/schemas.py +151 -0
  11. bot_cmder/adapters/slack/__init__.py +5 -0
  12. bot_cmder/adapters/slack/adapter.py +143 -0
  13. bot_cmder/adapters/slack/client.py +89 -0
  14. bot_cmder/adapters/slack/router.py +149 -0
  15. bot_cmder/adapters/slack/schemas.py +59 -0
  16. bot_cmder/adapters/slack/signing.py +88 -0
  17. bot_cmder/adapters/slack/socket.py +254 -0
  18. bot_cmder/adapters/telegram/__init__.py +5 -0
  19. bot_cmder/adapters/telegram/adapter.py +54 -0
  20. bot_cmder/adapters/telegram/client.py +153 -0
  21. bot_cmder/adapters/telegram/daemon.py +201 -0
  22. bot_cmder/adapters/telegram/router.py +63 -0
  23. bot_cmder/adapters/telegram/schemas.py +43 -0
  24. bot_cmder/audit/__init__.py +0 -0
  25. bot_cmder/audit/log.py +240 -0
  26. bot_cmder/auth/__init__.py +0 -0
  27. bot_cmder/auth/acl.py +44 -0
  28. bot_cmder/auth/emergency.py +173 -0
  29. bot_cmder/auth/lockout.py +202 -0
  30. bot_cmder/auth/lockout_store.py +168 -0
  31. bot_cmder/auth/migrations/0001_initial.sql +10 -0
  32. bot_cmder/auth/migrations/0002_lockout.sql +37 -0
  33. bot_cmder/auth/migrations/__init__.py +0 -0
  34. bot_cmder/auth/pending.py +77 -0
  35. bot_cmder/auth/secret_store.py +124 -0
  36. bot_cmder/auth/totp.py +65 -0
  37. bot_cmder/cli/__init__.py +59 -0
  38. bot_cmder/cli/discord_register.py +269 -0
  39. bot_cmder/cli/init_cmd.py +178 -0
  40. bot_cmder/cli/keys.py +38 -0
  41. bot_cmder/cli/serve.py +118 -0
  42. bot_cmder/cli/slack_manifest.py +226 -0
  43. bot_cmder/cli/totp.py +157 -0
  44. bot_cmder/commands/__init__.py +0 -0
  45. bot_cmder/commands/builtin/__init__.py +104 -0
  46. bot_cmder/commands/builtin/health.py +70 -0
  47. bot_cmder/commands/builtin/help.py +28 -0
  48. bot_cmder/commands/builtin/kubectl.py +68 -0
  49. bot_cmder/commands/builtin/otp.py +498 -0
  50. bot_cmder/commands/builtin/runbook.py +123 -0
  51. bot_cmder/commands/builtin/service.py +388 -0
  52. bot_cmder/commands/builtin/ssh.py +129 -0
  53. bot_cmder/commands/builtin/whoami.py +28 -0
  54. bot_cmder/config/__init__.py +0 -0
  55. bot_cmder/config/paths.py +106 -0
  56. bot_cmder/config/schema.py +365 -0
  57. bot_cmder/config/settings.py +218 -0
  58. bot_cmder/connectors/__init__.py +0 -0
  59. bot_cmder/connectors/base.py +39 -0
  60. bot_cmder/connectors/local.py +73 -0
  61. bot_cmder/connectors/ssh.py +203 -0
  62. bot_cmder/core/__init__.py +0 -0
  63. bot_cmder/core/context.py +37 -0
  64. bot_cmder/core/dispatcher.py +209 -0
  65. bot_cmder/core/errors.py +22 -0
  66. bot_cmder/core/events.py +82 -0
  67. bot_cmder/core/parser.py +47 -0
  68. bot_cmder/core/redact.py +81 -0
  69. bot_cmder/core/registry.py +213 -0
  70. bot_cmder/data/__init__.py +8 -0
  71. bot_cmder/data/app.yaml.example +317 -0
  72. bot_cmder/main.py +440 -0
  73. bot_cmder/storage/__init__.py +0 -0
  74. bot_cmder/storage/migrator.py +178 -0
  75. bot_cmder-0.2.0.dist-info/METADATA +151 -0
  76. bot_cmder-0.2.0.dist-info/RECORD +79 -0
  77. bot_cmder-0.2.0.dist-info/WHEEL +4 -0
  78. bot_cmder-0.2.0.dist-info/entry_points.txt +2 -0
  79. bot_cmder-0.2.0.dist-info/licenses/LICENSE +21 -0
bot_cmder/__init__.py ADDED
@@ -0,0 +1,10 @@
1
+ """bot-cmder — multi-platform SRE ChatOps bot."""
2
+
3
+ from __future__ import annotations
4
+
5
+ # Single source of truth for the package version. `pyproject.toml`
6
+ # reads this via `[tool.hatch.version] path = "bot_cmder/__init__.py"`,
7
+ # `bot_cmder/main.py` passes it to FastAPI's OpenAPI metadata, and the
8
+ # CLI dispatcher prints it for `bot-cmder --version`. Bump here before
9
+ # tagging a release; everything downstream picks up automatically.
10
+ __version__ = "0.2.0"
bot_cmder/__main__.py ADDED
@@ -0,0 +1,15 @@
1
+ """`python -m bot_cmder` entry point — delegates to the CLI dispatcher.
2
+
3
+ Pairs with the console-script `bot-cmder` declared in `pyproject.toml`'s
4
+ `[project.scripts]`. Both invocations end up in `bot_cmder.cli.main`,
5
+ so `python -m bot_cmder serve` and `bot-cmder serve` are equivalent.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import sys
11
+
12
+ from bot_cmder.cli import main
13
+
14
+ if __name__ == "__main__":
15
+ sys.exit(main())
File without changes
@@ -0,0 +1,23 @@
1
+ from __future__ import annotations
2
+
3
+ from abc import ABC, abstractmethod
4
+ from typing import Any
5
+
6
+ from bot_cmder.core.events import IncomingMessage, OutgoingResponse, Platform
7
+
8
+
9
+ class PlatformAdapter(ABC):
10
+ """How a chat platform talks to bot-cmder.
11
+
12
+ Each adapter owns its own incoming-payload schema and outbound
13
+ client; the only contract here is the conversion in/out of the
14
+ platform-neutral IncomingMessage / OutgoingResponse types.
15
+ """
16
+
17
+ platform: Platform
18
+
19
+ @abstractmethod
20
+ def parse(self, raw: Any) -> IncomingMessage | None: ...
21
+
22
+ @abstractmethod
23
+ async def send(self, msg: IncomingMessage, resp: OutgoingResponse) -> None: ...
@@ -0,0 +1,5 @@
1
+ from bot_cmder.adapters.discord.adapter import DiscordAdapter
2
+ from bot_cmder.adapters.discord.client import DiscordClient
3
+ from bot_cmder.adapters.discord.router import make_router
4
+
5
+ __all__ = ["DiscordAdapter", "DiscordClient", "make_router"]
@@ -0,0 +1,203 @@
1
+ """DiscordAdapter — Discord Interactions OR Gateway events → IncomingMessages.
2
+
3
+ Two ingestion paths feed the same dispatcher:
4
+
5
+ - **HTTP Interactions** (Phase 4, default): slash commands arrive
6
+ as the `Interaction` envelope. Schema is intentionally flat —
7
+ one Discord application command per top-level chat command, with
8
+ a single optional `args` STRING option. We rebuild that into the
9
+ `/cmd <args...>` text shape Telegram produces, so behavior matches
10
+ one-to-one.
11
+
12
+ - **Gateway** (Phase 6c, `DISCORD_MODE=gateway`): chat messages
13
+ arrive as `MESSAGE_CREATE` dispatch events over WSS. Discord does
14
+ NOT deliver slash command interactions through the Gateway
15
+ (platform limitation), so the UX shifts to "@bot cmd args" in
16
+ guild channels OR plain `cmd args` in DMs. The adapter strips
17
+ the `<@bot_id>` mention prefix and normalizes the result into the
18
+ same `/cmd args...` shape the dispatcher expects.
19
+
20
+ Replies:
21
+ - Interactions path → PATCH @original webhook URL (no bot-token auth)
22
+ - Gateway path → POST /channels/{id}/messages (bot-token auth)
23
+
24
+ The two paths share `parse()` via type sniffing on the raw payload —
25
+ Interaction objects always have a `type` int field; MessageCreatePayload
26
+ always has `content` + `author`. Easier than splitting into two
27
+ adapters and saves the router/daemon from caring which path the
28
+ adapter chose.
29
+ """
30
+
31
+ from __future__ import annotations
32
+
33
+ import re
34
+ from datetime import datetime, timezone
35
+ from typing import TYPE_CHECKING, Any
36
+
37
+ from bot_cmder.adapters.base import PlatformAdapter
38
+ from bot_cmder.adapters.discord.client import DiscordClient
39
+ from bot_cmder.adapters.discord.schemas import Interaction, InteractionType, MessageCreatePayload
40
+ from bot_cmder.core.events import IncomingMessage, OutgoingResponse, Platform, PlatformUser
41
+
42
+ if TYPE_CHECKING:
43
+ pass
44
+
45
+
46
+ # Discord renders @-mentions in message content as `<@USER_ID>` (or
47
+ # `<@!USER_ID>` for nicknames in older clients). Strip both shapes.
48
+ _MENTION_RE = re.compile(r"<@!?(\d+)>")
49
+
50
+
51
+ class DiscordAdapter(PlatformAdapter):
52
+ platform = Platform.DISCORD
53
+
54
+ def __init__(self, client: DiscordClient, bot_user_id: str | None = None) -> None:
55
+ """`bot_user_id` is required for Gateway mode (so the adapter
56
+ knows which mention IDs to strip from message content); for
57
+ Interactions-only mode it can be None."""
58
+ self._client = client
59
+ self._bot_user_id = bot_user_id
60
+
61
+ @property
62
+ def client(self) -> DiscordClient:
63
+ return self._client
64
+
65
+ def parse(self, raw: Any) -> IncomingMessage | None:
66
+ # Type-sniff between the two payload shapes. MessageCreatePayload
67
+ # always has `content` + `author`; Interaction always has `type`
68
+ # + `application_id`. Pre-validated objects pass through.
69
+ if isinstance(raw, Interaction):
70
+ return self._parse_interaction(raw)
71
+ if isinstance(raw, MessageCreatePayload):
72
+ return self._parse_message_create(raw)
73
+ if isinstance(raw, dict):
74
+ if "application_id" in raw and "type" in raw:
75
+ return self._parse_interaction(Interaction.model_validate(raw))
76
+ if "content" in raw and "author" in raw:
77
+ return self._parse_message_create(MessageCreatePayload.model_validate(raw))
78
+ return None
79
+
80
+ def _parse_interaction(self, interaction: Interaction) -> IncomingMessage | None:
81
+ if interaction.type != InteractionType.APPLICATION_COMMAND:
82
+ # PING (type 1) is answered inline in the router. Other
83
+ # types (autocomplete, modal submit, message component)
84
+ # aren't part of Phase 4's surface.
85
+ return None
86
+ if interaction.data is None or not interaction.data.name:
87
+ return None
88
+ caller = interaction.caller()
89
+ if caller is None:
90
+ return None
91
+
92
+ # Flatten the option tree back into `/cmd args...` text so the
93
+ # dispatcher sees the same shape Telegram produces. Two layouts
94
+ # we accept (Discord rule: a slash command's options are EITHER
95
+ # all leaf values OR all sub-commands, never mixed):
96
+ #
97
+ # 1. Leaf options only — e.g. /service with one `args` STRING:
98
+ # options=[{name:"args", type:3, value:"restart api"}]
99
+ # → "/service restart api"
100
+ #
101
+ # 2. SUB_COMMAND wrapper (issue #18) — e.g. /otp emergency 5:
102
+ # options=[{name:"emergency", type:1, options:[
103
+ # {name:"minutes", type:4, value:5}]}]
104
+ # → "/otp emergency 5"
105
+ #
106
+ # SUB_COMMAND_GROUP (type=2) would add another nesting level,
107
+ # but we don't emit any in our schema, so a single descent
108
+ # suffices. If we ever do, this should grow into a recursive
109
+ # walk.
110
+ parts: list[str] = [f"/{interaction.data.name}"]
111
+ for opt in interaction.data.options or []:
112
+ if opt.type == 1: # SUB_COMMAND — descend one level
113
+ parts.append(opt.name)
114
+ for inner in opt.options or []:
115
+ if inner.value is None:
116
+ continue
117
+ parts.append(str(inner.value))
118
+ continue
119
+ if opt.value is None:
120
+ continue
121
+ parts.append(str(opt.value))
122
+ text = " ".join(parts)
123
+
124
+ return IncomingMessage(
125
+ platform=Platform.DISCORD,
126
+ user=PlatformUser(
127
+ platform=Platform.DISCORD,
128
+ raw_id=caller.id,
129
+ handle=caller.username,
130
+ display_name=caller.global_name or caller.username,
131
+ ),
132
+ chat_id=interaction.channel_id or interaction.guild_id or "dm",
133
+ text=text,
134
+ message_id=interaction.id,
135
+ raw=interaction.model_dump(),
136
+ received_at=datetime.now(timezone.utc),
137
+ )
138
+
139
+ def _parse_message_create(self, msg: MessageCreatePayload) -> IncomingMessage | None:
140
+ """Decide whether a Gateway MESSAGE_CREATE counts as a command,
141
+ and if so, normalize it to `/cmd args...` text shape.
142
+
143
+ Three filters in order:
144
+ 1. Skip messages from any bot account (including ourselves) —
145
+ prevents reply loops.
146
+ 2. Decide if the message addresses our bot:
147
+ - DM (guild_id null): every message counts
148
+ - guild channel: only if our bot is in `mentions`
149
+ 3. Strip the `<@bot_id>` mention prefix from content; if the
150
+ remaining text doesn't already start with `/`, prepend it
151
+ so the dispatcher recognizes it as a command.
152
+
153
+ Returns None when any filter rejects (caller logs and moves on).
154
+ """
155
+ if msg.author.bot:
156
+ return None
157
+ # Guild messages must @-mention us to count as a command;
158
+ # DMs accept any content.
159
+ if not msg.is_dm() and (self._bot_user_id is None or not msg.mentions_user(self._bot_user_id)):
160
+ return None
161
+
162
+ # Strip every <@id> / <@!id> mention from the content (not just
163
+ # the leading one) and collapse whitespace. This handles
164
+ # `@sre_bot service restart` AND `service @sre_bot restart`
165
+ # (the second is unusual but Discord allows mentions anywhere).
166
+ bare = _MENTION_RE.sub(" ", msg.content).strip()
167
+ bare = " ".join(bare.split()) # collapse runs of whitespace
168
+ if not bare:
169
+ return None
170
+
171
+ text = bare if bare.startswith("/") else f"/{bare}"
172
+
173
+ return IncomingMessage(
174
+ platform=Platform.DISCORD,
175
+ user=PlatformUser(
176
+ platform=Platform.DISCORD,
177
+ raw_id=msg.author.id,
178
+ handle=msg.author.username,
179
+ display_name=msg.author.global_name or msg.author.username,
180
+ ),
181
+ chat_id=msg.channel_id,
182
+ text=text,
183
+ message_id=msg.id,
184
+ # Stash channel_id explicitly — adapter.send() uses it to
185
+ # route the reply via the Gateway path (POST /channels/.../messages).
186
+ raw={**msg.model_dump(), "_via": "gateway"},
187
+ received_at=datetime.now(timezone.utc),
188
+ )
189
+
190
+ async def send(self, msg: IncomingMessage, resp: OutgoingResponse) -> None:
191
+ # Branch by ingestion path. Gateway-originated messages
192
+ # don't have a per-invocation interaction token — they reply
193
+ # via the regular bot-authed REST endpoint.
194
+ raw = msg.raw if isinstance(msg.raw, dict) else {}
195
+ if raw.get("_via") == "gateway":
196
+ await self._client.create_message(msg.chat_id, resp.text)
197
+ return
198
+ token = raw.get("token")
199
+ if not token:
200
+ # Without the per-interaction token we can't reach the
201
+ # @original webhook URL; nothing to do.
202
+ return
203
+ await self._client.patch_original_response(token, resp.text)
@@ -0,0 +1,126 @@
1
+ """Minimal async Discord REST client.
2
+
3
+ Two-purpose:
4
+
5
+ 1. Reply to a deferred interaction by PATCHing the @original
6
+ message via the interaction's webhook URL. These endpoints are
7
+ keyed by interaction_token and don't take the bot's Bearer auth
8
+ — they're effectively per-interaction one-shot URLs.
9
+ 2. Register the application's slash command schema (PUT
10
+ /applications/{id}/commands or .../guilds/{guild_id}/commands).
11
+ These endpoints DO need the bot token in `Authorization: Bot`.
12
+
13
+ Discord caps message content at 2000 chars; longer replies are
14
+ truncated with a `[...truncated]` marker so the operator knows.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ from typing import Any
20
+
21
+ import httpx
22
+
23
+ _DISCORD_MAX_MESSAGE_CHARS = 2000
24
+
25
+
26
+ class DiscordClient:
27
+ BASE_URL = "https://discord.com/api/v10"
28
+
29
+ def __init__(
30
+ self,
31
+ *,
32
+ bot_token: str,
33
+ application_id: str,
34
+ base_url: str = BASE_URL,
35
+ timeout_s: float = 10.0,
36
+ transport: httpx.AsyncBaseTransport | None = None,
37
+ ) -> None:
38
+ self._bot_token = bot_token
39
+ self._application_id = application_id
40
+ self._client = httpx.AsyncClient(
41
+ base_url=base_url,
42
+ timeout=timeout_s,
43
+ transport=transport,
44
+ )
45
+
46
+ async def aclose(self) -> None:
47
+ await self._client.aclose()
48
+
49
+ async def __aenter__(self) -> DiscordClient:
50
+ return self
51
+
52
+ async def __aexit__(self, *exc_info: Any) -> None:
53
+ await self.aclose()
54
+
55
+ async def patch_original_response(
56
+ self,
57
+ interaction_token: str,
58
+ content: str,
59
+ ) -> dict[str, Any]:
60
+ """Replace a deferred interaction's @original message body.
61
+
62
+ No bot-token auth: the interaction_token itself authorizes
63
+ the call. Discord's content limit is 2000 chars; we truncate
64
+ beyond that with a marker so reply contents that come back
65
+ from a chatty SSH command don't silently disappear.
66
+ """
67
+ url = f"/webhooks/{self._application_id}/{interaction_token}/messages/@original"
68
+ truncated = _truncate(content)
69
+ resp = await self._client.patch(url, json={"content": truncated})
70
+ resp.raise_for_status()
71
+ data: dict[str, Any] = resp.json()
72
+ return data
73
+
74
+ async def create_message(self, channel_id: str, content: str) -> dict[str, Any]:
75
+ """POST a chat message to a channel.
76
+
77
+ Used by Phase 6c Gateway-mode replies — interactions reply via
78
+ the per-invocation webhook URL (no auth needed), but Gateway-
79
+ originated messages don't have one, so we go through the
80
+ regular bot-authed REST endpoint. Same 2000-char truncation
81
+ the @original PATCH path uses.
82
+ """
83
+ url = f"/channels/{channel_id}/messages"
84
+ truncated = _truncate(content)
85
+ resp = await self._client.post(
86
+ url,
87
+ json={"content": truncated},
88
+ headers={"Authorization": f"Bot {self._bot_token}"},
89
+ )
90
+ resp.raise_for_status()
91
+ data: dict[str, Any] = resp.json()
92
+ return data
93
+
94
+ async def overwrite_global_commands(self, commands: list[dict[str, Any]]) -> list[dict[str, Any]]:
95
+ """PUT-replace the application's global slash command set."""
96
+ return await self._put_commands(f"/applications/{self._application_id}/commands", commands)
97
+
98
+ async def overwrite_guild_commands(
99
+ self,
100
+ guild_id: str,
101
+ commands: list[dict[str, Any]],
102
+ ) -> list[dict[str, Any]]:
103
+ """PUT-replace a guild's slash command set. Updates propagate instantly."""
104
+ return await self._put_commands(
105
+ f"/applications/{self._application_id}/guilds/{guild_id}/commands",
106
+ commands,
107
+ )
108
+
109
+ async def _put_commands(self, url: str, commands: list[dict[str, Any]]) -> list[dict[str, Any]]:
110
+ resp = await self._client.put(
111
+ url,
112
+ json=commands,
113
+ headers={"Authorization": f"Bot {self._bot_token}"},
114
+ )
115
+ resp.raise_for_status()
116
+ data: list[dict[str, Any]] = resp.json()
117
+ return data
118
+
119
+
120
+ def _truncate(content: str) -> str:
121
+ if not content:
122
+ return "(no output)"
123
+ if len(content) <= _DISCORD_MAX_MESSAGE_CHARS:
124
+ return content
125
+ marker = "\n[...truncated]"
126
+ return content[: _DISCORD_MAX_MESSAGE_CHARS - len(marker)] + marker