3 Commits

Author SHA1 Message Date
Abdulkadir Furkan Şanlı 611a9551df Update README.
Signed-off-by: Abdulkadir Furkan Şanlı <me@abdulocra.cy>
2026-04-20 11:42:59 +02:00
Abdulkadir Furkan Şanlı 512275a8b8 Switch to synchronous Python.
Signed-off-by: Abdulkadir Furkan Şanlı <me@abdulocra.cy>
2026-04-19 19:47:28 +02:00
Abdulkadir Furkan Şanlı 3abc37bb3c Pin versions and use uv for speed.
Signed-off-by: Abdulkadir Furkan Şanlı <me@abdulocra.cy>
2026-04-19 19:37:26 +02:00
4 changed files with 63 additions and 50 deletions
+6 -3
View File
@@ -1,12 +1,15 @@
FROM python:3 FROM python:3.14
COPY --from=ghcr.io/astral-sh/uv:latest /uv /uvx /bin/
ENV PYTHONUNBUFFERED=1 ENV PYTHONUNBUFFERED=1
WORKDIR /usr/src/app WORKDIR /usr/src/app
COPY main.py parker.gif requirements.txt ./ COPY requirements.txt ./
RUN uv pip install --system --no-cache -r requirements.txt
RUN pip install --no-cache-dir -r requirements.txt COPY main.py parker.gif ./
USER 1000:1000 USER 1000:1000
+5 -4
View File
@@ -1,16 +1,17 @@
# ParkerBot # ParkerBot
ParkerBot is a Matrix bot that monitors a channel for YouTube links and ParkerBot is a Matrix bot that monitors a channel for YouTube links and
generates weekly playlists from them. generates weekly playlists from them. It also sends YouTube link titles to the
channel it's in.
## Running locally ## Running locally
1. Clone the repo 1. Clone the repo
2. Install the dependencies, preferably in a venv: 2. Install the dependencies, preferably in a venv:
```shell ```shell
python3 -m venv venv uv venv --python 3.14 --seed
source ./venv/bin/activate source .venv/bin/activate
pip3 install -r requirements.txt uv pip install -r requirements.txt
``` ```
3. Copy [example.env](example.env) to `.env`, customize it. 3. Copy [example.env](example.env) to `.env`, customize it.
4. Source `.env`: 4. Source `.env`:
+50 -41
View File
@@ -2,7 +2,6 @@
"""ParkerBot""" """ParkerBot"""
import argparse import argparse
import asyncio
import datetime import datetime
import html import html
import os import os
@@ -15,7 +14,7 @@ from google.auth.transport.requests import Request
from google_auth_oauthlib.flow import InstalledAppFlow from google_auth_oauthlib.flow import InstalledAppFlow
from googleapiclient.discovery import build from googleapiclient.discovery import build
from googleapiclient import errors from googleapiclient import errors
from nio import AsyncClient, RoomMessageText, SyncResponse, UploadResponse from nio import HttpClient, RoomMessageText, SyncResponse, UploadResponse
DATA_DIR = os.getenv("DATA_DIR", "./") DATA_DIR = os.getenv("DATA_DIR", "./")
DB_PATH = os.path.join(DATA_DIR, "parkerbot.sqlite3") DB_PATH = os.path.join(DATA_DIR, "parkerbot.sqlite3")
@@ -183,18 +182,18 @@ def get_video_info(youtube, video_id):
"""Check whether a YouTube video is music and return its title and channel.""" """Check whether a YouTube video is music and return its title and channel."""
try: try:
video_details = youtube.videos().list(id=video_id, part="snippet").execute() video_details = youtube.videos().list(id=video_id, part="snippet").execute()
# Check if the video actually exists/is accessible # Check if the video actually exists/is accessible
if not video_details.get("items"): if not video_details.get("items"):
return False, "[Video unavailable or private]", "" return False, "[Video unavailable or private]", ""
snippet = video_details["items"][0]["snippet"] snippet = video_details["items"][0]["snippet"]
# Check if the video category is Music (10) or Entertainment (24) # Check if the video category is Music (10) or Entertainment (24)
is_music = snippet.get("categoryId") in ("10", "24") is_music = snippet.get("categoryId") in ("10", "24")
title = snippet.get("title", "[Unknown Title]") title = snippet.get("title", "[Unknown Title]")
channel = snippet.get("channelTitle", "[Unknown Channel]") channel = snippet.get("channelTitle", "[Unknown Channel]")
return is_music, title, channel return is_music, title, channel
except errors.HttpError as error: except errors.HttpError as error:
@@ -205,14 +204,14 @@ def get_video_info(youtube, video_id):
return False, "[Error fetching title]", "" return False, "[Error fetching title]", ""
async def send_intro_message(client, sender, room_id): def send_intro_message(client, sender, room_id):
"""Sends introduction message in reply to sender, in room with room_id.""" """Sends introduction message in reply to sender, in room with room_id."""
intro_message = ( intro_message = (
f"Hi {sender}, I'm ParkerBot! I generate YouTube playlists from links " f"Hi {sender}, I'm ParkerBot! I generate YouTube playlists from links "
"sent to this channel. You can find my source code here: " "sent to this channel. You can find my source code here: "
"https://git.abdulocra.cy/abdulocracy/parkerbot" "https://git.abdulocra.cy/abdulocracy/parkerbot"
) )
await client.room_send( client.room_send(
room_id=room_id, room_id=room_id,
message_type="m.room.message", message_type="m.room.message",
content={"msgtype": "m.text", "body": intro_message}, content={"msgtype": "m.text", "body": intro_message},
@@ -220,11 +219,11 @@ async def send_intro_message(client, sender, room_id):
# TODO: Figure out how to properly send GIF, this is broken as shit. # TODO: Figure out how to properly send GIF, this is broken as shit.
with open("./parker.gif", "rb") as gif_file: with open("./parker.gif", "rb") as gif_file:
response = await client.upload(gif_file, content_type="image/gif") response = client.upload(gif_file, content_type="image/gif")
if isinstance(response, UploadResponse): if isinstance(response, UploadResponse):
print("Image was uploaded successfully to server. ") print("Image was uploaded successfully to server. ")
gif_uri = response.content_uri gif_uri = response.content_uri
await client.room_send( client.room_send(
room_id=room_id, room_id=room_id,
message_type="m.room.message", message_type="m.room.message",
content={ content={
@@ -238,29 +237,29 @@ async def send_intro_message(client, sender, room_id):
print(f"Failed to upload image. Failure response: {response}") print(f"Failed to upload image. Failure response: {response}")
async def send_playlist_of_week(client, sender, room_id, playlist_id): def send_playlist_of_week(client, sender, room_id, playlist_id):
"""Sends playlist of the week in reply to sender, in room with room_id.""" """Sends playlist of the week in reply to sender, in room with room_id."""
playlist_link = f"https://www.youtube.com/playlist?list={playlist_id}" playlist_link = f"https://www.youtube.com/playlist?list={playlist_id}"
reply_msg = f"{sender}, here's the playlist of the week: {playlist_link}" reply_msg = f"{sender}, here's the playlist of the week: {playlist_link}"
await client.room_send( client.room_send(
room_id=room_id, room_id=room_id,
message_type="m.room.message", message_type="m.room.message",
content={"msgtype": "m.text", "body": reply_msg}, content={"msgtype": "m.text", "body": reply_msg},
) )
async def send_playlist_of_all(client, sender, room_id, playlist_id): def send_playlist_of_all(client, sender, room_id, playlist_id):
"""Sends playlist of all time in reply to sender, in room with room_id.""" """Sends playlist of all time in reply to sender, in room with room_id."""
playlist_link = f"https://www.youtube.com/playlist?list={playlist_id}" playlist_link = f"https://www.youtube.com/playlist?list={playlist_id}"
reply_msg = f"{sender}, here's the playlist of all time: {playlist_link}" reply_msg = f"{sender}, here's the playlist of all time: {playlist_link}"
await client.room_send( client.room_send(
room_id=room_id, room_id=room_id,
message_type="m.room.message", message_type="m.room.message",
content={"msgtype": "m.text", "body": reply_msg}, content={"msgtype": "m.text", "body": reply_msg},
) )
async def message_callback(conn, cursor, youtube, client, room, event): def message_callback(conn, cursor, youtube, client, room, event):
"""Event handler for received messages.""" """Event handler for received messages."""
sender = event.sender sender = event.sender
if sender != MATRIX_USER: if sender != MATRIX_USER:
@@ -274,23 +273,24 @@ async def message_callback(conn, cursor, youtube, client, room, event):
) )
timestamp_sec = datetime.datetime.fromtimestamp( timestamp_sec = datetime.datetime.fromtimestamp(
event.server_timestamp / 1000, datetime.UTC # millisec to sec event.server_timestamp / 1000,
datetime.UTC, # millisec to sec
) )
current_time = datetime.datetime.now(datetime.UTC) current_time = datetime.datetime.now(datetime.UTC)
# Account for up to 5 minutes of clock drift # Account for up to 5 minutes of clock drift
recent = abs(current_time - timestamp_sec) < datetime.timedelta(minutes=5) recent = abs(current_time - timestamp_sec) < datetime.timedelta(minutes=5)
if body == "!parkerbot" and recent: if body == "!parkerbot" and recent:
await send_intro_message(client, sender, room.room_id) send_intro_message(client, sender, room.room_id)
return return
if body == "!week" and recent: if body == "!week" and recent:
await send_playlist_of_week(client, sender, room.room_id, playlist_id) send_playlist_of_week(client, sender, room.room_id, playlist_id)
return return
if body == "!all" and recent: if body == "!all" and recent:
await send_playlist_of_all(client, sender, room.room_id, all_playlist_id) send_playlist_of_all(client, sender, room.room_id, all_playlist_id)
return return
youtube_link_pattern = ( youtube_link_pattern = (
@@ -301,29 +301,31 @@ async def message_callback(conn, cursor, youtube, client, room, event):
for link in youtube_links: for link in youtube_links:
video_id = link.split("v=")[-1].split("&")[0].split("/")[-1] video_id = link.split("v=")[-1].split("&")[0].split("/")[-1]
# Safely fetch the category check, title, and channel # Safely fetch the category check, title, and channel
is_music_vid, title, channel = get_video_info(youtube, video_id) is_music_vid, title, channel = get_video_info(youtube, video_id)
# Send the title to the channel so people know what the link is # Send the title to the channel so people know what the link is
# Only do this for recent messages to prevent spam during backwards-sync # Only do this for recent messages to prevent spam during backwards-sync
if recent: if recent:
plain_text = f"{title}" plain_text = f"{title}"
if channel: if channel:
plain_text += f" - {channel}" plain_text += f" - {channel}"
# Escape HTML characters to prevent broken rendering in Matrix # Escape HTML characters to prevent broken rendering in Matrix
escaped_text = html.escape(plain_text) escaped_text = html.escape(plain_text)
html_text = f"<em><span data-mx-color='#808080'>{escaped_text}</span></em>" html_text = (
f"<em><span data-mx-color='#808080'>{escaped_text}</span></em>"
await client.room_send( )
client.room_send(
room_id=room.room_id, room_id=room.room_id,
message_type="m.room.message", message_type="m.room.message",
content={ content={
"msgtype": "m.text", "msgtype": "m.text",
"body": plain_text, "body": plain_text,
"format": "org.matrix.custom.html", "format": "org.matrix.custom.html",
"formatted_body": html_text "formatted_body": html_text,
}, },
) )
@@ -380,7 +382,7 @@ def record_message(conn, cursor, sender, link, timestamp):
return cursor.fetchone()[0] return cursor.fetchone()[0]
async def sync_callback(response): def sync_callback(response):
"""Saves Matrix sync token.""" """Saves Matrix sync token."""
with open(TOKEN_PATH, "w", encoding="utf-8") as f: with open(TOKEN_PATH, "w", encoding="utf-8") as f:
f.write(response.next_batch) f.write(response.next_batch)
@@ -395,9 +397,9 @@ def load_sync_token():
return None return None
async def get_client(conn, cursor, youtube): def get_client(conn, cursor, youtube):
"""Returns configured and logged in Matrix client.""" """Returns configured and logged in Matrix client."""
client = AsyncClient(MATRIX_SERVER, MATRIX_USER) client = HttpClient(MATRIX_SERVER, MATRIX_USER)
client.add_event_callback( client.add_event_callback(
lambda room, event: message_callback( lambda room, event: message_callback(
conn, cursor, youtube, client, room, event conn, cursor, youtube, client, room, event
@@ -405,23 +407,23 @@ async def get_client(conn, cursor, youtube):
RoomMessageText, RoomMessageText,
) )
client.add_response_callback(sync_callback, SyncResponse) client.add_response_callback(sync_callback, SyncResponse)
print(await client.login(MATRIX_PASSWORD)) print(client.login(MATRIX_PASSWORD))
return client return client
async def backwards_sync(conn, cursor, youtube, client, room, start_token): def backwards_sync(conn, cursor, youtube, client, room, start_token):
"""Fetch and process historical messages from a given room.""" """Fetch and process historical messages from a given room."""
print("Starting to process channel log...") print("Starting to process channel log...")
from_token = start_token from_token = start_token
room_id = room.room_id room_id = room.room_id
while True: while True:
# Fetch room messages # Fetch room messages
response = await client.room_messages(room_id, from_token, direction="b") response = client.room_messages(room_id, from_token, direction="b")
# Process each message # Process each message
for event in response.chunk: for event in response.chunk:
if isinstance(event, RoomMessageText): if isinstance(event, RoomMessageText):
await message_callback(conn, cursor, youtube, client, room, event) message_callback(conn, cursor, youtube, client, room, event)
# Break if there are no more messages to fetch # Break if there are no more messages to fetch
if not response.end or response.end == from_token: if not response.end or response.end == from_token:
@@ -431,23 +433,30 @@ async def backwards_sync(conn, cursor, youtube, client, room, start_token):
from_token = response.end from_token = response.end
async def main(): def main():
"""Get DB and Matrix client ready, and start syncing.""" """Get DB and Matrix client ready, and start syncing."""
args = parse_arguments() args = parse_arguments()
conn, cursor = connect_db() conn, cursor = connect_db()
define_tables(conn, cursor) define_tables(conn, cursor)
youtube = get_authenticated_service() youtube = get_authenticated_service()
client = await get_client(conn, cursor, youtube) client = get_client(conn, cursor, youtube)
sync_token = load_sync_token() sync_token = load_sync_token()
# This is incredibly dumb and most probably will exceed your YouTube API quota. # This is incredibly dumb and most probably will exceed your YouTube API quota.
if args.backwards_sync: if args.backwards_sync:
init_sync = await client.sync(30000) init_sync = client.sync(30000)
room = await client.room_resolve_alias(MATRIX_ROOM) room = client.room_resolve_alias(MATRIX_ROOM)
await backwards_sync(conn, cursor, youtube, client, room, init_sync.next_batch) backwards_sync(conn, cursor, youtube, client, room, init_sync.next_batch)
await client.sync_forever(30000, full_state=True, since=sync_token) print("Started syncing...")
while True:
try:
client.sync(timeout=30000, full_state=True, since=sync_token)
sync_token = load_sync_token()
except Exception as e:
print(f"Sync error: {e}")
time.sleep(5)
if __name__ == "__main__": if __name__ == "__main__":
asyncio.run(main()) main()
+2 -2
View File
@@ -1,3 +1,3 @@
matrix-nio == 0.24.0 matrix-nio == 0.24.0
google-auth-oauthlib google-auth-oauthlib == 1.3.1
google-api-python-client google-api-python-client == 2.194.0