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 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,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (83.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -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