304 lines
7.9 KiB
Python
Executable File
304 lines
7.9 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
import os, random, requests, discord
|
|
from discord import app_commands
|
|
from discord.ext import commands
|
|
from datetime import datetime
|
|
from pathlib import Path
|
|
|
|
docstring = """
|
|
This script connects to a Miniflux RSS feed reader instance and a Discord bot.
|
|
It allows users to fetch random unread articles from specified RSS feeds
|
|
via Discord commands, mark them as read in Miniflux, and append their details
|
|
to an Org-mode file for personal knowledge management.
|
|
|
|
These are the available commands:
|
|
- /rss [feed]: Fetch a random unread article from the specified feed.
|
|
- /rss-any: Fetch a random unread article from any feed.
|
|
- /next: Fetch another article from the same feed.
|
|
- /done: Mark the current article as read and save it to the Org file.
|
|
"""
|
|
|
|
# -----------------------------
|
|
# CONFIG
|
|
# -----------------------------
|
|
MINIFLUX_URL = "https://miniflux.zainezq.com"
|
|
MINIFLUX_TOKEN = "Q_tDLF7Ht-aPjub2Xsbn6JCKM1WbNUEDfjHPYAIzT0I="
|
|
DISCORD_TOKEN = "MTQ1OTYwNzg2Mjg1Nzk1NzY0OQ.GBTRdl.iqfwD3kMN277z7rhm6lG5EgwSyqyPoCF1t8NVI"
|
|
ORG_RSS_FILE = "/home/zaine/master-folder/org_files/org_roam/20260111204228-rss_articles.org"
|
|
GUILD_ID = 1459545736961327116
|
|
|
|
HEADERS = {
|
|
"X-Auth-Token": MINIFLUX_TOKEN
|
|
}
|
|
|
|
intents = discord.Intents.default()
|
|
bot = commands.Bot(command_prefix="!", intents=intents)
|
|
|
|
tree = bot.tree
|
|
|
|
current_state = {}
|
|
|
|
|
|
|
|
def append_to_org(entry):
|
|
org_path = Path(ORG_RSS_FILE)
|
|
org_path.parent.mkdir(parents=True, exist_ok=True)
|
|
|
|
title = entry["title"].replace("\n", " ").strip()
|
|
url = entry["url"]
|
|
feed = entry["feed"]["title"]
|
|
entry_id = entry["id"]
|
|
date_read = datetime.now().strftime("%Y-%m-%d %H:%M")
|
|
|
|
org_block = f"""* [[{url}][{title}]]
|
|
:PROPERTIES:
|
|
:FEED: {feed}
|
|
:MINIFLUX_ID: {entry_id}
|
|
:READ_AT: {date_read}
|
|
:END:
|
|
|
|
"""
|
|
|
|
with org_path.open("a", encoding="utf-8") as f:
|
|
f.write(org_block)
|
|
|
|
|
|
def get_feeds():
|
|
r = requests.get(f"{MINIFLUX_URL}/v1/feeds", headers=HEADERS, timeout=10)
|
|
r.raise_for_status()
|
|
return r.json()
|
|
|
|
|
|
def get_unread_entries(feed_id=None, limit=100):
|
|
params = {"status": "unread", "limit": limit}
|
|
if feed_id:
|
|
params["feed_id"] = feed_id
|
|
|
|
r = requests.get(
|
|
f"{MINIFLUX_URL}/v1/entries",
|
|
headers=HEADERS,
|
|
params=params,
|
|
timeout=10
|
|
)
|
|
r.raise_for_status()
|
|
return r.json()["entries"]
|
|
|
|
|
|
def mark_as_read(entry_id):
|
|
r = requests.put(
|
|
f"{MINIFLUX_URL}/v1/entries",
|
|
headers=HEADERS,
|
|
json={"entry_ids": [entry_id], "status": "read"},
|
|
timeout=10
|
|
)
|
|
r.raise_for_status()
|
|
|
|
async def send_random_entry(
|
|
interaction: discord.Interaction,
|
|
feed_id: int,
|
|
):
|
|
entries = get_unread_entries(feed_id=feed_id)
|
|
|
|
if not entries:
|
|
await interaction.followup.send(
|
|
"🎉 No unread articles in this feed!"
|
|
)
|
|
return
|
|
|
|
entry = random.choice(entries)
|
|
|
|
current_state[interaction.channel_id] = {
|
|
"entry": entry,
|
|
"feed_id": feed_id
|
|
}
|
|
|
|
embed = discord.Embed(
|
|
title=entry["title"],
|
|
url=entry["url"],
|
|
description=(entry.get("summary") or "")[:500],
|
|
color=0x3498DB
|
|
)
|
|
|
|
embed.set_footer(
|
|
text=f"{entry['feed']['title']} • /next or /done"
|
|
)
|
|
|
|
await interaction.followup.send(embed=embed)
|
|
|
|
|
|
|
|
async def feed_autocomplete(
|
|
interaction: discord.Interaction,
|
|
current: str,
|
|
):
|
|
feeds = get_feeds()
|
|
return [
|
|
app_commands.Choice(name=f["title"], value=str(f["id"]))
|
|
for f in feeds
|
|
if current.lower() in f["title"].lower()
|
|
][:25]
|
|
|
|
async def entry_autocomplete(
|
|
interaction: discord.Interaction,
|
|
current: str,
|
|
):
|
|
if len(current) < 3:
|
|
return []
|
|
|
|
matches = []
|
|
offset = 0
|
|
limit = 100
|
|
|
|
while len(matches) < 25:
|
|
r = requests.get(
|
|
f"{MINIFLUX_URL}/v1/entries",
|
|
headers=HEADERS,
|
|
params={
|
|
"status": "unread",
|
|
"limit": limit,
|
|
"offset": offset,
|
|
},
|
|
timeout=10
|
|
)
|
|
r.raise_for_status()
|
|
|
|
entries = r.json()["entries"]
|
|
if not entries:
|
|
break # no more unread entries
|
|
|
|
for e in entries:
|
|
if current.lower() in e["title"].lower():
|
|
matches.append(
|
|
app_commands.Choice(
|
|
name=e["title"][:100],
|
|
value=str(e["id"])
|
|
)
|
|
)
|
|
if len(matches) >= 25:
|
|
break
|
|
|
|
offset += limit
|
|
|
|
return matches
|
|
|
|
|
|
def get_entry(entry_id: int):
|
|
r = requests.get(
|
|
f"{MINIFLUX_URL}/v1/entries/{entry_id}",
|
|
headers=HEADERS,
|
|
timeout=10
|
|
)
|
|
r.raise_for_status()
|
|
return r.json()
|
|
|
|
async def send_entry_embed(interaction, entry):
|
|
current_state[interaction.channel_id] = {
|
|
"entry": entry,
|
|
"feed_id": entry["feed"]["id"]
|
|
}
|
|
|
|
embed = discord.Embed(
|
|
title=entry["title"],
|
|
url=entry["url"],
|
|
description=(entry.get("summary") or "")[:500],
|
|
color=0x3498DB
|
|
)
|
|
|
|
embed.set_footer(
|
|
text=f"{entry['feed']['title']} • /next or /done"
|
|
)
|
|
|
|
await interaction.followup.send(embed=embed)
|
|
|
|
|
|
@tree.command(name="rss", description="Get a random unread RSS article")
|
|
@app_commands.describe(feed="Which feed to read from")
|
|
@app_commands.autocomplete(feed=feed_autocomplete)
|
|
async def rss(interaction: discord.Interaction, feed: str):
|
|
await interaction.response.defer()
|
|
await send_random_entry(interaction, feed_id=int(feed))
|
|
|
|
@tree.command(name="rss-any", description="Get a random unread RSS article from any feed")
|
|
async def rss_any(interaction: discord.Interaction):
|
|
await interaction.response.defer()
|
|
await send_random_entry(interaction, feed_id=None)
|
|
|
|
@tree.command(
|
|
name="rss-find",
|
|
description="Search unread RSS articles by title"
|
|
)
|
|
@app_commands.describe(title="Start typing the article title")
|
|
@app_commands.autocomplete(title=entry_autocomplete)
|
|
async def rss_find(interaction: discord.Interaction, title: str):
|
|
await interaction.response.defer()
|
|
|
|
entry_id = int(title)
|
|
entry = get_entry(entry_id)
|
|
|
|
await send_entry_embed(interaction, entry)
|
|
|
|
|
|
@tree.command(name="next", description="Get another article from the same feed")
|
|
async def next_article(interaction: discord.Interaction):
|
|
state = current_state.get(interaction.channel_id)
|
|
|
|
if not state:
|
|
await interaction.response.send_message(
|
|
"⚠️ No active feed. Use /rss first.",
|
|
ephemeral=True
|
|
)
|
|
return
|
|
|
|
await interaction.response.defer()
|
|
await send_random_entry(
|
|
interaction,
|
|
feed_id=state.get("feed_id")
|
|
|
|
)
|
|
|
|
@tree.command(name="done", description="Mark the current article as read and save to Org")
|
|
async def done(interaction: discord.Interaction):
|
|
state = current_state.get(interaction.channel_id)
|
|
|
|
if not state:
|
|
await interaction.response.send_message(
|
|
"⚠️ No article to mark as read.",
|
|
ephemeral=True
|
|
)
|
|
return
|
|
|
|
if "entry" not in state:
|
|
await interaction.response.send_message(
|
|
"⚠️ This article was queued before the last update. Please use /rss again.",
|
|
ephemeral=True
|
|
)
|
|
del current_state[interaction.channel_id]
|
|
return
|
|
|
|
entry = state["entry"]
|
|
|
|
mark_as_read(entry["id"])
|
|
|
|
append_to_org(entry)
|
|
|
|
del current_state[interaction.channel_id]
|
|
|
|
await interaction.response.send_message(
|
|
"✅ Article marked as read and added to Org"
|
|
)
|
|
|
|
|
|
@bot.event
|
|
async def on_ready():
|
|
guild = discord.Object(id=GUILD_ID)
|
|
tree.copy_global_to(guild=guild)
|
|
await tree.sync(guild=guild)
|
|
print(f"✅ Logged in as {bot.user} (guild-only sync)")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
if not all([MINIFLUX_URL, MINIFLUX_TOKEN, DISCORD_TOKEN]):
|
|
raise RuntimeError("Missing environment variables")
|
|
|
|
bot.run(DISCORD_TOKEN)
|