yarno 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.
- yarno/__init__.py +20 -0
- yarno/bot.py +658 -0
- yarno/ui.py +278 -0
- yarno/voice.py +118 -0
- yarno-0.2.0.dist-info/METADATA +96 -0
- yarno-0.2.0.dist-info/RECORD +9 -0
- yarno-0.2.0.dist-info/WHEEL +5 -0
- yarno-0.2.0.dist-info/licenses/LICENSE +21 -0
- yarno-0.2.0.dist-info/top_level.txt +1 -0
yarno/__init__.py
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
"""yarno — write bots for we.yarno.
|
|
2
|
+
|
|
3
|
+
from yarno import Bot, ui
|
|
4
|
+
|
|
5
|
+
bot = Bot()
|
|
6
|
+
|
|
7
|
+
@bot.command("hi", "says hi")
|
|
8
|
+
async def hi(ctx):
|
|
9
|
+
return "hi"
|
|
10
|
+
|
|
11
|
+
bot.run()
|
|
12
|
+
|
|
13
|
+
Get a token at https://dev.we.yarno.lol, docs at https://dev.we.yarno.lol/docs.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from . import ui
|
|
17
|
+
from .bot import Bot, CommandError, Context, RateLimited, mention
|
|
18
|
+
|
|
19
|
+
__all__ = ["Bot", "Context", "CommandError", "RateLimited", "mention", "ui"]
|
|
20
|
+
__version__ = "0.2.0"
|
yarno/bot.py
ADDED
|
@@ -0,0 +1,658 @@
|
|
|
1
|
+
"""The Bot client.
|
|
2
|
+
|
|
3
|
+
Design goal, stated plainly: the shortest complete bot should be six lines,
|
|
4
|
+
and nothing in those six lines should be ceremony.
|
|
5
|
+
|
|
6
|
+
from yarno import Bot
|
|
7
|
+
|
|
8
|
+
bot = Bot()
|
|
9
|
+
|
|
10
|
+
@bot.command("hi", "says hi")
|
|
11
|
+
async def hi(ctx):
|
|
12
|
+
return "hi"
|
|
13
|
+
|
|
14
|
+
bot.run()
|
|
15
|
+
|
|
16
|
+
No intents object. No command tree. No sync() step — the decorator IS the
|
|
17
|
+
registration, pushed the moment the socket connects. No 3-second deadline, no
|
|
18
|
+
defer(), no followup: the connection is already open, so a handler can take as
|
|
19
|
+
long as it likes and still answer on the same path it was called through.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
import asyncio
|
|
23
|
+
import inspect
|
|
24
|
+
import os
|
|
25
|
+
import shlex
|
|
26
|
+
import sys
|
|
27
|
+
import traceback
|
|
28
|
+
import typing
|
|
29
|
+
|
|
30
|
+
import socketio
|
|
31
|
+
|
|
32
|
+
from . import ui
|
|
33
|
+
|
|
34
|
+
DEFAULT_BASE = "https://we.yarno.lol"
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def log(*a):
|
|
38
|
+
print("[yarno]", *a, flush=True, file=sys.stderr)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class CommandError(Exception):
|
|
42
|
+
"""Raise this in a handler to show the caller a message instead of a
|
|
43
|
+
stack trace. The SDK also raises it for bad arguments."""
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class RateLimited(RuntimeError):
|
|
47
|
+
"""The server said slow down and told us for how long. The SDK already
|
|
48
|
+
waited and retried twice before raising this — catching it means you're
|
|
49
|
+
sustaining more than the limit, not just bursting."""
|
|
50
|
+
|
|
51
|
+
def __init__(self, retry_after: float):
|
|
52
|
+
super().__init__(f"rate limited — retry in {retry_after}s")
|
|
53
|
+
self.retry_after = retry_after
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
# --- argument binding --------------------------------------------------------
|
|
57
|
+
#
|
|
58
|
+
# A command's Python signature is the single source of truth: it generates the
|
|
59
|
+
# usage string shown in the composer AND parses what the user typed. There is
|
|
60
|
+
# no second place to declare options and therefore no way for the two to drift.
|
|
61
|
+
|
|
62
|
+
_TRUE = {"1", "true", "yes", "y", "on"}
|
|
63
|
+
_FALSE = {"0", "false", "no", "n", "off"}
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _convert(value: str, kind, name: str):
|
|
67
|
+
if kind in (str, inspect.Parameter.empty, None):
|
|
68
|
+
return value
|
|
69
|
+
if kind is int:
|
|
70
|
+
try:
|
|
71
|
+
return int(value)
|
|
72
|
+
except ValueError:
|
|
73
|
+
raise CommandError(f"{name} should be a whole number, got {value!r}")
|
|
74
|
+
if kind is float:
|
|
75
|
+
try:
|
|
76
|
+
return float(value)
|
|
77
|
+
except ValueError:
|
|
78
|
+
raise CommandError(f"{name} should be a number, got {value!r}")
|
|
79
|
+
if kind is bool:
|
|
80
|
+
low = value.lower()
|
|
81
|
+
if low in _TRUE:
|
|
82
|
+
return True
|
|
83
|
+
if low in _FALSE:
|
|
84
|
+
return False
|
|
85
|
+
raise CommandError(f"{name} should be yes or no, got {value!r}")
|
|
86
|
+
origin = typing.get_origin(kind)
|
|
87
|
+
if origin is typing.Literal:
|
|
88
|
+
choices = [str(c) for c in typing.get_args(kind)]
|
|
89
|
+
if value not in choices:
|
|
90
|
+
raise CommandError(f"{name} should be one of: {', '.join(choices)}")
|
|
91
|
+
return value
|
|
92
|
+
return value
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
class Command:
|
|
96
|
+
def __init__(self, name: str, description: str, fn):
|
|
97
|
+
self.name = name
|
|
98
|
+
self.description = description
|
|
99
|
+
self.fn = fn
|
|
100
|
+
sig = inspect.signature(fn)
|
|
101
|
+
# Drop ctx, which is always first.
|
|
102
|
+
self.params = list(sig.parameters.values())[1:]
|
|
103
|
+
|
|
104
|
+
def usage(self) -> str:
|
|
105
|
+
out = []
|
|
106
|
+
for p in self.params:
|
|
107
|
+
required = p.default is inspect.Parameter.empty
|
|
108
|
+
out.append(f"<{p.name}>" if required else f"[{p.name}]")
|
|
109
|
+
return " ".join(out)
|
|
110
|
+
|
|
111
|
+
def wire(self) -> dict:
|
|
112
|
+
params = []
|
|
113
|
+
for p in self.params:
|
|
114
|
+
kind = p.annotation
|
|
115
|
+
origin = typing.get_origin(kind)
|
|
116
|
+
params.append({
|
|
117
|
+
"name": p.name,
|
|
118
|
+
"type": (
|
|
119
|
+
"choice" if origin is typing.Literal
|
|
120
|
+
else getattr(kind, "__name__", "str")
|
|
121
|
+
),
|
|
122
|
+
"required": p.default is inspect.Parameter.empty,
|
|
123
|
+
"choices": (
|
|
124
|
+
[str(c) for c in typing.get_args(kind)]
|
|
125
|
+
if origin is typing.Literal else []
|
|
126
|
+
),
|
|
127
|
+
})
|
|
128
|
+
return {
|
|
129
|
+
"name": self.name,
|
|
130
|
+
"description": self.description,
|
|
131
|
+
"args": self.usage(),
|
|
132
|
+
"params": params,
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
def bind(self, argstr: str) -> list:
|
|
136
|
+
"""Turn the raw argument string into positional values.
|
|
137
|
+
|
|
138
|
+
The last string parameter slurps the remainder, so `/play never gonna
|
|
139
|
+
give you up` works without anyone having to remember quotes. That one
|
|
140
|
+
rule removes most of the friction of typed options."""
|
|
141
|
+
if not self.params:
|
|
142
|
+
return []
|
|
143
|
+
try:
|
|
144
|
+
tokens = shlex.split(argstr)
|
|
145
|
+
except ValueError:
|
|
146
|
+
tokens = argstr.split()
|
|
147
|
+
values, last = [], len(self.params) - 1
|
|
148
|
+
for i, p in enumerate(self.params):
|
|
149
|
+
annotation = p.annotation
|
|
150
|
+
slurp = i == last and annotation in (str, inspect.Parameter.empty)
|
|
151
|
+
if i >= len(tokens):
|
|
152
|
+
if p.default is inspect.Parameter.empty:
|
|
153
|
+
raise CommandError(f"usage: /{self.name} {self.usage()}".strip())
|
|
154
|
+
values.append(p.default)
|
|
155
|
+
continue
|
|
156
|
+
raw = " ".join(tokens[i:]) if slurp else tokens[i]
|
|
157
|
+
values.append(_convert(raw, annotation, p.name))
|
|
158
|
+
return values
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
# --- context -----------------------------------------------------------------
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
class Context:
|
|
165
|
+
"""What a handler is handed. One object for both commands and clicks, so
|
|
166
|
+
there's nothing to learn twice."""
|
|
167
|
+
|
|
168
|
+
def __init__(self, bot: "Bot", data: dict, *, action=False):
|
|
169
|
+
self._bot = bot
|
|
170
|
+
self.raw = data
|
|
171
|
+
self.is_action = action
|
|
172
|
+
self.user = data.get("user") or {}
|
|
173
|
+
self.channel_id = data.get("channel_id")
|
|
174
|
+
self.server_id = data.get("server_id")
|
|
175
|
+
self.args = data.get("args", "")
|
|
176
|
+
self.command = data.get("command")
|
|
177
|
+
# Message events (DMs) only: what they said.
|
|
178
|
+
self.content = data.get("content", "")
|
|
179
|
+
# Action-only. `card` and `store` come down with the click, so a bot
|
|
180
|
+
# that just booted can service a button it drew before it restarted.
|
|
181
|
+
self.message_id = data.get("message_id")
|
|
182
|
+
self.on = data.get("on")
|
|
183
|
+
self.arg = data.get("arg")
|
|
184
|
+
self.card = data.get("card")
|
|
185
|
+
self.store = data.get("store") or {}
|
|
186
|
+
# Current values of the card's form fields, keyed by name. Any click
|
|
187
|
+
# submits the whole card, so a submit button plus ui.input/select/
|
|
188
|
+
# toggle/slider is a form with no modal in sight.
|
|
189
|
+
self.values = data.get("values") or {}
|
|
190
|
+
|
|
191
|
+
@property
|
|
192
|
+
def username(self) -> str:
|
|
193
|
+
return self.user.get("username", "?")
|
|
194
|
+
|
|
195
|
+
async def send(self, body=None, *, card=None, store=None, reply_to=None,
|
|
196
|
+
dock=False) -> int:
|
|
197
|
+
"""Post into the channel this came from. Returns the message id."""
|
|
198
|
+
return await self._bot.send(
|
|
199
|
+
self.channel_id, body, card=card, store=store, reply_to=reply_to,
|
|
200
|
+
dock=dock,
|
|
201
|
+
)
|
|
202
|
+
|
|
203
|
+
async def update(self, body=None, *, card=None, store=None) -> None:
|
|
204
|
+
"""Edit the card that was clicked. Only meaningful for actions."""
|
|
205
|
+
if not self.message_id:
|
|
206
|
+
raise RuntimeError("update() needs a message — use send() instead")
|
|
207
|
+
await self._bot.edit(self.message_id, body, card=card, store=store)
|
|
208
|
+
|
|
209
|
+
async def delete(self) -> None:
|
|
210
|
+
if self.message_id:
|
|
211
|
+
await self._bot.delete(self.message_id)
|
|
212
|
+
|
|
213
|
+
async def private(self, body=None, *, card=None) -> None:
|
|
214
|
+
"""Reply so that only the person who acted can see it.
|
|
215
|
+
|
|
216
|
+
It's a real message in the channel — rendered with an "only you can
|
|
217
|
+
see this" footer and a dismiss — not a system notification. Bots don't
|
|
218
|
+
get to fire the app's own toasts. Nothing is written to the database,
|
|
219
|
+
so it's gone on reload.
|
|
220
|
+
"""
|
|
221
|
+
content, card = _split_body(body, card)
|
|
222
|
+
payload = {
|
|
223
|
+
"user_id": self.user.get("id"),
|
|
224
|
+
"channel_id": self.channel_id,
|
|
225
|
+
}
|
|
226
|
+
if content is not None:
|
|
227
|
+
payload["content"] = content
|
|
228
|
+
if card is not None:
|
|
229
|
+
payload["card"] = card
|
|
230
|
+
await self._bot.emit("bot_private", payload)
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
# --- the bot -----------------------------------------------------------------
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
class Bot:
|
|
237
|
+
def __init__(self, token: str = "", base: str = ""):
|
|
238
|
+
self.token = token or os.environ.get("YARNO_TOKEN", "")
|
|
239
|
+
self.base = (base or os.environ.get("YARNO_BASE") or DEFAULT_BASE).rstrip("/")
|
|
240
|
+
if not self.token:
|
|
241
|
+
raise SystemExit(
|
|
242
|
+
"no token — pass Bot(token=...) or set YARNO_TOKEN.\n"
|
|
243
|
+
"make one at https://dev.we.yarno.lol"
|
|
244
|
+
)
|
|
245
|
+
self.commands: dict[str, Command] = {}
|
|
246
|
+
self.actions: dict[str, callable] = {}
|
|
247
|
+
self._events: dict[str, list] = {}
|
|
248
|
+
self._message_handlers: list = []
|
|
249
|
+
self._autocompleters: dict[tuple[str, str], callable] = {}
|
|
250
|
+
# Filled from the bot_ready ack: {"id", "username", ...}.
|
|
251
|
+
self.user: dict | None = None
|
|
252
|
+
self.can_voice = False
|
|
253
|
+
self.sio = socketio.AsyncClient(reconnection=True, reconnection_delay_max=20)
|
|
254
|
+
self._wire()
|
|
255
|
+
|
|
256
|
+
# -- registration --
|
|
257
|
+
|
|
258
|
+
def command(self, name: str, description: str = ""):
|
|
259
|
+
def wrap(fn):
|
|
260
|
+
self.commands[name] = Command(name, description, fn)
|
|
261
|
+
return fn
|
|
262
|
+
|
|
263
|
+
return wrap
|
|
264
|
+
|
|
265
|
+
def on(self, action: str):
|
|
266
|
+
"""Handle a button press. `action` matches ui.button(on=...)."""
|
|
267
|
+
|
|
268
|
+
def wrap(fn):
|
|
269
|
+
self.actions[action] = fn
|
|
270
|
+
return fn
|
|
271
|
+
|
|
272
|
+
return wrap
|
|
273
|
+
|
|
274
|
+
def event(self, name: str):
|
|
275
|
+
"""Lifecycle hooks: 'ready', 'disconnect'."""
|
|
276
|
+
|
|
277
|
+
def wrap(fn):
|
|
278
|
+
self._events.setdefault(name, []).append(fn)
|
|
279
|
+
return fn
|
|
280
|
+
|
|
281
|
+
return wrap
|
|
282
|
+
|
|
283
|
+
def autocomplete(self, command: str, param: str):
|
|
284
|
+
"""Suggest values while someone is still typing an argument. The
|
|
285
|
+
handler gets (ctx, partial) and returns up to 12 choices — strings,
|
|
286
|
+
(value, label) pairs, or dicts. The round trip is your socket, so
|
|
287
|
+
aim for instant.
|
|
288
|
+
|
|
289
|
+
@bot.autocomplete("play", "query")
|
|
290
|
+
async def suggest(ctx, partial):
|
|
291
|
+
return search_titles(partial)
|
|
292
|
+
"""
|
|
293
|
+
|
|
294
|
+
def wrap(fn):
|
|
295
|
+
self._autocompleters[(command, param)] = fn
|
|
296
|
+
return fn
|
|
297
|
+
|
|
298
|
+
return wrap
|
|
299
|
+
|
|
300
|
+
def on_message(self, fn):
|
|
301
|
+
"""Handle a DM sent to the bot. DMs are the only messages that reach
|
|
302
|
+
a bot — channel chatter structurally doesn't. The handler gets a ctx
|
|
303
|
+
with .content, .user, .channel_id; returning a string (or card)
|
|
304
|
+
replies in the DM.
|
|
305
|
+
|
|
306
|
+
@bot.on_message
|
|
307
|
+
async def dm(ctx):
|
|
308
|
+
return f"you said: {ctx.content}"
|
|
309
|
+
"""
|
|
310
|
+
self._message_handlers.append(fn)
|
|
311
|
+
return fn
|
|
312
|
+
|
|
313
|
+
async def _fire(self, name: str, *a):
|
|
314
|
+
for fn in self._events.get(name, []):
|
|
315
|
+
try:
|
|
316
|
+
await fn(*a) if inspect.iscoroutinefunction(fn) else fn(*a)
|
|
317
|
+
except Exception:
|
|
318
|
+
traceback.print_exc()
|
|
319
|
+
|
|
320
|
+
# -- outbound --
|
|
321
|
+
|
|
322
|
+
async def emit(self, event: str, data: dict):
|
|
323
|
+
await self.sio.emit(event, data)
|
|
324
|
+
|
|
325
|
+
async def _call(self, event: str, data: dict, timeout: int = 15):
|
|
326
|
+
for attempt in range(3):
|
|
327
|
+
res = await self.sio.call(event, data, timeout=timeout)
|
|
328
|
+
if isinstance(res, dict) and res.get("error"):
|
|
329
|
+
wait = res.get("retry_after")
|
|
330
|
+
if wait is not None:
|
|
331
|
+
# Back off exactly as long as the server asked, twice,
|
|
332
|
+
# before making it the caller's problem.
|
|
333
|
+
if attempt < 2 and wait <= 10:
|
|
334
|
+
await asyncio.sleep(float(wait) + 0.05)
|
|
335
|
+
continue
|
|
336
|
+
raise RateLimited(wait)
|
|
337
|
+
raise RuntimeError(res["error"])
|
|
338
|
+
return res or {}
|
|
339
|
+
|
|
340
|
+
async def send(self, channel_id, body=None, *, card=None, store=None,
|
|
341
|
+
reply_to=None, dock=False, files=None) -> int:
|
|
342
|
+
"""Post a message. `dock=True` pins the card to the top of the
|
|
343
|
+
channel — the one slot every channel has for a live surface.
|
|
344
|
+
`files` is a list of paths / bytes / file objects (or attachment ids
|
|
345
|
+
from a prior upload); they're uploaded and attached for you."""
|
|
346
|
+
payload = {"channel_id": channel_id}
|
|
347
|
+
content, card = _split_body(body, card)
|
|
348
|
+
if content is not None:
|
|
349
|
+
payload["content"] = content
|
|
350
|
+
if card is not None:
|
|
351
|
+
payload["card"] = card
|
|
352
|
+
if store is not None:
|
|
353
|
+
payload["store"] = store
|
|
354
|
+
if reply_to:
|
|
355
|
+
payload["reply_to"] = reply_to
|
|
356
|
+
if dock:
|
|
357
|
+
payload["dock"] = True
|
|
358
|
+
if files:
|
|
359
|
+
ids = []
|
|
360
|
+
for f in files:
|
|
361
|
+
if isinstance(f, int):
|
|
362
|
+
ids.append(f)
|
|
363
|
+
else:
|
|
364
|
+
ids.append((await self.upload(channel_id, f))["id"])
|
|
365
|
+
payload["attachment_ids"] = ids
|
|
366
|
+
return (await self._call("bot_send", payload)).get("message_id")
|
|
367
|
+
|
|
368
|
+
async def edit(self, message_id, body=None, *, card=None, store=None) -> None:
|
|
369
|
+
payload = {"message_id": message_id}
|
|
370
|
+
content, card = _split_body(body, card)
|
|
371
|
+
if content is not None:
|
|
372
|
+
payload["content"] = content
|
|
373
|
+
if card is not None:
|
|
374
|
+
payload["card"] = card
|
|
375
|
+
if store is not None:
|
|
376
|
+
payload["store"] = store
|
|
377
|
+
await self._call("bot_edit", payload)
|
|
378
|
+
|
|
379
|
+
async def delete(self, message_id) -> None:
|
|
380
|
+
await self._call("bot_delete", {"message_id": message_id})
|
|
381
|
+
|
|
382
|
+
async def dock(self, message_id, dock: bool = True) -> None:
|
|
383
|
+
"""Pin one of your cards to the top of its channel (or unpin it).
|
|
384
|
+
A channel has one dock slot; docking replaces whatever held it."""
|
|
385
|
+
await self._call("bot_dock", {"message_id": message_id, "dock": dock})
|
|
386
|
+
|
|
387
|
+
async def react(self, message_id, emoji: str, *, remove=False) -> None:
|
|
388
|
+
"""Toggle a reaction on a DM message or one of your own."""
|
|
389
|
+
await self._call(
|
|
390
|
+
"bot_react", {"message_id": message_id, "emoji": emoji, "remove": remove}
|
|
391
|
+
)
|
|
392
|
+
|
|
393
|
+
async def history(self, channel_id, *, before=None, limit=50) -> list:
|
|
394
|
+
"""Messages from a DM the bot is part of, oldest first. DMs only —
|
|
395
|
+
channel history is structurally out of reach."""
|
|
396
|
+
payload = {"channel_id": channel_id, "limit": limit}
|
|
397
|
+
if before:
|
|
398
|
+
payload["before"] = before
|
|
399
|
+
return (await self._call("bot_history", payload)).get("messages", [])
|
|
400
|
+
|
|
401
|
+
async def fetch_message(self, message_id) -> dict:
|
|
402
|
+
"""One message: a DM the bot is in, or a message the bot wrote."""
|
|
403
|
+
return (await self._call("bot_fetch_message", {"message_id": message_id}))[
|
|
404
|
+
"message"
|
|
405
|
+
]
|
|
406
|
+
|
|
407
|
+
async def fetch_user(self, user=None, *, username=None) -> dict:
|
|
408
|
+
"""Look up someone the bot shares a server or DM with."""
|
|
409
|
+
payload = {"username": username} if username else {"user_id": user}
|
|
410
|
+
return (await self._call("bot_fetch_user", payload))["user"]
|
|
411
|
+
|
|
412
|
+
async def fetch_channel(self, channel_id) -> dict:
|
|
413
|
+
"""A channel the bot can see: name, type, and whether it can post."""
|
|
414
|
+
return (await self._call("bot_fetch_channel", {"channel_id": channel_id}))[
|
|
415
|
+
"channel"
|
|
416
|
+
]
|
|
417
|
+
|
|
418
|
+
async def fetch_server(self, server_id) -> dict:
|
|
419
|
+
"""A server the bot is in: channels it can see, the member list."""
|
|
420
|
+
return (await self._call("bot_fetch_server", {"server_id": server_id}))[
|
|
421
|
+
"server"
|
|
422
|
+
]
|
|
423
|
+
|
|
424
|
+
async def servers(self) -> list:
|
|
425
|
+
"""The servers this bot has been invited into."""
|
|
426
|
+
return (await self._call("bot_servers", {})).get("servers", [])
|
|
427
|
+
|
|
428
|
+
# -- http (files ride over http; everything else is the socket) --
|
|
429
|
+
|
|
430
|
+
def _http_headers(self):
|
|
431
|
+
return {"Authorization": f"Bearer {self.token}"}
|
|
432
|
+
|
|
433
|
+
async def upload(self, channel_id, file, *, filename=None) -> dict:
|
|
434
|
+
"""Park a file for sending: a path, bytes, or a file object. Returns
|
|
435
|
+
the attachment dict; pass its id in send(..., files=[...]) — or just
|
|
436
|
+
hand send() the file directly and it does both steps."""
|
|
437
|
+
import aiohttp
|
|
438
|
+
|
|
439
|
+
if isinstance(file, (str, os.PathLike)):
|
|
440
|
+
name = filename or os.path.basename(str(file))
|
|
441
|
+
with open(file, "rb") as f:
|
|
442
|
+
payload = f.read()
|
|
443
|
+
elif isinstance(file, bytes):
|
|
444
|
+
name = filename or "file"
|
|
445
|
+
payload = file
|
|
446
|
+
else:
|
|
447
|
+
name = filename or getattr(file, "name", "file")
|
|
448
|
+
payload = file.read()
|
|
449
|
+
form = aiohttp.FormData()
|
|
450
|
+
form.add_field("file", payload, filename=os.path.basename(name))
|
|
451
|
+
async with aiohttp.ClientSession() as http:
|
|
452
|
+
async with http.post(
|
|
453
|
+
f"{self.base}/api/bot/upload",
|
|
454
|
+
params={"channel_id": str(channel_id)},
|
|
455
|
+
data=form,
|
|
456
|
+
headers=self._http_headers(),
|
|
457
|
+
) as resp:
|
|
458
|
+
body = await resp.json()
|
|
459
|
+
if resp.status != 200:
|
|
460
|
+
raise RuntimeError(body.get("detail") or f"upload failed ({resp.status})")
|
|
461
|
+
return body["attachment"]
|
|
462
|
+
|
|
463
|
+
async def set_avatar(self, file) -> str:
|
|
464
|
+
"""Give the bot a face — a path, bytes, or a file object."""
|
|
465
|
+
import aiohttp
|
|
466
|
+
|
|
467
|
+
if isinstance(file, (str, os.PathLike)):
|
|
468
|
+
with open(file, "rb") as f:
|
|
469
|
+
payload = f.read()
|
|
470
|
+
elif isinstance(file, bytes):
|
|
471
|
+
payload = file
|
|
472
|
+
else:
|
|
473
|
+
payload = file.read()
|
|
474
|
+
form = aiohttp.FormData()
|
|
475
|
+
form.add_field("file", payload, filename="avatar")
|
|
476
|
+
async with aiohttp.ClientSession() as http:
|
|
477
|
+
async with http.post(
|
|
478
|
+
f"{self.base}/api/bot/avatar", data=form, headers=self._http_headers()
|
|
479
|
+
) as resp:
|
|
480
|
+
body = await resp.json()
|
|
481
|
+
if resp.status != 200:
|
|
482
|
+
raise RuntimeError(body.get("detail") or f"avatar failed ({resp.status})")
|
|
483
|
+
return body["avatar"]
|
|
484
|
+
|
|
485
|
+
async def voice_join(self, channel_id):
|
|
486
|
+
"""Join a voice channel and get a VoiceSession (play/stop/leave).
|
|
487
|
+
Needs the voice extra (pip install 'yarno[voice]') and ffmpeg, and
|
|
488
|
+
the bot must have been granted can_voice by the instance operator."""
|
|
489
|
+
from . import voice
|
|
490
|
+
|
|
491
|
+
return await voice.join(self, channel_id)
|
|
492
|
+
|
|
493
|
+
async def tallies(self, message_id) -> dict:
|
|
494
|
+
"""Counts behind a card's collect buttons: {key: [{"id", "username"}]}.
|
|
495
|
+
The server recorded these — they accrued even while you were down."""
|
|
496
|
+
return (await self._call("bot_tallies", {"message_id": message_id})).get(
|
|
497
|
+
"tallies", {}
|
|
498
|
+
)
|
|
499
|
+
|
|
500
|
+
# -- inbound --
|
|
501
|
+
|
|
502
|
+
def _wire(self):
|
|
503
|
+
@self.sio.event
|
|
504
|
+
async def connect():
|
|
505
|
+
declared = [c.wire() for c in self.commands.values()]
|
|
506
|
+
# Mark which params answer autocomplete, so the composer knows
|
|
507
|
+
# to ask as the person types.
|
|
508
|
+
for cmd in declared:
|
|
509
|
+
for p in cmd["params"]:
|
|
510
|
+
if (cmd["name"], p["name"]) in self._autocompleters:
|
|
511
|
+
p["autocomplete"] = True
|
|
512
|
+
res = await self.sio.call("bot_ready", {"commands": declared}, timeout=15)
|
|
513
|
+
self.user = (res or {}).get("you") or self.user
|
|
514
|
+
self.can_voice = bool((res or {}).get("can_voice"))
|
|
515
|
+
names = (res or {}).get("registered") or []
|
|
516
|
+
who = f"@{self.user['username']}" if self.user else "connected"
|
|
517
|
+
log(f"{who} — {len(names)} command(s): " + ", ".join("/" + n for n in names))
|
|
518
|
+
for bad in (res or {}).get("skipped") or []:
|
|
519
|
+
log(f"rejected command name {bad!r} (a-z 0-9 _ - only)")
|
|
520
|
+
await self._fire("ready")
|
|
521
|
+
|
|
522
|
+
@self.sio.event
|
|
523
|
+
async def disconnect():
|
|
524
|
+
log("disconnected")
|
|
525
|
+
await self._fire("disconnect")
|
|
526
|
+
|
|
527
|
+
@self.sio.on("bot_error")
|
|
528
|
+
async def bot_error(data):
|
|
529
|
+
log("server said:", (data or {}).get("text"))
|
|
530
|
+
|
|
531
|
+
@self.sio.on("bot_command")
|
|
532
|
+
async def bot_command(data):
|
|
533
|
+
ctx = Context(self, data)
|
|
534
|
+
cmd = self.commands.get(data.get("command"))
|
|
535
|
+
if not cmd:
|
|
536
|
+
return await ctx.private(f"/{data.get('command')} isn't a thing anymore")
|
|
537
|
+
await self._run(ctx, lambda: cmd.fn(ctx, *cmd.bind(ctx.args)))
|
|
538
|
+
|
|
539
|
+
@self.sio.on("bot_action")
|
|
540
|
+
async def bot_action(data):
|
|
541
|
+
ctx = Context(self, data, action=True)
|
|
542
|
+
fn = self.actions.get(data.get("on"))
|
|
543
|
+
if not fn:
|
|
544
|
+
return await ctx.private("that button isn't wired up")
|
|
545
|
+
await self._run(ctx, lambda: fn(ctx))
|
|
546
|
+
|
|
547
|
+
@self.sio.on("bot_autocomplete")
|
|
548
|
+
async def bot_autocomplete(data):
|
|
549
|
+
fn = self._autocompleters.get(
|
|
550
|
+
((data or {}).get("command"), (data or {}).get("param"))
|
|
551
|
+
)
|
|
552
|
+
if not fn:
|
|
553
|
+
return {"choices": []}
|
|
554
|
+
try:
|
|
555
|
+
res = fn(Context(self, data), (data or {}).get("partial", ""))
|
|
556
|
+
if inspect.isawaitable(res):
|
|
557
|
+
res = await res
|
|
558
|
+
return {"choices": list(res or [])[:12]}
|
|
559
|
+
except Exception:
|
|
560
|
+
traceback.print_exc()
|
|
561
|
+
return {"choices": []}
|
|
562
|
+
|
|
563
|
+
@self.sio.on("message_new")
|
|
564
|
+
async def message_new(data):
|
|
565
|
+
# Only DMs reach a bot's socket — the server guarantees it. Skip
|
|
566
|
+
# our own sends and other bots (no two bots ping-ponging forever).
|
|
567
|
+
if not self._message_handlers:
|
|
568
|
+
return
|
|
569
|
+
author = (data or {}).get("author") or {}
|
|
570
|
+
if self.user and author.get("id") == self.user.get("id"):
|
|
571
|
+
return
|
|
572
|
+
if author.get("is_bot"):
|
|
573
|
+
return
|
|
574
|
+
ctx = Context(self, {
|
|
575
|
+
"user": {"id": author.get("id"), "username": author.get("username")},
|
|
576
|
+
"channel_id": data.get("channel_id"),
|
|
577
|
+
"server_id": None,
|
|
578
|
+
"message_id": data.get("id"),
|
|
579
|
+
"content": data.get("content", ""),
|
|
580
|
+
})
|
|
581
|
+
for fn in self._message_handlers:
|
|
582
|
+
await self._run(ctx, lambda fn=fn: fn(ctx))
|
|
583
|
+
|
|
584
|
+
async def _run(self, ctx: Context, thunk):
|
|
585
|
+
"""Run a handler and turn whatever it returns into output.
|
|
586
|
+
|
|
587
|
+
Returning a string sends a message. Returning a card sends a card. On
|
|
588
|
+
an action, returning either edits the clicked message instead — which
|
|
589
|
+
is nearly always what you meant.
|
|
590
|
+
"""
|
|
591
|
+
try:
|
|
592
|
+
result = thunk()
|
|
593
|
+
if inspect.isawaitable(result):
|
|
594
|
+
result = await result
|
|
595
|
+
if result is None:
|
|
596
|
+
return
|
|
597
|
+
if ctx.is_action:
|
|
598
|
+
await ctx.update(result)
|
|
599
|
+
else:
|
|
600
|
+
await ctx.send(result)
|
|
601
|
+
except CommandError as e:
|
|
602
|
+
await ctx.private(str(e))
|
|
603
|
+
except Exception as e:
|
|
604
|
+
traceback.print_exc()
|
|
605
|
+
await ctx.private(f"that broke: {type(e).__name__}: {e}"[:180])
|
|
606
|
+
|
|
607
|
+
# -- lifecycle --
|
|
608
|
+
|
|
609
|
+
async def start(self):
|
|
610
|
+
try:
|
|
611
|
+
await self.sio.connect(
|
|
612
|
+
self.base,
|
|
613
|
+
headers={"Authorization": f"Bearer {self.token}"},
|
|
614
|
+
transports=["websocket"],
|
|
615
|
+
wait_timeout=15,
|
|
616
|
+
)
|
|
617
|
+
except socketio.exceptions.ConnectionError:
|
|
618
|
+
# The server refuses the handshake outright for a bad token —
|
|
619
|
+
# turn the library's shrug into the actual likely cause.
|
|
620
|
+
raise SystemExit(
|
|
621
|
+
f"couldn't connect to {self.base} — if it's up, the token is"
|
|
622
|
+
" wrong or was rotated. check YARNO_TOKEN against the console"
|
|
623
|
+
" at https://dev.we.yarno.lol"
|
|
624
|
+
)
|
|
625
|
+
await self.sio.wait()
|
|
626
|
+
|
|
627
|
+
def run(self):
|
|
628
|
+
try:
|
|
629
|
+
asyncio.run(self.start())
|
|
630
|
+
except KeyboardInterrupt:
|
|
631
|
+
pass
|
|
632
|
+
|
|
633
|
+
|
|
634
|
+
def mention(user) -> str:
|
|
635
|
+
"""Ping someone from a message: `f"{mention(ctx.user)} it's ready"`.
|
|
636
|
+
|
|
637
|
+
Takes a user dict or a raw id. Ids rather than names, because you already
|
|
638
|
+
have the id and it survives a rename — the server expands it to the
|
|
639
|
+
person's current @name when the message lands, and only if they can
|
|
640
|
+
actually see the channel.
|
|
641
|
+
|
|
642
|
+
Pings live in plain message content, not in cards. If you want to get
|
|
643
|
+
someone's attention, say it in words.
|
|
644
|
+
"""
|
|
645
|
+
uid = user.get("id") if isinstance(user, dict) else user
|
|
646
|
+
return f"<@{int(uid)}>"
|
|
647
|
+
|
|
648
|
+
|
|
649
|
+
def _split_body(body, card):
|
|
650
|
+
"""Accept send('text'), send(card=...), or send(ui.card(...)) — one
|
|
651
|
+
argument that does the obvious thing with whatever you hand it."""
|
|
652
|
+
if body is None:
|
|
653
|
+
return None, card
|
|
654
|
+
if isinstance(body, dict):
|
|
655
|
+
if card is not None:
|
|
656
|
+
raise ValueError("pass a card positionally or as card=, not both")
|
|
657
|
+
return None, body
|
|
658
|
+
return str(body), card
|
yarno/ui.py
ADDED
|
@@ -0,0 +1,278 @@
|
|
|
1
|
+
"""Card components.
|
|
2
|
+
|
|
3
|
+
Every function here returns a plain dict, so a card is just data — printable,
|
|
4
|
+
diffable, testable, and storable. There's no widget object holding a callback
|
|
5
|
+
in memory, which is exactly why a yarno bot's buttons don't die when it
|
|
6
|
+
restarts.
|
|
7
|
+
|
|
8
|
+
Falsy children are dropped, so conditionals go inline:
|
|
9
|
+
|
|
10
|
+
ui.col(
|
|
11
|
+
ui.title(name),
|
|
12
|
+
ui.bar(indeterminate=True) if loading else None,
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
Lists are flattened, so comprehensions do too:
|
|
16
|
+
|
|
17
|
+
ui.col([ui.text(t) for t in tracks])
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _kids(children):
|
|
22
|
+
out = []
|
|
23
|
+
for child in children:
|
|
24
|
+
if child is None or child is False:
|
|
25
|
+
continue
|
|
26
|
+
if isinstance(child, (list, tuple)):
|
|
27
|
+
out.extend(_kids(child))
|
|
28
|
+
else:
|
|
29
|
+
out.append(child)
|
|
30
|
+
return out
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _container(kind, children, align=None, grow=False, **extra):
|
|
34
|
+
node = {"type": kind, "children": _kids(children)}
|
|
35
|
+
if align:
|
|
36
|
+
node["align"] = align
|
|
37
|
+
if grow:
|
|
38
|
+
node["grow"] = True
|
|
39
|
+
node.update({k: v for k, v in extra.items() if v})
|
|
40
|
+
return node
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def card(*children, accent=None):
|
|
44
|
+
"""The outer surface. Everything a bot draws lives in one of these."""
|
|
45
|
+
return _container("card", children, accent=accent)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def row(*children, align=None, grow=False):
|
|
49
|
+
"""Left-to-right. `align` is start / center / end / between."""
|
|
50
|
+
return _container("row", children, align, grow)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def col(*children, align=None, grow=False):
|
|
54
|
+
"""Top-to-bottom."""
|
|
55
|
+
return _container("col", children, align, grow)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def text(value, *, style="body", href=None, scroll=False, grow=False):
|
|
59
|
+
node = {"type": "text", "value": str(value), "style": style}
|
|
60
|
+
if href:
|
|
61
|
+
node["href"] = href
|
|
62
|
+
if scroll:
|
|
63
|
+
# Marquee long values instead of truncating them.
|
|
64
|
+
node["scroll"] = True
|
|
65
|
+
if grow:
|
|
66
|
+
node["grow"] = True
|
|
67
|
+
return node
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def title(value, **kw):
|
|
71
|
+
return text(value, style="title", **kw)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def dim(value, **kw):
|
|
75
|
+
return text(value, style="dim", **kw)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def mono(value, **kw):
|
|
79
|
+
return text(value, style="mono", **kw)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def image(src, *, size="thumb", alt=""):
|
|
83
|
+
"""size: thumb (square, inline) / banner (wide strip) / full."""
|
|
84
|
+
return {"type": "image", "src": src, "size": size, "alt": alt}
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def bar(value=None, *, indeterminate=False, label=None, rate=None):
|
|
88
|
+
"""A progress bar. `value` is 0..1; pass indeterminate=True when you don't
|
|
89
|
+
know how long it'll take. Discord has no equivalent — people fake it with
|
|
90
|
+
block characters in an embed description.
|
|
91
|
+
|
|
92
|
+
With `rate` (units/sec) the bar advances client-side from `value` — a
|
|
93
|
+
playhead is bar(value=elapsed/length, rate=1/length), edited once per
|
|
94
|
+
track instead of once per second."""
|
|
95
|
+
node = {"type": "bar"}
|
|
96
|
+
if indeterminate or value is None:
|
|
97
|
+
node["indeterminate"] = True
|
|
98
|
+
else:
|
|
99
|
+
node["value"] = value
|
|
100
|
+
if rate is not None:
|
|
101
|
+
node["rate"] = rate
|
|
102
|
+
if label:
|
|
103
|
+
node["label"] = label
|
|
104
|
+
return node
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def ticker(value, *, rate=1, format="clock", prefix=None, suffix=None,
|
|
108
|
+
style="dim", min=None, max=None):
|
|
109
|
+
"""A number that advances by itself: elapsed time, a countdown, anything
|
|
110
|
+
with a steady rate. You push state changes; every viewer's client renders
|
|
111
|
+
the frames. `format` is clock (1:23), number, or percent. A countdown is
|
|
112
|
+
ticker(seconds_left, rate=-1, min=0)."""
|
|
113
|
+
node = {"type": "ticker", "value": value, "rate": rate, "format": format,
|
|
114
|
+
"style": style}
|
|
115
|
+
if prefix:
|
|
116
|
+
node["prefix"] = prefix
|
|
117
|
+
if suffix:
|
|
118
|
+
node["suffix"] = suffix
|
|
119
|
+
if min is not None:
|
|
120
|
+
node["min"] = min
|
|
121
|
+
if max is not None:
|
|
122
|
+
node["max"] = max
|
|
123
|
+
return node
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def button(on=None, label=None, *, icon=None, style=None, arg=None,
|
|
127
|
+
disabled=False, collect=None):
|
|
128
|
+
"""`on` names the @bot.on handler this click runs. `arg` is a small scalar
|
|
129
|
+
that rides along — anything bigger belongs in the message's store, which
|
|
130
|
+
the server keeps for you.
|
|
131
|
+
|
|
132
|
+
`collect` makes it a counted button: the SERVER records who's pressed it
|
|
133
|
+
(toggling), so it keeps working while your bot is offline. Keys shaped
|
|
134
|
+
`group:key` are exclusive per person within the group — that's a poll.
|
|
135
|
+
Read the counts with ui.tally(key) in the card, or bot.tallies(id)."""
|
|
136
|
+
node = {"type": "button"}
|
|
137
|
+
if on:
|
|
138
|
+
node["on"] = on
|
|
139
|
+
if collect:
|
|
140
|
+
node["collect"] = collect
|
|
141
|
+
if label:
|
|
142
|
+
node["label"] = label
|
|
143
|
+
if icon:
|
|
144
|
+
node["icon"] = icon
|
|
145
|
+
if style:
|
|
146
|
+
node["style"] = style
|
|
147
|
+
if arg is not None:
|
|
148
|
+
node["arg"] = arg
|
|
149
|
+
if disabled:
|
|
150
|
+
node["disabled"] = True
|
|
151
|
+
return node
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def tally(key, label=None):
|
|
155
|
+
"""The live count behind a collect key. Renders as a chip; updates for
|
|
156
|
+
everyone the moment anyone presses the matching collect button."""
|
|
157
|
+
node = {"type": "tally", "key": key}
|
|
158
|
+
if label:
|
|
159
|
+
node["label"] = label
|
|
160
|
+
return node
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def only(*children, user=None, perm=None):
|
|
164
|
+
"""Wrap components so only some viewers receive them — stripped
|
|
165
|
+
server-side for everyone else, not hidden client-side. `user` is a user
|
|
166
|
+
id; `perm` is a permission name checked in the card's server.
|
|
167
|
+
|
|
168
|
+
ui.only(ui.button("reveal", "your hand"), user=ctx.user["id"])
|
|
169
|
+
"""
|
|
170
|
+
node = _container("col", children)
|
|
171
|
+
if user is not None:
|
|
172
|
+
node["for_user"] = user
|
|
173
|
+
if perm:
|
|
174
|
+
node["for_perm"] = perm
|
|
175
|
+
return node
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def kv(k, v):
|
|
179
|
+
"""A labelled value — the one genuinely useful thing embed fields gave us."""
|
|
180
|
+
return {"type": "kv", "k": str(k), "v": str(v)}
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
# --- form fields --------------------------------------------------------------
|
|
184
|
+
#
|
|
185
|
+
# A card is also a form. Give a field a name and its current value arrives in
|
|
186
|
+
# ctx.values on EVERY action from that card — so one submit button reads the
|
|
187
|
+
# whole form, no modal required. A field with on= additionally submits itself:
|
|
188
|
+
# inputs on Enter, selects on pick, toggles on flip, sliders on release.
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def input(name, *, placeholder=None, value=None, on=None, grow=False):
|
|
192
|
+
"""An inline text field. This is the thing Discord needs a modal for."""
|
|
193
|
+
node = {"type": "input", "name": name}
|
|
194
|
+
if placeholder:
|
|
195
|
+
node["placeholder"] = placeholder
|
|
196
|
+
if value is not None:
|
|
197
|
+
node["value"] = str(value)
|
|
198
|
+
if on:
|
|
199
|
+
node["on"] = on
|
|
200
|
+
if grow:
|
|
201
|
+
node["grow"] = True
|
|
202
|
+
return node
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def select(name, options, *, value=None, placeholder=None, on=None, grow=False):
|
|
206
|
+
"""A dropdown. `options` can be plain strings, (value, label) pairs, or
|
|
207
|
+
{"value", "label"} dicts."""
|
|
208
|
+
opts = []
|
|
209
|
+
for opt in options:
|
|
210
|
+
if isinstance(opt, dict):
|
|
211
|
+
opts.append({"value": str(opt.get("value")),
|
|
212
|
+
"label": str(opt.get("label") or opt.get("value"))})
|
|
213
|
+
elif isinstance(opt, (list, tuple)) and len(opt) == 2:
|
|
214
|
+
opts.append({"value": str(opt[0]), "label": str(opt[1])})
|
|
215
|
+
else:
|
|
216
|
+
opts.append({"value": str(opt), "label": str(opt)})
|
|
217
|
+
node = {"type": "select", "name": name, "options": opts}
|
|
218
|
+
if value is not None:
|
|
219
|
+
node["value"] = str(value)
|
|
220
|
+
if placeholder:
|
|
221
|
+
node["placeholder"] = placeholder
|
|
222
|
+
if on:
|
|
223
|
+
node["on"] = on
|
|
224
|
+
if grow:
|
|
225
|
+
node["grow"] = True
|
|
226
|
+
return node
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def toggle(name, *, value=False, label=None, on=None):
|
|
230
|
+
"""An on/off switch."""
|
|
231
|
+
node = {"type": "toggle", "name": name}
|
|
232
|
+
if value:
|
|
233
|
+
node["value"] = True
|
|
234
|
+
if label:
|
|
235
|
+
node["label"] = label
|
|
236
|
+
if on:
|
|
237
|
+
node["on"] = on
|
|
238
|
+
return node
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
def slider(name, *, min=0, max=1, step=None, value=None, label=None, on=None,
|
|
242
|
+
grow=False):
|
|
243
|
+
"""A drag control. With on= it submits when released, so a volume slider
|
|
244
|
+
is one line: ui.slider("vol", max=100, value=80, on="set_volume")."""
|
|
245
|
+
node = {"type": "slider", "name": name, "min": min, "max": max}
|
|
246
|
+
if step is not None:
|
|
247
|
+
node["step"] = step
|
|
248
|
+
if value is not None:
|
|
249
|
+
node["value"] = value
|
|
250
|
+
if label:
|
|
251
|
+
node["label"] = label
|
|
252
|
+
if on:
|
|
253
|
+
node["on"] = on
|
|
254
|
+
if grow:
|
|
255
|
+
node["grow"] = True
|
|
256
|
+
return node
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
def section(label, *children, open=False, name=None):
|
|
260
|
+
"""A collapsible group — long tails (queues, logs, extra settings) fold
|
|
261
|
+
away instead of making the card scroll. Viewers' open/closed choices
|
|
262
|
+
survive your re-renders."""
|
|
263
|
+
node = _container("section", children)
|
|
264
|
+
node["label"] = str(label)
|
|
265
|
+
if open:
|
|
266
|
+
node["open"] = True
|
|
267
|
+
if name:
|
|
268
|
+
node["name"] = name
|
|
269
|
+
return node
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
def divider():
|
|
273
|
+
return {"type": "divider"}
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
def spacer():
|
|
277
|
+
"""Eats the free space in a row — put one before trailing buttons."""
|
|
278
|
+
return {"type": "spacer"}
|
yarno/voice.py
ADDED
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
"""Voice for bots.
|
|
2
|
+
|
|
3
|
+
Optional extra: `pip install 'yarno[voice]'` (pulls the livekit sdk) and
|
|
4
|
+
ffmpeg on PATH. The bot itself needs `can_voice`, which the instance
|
|
5
|
+
operator grants — publishing audio is the one genuinely privileged thing a
|
|
6
|
+
bot can do.
|
|
7
|
+
|
|
8
|
+
session = await bot.voice_join(channel_id)
|
|
9
|
+
await session.play("song.mp3") # blocks until the track ends
|
|
10
|
+
await session.leave()
|
|
11
|
+
|
|
12
|
+
play() decodes through ffmpeg, so anything ffmpeg reads works: files, urls,
|
|
13
|
+
streams. Call stop() from another task to cut a running play() short.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
import asyncio
|
|
17
|
+
import os
|
|
18
|
+
|
|
19
|
+
SAMPLE_RATE = 48000
|
|
20
|
+
CHANNELS = 2
|
|
21
|
+
FRAME = 480 # samples per 10ms frame
|
|
22
|
+
_FRAME_BYTES = FRAME * CHANNELS * 2 # s16le
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _rtc():
|
|
26
|
+
try:
|
|
27
|
+
from livekit import rtc
|
|
28
|
+
except ImportError:
|
|
29
|
+
raise RuntimeError(
|
|
30
|
+
"voice needs the livekit sdk — pip install 'yarno[voice]'"
|
|
31
|
+
)
|
|
32
|
+
return rtc
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
async def join(bot, channel_id) -> "VoiceSession":
|
|
36
|
+
rtc = _rtc()
|
|
37
|
+
res = await bot._call("bot_voice_join", {"channel_id": channel_id})
|
|
38
|
+
room = rtc.Room()
|
|
39
|
+
await room.connect(res["url"], res["token"])
|
|
40
|
+
source = rtc.AudioSource(SAMPLE_RATE, CHANNELS)
|
|
41
|
+
track = rtc.LocalAudioTrack.create_audio_track("audio", source)
|
|
42
|
+
await room.local_participant.publish_track(
|
|
43
|
+
track, rtc.TrackPublishOptions(source=rtc.TrackSource.SOURCE_MICROPHONE)
|
|
44
|
+
)
|
|
45
|
+
return VoiceSession(bot, channel_id, room, source)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class VoiceSession:
|
|
49
|
+
def __init__(self, bot, channel_id, room, source):
|
|
50
|
+
self._bot = bot
|
|
51
|
+
self.channel_id = channel_id
|
|
52
|
+
self._room = room
|
|
53
|
+
self._source = source
|
|
54
|
+
self._proc = None
|
|
55
|
+
# Checked by the pump on every frame, set BEFORE any teardown starts.
|
|
56
|
+
# A session that's been left must not be resurrectable by a pump that
|
|
57
|
+
# was mid-await when it happened.
|
|
58
|
+
self._dead = False
|
|
59
|
+
|
|
60
|
+
@property
|
|
61
|
+
def playing(self) -> bool:
|
|
62
|
+
return self._proc is not None
|
|
63
|
+
|
|
64
|
+
async def play(self, source: str, *, volume: float = 1.0):
|
|
65
|
+
"""Decode `source` (anything ffmpeg accepts — a path, a url) and
|
|
66
|
+
stream it into the channel. Returns when the track ends or stop()
|
|
67
|
+
is called. One track at a time; a second play() cuts the first."""
|
|
68
|
+
rtc = _rtc()
|
|
69
|
+
await self.stop()
|
|
70
|
+
if self._dead:
|
|
71
|
+
raise RuntimeError("this voice session has been left")
|
|
72
|
+
args = ["-loglevel", "quiet"]
|
|
73
|
+
if not os.path.exists(source):
|
|
74
|
+
# Network sources appreciate a nudge to reconnect on hiccups.
|
|
75
|
+
args += ["-reconnect", "1", "-reconnect_streamed", "1"]
|
|
76
|
+
args += ["-i", source]
|
|
77
|
+
if volume != 1.0:
|
|
78
|
+
args += ["-filter:a", f"volume={max(0.0, min(4.0, volume))}"]
|
|
79
|
+
args += ["-f", "s16le", "-ar", str(SAMPLE_RATE), "-ac", str(CHANNELS), "-"]
|
|
80
|
+
self._proc = await asyncio.create_subprocess_exec(
|
|
81
|
+
"ffmpeg", *args, stdout=asyncio.subprocess.PIPE,
|
|
82
|
+
stderr=asyncio.subprocess.DEVNULL,
|
|
83
|
+
)
|
|
84
|
+
proc = self._proc
|
|
85
|
+
try:
|
|
86
|
+
while not self._dead and self._proc is proc:
|
|
87
|
+
try:
|
|
88
|
+
chunk = await proc.stdout.readexactly(_FRAME_BYTES)
|
|
89
|
+
except asyncio.IncompleteReadError:
|
|
90
|
+
break
|
|
91
|
+
frame = rtc.AudioFrame(
|
|
92
|
+
data=chunk, sample_rate=SAMPLE_RATE,
|
|
93
|
+
num_channels=CHANNELS, samples_per_channel=FRAME,
|
|
94
|
+
)
|
|
95
|
+
await self._source.capture_frame(frame)
|
|
96
|
+
finally:
|
|
97
|
+
if self._proc is proc:
|
|
98
|
+
self._proc = None
|
|
99
|
+
if proc.returncode is None:
|
|
100
|
+
proc.kill()
|
|
101
|
+
|
|
102
|
+
async def stop(self):
|
|
103
|
+
"""Cut whatever is playing. The play() call that owned it returns."""
|
|
104
|
+
proc, self._proc = self._proc, None
|
|
105
|
+
if proc and proc.returncode is None:
|
|
106
|
+
proc.kill()
|
|
107
|
+
|
|
108
|
+
async def leave(self):
|
|
109
|
+
"""Stop, disconnect from the room, release the voice slot."""
|
|
110
|
+
self._dead = True # before any await — nothing may resurrect us
|
|
111
|
+
await self.stop()
|
|
112
|
+
try:
|
|
113
|
+
await self._room.disconnect()
|
|
114
|
+
finally:
|
|
115
|
+
try:
|
|
116
|
+
await self._bot._call("bot_voice_leave", {})
|
|
117
|
+
except Exception:
|
|
118
|
+
pass # the server reaps on socket disconnect anyway
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: yarno
|
|
3
|
+
Version: 0.2.0
|
|
4
|
+
Summary: write bots for we.yarno — cards, inline forms, server-counted polls, live values, voice
|
|
5
|
+
Author: alfa
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: docs, https://dev.we.yarno.lol/docs
|
|
8
|
+
Project-URL: source, https://github.com/alfaoz/yarno-sdk
|
|
9
|
+
Keywords: yarno,bot,chat,socketio
|
|
10
|
+
Classifier: Development Status :: 4 - Beta
|
|
11
|
+
Classifier: Intended Audience :: Developers
|
|
12
|
+
Classifier: Programming Language :: Python :: 3
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
17
|
+
Classifier: Topic :: Communications :: Chat
|
|
18
|
+
Classifier: Framework :: AsyncIO
|
|
19
|
+
Requires-Python: >=3.10
|
|
20
|
+
Description-Content-Type: text/markdown
|
|
21
|
+
License-File: LICENSE
|
|
22
|
+
Requires-Dist: python-socketio[asyncio_client]>=5.11
|
|
23
|
+
Requires-Dist: aiohttp>=3.9
|
|
24
|
+
Provides-Extra: voice
|
|
25
|
+
Requires-Dist: livekit>=0.17; extra == "voice"
|
|
26
|
+
Dynamic: license-file
|
|
27
|
+
|
|
28
|
+
# yarno
|
|
29
|
+
|
|
30
|
+
write bots for [we.yarno](https://we.yarno.lol) — the invite-only
|
|
31
|
+
servers/channels/voice app.
|
|
32
|
+
|
|
33
|
+
```bash
|
|
34
|
+
pip install yarno
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
the shortest complete bot:
|
|
38
|
+
|
|
39
|
+
```python
|
|
40
|
+
from yarno import Bot
|
|
41
|
+
|
|
42
|
+
bot = Bot() # reads $YARNO_TOKEN
|
|
43
|
+
|
|
44
|
+
@bot.command("hi", "says hi")
|
|
45
|
+
async def hi(ctx):
|
|
46
|
+
return f"hi {ctx.username}"
|
|
47
|
+
|
|
48
|
+
bot.run()
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
no intents, no command tree, no `sync()` step, no 3-second deadline. your bot
|
|
52
|
+
holds a live socket to the instance, so the decorator *is* the registration
|
|
53
|
+
and a handler can take as long as it needs.
|
|
54
|
+
|
|
55
|
+
## what you get
|
|
56
|
+
|
|
57
|
+
- **cards** — compose rows, columns, progress bars, images, buttons instead
|
|
58
|
+
of filling in an embed
|
|
59
|
+
- **inline forms** — `ui.input` / `ui.select` / `ui.toggle` / `ui.slider`
|
|
60
|
+
live in the card, in the channel; any click submits the whole form as
|
|
61
|
+
`ctx.values`. no modals.
|
|
62
|
+
- **polls that outlive you** — `ui.button(collect="v:yes")` is counted by the
|
|
63
|
+
server, so votes land while your process is down; read them back with
|
|
64
|
+
`bot.tallies()`
|
|
65
|
+
- **live values** — `ui.ticker` / `ui.bar(rate=...)` advance on every
|
|
66
|
+
viewer's screen; you push state changes, not frames
|
|
67
|
+
- **docked cards** — pin one card above the channel's scroll (now playing,
|
|
68
|
+
scoreboards, countdowns)
|
|
69
|
+
- **autocomplete** — suggest argument values as someone types, over your
|
|
70
|
+
already-open socket
|
|
71
|
+
- **dms, files, reactions, avatars, voice** — `@bot.on_message`,
|
|
72
|
+
`send(files=[...])`, `bot.react()`, `bot.set_avatar()`, and
|
|
73
|
+
`bot.voice_join()` with the `yarno[voice]` extra
|
|
74
|
+
- **buttons that survive restarts** — per-message state lives on the server
|
|
75
|
+
and rides back with every click
|
|
76
|
+
|
|
77
|
+
## privacy, stated up front
|
|
78
|
+
|
|
79
|
+
a bot sees nothing by default: commands aimed at it, clicks on its own
|
|
80
|
+
cards, and dms it's part of. it does **not** receive channel messages, and
|
|
81
|
+
there is no intent to flip on. the read apis hold the same line.
|
|
82
|
+
|
|
83
|
+
## docs
|
|
84
|
+
|
|
85
|
+
- [quickstart](https://dev.we.yarno.lol/docs/quickstart) — token to working
|
|
86
|
+
command in about two minutes
|
|
87
|
+
- [cards & components](https://dev.we.yarno.lol/docs/cards)
|
|
88
|
+
- [the full sdk reference](https://dev.we.yarno.lol/docs/sdk)
|
|
89
|
+
- [wire protocol](https://dev.we.yarno.lol/docs/protocol) — for writing a
|
|
90
|
+
bot in another language
|
|
91
|
+
|
|
92
|
+
examples live in [`examples/`](examples/): `pingpong.py` is the core loop,
|
|
93
|
+
`lunchbot.py` is forms + polls + autocomplete + a docked countdown.
|
|
94
|
+
|
|
95
|
+
tokens come from the [dev console](https://dev.we.yarno.lol). we.yarno is
|
|
96
|
+
invite-only; the sdk is public so bots can be written for it.
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
yarno/__init__.py,sha256=8xBK4wrFf_dDkU3YMZxENXDbTaU-FY909EnDlilgyZI,448
|
|
2
|
+
yarno/bot.py,sha256=JIviaIrfIlA7AStxtcu2UrwsUlr9MIdPaIPABSvGw2Q,25086
|
|
3
|
+
yarno/ui.py,sha256=WZ1fe53ikUCpCLEgnz_KIu4Sdipj4s70E49T2r23mTw,8668
|
|
4
|
+
yarno/voice.py,sha256=sMzb5NI4D6Ht6REgfyjPVOqgFL_luSRG6FcAIe8oXyY,4309
|
|
5
|
+
yarno-0.2.0.dist-info/licenses/LICENSE,sha256=pTQEeW2P7zO0bpe01HFTkRzRnUF7bEl7-RSorPU9mus,1061
|
|
6
|
+
yarno-0.2.0.dist-info/METADATA,sha256=17r_cJrgf_-R5shCdL18boAPURN6JNVMeJpEtLTVLlc,3496
|
|
7
|
+
yarno-0.2.0.dist-info/WHEEL,sha256=K260EYznzXsJYBQGqmI8VTxEdiZYNvDZwW9cBh9-_MA,91
|
|
8
|
+
yarno-0.2.0.dist-info/top_level.txt,sha256=FoTXx9xAFAipSPpBJws4zk2jwgEyfUhFVeQHvhIgCsM,6
|
|
9
|
+
yarno-0.2.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 alfa
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
yarno
|