-
Notifications
You must be signed in to change notification settings - Fork 17
Expand file tree
/
Copy pathsetup_wizard.py
More file actions
124 lines (101 loc) · 4.41 KB
/
setup_wizard.py
File metadata and controls
124 lines (101 loc) · 4.41 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
"""``/setup`` — a zero-DSN connection wizard for non-developers.
The user never sees a SQLAlchemy URL or an env file. They run ``/setup``, pick
their database from a dropdown, and fill a short form. We assemble the DSN,
test the connection by listing tables, and store the credentials encrypted via
:class:`EncryptedSecrets` keyed by the guild scope. The next message in that
guild transparently uses the new database.
Discord coupling lives only here and in ``bot.py``: the actual register-and-
test logic is :meth:`CommandHandlers.register_db_for_guild` (pure, testable).
"""
from __future__ import annotations
from typing import TYPE_CHECKING
import discord
from discord import ui
from ...adapters.db.dsn_builder import FIELD_SCHEMA, SUPPORTED_DB_TYPES
from .session_router import to_identity
if TYPE_CHECKING:
from .commands import CommandHandlers
from .bot import InteractionContext
# Per-DB human labels surfaced in the dropdown.
_LABELS: dict[str, str] = {
"postgresql": "PostgreSQL",
"mysql": "MySQL",
"snowflake": "Snowflake",
"bigquery": "BigQuery",
"duckdb": "DuckDB (file)",
"d1": "Cloudflare D1",
}
class _ConnectionFormModal(ui.Modal):
"""The per-DB-type form. Fields come from :data:`FIELD_SCHEMA`.
Discord modals cap at 5 :class:`ui.TextInput` rows, which matches our
widest schema (Postgres/MySQL/Snowflake). Passwords/tokens are plain text
inputs — Discord has no masked input style — but the form is ephemeral so
only the user sees what they typed.
"""
def __init__(
self,
db_type: str,
handlers: "CommandHandlers",
ctx_factory,
) -> None:
super().__init__(title=f"Connect to {_LABELS.get(db_type, db_type)}")
self._db_type = db_type
self._handlers = handlers
self._ctx_factory = ctx_factory # () -> InteractionContext
self._inputs: dict[str, ui.TextInput] = {}
for name, placeholder, required, _masked in FIELD_SCHEMA[db_type]:
inp = ui.TextInput(
label=name,
placeholder=placeholder,
required=required,
style=discord.TextStyle.short,
max_length=200,
)
self._inputs[name] = inp
self.add_item(inp)
async def on_submit(self, interaction: discord.Interaction) -> None:
# Connection test can take a few seconds; defer so Discord doesn't
# timeout the interaction. Ephemeral so only the user sees the result.
await interaction.response.defer(ephemeral=True, thinking=True)
fields = {name: inp.value for name, inp in self._inputs.items()}
identity = to_identity(self._ctx_factory(interaction))
result = await self._handlers.register_db_for_guild(
identity, self._db_type, fields
)
await interaction.followup.send(result.text, ephemeral=True)
class _DbTypeSelect(ui.Select):
"""Step 1 dropdown — pick which DB type to connect."""
def __init__(self, handlers: "CommandHandlers", ctx_factory) -> None:
options = [
discord.SelectOption(label=_LABELS[t], value=t) for t in SUPPORTED_DB_TYPES
]
super().__init__(
placeholder="Choose your database…",
options=options,
min_values=1,
max_values=1,
)
self._handlers = handlers
self._ctx_factory = ctx_factory
async def callback(self, interaction: discord.Interaction) -> None:
# Opening a modal *is* the response to this select interaction.
await interaction.response.send_modal(
_ConnectionFormModal(self.values[0], self._handlers, self._ctx_factory)
)
class _SetupView(ui.View):
"""Holds the DB-type dropdown. Auto-times out after 2 minutes."""
def __init__(self, handlers: "CommandHandlers", ctx_factory) -> None:
super().__init__(timeout=120.0)
self.add_item(_DbTypeSelect(handlers, ctx_factory))
async def start_setup_flow(
interaction: discord.Interaction,
handlers: "CommandHandlers",
ctx_factory,
) -> None:
"""Entry point bot.py wires to ``/setup`` — surfaces the picker ephemerally."""
await interaction.response.send_message(
"Let's connect your database. Pick its type, then fill the form. "
"Your credentials are stored encrypted; nobody else sees what you type.",
view=_SetupView(handlers, ctx_factory),
ephemeral=True,
)