diff --git a/migrations/0093_youtube_notify_follows.sql b/migrations/0093_youtube_notify_follows.sql new file mode 100644 index 0000000..39644da --- /dev/null +++ b/migrations/0093_youtube_notify_follows.sql @@ -0,0 +1,67 @@ +-- Extend YouTube notification follows to support personal (DM) subscriptions +-- and per-follow event/mention configuration from the ``notify`` hybrid group. +-- +-- The website's guild-only API keeps working: existing rows have ``guild_id`` +-- set and ``user_id`` NULL, and the new ``user_id``/mention columns default to +-- NULL/FALSE so those inserts need no change. A follow is either guild-scoped +-- or user-scoped, never both, and the row keeps the existing channel-keyed +-- WebSub subscription lifecycle. + +-- The old primary key (guild_id, youtube_channel_id) makes ``guild_id`` NOT +-- NULL, which cannot represent a DM follow. Replace it with a UNIQUE +-- constraint (NULLs are distinct, so DM rows pass through) that keeps the +-- website's ``ON CONFLICT (guild_id, youtube_channel_id)`` inserts working. +ALTER TABLE youtube_follows + DROP CONSTRAINT IF EXISTS youtube_follows_pkey; + +ALTER TABLE youtube_follows + ALTER COLUMN guild_id DROP NOT NULL, + ALTER COLUMN announce_channel_id DROP NOT NULL; + +ALTER TABLE youtube_follows + ADD CONSTRAINT youtube_follows_guild_channel_key + UNIQUE (guild_id, youtube_channel_id); + +ALTER TABLE youtube_follows + ADD COLUMN IF NOT EXISTS user_id BIGINT, + ADD COLUMN IF NOT EXISTS mention_role_id BIGINT, + ADD COLUMN IF NOT EXISTS mention_everyone BOOLEAN NOT NULL DEFAULT FALSE; + +-- The ``notify`` group addresses follows by a stable public ID (mirroring +-- ``notify_twitch_follows``/``notify_anime_follows``). Add an identity +-- column and make it the primary key. +ALTER TABLE youtube_follows + ADD COLUMN IF NOT EXISTS id BIGINT GENERATED ALWAYS AS IDENTITY; +ALTER TABLE youtube_follows + ADD CONSTRAINT youtube_follows_pkey PRIMARY KEY (id); + +-- One personal follow per (user, channel). +CREATE UNIQUE INDEX IF NOT EXISTS youtube_follows_user_channel_uniq + ON youtube_follows (user_id, youtube_channel_id) + WHERE user_id IS NOT NULL; + +ALTER TABLE youtube_follows + ADD CONSTRAINT youtube_follows_owner_check + CHECK ((guild_id IS NOT NULL) <> (user_id IS NOT NULL)); + +ALTER TABLE youtube_follows + ADD CONSTRAINT youtube_follows_mention_check + CHECK (NOT (mention_role_id IS NOT NULL AND mention_everyone)); + +-- Delivery dedup must now work for DM destinations too. The old primary +-- key (guild_id, youtube_channel_id, item_id, event_type) cannot represent a +-- DM row, so drop it before relaxing the NOT NULL and add the user_id column. +ALTER TABLE youtube_announcement_deliveries + DROP CONSTRAINT IF EXISTS youtube_announcement_deliveries_pkey; + +ALTER TABLE youtube_announcement_deliveries + ALTER COLUMN guild_id DROP NOT NULL, + ADD COLUMN IF NOT EXISTS user_id BIGINT; + +CREATE UNIQUE INDEX IF NOT EXISTS youtube_announcement_delivery_guild_key + ON youtube_announcement_deliveries (guild_id, youtube_channel_id, item_id, event_type) + WHERE guild_id IS NOT NULL; + +CREATE UNIQUE INDEX IF NOT EXISTS youtube_announcement_delivery_user_key + ON youtube_announcement_deliveries (user_id, youtube_channel_id, item_id, event_type) + WHERE user_id IS NOT NULL; diff --git a/src/core/privacy.py b/src/core/privacy.py index 1ba3085..07de301 100644 --- a/src/core/privacy.py +++ b/src/core/privacy.py @@ -31,6 +31,7 @@ "mudae_wishes", "notify_twitch_follows", "notify_anime_follows", + "youtube_follows", "phone_consent", "download_stats", "download_events", diff --git a/src/extensions/events/youtube.py b/src/extensions/events/youtube.py index 48bf3de..debf19a 100644 --- a/src/extensions/events/youtube.py +++ b/src/extensions/events/youtube.py @@ -268,6 +268,53 @@ async def resolve_youtube_channel(self, value: str) -> dict[str, Any] | None: "thumbnail": _best_thumbnail(snippet.get("thumbnails")), } + async def search_youtube_channels(self, query: str) -> list[dict[str, Any]]: + """Search YouTube channels by name for the notify add autocomplete.""" + + key = self._youtube_api_key() + if not key: + return [] + try: + async with self.bot.session.get( + "https://www.googleapis.com/youtube/v3/search", + params={ + "part": "snippet", + "type": "channel", + "q": query, + "maxResults": 25, + "key": key, + }, + ) as response: + data = await response.json(content_type=None) + except Exception as error: + self.bot.logger.warning("Could not search YouTube channels: %s", error) + return [] + items = data.get("items") if isinstance(data, dict) else None + if response.status != 200 or not isinstance(items, list): + return [] + results: list[dict[str, Any]] = [] + seen: set[str] = set() + for item in items: + if not isinstance(item, dict): + continue + channel_id = item.get("id", {}).get("channelId") if isinstance(item.get("id"), dict) else None + snippet = item.get("snippet") + if not isinstance(channel_id, str) or not isinstance(snippet, dict): + continue + if channel_id in seen: + continue + seen.add(channel_id) + title = str(snippet.get("title") or channel_id) + results.append( + { + "id": channel_id, + "name": title, + "handle": f"@{snippet.get('channelTitle') or title}", + "thumbnail": _best_thumbnail(snippet.get("thumbnails")), + } + ) + return results + async def get_youtube_video(self, video_id: str) -> dict[str, Any] | None: key = self._youtube_api_key() if not key: @@ -481,8 +528,9 @@ async def _dispatch_youtube_item( payload: dict[str, Any], ) -> None: rows = await self.bot.pool.fetch( - "SELECT guild_id, youtube_channel_id, channel_name, channel_handle, " - "announce_channel_id, message_template, event_types " + "SELECT id, guild_id, user_id, youtube_channel_id, channel_name, " + "channel_handle, announce_channel_id, message_template, event_types, " + "mention_role_id, mention_everyone " "FROM youtube_follows WHERE youtube_channel_id = $1 " "AND $2 = ANY(event_types)", channel_id, @@ -502,16 +550,58 @@ async def _dispatch_youtube_item( f"YouTube delivery failed for {failed} announcement destination(s)" ) + @staticmethod + def _youtube_owner(row: Any) -> tuple[str, int]: + """Return the delivery-owner column and value for a follow row.""" + + if row.get("guild_id") is not None: + return "guild_id", int(row["guild_id"]) + return "user_id", int(row["user_id"]) + + async def _youtube_destination(self, row: Any) -> Any | None: + """Resolve a YouTube follow's channel or DM destination.""" + + channel_id = row.get("announce_channel_id") + if channel_id: + channel = self.bot.get_channel(int(channel_id)) + if channel is None: + try: + channel = await self.bot.fetch_channel(int(channel_id)) + except Exception as error: + self.bot.logger.warning( + "Could not find YouTube announcement channel %s: %s", + channel_id, + error, + ) + return channel + user_id = row.get("user_id") + if not user_id: + return None + user = self.bot.get_user(int(user_id)) + if user is None: + try: + user = await self.bot.fetch_user(int(user_id)) + except Exception as error: + self.bot.logger.warning( + "Could not resolve YouTube notify DM user %s: %s", user_id, error + ) + return user + async def _announce_youtube_once( self, row: Any, item_id: str, event_type: str, payload: dict[str, Any] ) -> bool: + owner_col, owner_val = self._youtube_owner(row) + conflict = ( + f"({owner_col}, youtube_channel_id, item_id, event_type) " + f"WHERE {owner_col} IS NOT NULL" + ) claimed = await self.bot.pool.fetchval( - """ + f""" INSERT INTO youtube_announcement_deliveries - (guild_id, youtube_channel_id, item_id, event_type, payload, + ({owner_col}, youtube_channel_id, item_id, event_type, payload, status, attempts) VALUES ($1, $2, $3, $4, $5::jsonb, 'processing', 1) - ON CONFLICT (guild_id, youtube_channel_id, item_id, event_type) DO UPDATE + ON CONFLICT {conflict} DO UPDATE SET status = 'processing', attempts = youtube_announcement_deliveries.attempts + 1, payload = EXCLUDED.payload, updated_at = now() @@ -522,7 +612,7 @@ async def _announce_youtube_once( AND youtube_announcement_deliveries.attempts < 10) RETURNING item_id """, - row["guild_id"], + owner_val, row["youtube_channel_id"], item_id, event_type, @@ -530,60 +620,44 @@ async def _announce_youtube_once( ) if claimed is None: status = await self.bot.pool.fetchval( - "SELECT status FROM youtube_announcement_deliveries " - "WHERE guild_id = $1 AND youtube_channel_id = $2 " + f"SELECT status FROM youtube_announcement_deliveries " + f"WHERE {owner_col} = $1 AND youtube_channel_id = $2 " "AND item_id = $3 AND event_type = $4", - row["guild_id"], + owner_val, row["youtube_channel_id"], item_id, event_type, ) return status == "done" announced = await self._announce_youtube(row, item_id, event_type, payload) + dead = await self.bot.pool.fetchval( + f"SELECT attempts >= 10 FROM youtube_announcement_deliveries " + f"WHERE {owner_col} = $1 AND youtube_channel_id = $2 " + "AND item_id = $3 AND event_type = $4", + owner_val, + row["youtube_channel_id"], + item_id, + event_type, + ) await self.bot.pool.execute( - "UPDATE youtube_announcement_deliveries SET status = $5, " + f"UPDATE youtube_announcement_deliveries SET status = $5, " "updated_at = now(), last_error = $6 " - "WHERE guild_id = $1 AND youtube_channel_id = $2 " + f"WHERE {owner_col} = $1 AND youtube_channel_id = $2 " "AND item_id = $3 AND event_type = $4", - row["guild_id"], + owner_val, row["youtube_channel_id"], item_id, event_type, - ( - "done" - if announced - else ( - "dead" - if await self.bot.pool.fetchval( - "SELECT attempts >= 10 FROM youtube_announcement_deliveries " - "WHERE guild_id = $1 AND youtube_channel_id = $2 " - "AND item_id = $3 AND event_type = $4", - row["guild_id"], - row["youtube_channel_id"], - item_id, - event_type, - ) - else "pending" - ) - ), + "done" if announced else ("dead" if dead else "pending"), None if announced else "Discord delivery failed", ) return announced - async def _announce_youtube( self, row: Any, item_id: str, event_type: str, payload: dict[str, Any] ) -> bool: - channel = self.bot.get_channel(row["announce_channel_id"]) - if channel is None: - try: - channel = await self.bot.fetch_channel(row["announce_channel_id"]) - except Exception as error: - self.bot.logger.warning( - "Could not find YouTube announcement channel %s: %s", - row["announce_channel_id"], - error, - ) - return False + destination = await self._youtube_destination(row) + if destination is None: + return False label = { "video": "New video", "live": "Live now", @@ -650,23 +724,34 @@ async def _announce_youtube( ) ) ) + mention_role_id = row.get("mention_role_id") + mention_everyone = bool(row.get("mention_everyone")) + from extensions.settings.notify import mention_text + + mention = mention_text(mention_role_id, mention_everyone) + if mention: + children.extend( + (discord.ui.Separator(), discord.ui.TextDisplay(f"-# {mention}")) + ) view = type("YouTubeAnnouncementView", (discord.ui.LayoutView,), {})( timeout=None ) view.add_item(discord.ui.Container(*children, accent_color=self.bot.embedcolor)) + from extensions.settings.notify import notify_allowed_mentions + try: - await cast(Any, channel).send( + await cast(Any, destination).send( view=view, - allowed_mentions=discord.AllowedMentions( - everyone=True, users=True, roles=True, replied_user=False + allowed_mentions=notify_allowed_mentions( + mention_role_id, mention_everyone ), ) except Exception as error: self.bot.logger.warning( - "Could not announce YouTube %s %s in guild %s: %s", + "Could not announce YouTube %s %s to %s: %s", event_type, item_id, - row["guild_id"], + row.get("guild_id") or row.get("user_id"), error, ) return False diff --git a/src/extensions/settings/notify.py b/src/extensions/settings/notify.py index e630dbb..1546665 100644 --- a/src/extensions/settings/notify.py +++ b/src/extensions/settings/notify.py @@ -400,6 +400,48 @@ def anime_list_details(rows: Iterable[Any]) -> str: lines.append(f"{follow_id} · {title}\n-# *{metadata}*") return "\n".join(lines) +def youtube_list_details(rows: Iterable[Any]) -> str: + """Render the YouTube follows for the combined notify list panel.""" + + lines: list[str] = [] + for row in rows: + follow_id = row.get("id") + if follow_id is None: + follow_id = "?" + name = _safe_text(row["channel_name"], 80) + handle = _safe_text(row.get("channel_handle"), 40) + destination = ( + f"<#{row['announce_channel_id']}>" + if row.get("announce_channel_id") + else "DM" + ) + events = tuple(str(value) for value in row.get("event_types", ()) or ()) + label = _youtube_event_label(events) + mention = mention_text(row.get("mention_role_id"), row.get("mention_everyone")) + metadata = f"{destination} · {label}" + if handle: + metadata = f"{destination} · {handle} · {label}" + if mention: + metadata = f"{destination} · {mention} · {label}" + lines.append(f"{follow_id} · {name}\n-# *{metadata}*") + return "\n".join(lines) + +def _youtube_event_label(events: Iterable[str]) -> str: + """Return a short human label for a follow's configured event types.""" + + selected = {str(value).strip().lower() for value in events} + if selected == {"video", "live"}: + return "uploads + live" + if selected == {"video"}: + return "uploads" + if selected == {"live"}: + return "live" + if selected == {"short"}: + return "shorts" + if selected == {"community"}: + return "community" + return ", ".join(sorted(selected)) or "no events" + __all__ = [ "ANILIST_MEDIA_URL_RE", @@ -426,4 +468,5 @@ def anime_list_details(rows: Iterable[Any]) -> str: "notify_allowed_mentions", "select_anilist_media", "twitch_list_details", + "youtube_list_details", ] diff --git a/src/extensions/settings/notify_commands.py b/src/extensions/settings/notify_commands.py index 351fe34..7648d2d 100644 --- a/src/extensions/settings/notify_commands.py +++ b/src/extensions/settings/notify_commands.py @@ -36,6 +36,7 @@ normalize_twitch_channel, select_anilist_media, twitch_list_details, + youtube_list_details, ) if TYPE_CHECKING: @@ -220,10 +221,17 @@ def _split_anime_mention( if tail.casefold().endswith(suffix): candidate = role.name tail = tail[: -len(suffix)].rstrip() - break return tail or value, candidate if candidate is not None else mention +def _follow_display_name(row: Any, kind: str) -> str: + """Return a follow's human display name for notification messages.""" + + if kind in {"youtube", "twitch"}: + return str(row.get("channel_name") or "") + return str(row.get("title") or "") + + class Notify(Cog): """Follow Twitch channels and anime release dates.""" @@ -267,12 +275,12 @@ async def _send_info(self, ctx: Context) -> None: renderer here also ensures text and slash fallbacks use identical Components V2 output. """ - text = ( "## Notifications\n" - "Follow Twitch channels or anime releases and receive notifications " - "in this server or by DM.\n\n" - "Use `notify add twitch ` or `notify add anime `." + "Follow Twitch channels, YouTube channels, or anime releases and " + "receive notifications in this server or by DM.\n\n" + "Use `notify add twitch <channel>`, `notify add youtube <channel>`, " + "or `notify add anime <title>`." ) await self._send( ctx, @@ -281,6 +289,120 @@ async def _send_info(self, ctx: Context) -> None: ephemeral=ctx.interaction is not None, ) + + async def _youtube_channel(self, value: str) -> dict[str, Any] | None: + """Resolve a YouTube channel via the Events cog.""" + + events = self._events() + if events is None or not hasattr(events, "resolve_youtube_channel"): + return None + return await events.resolve_youtube_channel(value) + + async def _youtube_search(self, query: str) -> list[dict[str, Any]]: + """Search YouTube channels via the Events cog.""" + + events = self._events() + if events is None or not hasattr(events, "search_youtube_channels"): + return [] + return await events.search_youtube_channels(query) + + @staticmethod + def _youtube_events(value: str | None) -> tuple[str, ...]: + """Map a ``events`` option to YouTube event type names. + + ``uploads`` and ``live`` are the only choices exposed by the command; + ``both`` expands to ``video`` + ``live``. + """ + + choice = (value or "both").strip().lower() + if choice in {"upload", "uploads", "video", "videos"}: + return ("video",) + if choice in {"live", "livestream", "stream"}: + return ("live",) + return ("video", "live") + + async def _add_youtube( + self, ctx: Context, channel: str, events: str | None + ) -> None: + """Resolve and persist one YouTube follow in the current scope.""" + + self._require_server_admin(ctx) + youtube_channel = await self._youtube_channel(channel) + if not youtube_channel or not youtube_channel.get("id"): + raise commands.BadArgument( + f"Could not find a YouTube channel named **{discord.utils.escape_markdown(channel)}**." + ) + channel_id = str(youtube_channel["id"]) + event_types = self._youtube_events(events) + guild_id, user_id = _scope(ctx) + scope_column = "guild_id" if guild_id is not None else "user_id" + scope_id = guild_id if guild_id is not None else user_id + announce_channel_id = int(ctx.channel.id) if guild_id is not None else None + async with self.bot.pool.acquire() as connection: + async with connection.transaction(): + await connection.execute( + "SELECT pg_advisory_xact_lock(hashtext($1))", + f"notify:youtube:{scope_id}", + ) + existing = await connection.fetchval( + f"SELECT id FROM youtube_follows WHERE {scope_column} = $1 " + "AND youtube_channel_id = $2", + scope_id, + channel_id, + ) + if existing is not None: + raise commands.BadArgument( + "That YouTube channel is already followed here." + ) + count = await connection.fetchval( + f"SELECT COUNT(*) FROM youtube_follows WHERE {scope_column} = $1", + scope_id, + ) + if int(count or 0) >= 10: + raise commands.BadArgument( + "You can follow up to 10 YouTube channels per server or DM." + ) + inserted = await connection.fetchrow( + f"INSERT INTO youtube_follows " + f"({scope_column}, youtube_channel_id, channel_name, " + "channel_handle, announce_channel_id, event_types) " + "VALUES ($1, $2, $3, $4, $5, $6::text[]) " + "ON CONFLICT DO NOTHING RETURNING id", + scope_id, + channel_id, + youtube_channel["name"], + youtube_channel.get("handle"), + announce_channel_id, + list(event_types), + ) + if inserted is None: + raise commands.BadArgument( + "That YouTube channel is already followed here." + ) + follow_id = int(inserted["id"]) + events_cog = self._events() + if events_cog is not None and hasattr( + events_cog, "ensure_youtube_subscription" + ): + await events_cog.ensure_youtube_subscription(channel_id) + label = ( + "uploads + live" + if event_types == ("video", "live") + else "uploads" + if event_types == ("video",) + else "live" + ) + await self._send( + ctx, + view=NotifyView( + "## YouTube notifications", + f"Following **{discord.utils.escape_markdown(youtube_channel['name'])}** " + f"(ID: `{follow_id}`) for {label}.", + ), + allowed_mentions=discord.AllowedMentions.none(), + ephemeral=ctx.interaction is not None, + ) + async def _twitch_user(self, name: str) -> dict[str, Any] | None: events = self._events() if events is None or not hasattr(events, "_get_twitch_user"): @@ -436,12 +558,16 @@ async def _resolve_follow( raw = str(selector or "").strip() if not raw: return None - if kind not in {"twitch", "anime"}: + if kind not in {"twitch", "anime", "youtube"}: raise ValueError(f"Unknown notification kind: {kind}") guild_id, user_id = _scope(ctx) scope_column = "guild_id" if guild_id is not None else "user_id" scope_id = guild_id if guild_id is not None else user_id - table = f"notify_{kind}_follows" + table = ( + "youtube_follows" + if kind == "youtube" + else f"notify_{kind}_follows" + ) # A follow ID is deliberately checked before any provider-specific ID # (such as AniList's media ID). This makes the IDs shown by @@ -472,6 +598,15 @@ async def _resolve_follow( name, ) + if kind == "youtube": + return await self.bot.pool.fetchrow( + f"SELECT * FROM {table} WHERE {scope_column} = $1 " + "AND (lower(btrim(channel_name)) = lower(btrim($2)) " + "OR youtube_channel_id = $2) ORDER BY id LIMIT 1", + scope_id, + raw, + ) + anime = self._normalize_anime_query(raw) media_id = anilist_media_id(anime) if media_id is None and anime.isdecimal(): @@ -579,6 +714,74 @@ async def _followed_anime_choices( ) return choices[:25] + async def _followed_youtube_choices( + self, interaction: discord.Interaction, current: str + ) -> list[app_commands.Choice[str]]: + """Return followed YouTube channels visible in the current scope.""" + + try: + if interaction.guild is None: + rows = await self.bot.pool.fetch( + "SELECT id, channel_name FROM youtube_follows " + "WHERE user_id = $1 ORDER BY lower(btrim(channel_name)), id LIMIT 100", + interaction.user.id, + ) + else: + rows = await self.bot.pool.fetch( + "SELECT id, channel_name FROM youtube_follows " + "WHERE guild_id = $1 " + "ORDER BY lower(btrim(channel_name)), id LIMIT 100", + interaction.guild.id, + ) + except Exception: + self.bot.logger.exception("Notify YouTube autocomplete lookup failed") + return [] + needle = self._autocomplete_needle(current) + choices: list[app_commands.Choice[str]] = [] + for row in rows: + name = str(row["channel_name"]).strip() + follow_id = str(row["id"]) + if not name or ( + needle and needle not in name.casefold() and needle not in follow_id + ): + continue + choices.append( + app_commands.Choice(name=f"{follow_id} · {name}"[:100], value=follow_id) + ) + return choices[:25] + + async def _resolve_youtube_follow( + self, ctx: Context, selector: str + ) -> Any | None: + """Resolve a YouTube follow by ID, channel name, or channel ID.""" + + raw = str(selector or "").strip() + if not raw: + return None + guild_id, user_id = _scope(ctx) + scope_column = "guild_id" if guild_id is not None else "user_id" + scope_id = guild_id if guild_id is not None else user_id + table = "youtube_follows" + try: + follow_id = int(raw) + except (TypeError, ValueError, OverflowError): + follow_id = 0 + if 0 < follow_id <= 9_223_372_036_854_775_807: + row = await self.bot.pool.fetchrow( + f"SELECT * FROM {table} WHERE {scope_column} = $1 AND id = $2", + scope_id, + follow_id, + ) + if row is not None: + return row + return await self.bot.pool.fetchrow( + f"SELECT * FROM {table} WHERE {scope_column} = $1 " + "AND (lower(btrim(channel_name)) = lower(btrim($2)) " + "OR youtube_channel_id = $2) ORDER BY id LIMIT 1", + scope_id, + raw, + ) + @staticmethod def _guild_channel_choices( interaction: discord.Interaction, current: str @@ -766,6 +969,49 @@ async def _anime_search_choices( _AUTOCOMPLETE_CACHE[cache_key] = tuple(result) return result + async def _youtube_search_choices( + self, interaction: discord.Interaction, current: str + ) -> list[app_commands.Choice[str]]: + """Search YouTube channels for add/lookup command suggestions.""" + + query = str(current or "").strip() + if not query: + return [] + if not _autocomplete_allowed(interaction): + return [] + cache_key = f"youtube:{query.casefold()[:100]}" + cached = _AUTOCOMPLETE_CACHE.get(cache_key) + if cached is not None: + return list(cached) + # An exact channel URL/handle/ID is cheaper to resolve through the + # existing lookup than through the search endpoint. + exact = await self._youtube_channel(query) + choices: list[app_commands.Choice[str]] = [] + if exact and exact.get("id"): + name = str(exact.get("name") or exact["id"]) + choices.append( + app_commands.Choice(name=name[:100], value=str(exact["id"])[:100]) + ) + _AUTOCOMPLETE_CACHE[cache_key] = tuple(choices) + return choices + try: + results = await self._youtube_search(query) + except Exception: + results = [] + seen: set[str] = set() + for result in results: + channel_id = str(result.get("id") or "") + if not channel_id or channel_id.casefold() in seen: + continue + seen.add(channel_id.casefold()) + name = str(result.get("name") or channel_id) + choices.append( + app_commands.Choice(name=name[:100], value=channel_id[:100]) + ) + result = choices[:25] + _AUTOCOMPLETE_CACHE[cache_key] = tuple(result) + return result + @commands.hybrid_group(name="notify", fallback="info") @app_commands.allowed_installs(guilds=True, users=True) @app_commands.allowed_contexts(guilds=True, dms=True, private_channels=True) @@ -1103,6 +1349,70 @@ async def notify_anime_autocomplete( ) -> list[app_commands.Choice[str]]: return await self._anime_search_choices(interaction, current) + @notify_add.command(name="youtube") + @app_commands.describe( + channel="YouTube channel name, handle, or channel URL.", + events="Which events to notify for: uploads, live, or both (default both).", + ) + @app_commands.allowed_installs(guilds=True, users=True) + @app_commands.allowed_contexts(guilds=True, dms=True, private_channels=True) + async def notify_add_youtube( + self, + ctx: Context, + channel: str, + events: str | None = None, + ) -> None: + """Follow a YouTube channel in this server or by DM.""" + async with ctx.typing(ephemeral=ctx.interaction is not None): + await self._add_youtube(ctx, channel, events) + + @notify_add_youtube.autocomplete("channel") + async def notify_add_youtube_autocomplete( + self, interaction: discord.Interaction, current: str + ) -> list[app_commands.Choice[str]]: + return await self._youtube_search_choices(interaction, current) + + @notify.command(name="youtube") + @app_commands.describe( + channel="YouTube channel name, handle, or channel URL.", + events="Which events to notify for: uploads, live, or both (default both).", + ) + @app_commands.allowed_installs(guilds=True, users=True) + @app_commands.allowed_contexts(guilds=True, dms=True, private_channels=True) + async def notify_youtube( + self, + ctx: Context, + channel: str, + events: str | None = None, + ) -> None: + """Follow a YouTube channel (shorthand for ``notify add youtube``).""" + async with ctx.typing(ephemeral=ctx.interaction is not None): + await self._add_youtube(ctx, channel, events) + + @notify_youtube.autocomplete("channel") + async def notify_youtube_autocomplete( + self, interaction: discord.Interaction, current: str + ) -> list[app_commands.Choice[str]]: + return await self._youtube_search_choices(interaction, current) + + @notify_youtube.autocomplete("events") + async def notify_youtube_events_autocomplete( + self, interaction: discord.Interaction, current: str + ) -> list[app_commands.Choice[str]]: + options = [ + app_commands.Choice(name="Uploads", value="uploads"), + app_commands.Choice(name="Live streams", value="live"), + app_commands.Choice(name="Both (default)", value="both"), + ] + needle = current.casefold() + return [choice for choice in options if needle in choice.name.casefold()][:25] + + @notify_add_youtube.autocomplete("events") + async def notify_add_youtube_events_autocomplete( + self, interaction: discord.Interaction, current: str + ) -> list[app_commands.Choice[str]]: + return await self.notify_youtube_events_autocomplete(interaction, current) + @notify.group( name="remove", aliases=("unfollow",), @@ -1255,6 +1565,73 @@ async def notify_remove_anime_autocomplete( """Suggest followed anime titles for the current server or DM.""" return await self._followed_anime_choices(interaction, current) + async def _remove_youtube(self, ctx: Context, channel: str) -> None: + """Stop following a YouTube channel in this scope.""" + self._require_server_admin(ctx) + row = await self._resolve_follow(ctx, "youtube", channel) + if row is None: + raise commands.BadArgument( + f"You are not following **{discord.utils.escape_markdown(channel)}**." + ) + follow_id = int(row["id"]) + name = str(row["channel_name"]) + channel_id = str(row["youtube_channel_id"]) + guild_id, user_id = _scope(ctx) + scope_column = "guild_id" if guild_id is not None else "user_id" + scope_id = guild_id if guild_id is not None else user_id + result = await self.bot.pool.execute( + f"DELETE FROM youtube_follows WHERE id = $1 AND " + f"{scope_column} = $2", + follow_id, + scope_id, + ) + try: + removed = int(str(result).rsplit(" ", 1)[-1]) + except (ValueError, IndexError): + removed = 0 + if not removed: + raise commands.BadArgument(f"You are not following **{name}**.") + events_cog = self._events() + if events_cog is not None and hasattr( + events_cog, "remove_youtube_subscription" + ): + try: + await events_cog.remove_youtube_subscription(channel_id) + except Exception as error: + self.bot.logger.warning( + "Could not remove YouTube notification subscription for %s: %s", + name, + error, + ) + await self._send( + ctx, + view=NotifyView( + "## YouTube notifications", + f"No longer following **{discord.utils.escape_markdown(name)}** " + f"(ID: `{follow_id}`).", + ), + allowed_mentions=discord.AllowedMentions.none(), + ephemeral=ctx.interaction is not None, + ) + + @notify_remove.command(name="youtube") + @app_commands.describe( + channel="Follow ID, YouTube channel name, or channel URL to unfollow." + ) + @app_commands.allowed_installs(guilds=True, users=True) + @app_commands.allowed_contexts(guilds=True, dms=True, private_channels=True) + async def notify_remove_youtube(self, ctx: Context, *, channel: str) -> None: + """Stop following a YouTube channel in this scope.""" + async with ctx.typing(ephemeral=ctx.interaction is not None): + await self._remove_youtube(ctx, channel) + + @notify_remove_youtube.autocomplete("channel") + async def notify_remove_youtube_autocomplete( + self, interaction: discord.Interaction, current: str + ) -> list[app_commands.Choice[str]]: + """Suggest followed YouTube channels for the current server or DM.""" + return await self._followed_youtube_choices(interaction, current) + @notify.group(name="mention", fallback="info") @app_commands.allowed_installs(guilds=True, users=True) @app_commands.allowed_contexts(guilds=True, dms=True, private_channels=True) @@ -1271,7 +1648,7 @@ async def _set_mention( row = await self._resolve_follow(ctx, kind, entity) if row is None: raise commands.BadArgument("That notification follow was not found.") - table = f"notify_{kind}_follows" + table = "youtube_follows" if kind == "youtube" else f"notify_{kind}_follows" follow_id = int(row["id"]) scope_column = "guild_id" if guild_id is not None else "user_id" scope_id = guild_id if guild_id is not None else user_id @@ -1290,11 +1667,11 @@ async def _set_mention( view=NotifyView( f"## {kind.title()} notifications", ( - f"Mention for **{row['channel_name'] if kind == 'twitch' else row['title']}** " + f"Mention for **{_follow_display_name(row, kind)}** " f"(ID: `{follow_id}`) set to {label}." if label else ( - f"Mentions for **{row['channel_name'] if kind == 'twitch' else row['title']}** " + f"Mentions for **{_follow_display_name(row, kind)}** " f"(ID: `{follow_id}`) cleared." ) ), @@ -1353,6 +1730,106 @@ async def notify_mention_anime_autocomplete( """Suggest the anime notifications available in the current scope.""" return await self._followed_anime_choices(interaction, current) + @notify_mention.command(name="youtube") + @app_commands.describe( + channel="Follow ID or followed YouTube channel name/URL.", + mention="Role, @everyone, or omit to clear mentions.", + ) + async def notify_mention_youtube( + self, ctx: Context, channel: str, mention: str | None = None + ) -> None: + """Configure the role or @everyone mention for a YouTube follow.""" + async with ctx.typing(ephemeral=ctx.interaction is not None): + await self._set_mention(ctx, "youtube", channel, mention) + + @notify_mention_youtube.autocomplete("channel") + async def notify_mention_youtube_channel_autocomplete( + self, interaction: discord.Interaction, current: str + ) -> list[app_commands.Choice[str]]: + return await self._followed_youtube_choices(interaction, current) + + @notify_mention_youtube.autocomplete("mention") + async def notify_mention_youtube_role_autocomplete( + self, interaction: discord.Interaction, current: str + ) -> list[app_commands.Choice[str]]: + return self._guild_role_choices(interaction, current) + + @notify.group(name="events", fallback="info") + @app_commands.allowed_installs(guilds=True, users=True) + @app_commands.allowed_contexts(guilds=True, dms=True, private_channels=True) + async def notify_events(self, ctx: Context) -> None: + """Choose which events a followed notification reports.""" + await self._send_info(ctx) + + async def _set_youtube_events( + self, ctx: Context, channel: str, events: str | None + ) -> None: + """Update the event types for an existing YouTube follow.""" + self._require_server_admin(ctx) + row = await self._resolve_follow(ctx, "youtube", channel) + if row is None: + raise commands.BadArgument( + "That YouTube notification follow was not found." + ) + event_types = self._youtube_events(events) + follow_id = int(row["id"]) + name = str(row["channel_name"]) + guild_id, user_id = _scope(ctx) + scope_column = "guild_id" if guild_id is not None else "user_id" + scope_id = guild_id if guild_id is not None else user_id + result = await self.bot.pool.execute( + "UPDATE youtube_follows SET event_types = $3::text[], updated_at = now() " + f"WHERE id = $1 AND {scope_column} = $2", + follow_id, + scope_id, + list(event_types), + ) + if result.endswith(" 0"): + raise commands.BadArgument( + "That YouTube notification follow was not found." + ) + label = ( + "uploads + live" + if event_types == ("video", "live") + else "uploads" + if event_types == ("video",) + else "live" + ) + await self._send( + ctx, + view=NotifyView( + "## YouTube notifications", + f"**{discord.utils.escape_markdown(name)}** (ID: `{follow_id}`) " + f"will now notify for {label}.", + ), + allowed_mentions=discord.AllowedMentions.none(), + ephemeral=ctx.interaction is not None, + ) + + @notify_events.command(name="youtube") + @app_commands.describe( + channel="Follow ID or followed YouTube channel name/URL.", + events="Which events to notify for: uploads, live, or both.", + ) + async def notify_events_youtube( + self, ctx: Context, channel: str, events: str + ) -> None: + """Change the event types for a followed YouTube channel.""" + async with ctx.typing(ephemeral=ctx.interaction is not None): + await self._set_youtube_events(ctx, channel, events) + + @notify_events_youtube.autocomplete("channel") + async def notify_events_youtube_channel_autocomplete( + self, interaction: discord.Interaction, current: str + ) -> list[app_commands.Choice[str]]: + return await self._followed_youtube_choices(interaction, current) + + @notify_events_youtube.autocomplete("events") + async def notify_events_youtube_events_autocomplete( + self, interaction: discord.Interaction, current: str + ) -> list[app_commands.Choice[str]]: + return await self.notify_youtube_events_autocomplete(interaction, current) + @notify.group(name="list", fallback="info") @app_commands.allowed_installs(guilds=True, users=True) @app_commands.allowed_contexts(guilds=True, dms=True, private_channels=True) @@ -1406,15 +1883,34 @@ async def _fetch_anime_follows(self, ctx: Context) -> list[Any]: ) return list(rows) + async def _fetch_youtube_follows(self, ctx: Context) -> list[Any]: + """Return YouTube follows for the current server or DM scope.""" + + guild_id, user_id = _scope(ctx) + predicate, scope_id = _scope_predicate(guild_id, user_id) + rows = await self.bot.pool.fetch( + f""" + SELECT id, youtube_channel_id, channel_name, channel_handle, + announce_channel_id, event_types, mention_role_id, mention_everyone + FROM youtube_follows + WHERE {predicate} + ORDER BY lower(btrim(channel_name)), id + """, + scope_id, + ) + return list(rows) + async def _list_all(self, ctx: Context) -> None: - """Render both follow types in one panel for ``notify list``.""" + """Render all follow types in one panel for ``notify list``.""" - twitch_rows, anime_rows = await asyncio.gather( + twitch_rows, anime_rows, youtube_rows = await asyncio.gather( self._fetch_twitch_follows(ctx), self._fetch_anime_follows(ctx), + self._fetch_youtube_follows(ctx), ) twitch = twitch_list_details(twitch_rows) or "No Twitch channels followed." anime = anime_list_details(anime_rows) or "No anime followed." + youtube = youtube_list_details(youtube_rows) or "No YouTube channels followed." scope_title = ( f"Notifications for {ctx.guild.name}" if ctx.guild is not None @@ -1424,10 +1920,10 @@ async def _list_all(self, ctx: Context) -> None: ctx, view=NotifyListView( f"## {discord.utils.escape_markdown(scope_title)}", - (("Twitch", twitch), ("Anime", anime)), + (("Twitch", twitch), ("YouTube", youtube), ("Anime", anime)), mention=( f"Remove a notification with `{_notify_prefix(ctx)}notify " - "remove twitch/anime <id, name, or link>`." + "remove twitch/youtube/anime <id, name, or link>`." ), ), allowed_mentions=discord.AllowedMentions.none(), @@ -1464,6 +1960,21 @@ async def notify_list_anime(self, ctx: Context) -> None: ephemeral=ctx.interaction is not None, ) + @notify_list.command(name="youtube") + async def notify_list_youtube(self, ctx: Context) -> None: + """List YouTube channels followed in this server or DM scope.""" + async with ctx.typing(ephemeral=ctx.interaction is not None): + rows = await self._fetch_youtube_follows(ctx) + await self._send( + ctx, + view=NotifyView( + "## Followed YouTube channels", + youtube_list_details(rows) or "No YouTube channels followed.", + ), + allowed_mentions=discord.AllowedMentions.none(), + ephemeral=ctx.interaction is not None, + ) + @notify.group(name="channel", fallback="info") @commands.guild_only() @commands.has_guild_permissions(manage_guild=True) @@ -1540,28 +2051,35 @@ async def _channel_for( except commands.BadArgument: pass if candidate_channel is not None: - rows = await self.bot.pool.fetch( - ( + if kind == "twitch": + query = ( "SELECT id, channel_name, announce_channel_id, last_live_at, " "last_offline_at, mention_role_id, mention_everyone " "FROM notify_twitch_follows " "WHERE guild_id = $1 AND announce_channel_id = $2 " "ORDER BY lower(btrim(channel_name)), id" - if kind == "twitch" - else "SELECT id, title, announce_channel_id, release_at, " + ) + detail_fn = twitch_list_details + elif kind == "youtube": + query = ( + "SELECT id, channel_name, channel_handle, announce_channel_id, " + "event_types, mention_role_id, mention_everyone " + "FROM youtube_follows " + "WHERE guild_id = $1 AND announce_channel_id = $2 " + "ORDER BY lower(btrim(channel_name)), id" + ) + detail_fn = youtube_list_details + else: + query = ( + "SELECT id, title, announce_channel_id, release_at, " "next_airing_at, next_episode, mention_role_id, " "mention_everyone FROM notify_anime_follows " "WHERE guild_id = $1 AND announce_channel_id = $2 " "ORDER BY lower(btrim(title)), id" - ), - guild_id, - candidate_channel.id, - ) - details = ( - twitch_list_details(rows) - if kind == "twitch" - else anime_list_details(rows) - ) + ) + detail_fn = anime_list_details + rows = await self.bot.pool.fetch(query, guild_id, candidate_channel.id) + details = detail_fn(rows) await self._send( ctx, view=NotifyView( @@ -1594,7 +2112,7 @@ async def _channel_for( if row is None: raise commands.BadArgument("That followed notification could not be found.") follow_id = int(row["id"]) - display_name = str(row["channel_name"] if kind == "twitch" else row["title"]) + display_name = _follow_display_name(row, kind) if not await ctx.prompt( f"Send {kind} notifications for **{display_name}** " f"(ID: `{follow_id}`) to {destination.mention}?", @@ -1623,6 +2141,14 @@ async def _channel_for( destination.id, row["broadcaster_id"], ) + elif kind == "youtube": + result = await self.bot.pool.execute( + "UPDATE youtube_follows SET announce_channel_id = $3, " + "updated_at = now() WHERE id = $1 AND guild_id = $2", + follow_id, + guild_id, + destination.id, + ) else: result = await self.bot.pool.execute( "UPDATE notify_anime_follows SET announce_channel_id = $3, " @@ -1698,3 +2224,31 @@ async def notify_channel_anime_autocomplete( self, interaction: discord.Interaction, current: str ) -> list[app_commands.Choice[str]]: return await self._followed_anime_choices(interaction, current) + + @notify_channel.command(name="youtube") + @app_commands.describe( + channel="Channel name, ID, or mention; can be omitted when using the current channel.", + youtube="Follow ID or followed YouTube channel name/URL.", + ) + async def notify_channel_youtube( + self, + ctx: GuildContext, + channel: str | None = None, + *, + youtube: str | None = None, + ) -> None: + """Route a followed YouTube channel to a server text channel.""" + async with ctx.typing(ephemeral=ctx.interaction is not None): + await self._channel_for(ctx, "youtube", channel, youtube) + + @notify_channel_youtube.autocomplete("channel") + async def notify_channel_youtube_channel_autocomplete( + self, interaction: discord.Interaction, current: str + ) -> list[app_commands.Choice[str]]: + return self._guild_channel_choices(interaction, current) + + @notify_channel_youtube.autocomplete("youtube") + async def notify_channel_youtube_autocomplete( + self, interaction: discord.Interaction, current: str + ) -> list[app_commands.Choice[str]]: + return await self._followed_youtube_choices(interaction, current) diff --git a/tests/test_notify_youtube.py b/tests/test_notify_youtube.py new file mode 100644 index 0000000..f487d02 --- /dev/null +++ b/tests/test_notify_youtube.py @@ -0,0 +1,181 @@ +from __future__ import annotations + +import pytest + +from extensions.events.youtube import YouTubeNotifications +from extensions.settings.notify import _youtube_event_label, youtube_list_details +from extensions.settings.notify_commands import Notify, _follow_display_name + + +def test_youtube_event_label_maps_command_choices() -> None: + assert _youtube_event_label(["video", "live"]) == "uploads + live" + assert _youtube_event_label(["video"]) == "uploads" + assert _youtube_event_label(["live"]) == "live" + assert _youtube_event_label(["short"]) == "shorts" + assert _youtube_event_label(["community"]) == "community" + assert _youtube_event_label(["video", "community"]) == "community, video" + assert _youtube_event_label([]) == "no events" + + +def test_youtube_events_maps_option_to_event_types() -> None: + assert Notify._youtube_events("uploads") == ("video",) + assert Notify._youtube_events("live") == ("live",) + assert Notify._youtube_events("both") == ("video", "live") + assert Notify._youtube_events(None) == ("video", "live") + assert Notify._youtube_events("UPLOADS") == ("video",) + assert Notify._youtube_events("stream") == ("live",) + + +def test_follow_display_name_by_kind() -> None: + row = {"channel_name": "Some Channel", "title": "Some Anime"} + assert _follow_display_name(row, "youtube") == "Some Channel" + assert _follow_display_name(row, "twitch") == "Some Channel" + assert _follow_display_name(row, "anime") == "Some Anime" + + +def test_youtube_list_details_renders_dm_and_channel_destinations() -> None: + rows = [ + { + "id": 3, + "channel_name": "Channel One", + "channel_handle": "@one", + "announce_channel_id": None, + "event_types": ["video", "live"], + "mention_role_id": None, + "mention_everyone": False, + }, + { + "id": 4, + "channel_name": "Channel Two", + "channel_handle": None, + "announce_channel_id": 123456789012345678, + "event_types": ["video"], + "mention_role_id": None, + "mention_everyone": False, + }, + ] + text = youtube_list_details(rows) + assert "3 · Channel One" in text + assert "DM · @one · uploads + live" in text + assert "4 · Channel Two" in text + assert "<#123456789012345678> · uploads" in text + + +def test_youtube_owner_resolves_guild_or_user_scope() -> None: + assert YouTubeNotifications._youtube_owner( + {"guild_id": 1, "user_id": None} + ) == ("guild_id", 1) + assert YouTubeNotifications._youtube_owner( + {"guild_id": None, "user_id": 42} + ) == ("user_id", 42) + + +@pytest.mark.asyncio +async def test_youtube_destination_prefers_channel_then_dm_user() -> None: + class Bot: + def get_channel(self, channel_id: int): + assert channel_id == 999 + return "the-channel" + + async def fetch_channel(self, _channel_id: int): + raise RuntimeError("unused") + + notifications = YouTubeNotifications() + notifications.bot = Bot() + row = {"announce_channel_id": 999, "user_id": 42} + assert await notifications._youtube_destination(row) == "the-channel" + + class BotDM: + def get_user(self, user_id: int): + assert user_id == 42 + return "the-user" + + async def fetch_user(self, _user_id: int): + raise RuntimeError("unused") + + notifications.bot = BotDM() + row_dm = {"announce_channel_id": None, "user_id": 42} + assert await notifications._youtube_destination(row_dm) == "the-user" + + class BotMissing: + def get_user(self, _user_id: int): + return None + + async def fetch_user(self, user_id: int): + assert user_id == 7 + return "fetched-user" + + notifications.bot = BotMissing() + row_missing = {"announce_channel_id": None, "user_id": 7} + assert await notifications._youtube_destination(row_missing) == "fetched-user" + + +def _make_notifications(executions: list[tuple]) -> YouTubeNotifications: + class Pool: + def __init__(self) -> None: + self.calls = 0 + + async def fetchval(self, *args): + executions.append(args) + self.calls += 1 + # The INSERT ... RETURNING claim returns the item_id on the first + # call; later status/attempt lookups return nothing. + return "video-id" if self.calls == 1 else None + + async def execute(self, *args): + executions.append(args) + + class Notifications(YouTubeNotifications): + def __init__(self) -> None: + self.bot = type("Bot", (), {"pool": Pool()})() + + async def _announce_youtube(self, *_args) -> bool: + return True + + return Notifications() + + +@pytest.mark.asyncio +async def test_announce_youtube_once_uses_scope_owner_for_claim() -> None: + """DM follows must claim deliveries by user scope, not guild scope.""" + executions: list[tuple] = [] + notifications = _make_notifications(executions) + row = { + "guild_id": None, + "user_id": 42, + "youtube_channel_id": "UC1234567890123456789012", + } + claimed = await notifications._announce_youtube_once( + row, "video-id", "video", {} + ) + assert claimed is True + # The first insert must be keyed on user_id so the partial unique index + # (user_id, youtube_channel_id, item_id, event_type) is honored. + insert_sql = str(executions[0][0]) + assert "user_id" in insert_sql + assert ( + "ON CONFLICT (user_id, youtube_channel_id, item_id, event_type)" + in insert_sql + ) + + +@pytest.mark.asyncio +async def test_announce_youtube_once_claims_by_guild_for_guild_rows() -> None: + """Guild follows keep the existing guild-keyed delivery dedup.""" + executions: list[tuple] = [] + notifications = _make_notifications(executions) + row = { + "guild_id": 7, + "user_id": None, + "youtube_channel_id": "UC1234567890123456789012", + } + claimed = await notifications._announce_youtube_once( + row, "video-id", "video", {} + ) + assert claimed is True + insert_sql = str(executions[0][0]) + assert "guild_id" in insert_sql + assert ( + "ON CONFLICT (guild_id, youtube_channel_id, item_id, event_type)" + in insert_sql + )