Skip to content
Open
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
11 changes: 10 additions & 1 deletion LOCAL_SETUP.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,13 @@ YOUTUBE_API_KEY=your-youtube-api-key

Edit `config/config.yaml`:
- Set real `discord_channel_id` values for `youtube` and `blog`.
- To tag @Campus Expert on the blog digest, set `blog.mention_role_id` to the
role's numeric ID in quotes. Copy the ID from the server's role settings with
Developer Mode enabled. Leave `null` to post without a tag.
- Check that the bot has `View Channel`, `Send Messages`, `Embed Links`, and,
for a role not mentionable by everyone, `Mention @everyone, @here, and All Roles`
in the blog channel. Existing permission is sufficient; no Administrator or
Manage Roles permission is needed.
- Optionally adjust `keywords`, `digest_count`, and `search_pool`.

## 4. Install dependencies and run
Expand All @@ -57,5 +64,7 @@ python main.py

## 5. What to expect right away

- The process logs in, posts the configured digests when run on Thursday, and exits.
- The process posts the configured digests whenever it is run, and exits.
- Only the blog message tags the configured role. A local run can notify real
members; leave `blog.mention_role_id` as `null` when you do not want a tag.
- Run it from the project root so `config/config.yaml` resolves correctly.
6 changes: 6 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,12 @@ Configure Sparked Host to run `python main.py` from the repository root every
Thursday. The script fetches both sources, posts through the Mona Media bot
token using Discord's HTTP API, and exits.

To tag **@Campus Expert** on the blog digest, set `blog.mention_role_id` in
`config/config.yaml` to the role's numeric ID in quotes. Leave it `null` to post
without a tag. YouTube never tags the role. The existing bot permission to
mention all roles is sufficient if it applies in the destination channel.
No Sparked Host schedule or startup command changes are needed.

See **[SETUP.md](SETUP.md)** for the full setup guide, including:

- How to create a Discord bot application and get a token
Expand Down
38 changes: 33 additions & 5 deletions SETUP.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,23 @@ API, and exits. It does not require a continuously running bot or Docker.
3. Enable Developer Mode in Discord and copy the destination channel IDs.
4. Set the `discord_channel_id` values in `config/config.yaml`.

### Optional Campus Expert role tag

Copy the Campus Expert role ID from the server's role settings with Developer
Mode enabled. Set `blog.mention_role_id` in `config/config.yaml` to that numeric
ID in quotes, not the role name. Leave it `null` to post without a tag.

The bot needs `View Channel`, `Send Messages`, and `Embed Links` in the destination
channel. To tag a role that is not mentionable by everyone, it also needs
`Mention @everyone, @here, and All Roles` there. If it already has that permission,
no extra permission is needed. Do not grant Administrator or Manage Roles or
make the role mentionable by everyone for this change.

The blog message allows only the configured role to be notified. It does not
notify everyone, here, other roles, or individual users. YouTube never tags the
role, even if no blog posts are found. Repeated runs can post and tag again.
Members' notification settings can suppress push notifications.

## Configure YouTube

1. Enable YouTube Data API v3 in the
Expand Down Expand Up @@ -48,20 +65,30 @@ The cron schedule is responsible for running it on Thursday.

## Sparked Host cron setup

Create a weekly cron job that:
Keep the existing weekly schedule that starts the Sparked Host server:

1. Runs on Thursday.
2. Uses the repository root as its working directory.
3. Provides `DISCORD_TOKEN` and `YOUTUBE_API_KEY`.
4. Runs `python main.py`.
2. Starts the server with `python main.py` as its beginning command.
3. Uses the repository root as its working directory.
4. Provides `DISCORD_TOKEN` and `YOUTUBE_API_KEY`.

The process posts the digests and exits. The server is expected to shut down
afterward; it does not need to remain running.

Equivalent command:

```bash
cd /path/to/DevRel-Discord-Bot && /path/to/python main.py
```

If one source fails, the process reports failure after attempting both.
To deploy the role tag, upload the updated application and configuration files
through your existing deployment method, then set the real `blog.mention_role_id`
on Sparked Host. Keep the existing schedule, beginning command, and secrets.
Coordinate one live check: the blog message should show @Campus Expert, YouTube
should have no tag, and the process should exit as before. This check notifies
real members, so do not repeatedly start the production job for testing.

