Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
67 changes: 67 additions & 0 deletions migrations/0093_youtube_notify_follows.sql
Original file line number Diff line number Diff line change
@@ -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;
1 change: 1 addition & 0 deletions src/core/privacy.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@
"mudae_wishes",
"notify_twitch_follows",
"notify_anime_follows",
"youtube_follows",
"phone_consent",
"download_stats",
"download_events",
Expand Down
177 changes: 131 additions & 46 deletions src/extensions/events/youtube.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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,
Expand All @@ -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()
Expand All @@ -522,68 +612,52 @@ 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,
payload,
)
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",
Expand Down Expand Up @@ -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
Expand Down
43 changes: 43 additions & 0 deletions src/extensions/settings/notify.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -426,4 +468,5 @@ def anime_list_details(rows: Iterable[Any]) -> str:
"notify_allowed_mentions",
"select_anilist_media",
"twitch_list_details",
"youtube_list_details",
]
Loading
Loading