Added emoji wall module to provide OBS overlay for floating chat emojis and emotes.
CI / Formatting (push) Successful in 5s
CI / Linting (push) Successful in 9s
CI / Tests (Python 3.12) (push) Successful in 38s
CI / Tests (Python 3.13) (push) Successful in 22s
CI / Tests (Python 3.14) (push) Successful in 18s
CI / Type Checking (push) Successful in 10s
CI / Spelling (push) Successful in 5s
CI / Formatting (push) Successful in 5s
CI / Linting (push) Successful in 9s
CI / Tests (Python 3.12) (push) Successful in 38s
CI / Tests (Python 3.13) (push) Successful in 22s
CI / Tests (Python 3.14) (push) Successful in 18s
CI / Type Checking (push) Successful in 10s
CI / Spelling (push) Successful in 5s
This commit is contained in:
@@ -81,6 +81,28 @@ modules:
|
|||||||
# Maximum nesting depth for placeholders in command responses.
|
# Maximum nesting depth for placeholders in command responses.
|
||||||
#max_nesting_depth: 4
|
#max_nesting_depth: 4
|
||||||
|
|
||||||
|
emoji_wall:
|
||||||
|
enabled: true
|
||||||
|
|
||||||
|
# Minimum emoji size in pixels.
|
||||||
|
#min_size: 20
|
||||||
|
# Maximum emoji size in pixels.
|
||||||
|
#max_size: 60
|
||||||
|
# Minimum animation duration in seconds.
|
||||||
|
#min_duration: 3
|
||||||
|
# Maximum animation duration in seconds.
|
||||||
|
#max_duration: 10
|
||||||
|
# Maximum number of emojis on screen at once.
|
||||||
|
#max_count: 50
|
||||||
|
# Direction emojis travel. Named presets: up, up-right, right,
|
||||||
|
# down-right, down, down-left, left, up-left, random.
|
||||||
|
# Also accepts a degree value (0-359, where 0 is up, clockwise).
|
||||||
|
#direction: "up"
|
||||||
|
# Maximum rotation angle in degrees. Each emoji gets a random
|
||||||
|
# rotation between -max_rotation and +max_rotation. Set to 0 to
|
||||||
|
# disable rotation.
|
||||||
|
#max_rotation: 25
|
||||||
|
|
||||||
quotes:
|
quotes:
|
||||||
enabled: true
|
enabled: true
|
||||||
|
|
||||||
|
|||||||
+1
-1
Submodule docs updated: 3c26a82898...2f7c73138a
@@ -18,6 +18,7 @@ BUILTIN_MODULE_NAMES: frozenset[str] = frozenset(
|
|||||||
{
|
{
|
||||||
"clips",
|
"clips",
|
||||||
"custom_commands",
|
"custom_commands",
|
||||||
|
"emoji_wall",
|
||||||
"quotes",
|
"quotes",
|
||||||
"timers",
|
"timers",
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,395 @@
|
|||||||
|
# Copyright 2026 Logan Fick
|
||||||
|
#
|
||||||
|
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
# you may not use this file except in compliance with the License.
|
||||||
|
# You may obtain a copy of the License at
|
||||||
|
#
|
||||||
|
# http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
#
|
||||||
|
# Unless required by applicable law or agreed to in writing, software
|
||||||
|
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
# See the License for the specific language governing permissions and
|
||||||
|
# limitations under the License.
|
||||||
|
|
||||||
|
"""Emoji wall module for Owlbot.
|
||||||
|
|
||||||
|
Listens to chat events, extracts Unicode emojis and custom Owncast emotes,
|
||||||
|
and broadcasts them via Server-Sent Events (SSE) to connected browser sources
|
||||||
|
for rendering as floating animations in an OBS overlay.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import contextlib
|
||||||
|
import json
|
||||||
|
import secrets
|
||||||
|
from html.parser import HTMLParser
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
|
import emoji
|
||||||
|
from aiohttp import web
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from owlbot.api import (
|
||||||
|
ChatEvent,
|
||||||
|
EventContext,
|
||||||
|
EventType,
|
||||||
|
ModuleContext,
|
||||||
|
RouteContext,
|
||||||
|
on_event,
|
||||||
|
on_route,
|
||||||
|
on_setup,
|
||||||
|
on_teardown,
|
||||||
|
)
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"events_stream",
|
||||||
|
"on_chat",
|
||||||
|
"setup",
|
||||||
|
"static_wall_js",
|
||||||
|
"teardown",
|
||||||
|
"wall_page",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
class _EmoteParser(HTMLParser):
|
||||||
|
"""HTML parser that extracts ``src`` from ``<img>`` tags with an emoji class."""
|
||||||
|
|
||||||
|
def __init__(self) -> None:
|
||||||
|
super().__init__()
|
||||||
|
self.urls: list[str] = []
|
||||||
|
|
||||||
|
def handle_starttag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None:
|
||||||
|
"""Collect src from img tags whose class is exactly ``emoji``."""
|
||||||
|
if tag != "img":
|
||||||
|
return
|
||||||
|
attr_dict = dict(attrs)
|
||||||
|
src = attr_dict.get("src")
|
||||||
|
if attr_dict.get("class") == "emoji" and src:
|
||||||
|
self.urls.append(src)
|
||||||
|
|
||||||
|
|
||||||
|
_WALL_JS_PATH: Path = Path(__file__).resolve().parent / "static" / "wall.js"
|
||||||
|
|
||||||
|
_DIRECTION_PRESETS: dict[str, int] = {
|
||||||
|
"up": 0,
|
||||||
|
"up-right": 45,
|
||||||
|
"right": 90,
|
||||||
|
"down-right": 135,
|
||||||
|
"down": 180,
|
||||||
|
"down-left": 225,
|
||||||
|
"left": 270,
|
||||||
|
"up-left": 315,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_direction(value: str | int) -> int | str:
|
||||||
|
"""Resolve a direction value to a degree integer or ``"random"``.
|
||||||
|
|
||||||
|
Accepts named presets (case-insensitive), the string ``"random"``,
|
||||||
|
or a numeric degree value (0-359). Invalid values fall back to 0.
|
||||||
|
|
||||||
|
:param value: Direction preset name, ``"random"``, or degree value.
|
||||||
|
:return: Integer degrees (0-359) or the string ``"random"``.
|
||||||
|
"""
|
||||||
|
if isinstance(value, str):
|
||||||
|
normalized = value.strip().lower()
|
||||||
|
if normalized == "random":
|
||||||
|
return "random"
|
||||||
|
if normalized in _DIRECTION_PRESETS:
|
||||||
|
return _DIRECTION_PRESETS[normalized]
|
||||||
|
try:
|
||||||
|
value = int(normalized)
|
||||||
|
except ValueError:
|
||||||
|
return 0
|
||||||
|
return max(0, min(359, int(value)))
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_settings(
|
||||||
|
config: dict[str, object],
|
||||||
|
query: dict[str, str],
|
||||||
|
) -> dict[str, int | str]:
|
||||||
|
"""Build a validated settings dict from config values and query overrides.
|
||||||
|
|
||||||
|
For each known setting key, the query parameter value (if present and
|
||||||
|
parseable) takes precedence over the config value. All numeric values
|
||||||
|
are clamped to their allowed ranges. Min/max pairs are swapped if
|
||||||
|
inverted.
|
||||||
|
|
||||||
|
:param config: Module config dict from ``ctx.config.as_dict()``.
|
||||||
|
:param query: Query parameters from the HTTP request.
|
||||||
|
:return: Validated settings dict ready for template injection.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def _int(key: str) -> int:
|
||||||
|
if key in query:
|
||||||
|
try:
|
||||||
|
return int(query[key])
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
pass
|
||||||
|
try:
|
||||||
|
return int(str(config[key]))
|
||||||
|
except (ValueError, TypeError, KeyError):
|
||||||
|
return 0
|
||||||
|
|
||||||
|
min_size = max(1, min(500, _int("min_size")))
|
||||||
|
max_size = max(1, min(500, _int("max_size")))
|
||||||
|
if min_size > max_size:
|
||||||
|
min_size, max_size = max_size, min_size
|
||||||
|
|
||||||
|
min_duration = max(1, min(60, _int("min_duration")))
|
||||||
|
max_duration = max(1, min(60, _int("max_duration")))
|
||||||
|
if min_duration > max_duration:
|
||||||
|
min_duration, max_duration = max_duration, min_duration
|
||||||
|
|
||||||
|
raw_direction = query.get("direction") or str(config.get("direction", "up"))
|
||||||
|
|
||||||
|
return {
|
||||||
|
"min_size": min_size,
|
||||||
|
"max_size": max_size,
|
||||||
|
"min_duration": min_duration,
|
||||||
|
"max_duration": max_duration,
|
||||||
|
"max_count": max(1, min(1000, _int("max_count"))),
|
||||||
|
"direction": _resolve_direction(raw_direction),
|
||||||
|
"max_rotation": max(0, min(360, _int("max_rotation"))),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_emojis(
|
||||||
|
raw_body: str, html_body: str, owncast_url: str
|
||||||
|
) -> list[dict[str, str]]:
|
||||||
|
"""Extract Unicode emojis and custom Owncast emotes from a chat message.
|
||||||
|
|
||||||
|
Unicode emojis are detected from the raw body (original user input)
|
||||||
|
to avoid matching emojis inside HTML attributes. Custom emotes are
|
||||||
|
extracted from the rendered HTML body, which contains ``<img>`` tags
|
||||||
|
with a class of ``emoji``.
|
||||||
|
|
||||||
|
:param raw_body: The plain-text message body.
|
||||||
|
:param html_body: The rendered HTML message body from the chat event.
|
||||||
|
:param owncast_url: The base URL of the Owncast server.
|
||||||
|
:return: List of SSE event dicts ready for broadcast.
|
||||||
|
"""
|
||||||
|
events: list[dict[str, str]] = [
|
||||||
|
{"type": "unicode", "emoji": e}
|
||||||
|
for e in (item["emoji"] for item in emoji.emoji_list(raw_body))
|
||||||
|
]
|
||||||
|
parser = _EmoteParser()
|
||||||
|
parser.feed(html_body)
|
||||||
|
events.extend(
|
||||||
|
{"type": "emote", "url": f"{owncast_url}/{src.lstrip('/')}"}
|
||||||
|
for src in parser.urls
|
||||||
|
)
|
||||||
|
return events
|
||||||
|
|
||||||
|
|
||||||
|
async def _broadcast(
|
||||||
|
clients: set[web.StreamResponse],
|
||||||
|
events: list[dict[str, str]],
|
||||||
|
logger: logging.Logger,
|
||||||
|
) -> None:
|
||||||
|
"""Broadcast SSE events to all connected clients.
|
||||||
|
|
||||||
|
Serializes each event as JSON, concatenates them into a single
|
||||||
|
payload, and writes it to every client in one call. Clients
|
||||||
|
that fail to receive the message are removed from the set.
|
||||||
|
|
||||||
|
:param clients: The set of connected SSE stream responses.
|
||||||
|
:param events: The event data dicts to broadcast.
|
||||||
|
:param logger: Logger instance from the module context.
|
||||||
|
"""
|
||||||
|
payload = "".join(f"data: {json.dumps(event)}\n\n" for event in events).encode()
|
||||||
|
snapshot = list(clients)
|
||||||
|
results = await asyncio.gather(
|
||||||
|
*(client.write(payload) for client in snapshot),
|
||||||
|
return_exceptions=True,
|
||||||
|
)
|
||||||
|
failed = 0
|
||||||
|
for client, result in zip(snapshot, results, strict=True):
|
||||||
|
if isinstance(result, Exception):
|
||||||
|
clients.discard(client)
|
||||||
|
failed += 1
|
||||||
|
if failed:
|
||||||
|
logger.debug("Removed %d failed client(s) during broadcast.", failed)
|
||||||
|
|
||||||
|
|
||||||
|
@on_setup
|
||||||
|
async def setup(ctx: ModuleContext) -> None:
|
||||||
|
"""Initialize the emoji wall module.
|
||||||
|
|
||||||
|
Registers config defaults, generates token if needed,
|
||||||
|
and creates the SSE client tracking set.
|
||||||
|
|
||||||
|
:param ctx: Module context with config, storage, and other services.
|
||||||
|
"""
|
||||||
|
ctx.config.register_defaults(
|
||||||
|
{
|
||||||
|
"min_size": 20,
|
||||||
|
"max_size": 60,
|
||||||
|
"min_duration": 3,
|
||||||
|
"max_duration": 10,
|
||||||
|
"max_count": 50,
|
||||||
|
"direction": "up",
|
||||||
|
"max_rotation": 25,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
ctx.state["clients"] = set[web.StreamResponse]()
|
||||||
|
|
||||||
|
token = ctx.config.get("token")
|
||||||
|
if not token:
|
||||||
|
token = secrets.token_urlsafe(32)
|
||||||
|
ctx.config.set("token", token)
|
||||||
|
ctx.logger.info("Generated emoji wall token and saved to config.")
|
||||||
|
|
||||||
|
ctx.state["token"] = token
|
||||||
|
overlay_url = ctx.routes.url_for(f"/{token}")
|
||||||
|
ctx.logger.info("Emoji wall overlay URL: %s", overlay_url)
|
||||||
|
|
||||||
|
|
||||||
|
@on_teardown
|
||||||
|
async def teardown(ctx: ModuleContext) -> None:
|
||||||
|
"""Clean up the emoji wall module.
|
||||||
|
|
||||||
|
Closes all connected SSE clients and clears the client set.
|
||||||
|
|
||||||
|
:param ctx: Module context.
|
||||||
|
"""
|
||||||
|
clients: set[web.StreamResponse] = ctx.state["clients"]
|
||||||
|
ctx.logger.debug("Closing %d SSE client(s).", len(clients))
|
||||||
|
for client in clients:
|
||||||
|
with contextlib.suppress(Exception):
|
||||||
|
await client.write_eof()
|
||||||
|
clients.clear()
|
||||||
|
ctx.logger.info("Emoji wall module cleaned up.")
|
||||||
|
|
||||||
|
|
||||||
|
@on_event(EventType.CHAT)
|
||||||
|
async def on_chat(ctx: EventContext[ChatEvent]) -> None:
|
||||||
|
"""Handle chat events by extracting emojis and broadcasting them via SSE.
|
||||||
|
|
||||||
|
Extracts both Unicode emojis and custom Owncast emotes from the chat
|
||||||
|
message and sends each one as an SSE event to all connected overlay
|
||||||
|
clients.
|
||||||
|
|
||||||
|
:param ctx: The event context containing the chat event.
|
||||||
|
"""
|
||||||
|
clients: set[web.StreamResponse] = ctx.module.state["clients"]
|
||||||
|
if not clients:
|
||||||
|
ctx.module.logger.debug(
|
||||||
|
"Skipping chat %s: no SSE clients connected.",
|
||||||
|
ctx.event.message_id,
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
owncast_url: str = ctx.module.owncast_client.base_url
|
||||||
|
events = _extract_emojis(ctx.event.raw_body, ctx.event.body, owncast_url)
|
||||||
|
|
||||||
|
if events:
|
||||||
|
ctx.module.logger.debug(
|
||||||
|
"Broadcasting %d emoji(s) from %s to %d client(s).",
|
||||||
|
len(events),
|
||||||
|
ctx.event.user.display_name,
|
||||||
|
len(clients),
|
||||||
|
)
|
||||||
|
await _broadcast(clients, events, ctx.module.logger)
|
||||||
|
|
||||||
|
|
||||||
|
@on_route("/{token}")
|
||||||
|
async def wall_page(ctx: RouteContext) -> web.Response:
|
||||||
|
"""Serve the emoji wall overlay page.
|
||||||
|
|
||||||
|
Validates the token and serves the overlay HTML. Invalid tokens return 401.
|
||||||
|
|
||||||
|
:param ctx: The route context.
|
||||||
|
:return: HTML response for the overlay page, or 401 if token is invalid.
|
||||||
|
"""
|
||||||
|
expected_token = ctx.module.state.get("token")
|
||||||
|
if not secrets.compare_digest(
|
||||||
|
ctx.match_info.get("token", ""), expected_token or ""
|
||||||
|
):
|
||||||
|
ctx.logger.debug("Wall page rejected: invalid token.")
|
||||||
|
return web.Response(
|
||||||
|
status=401,
|
||||||
|
text="Invalid token. Check the Owlbot log for the correct emoji wall URL.",
|
||||||
|
)
|
||||||
|
|
||||||
|
events_url = ctx.routes.url_for(f"/{expected_token}/events")
|
||||||
|
js_url = ctx.routes.url_for("/static/wall.js")
|
||||||
|
settings = _resolve_settings(ctx.config.as_dict(), dict(ctx.request.query))
|
||||||
|
|
||||||
|
ctx.logger.debug("Serving wall page with settings: %s.", settings)
|
||||||
|
page = ctx.templates.render(
|
||||||
|
"wall.html",
|
||||||
|
events_url=events_url,
|
||||||
|
js_url=js_url,
|
||||||
|
settings=settings,
|
||||||
|
)
|
||||||
|
return web.Response(text=page, content_type="text/html")
|
||||||
|
|
||||||
|
|
||||||
|
@on_route("/{token}/events", streaming=True)
|
||||||
|
async def events_stream(ctx: RouteContext) -> web.StreamResponse | web.Response:
|
||||||
|
"""Server-Sent Events endpoint for emoji wall updates.
|
||||||
|
|
||||||
|
Validates the token and establishes a persistent SSE connection.
|
||||||
|
Sends keepalive comments every 15 seconds to prevent proxy timeouts.
|
||||||
|
|
||||||
|
:param ctx: The route context.
|
||||||
|
:return: SSE stream response, or 401 if token is invalid.
|
||||||
|
"""
|
||||||
|
expected_token = ctx.module.state.get("token")
|
||||||
|
if not secrets.compare_digest(
|
||||||
|
ctx.match_info.get("token", ""), expected_token or ""
|
||||||
|
):
|
||||||
|
ctx.logger.debug("SSE stream rejected: invalid token.")
|
||||||
|
return web.Response(
|
||||||
|
status=401,
|
||||||
|
text="Invalid token. Check the Owlbot log for the correct emoji wall URL.",
|
||||||
|
)
|
||||||
|
|
||||||
|
response = web.StreamResponse(
|
||||||
|
status=200,
|
||||||
|
headers={
|
||||||
|
"Content-Type": "text/event-stream",
|
||||||
|
"Cache-Control": "no-cache",
|
||||||
|
"Connection": "keep-alive",
|
||||||
|
"X-Accel-Buffering": "no",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
await response.prepare(ctx.request)
|
||||||
|
|
||||||
|
clients: set[web.StreamResponse] = ctx.module.state["clients"]
|
||||||
|
clients.add(response)
|
||||||
|
ctx.logger.debug("SSE client connected (%d total).", len(clients))
|
||||||
|
|
||||||
|
try:
|
||||||
|
while True:
|
||||||
|
await asyncio.sleep(15)
|
||||||
|
try:
|
||||||
|
await response.write(b": keepalive\n\n")
|
||||||
|
except Exception:
|
||||||
|
ctx.logger.debug("SSE keepalive failed, disconnecting client.")
|
||||||
|
break
|
||||||
|
finally:
|
||||||
|
clients.discard(response)
|
||||||
|
ctx.logger.debug("SSE client disconnected (%d remaining).", len(clients))
|
||||||
|
|
||||||
|
return response
|
||||||
|
|
||||||
|
|
||||||
|
@on_route("/static/wall.js", methods=["GET"])
|
||||||
|
async def static_wall_js(ctx: RouteContext) -> web.FileResponse:
|
||||||
|
"""Serve the emoji wall JavaScript file.
|
||||||
|
|
||||||
|
:param ctx: The route context.
|
||||||
|
:return: The wall.js file with caching headers.
|
||||||
|
"""
|
||||||
|
return web.FileResponse(
|
||||||
|
_WALL_JS_PATH,
|
||||||
|
headers={"Cache-Control": "max-age=86400"},
|
||||||
|
)
|
||||||
@@ -0,0 +1,157 @@
|
|||||||
|
(function () {
|
||||||
|
"use strict";
|
||||||
|
|
||||||
|
const easings = ["ease", "ease-in", "ease-out", "ease-in-out", "linear"];
|
||||||
|
const activeEmojis = [];
|
||||||
|
const DEG_TO_RAD = Math.PI / 180;
|
||||||
|
|
||||||
|
function randomBetween(min, max) {
|
||||||
|
return Math.random() * (max - min) + min;
|
||||||
|
}
|
||||||
|
|
||||||
|
function computeTrajectory(angleDeg) {
|
||||||
|
// 0 = up, 90 = right, 180 = down, 270 = left (clockwise).
|
||||||
|
const rad = angleDeg * DEG_TO_RAD;
|
||||||
|
const dx = Math.sin(rad);
|
||||||
|
const dy = -Math.cos(rad);
|
||||||
|
|
||||||
|
// Starting position: opposite edge, randomized along that edge.
|
||||||
|
// Ending position: off-screen in travel direction.
|
||||||
|
// Units are in vw/vh for viewport-relative positioning.
|
||||||
|
const travel = 120;
|
||||||
|
let startX, startY;
|
||||||
|
const absDx = Math.abs(dx);
|
||||||
|
const absDy = Math.abs(dy);
|
||||||
|
|
||||||
|
if (absDy >= absDx) {
|
||||||
|
// Primarily vertical movement.
|
||||||
|
startX = Math.random() * 100;
|
||||||
|
startY = dy < 0 ? 100 : -10;
|
||||||
|
} else {
|
||||||
|
// Primarily horizontal movement.
|
||||||
|
startX = dx > 0 ? -10 : 100;
|
||||||
|
startY = Math.random() * 100;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Drift perpendicular to travel direction.
|
||||||
|
const drift = (Math.random() - 0.5) * 15;
|
||||||
|
const perpDx = -dy;
|
||||||
|
const perpDy = dx;
|
||||||
|
|
||||||
|
return {
|
||||||
|
startX,
|
||||||
|
startY,
|
||||||
|
endDx: dx * travel + perpDx * drift,
|
||||||
|
endDy: dy * travel + perpDy * drift
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function spawnEmoji(element) {
|
||||||
|
const size = randomBetween(SETTINGS.min_size, SETTINGS.max_size);
|
||||||
|
const duration = randomBetween(SETTINGS.min_duration, SETTINGS.max_duration);
|
||||||
|
const delay = Math.random() * 1.5;
|
||||||
|
const easing = easings[Math.floor(Math.random() * easings.length)];
|
||||||
|
|
||||||
|
const angle = SETTINGS.direction === "random"
|
||||||
|
? Math.floor(Math.random() * 360)
|
||||||
|
: SETTINGS.direction;
|
||||||
|
const traj = computeTrajectory(angle);
|
||||||
|
|
||||||
|
let rotStart = 0;
|
||||||
|
let rotEnd = 0;
|
||||||
|
if (SETTINGS.max_rotation > 0) {
|
||||||
|
rotStart = randomBetween(-SETTINGS.max_rotation, SETTINGS.max_rotation);
|
||||||
|
rotEnd = randomBetween(-SETTINGS.max_rotation, SETTINGS.max_rotation);
|
||||||
|
}
|
||||||
|
|
||||||
|
element.classList.add("emoji_item");
|
||||||
|
element.style.left = `${traj.startX}vw`;
|
||||||
|
element.style.top = `${traj.startY}vh`;
|
||||||
|
|
||||||
|
if (element.tagName === "IMG") {
|
||||||
|
element.style.width = `${size}px`;
|
||||||
|
element.style.height = `${size}px`;
|
||||||
|
} else {
|
||||||
|
element.style.fontSize = `${size}px`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Enforce max count by removing oldest.
|
||||||
|
while (activeEmojis.length >= SETTINGS.max_count) {
|
||||||
|
const oldest = activeEmojis.shift();
|
||||||
|
if (oldest) {
|
||||||
|
oldest.remove();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
activeEmojis.push(element);
|
||||||
|
document.body.append(element);
|
||||||
|
|
||||||
|
const keyframes = [
|
||||||
|
{
|
||||||
|
transform: `translate(0, 0) rotate(${rotStart}deg)`,
|
||||||
|
opacity: 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
transform: `translate(${traj.endDx}vw, ${traj.endDy}vh) rotate(${rotEnd}deg)`,
|
||||||
|
opacity: 0
|
||||||
|
}
|
||||||
|
];
|
||||||
|
const timing = {
|
||||||
|
duration: duration * 1000,
|
||||||
|
delay: delay * 1000,
|
||||||
|
easing,
|
||||||
|
fill: "forwards"
|
||||||
|
};
|
||||||
|
|
||||||
|
const animation = element.animate(keyframes, timing);
|
||||||
|
animation.onfinish = () => {
|
||||||
|
element.remove();
|
||||||
|
const index = activeEmojis.indexOf(element);
|
||||||
|
if (index !== -1) {
|
||||||
|
activeEmojis.splice(index, 1);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleEvent(data) {
|
||||||
|
let element;
|
||||||
|
if (data.type === "unicode") {
|
||||||
|
element = document.createElement("span");
|
||||||
|
element.textContent = data.emoji;
|
||||||
|
} else if (data.type === "emote") {
|
||||||
|
element = document.createElement("img");
|
||||||
|
element.src = data.url;
|
||||||
|
element.alt = "emote";
|
||||||
|
} else {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
spawnEmoji(element);
|
||||||
|
}
|
||||||
|
|
||||||
|
const RECONNECT_DELAY = 5000;
|
||||||
|
|
||||||
|
function connect() {
|
||||||
|
const source = new EventSource(EVENTS_URL);
|
||||||
|
source.onmessage = (event) => {
|
||||||
|
try {
|
||||||
|
const data = JSON.parse(event.data);
|
||||||
|
handleEvent(data);
|
||||||
|
} catch (e) {
|
||||||
|
// Ignore malformed events.
|
||||||
|
}
|
||||||
|
};
|
||||||
|
source.onerror = () => {
|
||||||
|
// EventSource auto-reconnects on transient network errors
|
||||||
|
// (readyState stays CONNECTING). However, HTTP-level errors
|
||||||
|
// like 502/503 from a reverse proxy set readyState to CLOSED
|
||||||
|
// and the browser gives up. Reconnect manually so the overlay
|
||||||
|
// survives bot restarts behind a proxy.
|
||||||
|
if (source.readyState === EventSource.CLOSED) {
|
||||||
|
source.close();
|
||||||
|
setTimeout(connect, RECONNECT_DELAY);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
connect();
|
||||||
|
})();
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Emoji Wall</title>
|
||||||
|
<style>
|
||||||
|
* {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
html, body {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
.emoji_item {
|
||||||
|
position: fixed;
|
||||||
|
line-height: 1;
|
||||||
|
will-change: transform, opacity;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<script>
|
||||||
|
const EVENTS_URL = {{ events_url | tojson }};
|
||||||
|
const SETTINGS = {{ settings | tojson }};
|
||||||
|
</script>
|
||||||
|
<script src="{{ js_url }}?v={{ owlbot_version }}"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,382 @@
|
|||||||
|
# Copyright 2026 Logan Fick
|
||||||
|
#
|
||||||
|
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
# you may not use this file except in compliance with the License.
|
||||||
|
# You may obtain a copy of the License at
|
||||||
|
#
|
||||||
|
# http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
#
|
||||||
|
# Unless required by applicable law or agreed to in writing, software
|
||||||
|
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
# See the License for the specific language governing permissions and
|
||||||
|
# limitations under the License.
|
||||||
|
|
||||||
|
"""Tests for the emoji wall built-in module."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
from typing import TYPE_CHECKING, ClassVar
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from aiohttp import web
|
||||||
|
|
||||||
|
from owlbot.builtin_modules.emoji_wall import (
|
||||||
|
_broadcast,
|
||||||
|
_extract_emojis,
|
||||||
|
_resolve_direction,
|
||||||
|
_resolve_settings,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _img(src: str, class_: str = "emoji") -> str:
|
||||||
|
"""Build an ``<img>`` tag matching Owncast's emote rendering."""
|
||||||
|
return f'<img src="{src}" class="{class_}" />'
|
||||||
|
|
||||||
|
|
||||||
|
class _FakeStreamResponse:
|
||||||
|
"""Minimal stand-in for ``web.StreamResponse`` that records writes."""
|
||||||
|
|
||||||
|
def __init__(self, *, fail: bool = False) -> None:
|
||||||
|
self.written: list[bytes] = []
|
||||||
|
self._fail = fail
|
||||||
|
|
||||||
|
async def write(self, data: bytes) -> None:
|
||||||
|
"""Record *data* or raise if this client is configured to fail."""
|
||||||
|
if self._fail:
|
||||||
|
raise ConnectionResetError("client gone")
|
||||||
|
self.written.append(data)
|
||||||
|
|
||||||
|
|
||||||
|
_URL = "https://stream.example.com"
|
||||||
|
|
||||||
|
|
||||||
|
class TestExtractEmojis:
|
||||||
|
"""Tests ``_extract_emojis`` from the emoji wall module."""
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
("raw_body", "expected"),
|
||||||
|
[
|
||||||
|
pytest.param("no emojis here", [], id="plain-text"),
|
||||||
|
pytest.param(
|
||||||
|
"hello 🔥 world",
|
||||||
|
[{"type": "unicode", "emoji": "🔥"}],
|
||||||
|
id="single-emoji",
|
||||||
|
),
|
||||||
|
pytest.param(
|
||||||
|
"🎉🎊✨",
|
||||||
|
[
|
||||||
|
{"type": "unicode", "emoji": "🎉"},
|
||||||
|
{"type": "unicode", "emoji": "🎊"},
|
||||||
|
{"type": "unicode", "emoji": "✨"},
|
||||||
|
],
|
||||||
|
id="multiple-emojis",
|
||||||
|
),
|
||||||
|
pytest.param(
|
||||||
|
"hi 👋🏽",
|
||||||
|
[{"type": "unicode", "emoji": "👋🏽"}],
|
||||||
|
id="skin-tone-modifier",
|
||||||
|
),
|
||||||
|
pytest.param(
|
||||||
|
"family 👨👩👧👦 time",
|
||||||
|
[{"type": "unicode", "emoji": "👨👩👧👦"}],
|
||||||
|
id="zwj-sequence",
|
||||||
|
),
|
||||||
|
pytest.param(
|
||||||
|
"flag 🇺🇸 here",
|
||||||
|
[{"type": "unicode", "emoji": "🇺🇸"}],
|
||||||
|
id="flag-sequence",
|
||||||
|
),
|
||||||
|
pytest.param("", [], id="empty-string"),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_unicode(self, raw_body: str, expected: list[dict[str, str]]) -> None:
|
||||||
|
"""Unicode emojis are extracted from the raw body."""
|
||||||
|
assert _extract_emojis(raw_body, "", _URL) == expected
|
||||||
|
|
||||||
|
def test_custom_emote(self) -> None:
|
||||||
|
"""Custom emote img tags are resolved against the Owncast URL."""
|
||||||
|
html = _img("/img/emoji/catjam.gif")
|
||||||
|
result = _extract_emojis("", html, _URL)
|
||||||
|
assert result == [
|
||||||
|
{"type": "emote", "url": f"{_URL}/img/emoji/catjam.gif"},
|
||||||
|
]
|
||||||
|
|
||||||
|
def test_multiple_custom_emotes(self) -> None:
|
||||||
|
"""Multiple emotes in a single message are all extracted."""
|
||||||
|
html = _img("/img/emoji/a.gif") + _img("/img/emoji/b.gif")
|
||||||
|
result = _extract_emojis("", html, _URL)
|
||||||
|
assert result == [
|
||||||
|
{"type": "emote", "url": f"{_URL}/img/emoji/a.gif"},
|
||||||
|
{"type": "emote", "url": f"{_URL}/img/emoji/b.gif"},
|
||||||
|
]
|
||||||
|
|
||||||
|
def test_non_emoji_img_ignored(self) -> None:
|
||||||
|
"""An <img> without the emoji class is not matched."""
|
||||||
|
html = '<img src="/photo.jpg" class="avatar" />'
|
||||||
|
assert _extract_emojis("", html, _URL) == []
|
||||||
|
|
||||||
|
def test_class_before_src(self) -> None:
|
||||||
|
"""Handles <img> where class appears before src."""
|
||||||
|
html = '<img class="emoji" src="/img/emoji/wave.gif" />'
|
||||||
|
result = _extract_emojis("", html, _URL)
|
||||||
|
assert result == [
|
||||||
|
{"type": "emote", "url": f"{_URL}/img/emoji/wave.gif"},
|
||||||
|
]
|
||||||
|
|
||||||
|
def test_extra_class_ignored(self) -> None:
|
||||||
|
"""An <img> with a class other than exactly 'emoji' is not matched."""
|
||||||
|
html = '<img src="/img/emoji/wave.gif" class="emoji custom" />'
|
||||||
|
assert _extract_emojis("", html, _URL) == []
|
||||||
|
|
||||||
|
def test_img_without_src_ignored(self) -> None:
|
||||||
|
"""An <img> with class emoji but no src is not matched."""
|
||||||
|
html = '<img class="emoji" />'
|
||||||
|
assert _extract_emojis("", html, _URL) == []
|
||||||
|
|
||||||
|
def test_leading_slashes_deduped(self) -> None:
|
||||||
|
"""Extra leading slashes on the src don't double up."""
|
||||||
|
html = _img("///img/emoji/test.gif")
|
||||||
|
result = _extract_emojis("", html, _URL)
|
||||||
|
assert result == [
|
||||||
|
{"type": "emote", "url": f"{_URL}/img/emoji/test.gif"},
|
||||||
|
]
|
||||||
|
|
||||||
|
def test_unicode_and_custom_combined(self) -> None:
|
||||||
|
"""Unicode emojis and custom emotes are returned together."""
|
||||||
|
html = f"<p>🔥 {_img('/img/emoji/catjam.gif')}</p>"
|
||||||
|
result = _extract_emojis("🔥 :catjam:", html, _URL)
|
||||||
|
assert result == [
|
||||||
|
{"type": "unicode", "emoji": "🔥"},
|
||||||
|
{"type": "emote", "url": f"{_URL}/img/emoji/catjam.gif"},
|
||||||
|
]
|
||||||
|
|
||||||
|
def test_empty_message(self) -> None:
|
||||||
|
"""Empty raw body and HTML body returns an empty list."""
|
||||||
|
assert _extract_emojis("", "", _URL) == []
|
||||||
|
|
||||||
|
|
||||||
|
class TestResolveDirection:
|
||||||
|
"""Tests ``_resolve_direction`` from the emoji wall module."""
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
("value", "expected"),
|
||||||
|
[
|
||||||
|
pytest.param("up", 0, id="preset-up"),
|
||||||
|
pytest.param("down", 180, id="preset-down"),
|
||||||
|
pytest.param("left", 270, id="preset-left"),
|
||||||
|
pytest.param("right", 90, id="preset-right"),
|
||||||
|
pytest.param("up-right", 45, id="preset-up-right"),
|
||||||
|
pytest.param("down-left", 225, id="preset-down-left"),
|
||||||
|
pytest.param("UP", 0, id="case-insensitive"),
|
||||||
|
pytest.param(" Down ", 180, id="whitespace-trimmed"),
|
||||||
|
pytest.param("random", "random", id="random-string"),
|
||||||
|
pytest.param("RANDOM", "random", id="random-case-insensitive"),
|
||||||
|
pytest.param("45", 45, id="numeric-string"),
|
||||||
|
pytest.param("0", 0, id="zero-string"),
|
||||||
|
pytest.param("359", 359, id="max-degrees-string"),
|
||||||
|
pytest.param("nonsense", 0, id="invalid-string-fallback"),
|
||||||
|
pytest.param(90, 90, id="int-value"),
|
||||||
|
pytest.param(0, 0, id="int-zero"),
|
||||||
|
pytest.param(359, 359, id="int-max"),
|
||||||
|
pytest.param(-10, 0, id="negative-clamped"),
|
||||||
|
pytest.param(500, 359, id="over-max-clamped"),
|
||||||
|
pytest.param("999", 359, id="numeric-string-clamped-high"),
|
||||||
|
pytest.param("-5", 0, id="numeric-string-clamped-low"),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_resolution(self, value: str | int, expected: int | str) -> None:
|
||||||
|
"""Direction values resolve to the expected degree or 'random'."""
|
||||||
|
assert _resolve_direction(value) == expected
|
||||||
|
|
||||||
|
|
||||||
|
class TestResolveSettings:
|
||||||
|
"""Tests ``_resolve_settings`` from the emoji wall module."""
|
||||||
|
|
||||||
|
_DEFAULTS: ClassVar[dict[str, object]] = {
|
||||||
|
"min_size": 20,
|
||||||
|
"max_size": 60,
|
||||||
|
"min_duration": 3,
|
||||||
|
"max_duration": 10,
|
||||||
|
"max_count": 50,
|
||||||
|
"direction": "up",
|
||||||
|
"max_rotation": 25,
|
||||||
|
}
|
||||||
|
|
||||||
|
def test_defaults(self) -> None:
|
||||||
|
"""Config defaults produce expected settings."""
|
||||||
|
result = _resolve_settings(self._DEFAULTS, {})
|
||||||
|
assert result == {
|
||||||
|
"min_size": 20,
|
||||||
|
"max_size": 60,
|
||||||
|
"min_duration": 3,
|
||||||
|
"max_duration": 10,
|
||||||
|
"max_count": 50,
|
||||||
|
"direction": 0,
|
||||||
|
"max_rotation": 25,
|
||||||
|
}
|
||||||
|
|
||||||
|
def test_query_overrides_config(self) -> None:
|
||||||
|
"""Query parameters take precedence over config values."""
|
||||||
|
result = _resolve_settings(
|
||||||
|
self._DEFAULTS,
|
||||||
|
{"min_size": "30", "max_size": "80"},
|
||||||
|
)
|
||||||
|
assert result["min_size"] == 30
|
||||||
|
assert result["max_size"] == 80
|
||||||
|
|
||||||
|
def test_invalid_query_falls_back_to_config(self) -> None:
|
||||||
|
"""Unparsable query values fall back to config."""
|
||||||
|
result = _resolve_settings(
|
||||||
|
self._DEFAULTS,
|
||||||
|
{"min_size": "not_a_number"},
|
||||||
|
)
|
||||||
|
assert result["min_size"] == 20
|
||||||
|
|
||||||
|
def test_min_max_size_swapped(self) -> None:
|
||||||
|
"""Inverted min/max sizes are swapped."""
|
||||||
|
result = _resolve_settings(
|
||||||
|
{**self._DEFAULTS, "min_size": 100, "max_size": 10},
|
||||||
|
{},
|
||||||
|
)
|
||||||
|
assert result["min_size"] == 10
|
||||||
|
assert result["max_size"] == 100
|
||||||
|
|
||||||
|
def test_min_max_duration_swapped(self) -> None:
|
||||||
|
"""Inverted min/max durations are swapped."""
|
||||||
|
result = _resolve_settings(
|
||||||
|
{**self._DEFAULTS, "min_duration": 30, "max_duration": 1},
|
||||||
|
{},
|
||||||
|
)
|
||||||
|
assert result["min_duration"] == 1
|
||||||
|
assert result["max_duration"] == 30
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
("key", "value", "expected"),
|
||||||
|
[
|
||||||
|
pytest.param("min_size", -10, 1, id="size-floor"),
|
||||||
|
pytest.param("max_size", 9999, 500, id="size-ceiling"),
|
||||||
|
pytest.param("min_duration", -1, 1, id="duration-floor"),
|
||||||
|
pytest.param("max_duration", 999, 60, id="duration-ceiling"),
|
||||||
|
pytest.param("max_count", -1, 1, id="count-floor"),
|
||||||
|
pytest.param("max_count", 5000, 1000, id="count-ceiling"),
|
||||||
|
pytest.param("max_rotation", -5, 0, id="rotation-floor"),
|
||||||
|
pytest.param("max_rotation", 999, 360, id="rotation-ceiling"),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_clamping(self, key: str, value: int, expected: int) -> None:
|
||||||
|
"""Values outside allowed ranges are clamped."""
|
||||||
|
result = _resolve_settings({**self._DEFAULTS, key: value}, {})
|
||||||
|
assert result[key] == expected
|
||||||
|
|
||||||
|
def test_direction_random(self) -> None:
|
||||||
|
"""The 'random' direction string passes through."""
|
||||||
|
result = _resolve_settings(
|
||||||
|
{**self._DEFAULTS, "direction": "random"},
|
||||||
|
{},
|
||||||
|
)
|
||||||
|
assert result["direction"] == "random"
|
||||||
|
|
||||||
|
def test_direction_query_override(self) -> None:
|
||||||
|
"""Query parameter overrides the config direction."""
|
||||||
|
result = _resolve_settings(self._DEFAULTS, {"direction": "down"})
|
||||||
|
assert result["direction"] == 180
|
||||||
|
|
||||||
|
def test_empty_config_uses_zero_defaults(self) -> None:
|
||||||
|
"""Missing config keys produce clamped-to-minimum values."""
|
||||||
|
result = _resolve_settings({}, {})
|
||||||
|
assert result["min_size"] == 1
|
||||||
|
assert result["max_size"] == 1
|
||||||
|
assert result["min_duration"] == 1
|
||||||
|
assert result["max_duration"] == 1
|
||||||
|
assert result["max_count"] == 1
|
||||||
|
assert result["max_rotation"] == 0
|
||||||
|
|
||||||
|
|
||||||
|
class TestBroadcast:
|
||||||
|
"""Tests ``_broadcast`` from the emoji wall module."""
|
||||||
|
|
||||||
|
async def test_sends_to_all_clients(self) -> None:
|
||||||
|
"""Payload is written to every connected client."""
|
||||||
|
c1 = _FakeStreamResponse()
|
||||||
|
c2 = _FakeStreamResponse()
|
||||||
|
clients: set[web.StreamResponse] = {c1, c2} # type: ignore[arg-type]
|
||||||
|
events = [{"type": "unicode", "emoji": "🔥"}]
|
||||||
|
|
||||||
|
await _broadcast(clients, events, logging.getLogger("test"))
|
||||||
|
|
||||||
|
expected = f"data: {json.dumps(events[0])}\n\n".encode()
|
||||||
|
assert c1.written == [expected]
|
||||||
|
assert c2.written == [expected]
|
||||||
|
assert len(clients) == 2
|
||||||
|
|
||||||
|
async def test_failed_client_removed(self) -> None:
|
||||||
|
"""A client that raises on write is removed from the set."""
|
||||||
|
good = _FakeStreamResponse()
|
||||||
|
bad = _FakeStreamResponse(fail=True)
|
||||||
|
clients: set[web.StreamResponse] = {good, bad} # type: ignore[arg-type]
|
||||||
|
events = [{"type": "unicode", "emoji": "😀"}]
|
||||||
|
|
||||||
|
await _broadcast(clients, events, logging.getLogger("test"))
|
||||||
|
|
||||||
|
assert good in clients
|
||||||
|
assert bad not in clients
|
||||||
|
assert len(clients) == 1
|
||||||
|
|
||||||
|
async def test_failed_client_logged(self, caplog: pytest.LogCaptureFixture) -> None:
|
||||||
|
"""Removal of failed clients is logged at DEBUG level."""
|
||||||
|
bad = _FakeStreamResponse(fail=True)
|
||||||
|
clients: set[web.StreamResponse] = {bad} # type: ignore[arg-type]
|
||||||
|
|
||||||
|
with caplog.at_level(logging.DEBUG, logger="test"):
|
||||||
|
await _broadcast(
|
||||||
|
clients,
|
||||||
|
[{"type": "unicode", "emoji": "😀"}],
|
||||||
|
logging.getLogger("test"),
|
||||||
|
)
|
||||||
|
|
||||||
|
assert "Removed 1 failed client(s) during broadcast" in caplog.text
|
||||||
|
|
||||||
|
async def test_multiple_events_concatenated(self) -> None:
|
||||||
|
"""Multiple events are sent as a single concatenated payload."""
|
||||||
|
client = _FakeStreamResponse()
|
||||||
|
clients: set[web.StreamResponse] = {client} # type: ignore[arg-type]
|
||||||
|
events = [
|
||||||
|
{"type": "unicode", "emoji": "🎉"},
|
||||||
|
{"type": "emote", "url": "https://example.com/emote.gif"},
|
||||||
|
]
|
||||||
|
|
||||||
|
await _broadcast(clients, events, logging.getLogger("test"))
|
||||||
|
|
||||||
|
assert len(client.written) == 1
|
||||||
|
payload = client.written[0].decode()
|
||||||
|
assert f"data: {json.dumps(events[0])}\n\n" in payload
|
||||||
|
assert f"data: {json.dumps(events[1])}\n\n" in payload
|
||||||
|
|
||||||
|
async def test_empty_clients_noop(self) -> None:
|
||||||
|
"""Broadcasting to no clients completes without error."""
|
||||||
|
clients: set[web.StreamResponse] = set()
|
||||||
|
await _broadcast(
|
||||||
|
clients,
|
||||||
|
[{"type": "unicode", "emoji": "😀"}],
|
||||||
|
logging.getLogger("test"),
|
||||||
|
)
|
||||||
|
assert len(clients) == 0
|
||||||
|
|
||||||
|
async def test_all_clients_fail(self) -> None:
|
||||||
|
"""When every client fails, the set is emptied."""
|
||||||
|
b1 = _FakeStreamResponse(fail=True)
|
||||||
|
b2 = _FakeStreamResponse(fail=True)
|
||||||
|
clients: set[web.StreamResponse] = {b1, b2} # type: ignore[arg-type]
|
||||||
|
|
||||||
|
await _broadcast(
|
||||||
|
clients,
|
||||||
|
[{"type": "unicode", "emoji": "😀"}],
|
||||||
|
logging.getLogger("test"),
|
||||||
|
)
|
||||||
|
|
||||||
|
assert len(clients) == 0
|
||||||
Reference in New Issue
Block a user