Clear `blog.mention_role_id` to disable the tag.

## Configuration

Expand All @@ -70,6 +97,7 @@ If one source fails, the process reports failure after attempting both.
- GitHub YouTube channel and Discord destination
- GitHub Blog feed and Discord destination
- Blog keywords
- Optional blog role ID to tag
- Number of entries and candidate search pool

The process expects these environment variables:
Expand Down
4 changes: 4 additions & 0 deletions config/config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,10 @@ blog:
# Replace with your actual channel ID (integer)
discord_channel_id: 767777046746890261

# Optional: copy the Campus Expert role ID in Discord and paste it in quotes.
# Leave null to post without a role tag. YouTube posts never tag this role.
mention_role_id: null

# Topics to filter blog posts by (OR logic – a post matches if any keyword
# appears in its title, summary, or body)
keywords:
Expand Down
27 changes: 25 additions & 2 deletions main.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,10 +35,17 @@ def load_config() -> dict:
return config


def post_to_discord(token: str, channel_id: int, embed: dict) -> None:
def post_to_discord(
token: str, channel_id: int, embed: dict, mention_role_id: str | None = None
) -> None:
payload = {"embeds": [embed], "allowed_mentions": {"parse": []}}
if mention_role_id is not None:
payload["content"] = f"<@&{mention_role_id}>"
payload["allowed_mentions"]["roles"] = [mention_role_id]

request = Request(
f"https://discord.com/api/v10/channels/{channel_id}/messages",
data=json.dumps({"embeds": [embed]}).encode(),
data=json.dumps(payload).encode(),
headers={
"Authorization": f"Bot {token}",
"Content-Type": "application/json",
Expand All @@ -59,6 +66,21 @@ def run_digest(config: dict, token: str) -> None:
since = now - timedelta(days=7)

blog = config["blog"]
mention_role_id = blog.get("mention_role_id")
if mention_role_id is None or mention_role_id == "":
mention_role_id = None
else:
if isinstance(mention_role_id, bool) or not isinstance(mention_role_id, (int, str)):
raise ValueError("blog.mention_role_id must be a positive numeric Discord role ID")
mention_role_id = str(mention_role_id)
if (
not mention_role_id.isascii()
or not mention_role_id.isdecimal()
or not 0 < int(mention_role_id) < 2**64
):
raise ValueError("blog.mention_role_id must be a positive numeric Discord role ID")
mention_role_id = str(int(mention_role_id))

posts = BlogFetcher(blog["feed_url"]).get_posts_since_by_keywords(
since=since,
keywords=blog["keywords"],
Expand All @@ -70,6 +92,7 @@ def run_digest(config: dict, token: str) -> None:
token,
int(blog["discord_channel_id"]),
build_blog_embed(posts, blog["keywords"], since, now),
mention_role_id=mention_role_id,
)
logger.info("Posted blog digest with %d post(s).", len(posts))
else:
Expand Down
98 changes: 89 additions & 9 deletions tests/test_cron_runner.py
Original file line number Diff line number Diff line change
@@ -1,13 +1,16 @@
import io
import json
import os
import unittest
from urllib.error import HTTPError
from unittest.mock import patch

from main import run_digest
from main import post_to_discord, run_digest


class CronRunnerTests(unittest.TestCase):
def test_run_digest_fetches_and_posts_blog_and_youtube(self):
config = {
def setUp(self):
self.config = {
"blog": {
"feed_url": "https://github.blog/feed/",
"discord_channel_id": 123,
Expand All @@ -22,21 +25,98 @@ def test_run_digest_fetches_and_posts_blog_and_youtube(self):
"search_pool": 10,
},
}
posts = [{"title": "Security", "summary": "Summary", "url": "https://example.com", "published": "today"}]
videos = [{"title": "Video", "description": "Description", "url": "https://youtube.com/watch?v=1", "thumbnail": "", "view_count": 1}]
self.posts = [{"title": "Security", "summary": "Summary", "url": "https://example.com", "published": "today"}]
self.videos = [{"title": "Video", "description": "Description", "url": "https://youtube.com/watch?v=1", "thumbnail": "", "view_count": 1}]
environment = patch.dict(os.environ, {"YOUTUBE_API_KEY": "fake-key"})
environment.start()
self.addCleanup(environment.stop)

with patch.dict(os.environ, {"YOUTUBE_API_KEY": "fake-key"}), patch(
"main.BlogFetcher.get_posts_since_by_keywords", return_value=posts
def test_run_digest_fetches_and_posts_blog_and_youtube(self):
with patch(
"main.BlogFetcher.get_posts_since_by_keywords", return_value=self.posts
), patch(
"main.YouTubeClient.get_top_recent_videos", return_value=videos
"main.YouTubeClient.get_top_recent_videos", return_value=self.videos
), patch("main.post_to_discord") as post:
run_digest(config, "discord-token")
run_digest(self.config, "discord-token")

self.assertEqual(post.call_count, 2)
self.assertEqual(post.call_args_list[0].args[0], "discord-token")
self.assertEqual(post.call_args_list[0].args[1], 123)
self.assertIsNone(post.call_args_list[0].kwargs["mention_role_id"])
self.assertEqual(post.call_args_list[1].args[1], 456)

def test_run_digest_tags_only_blog_and_preserves_embeds(self):
for role_id in ("123456789012345678", 123456789012345678, None, ""):
with self.subTest(role_id=role_id):
self.config["blog"]["mention_role_id"] = role_id
with patch(
"main.BlogFetcher.get_posts_since_by_keywords", return_value=self.posts
), patch(
"main.YouTubeClient.get_top_recent_videos", return_value=self.videos
), patch("main.urlopen") as send:
run_digest(self.config, "discord-token")

self.assertEqual(send.call_count, 2)
blog_payload, youtube_payload = [
json.loads(call.args[0].data) for call in send.call_args_list
]
self.assertEqual(len(blog_payload["embeds"][0]["fields"]), 1)
self.assertEqual(len(youtube_payload["embeds"][0]["fields"]), 1)
if role_id:
self.assertEqual(blog_payload["content"], "<@&123456789012345678>")
self.assertEqual(
blog_payload["allowed_mentions"],
{"parse": [], "roles": ["123456789012345678"]},
)
else:
self.assertNotIn("content", blog_payload)
self.assertEqual(blog_payload["allowed_mentions"], {"parse": []})
self.assertNotIn("content", youtube_payload)
self.assertEqual(youtube_payload["allowed_mentions"], {"parse": []})

def test_invalid_role_id_fails_before_fetching_or_posting(self):
for role_id in ("Campus Expert", "<@&123>", "0", -1, True, 1.5, [], {}, "123", 2**64):
with self.subTest(role_id=role_id):
self.config["blog"]["mention_role_id"] = role_id
with patch(
"main.BlogFetcher.get_posts_since_by_keywords"
) as blog, patch(
"main.YouTubeClient.get_top_recent_videos"
) as youtube, patch("main.urlopen") as send:
with self.assertRaisesRegex(ValueError, "blog.mention_role_id"):
run_digest(self.config, "discord-token")
blog.assert_not_called()
youtube.assert_not_called()
send.assert_not_called()

def test_empty_blog_does_not_tag_youtube(self):
self.config["blog"]["mention_role_id"] = "123456789012345678"
with patch(
"main.BlogFetcher.get_posts_since_by_keywords", return_value=[]
), patch(
"main.YouTubeClient.get_top_recent_videos", return_value=self.videos
), patch("main.urlopen") as send:
run_digest(self.config, "discord-token")

send.assert_called_once()
request = send.call_args.args[0]
self.assertIn("/channels/456/messages", request.full_url)
payload = json.loads(request.data)
self.assertNotIn("content", payload)
self.assertEqual(payload["allowed_mentions"], {"parse": []})

def test_discord_http_errors_are_reported(self):
error = HTTPError(
"https://discord.com/api/v10/channels/123/messages",
403,
"Forbidden",
{},
io.BytesIO(b'{"message": "Missing Permissions"}'),
)
with patch("main.urlopen", side_effect=error):
with self.assertRaisesRegex(RuntimeError, "HTTP 403.*Missing Permissions"):
post_to_discord("discord-token", 123, {}, "123456789012345678")


if __name__ == "__main__":
unittest.main()
Loading