Initial commit.

This commit is contained in:
2024-01-07 12:26:19 -05:00
commit e6473513f0
9 changed files with 577 additions and 0 deletions

320
main.py Normal file
View File

@@ -0,0 +1,320 @@
import logging
import timer
from collections import OrderedDict
from typing import Optional
from telegram import InlineKeyboardButton, InlineKeyboardMarkup, Update
from telegram.ext import Application, CallbackQueryHandler, CommandHandler, ContextTypes, PicklePersistence
logging.basicConfig(format="%(asctime)s - %(name)s - %(levelname)s - %(message)s", level=logging.INFO)
logging.getLogger("httpx").setLevel(logging.WARNING)
logging.getLogger('apscheduler').setLevel(logging.WARNING)
logger = logging.getLogger(__name__)
class UserData:
def __init__(self, user_data):
if not "pings" in user_data:
user_data["pings"] = OrderedDict()
if not "tags" in user_data:
user_data["tags"] = set()
if not "started" in user_data:
user_data["started"] = False
if not "pending_pings" in user_data:
user_data["pending_pings"] = {}
self.user_data = user_data
@property
def started(self):
return self.user_data["started"]
@started.setter
def started(self, value):
self.user_data["started"] = value
@property
def tags(self):
return self.user_data["tags"]
@property
def pings(self):
return self.user_data["pings"]
def add_tag(self, tag):
self.user_data["tags"].add(tag)
def delete_tag(self, tag):
"""Remove an item from the inventory."""
if tag in self.user_data["tags"]:
self.user_data["tags"].remove(tag)
def add_ping(self, chat_id, message_id, ping_id):
self.user_data["pending_pings"][(chat_id, message_id)] = (ping_id, [])
def get_ping_list(self, chat_id, message_id) -> Optional[list]:
try:
_, ping_list = self.user_data["pending_pings"][(chat_id, message_id)]
return ping_list
except KeyError:
return None
def submit_ping(self, chat_id, message_id):
ping_id, ping_list = self.user_data["pending_pings"][(chat_id, message_id)]
del self.user_data["pending_pings"][(chat_id, message_id)]
self.user_data["pings"][ping_id] = ping_list
def get_pending_pings(self):
return self.user_data["pending_pings"].values()
def get_ping_reply_markup(self):
keyboard = []
tags_per_row = 5
tags = list(sorted(self.tags))
for i in range(0, len(tags), tags_per_row):
keyboard.append([])
for tag in tags[i:i + tags_per_row]:
keyboard[-1].append(InlineKeyboardButton(tag, callback_data=tag))
keyboard.append([InlineKeyboardButton("🗑️ clear", callback_data="clear"),
InlineKeyboardButton("🔄 reload", callback_data="reload"),
InlineKeyboardButton("📤 submit", callback_data="submit"),])
reply_markup = InlineKeyboardMarkup(keyboard)
return reply_markup
def ping_is_due(self) -> Optional[int]:
t = timer.Timer()
prev_ping = t.prev(t.time_now())
pending_pings = [ping for ping, _ in self.get_pending_pings()]
if prev_ping in self.pings or prev_ping in pending_pings:
return None
return prev_ping
async def callback_minute(context: ContextTypes.DEFAULT_TYPE):
assert context.job is not None
for chat_id, user_id in context.bot_data['chats'].items():
user_data = UserData(context.application.user_data[user_id])
if (ping := user_data.ping_is_due()) is not None:
reply_markup = user_data.get_ping_reply_markup()
text = f"Ping! {timer.time_to_str(ping)}"
message = await context.bot.send_message(chat_id=chat_id, text=text, reply_markup=reply_markup)
user_data.add_ping(chat_id, message.id, ping)
async def ping_command(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
assert context.user_data is not None
assert update.message is not None
user_data = UserData(context.user_data)
reply_markup = user_data.get_ping_reply_markup()
message = await update.message.reply_text("Ping! Select Tags.", reply_markup=reply_markup)
user_data.add_ping(update.message.chat_id, message.id, timer.now())
async def list_pings_command(update: Update, context: ContextTypes.DEFAULT_TYPE):
assert update.message is not None
assert context.user_data is not None
pings = sorted(UserData(context.user_data).pings.items())
text = "There aren't any pings, yet. Use /ping to get pinged now."
n_pings = 20
if pings:
text = f"Recent {n_pings} pings:\n"
for ping, tags in pings[-n_pings:]:
ping_time = timer.time_to_str(ping)
text += f" {ping} {ping_time} {', '.join(sorted(tags))}\n"
await context.bot.send_message(chat_id=update.message.chat.id, text=text)
async def list_pending_pings_command(update: Update, context: ContextTypes.DEFAULT_TYPE):
assert update.message is not None
assert context.user_data is not None
user_data = UserData(context.user_data)
pings = user_data.get_pending_pings()
text = "There are no pending pings."
if pings:
text = "Pending pings:\n"
for ping, tags in pings:
ping_time = timer.time_to_str(ping)
text += f" {ping_time}: {', '.join(sorted(tags))}\n"
await context.bot.send_message(chat_id=update.message.chat.id, text=text)
async def callback_button(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
"""Parses the CallbackQuery and updates the message text."""
assert update.callback_query is not None
assert context.chat_data is not None
query = update.callback_query
assert query.message is not None
await query.answer()
user_data = UserData(context.user_data)
tags = user_data.get_ping_list(query.message.chat_id, query.message.id)
tag = update.callback_query.data
text = None
reply_markup = user_data.get_ping_reply_markup()
if tags is None:
text = "Invalid ping. Removed."
reply_markup = None
elif tag == "submit" and len(tags) > 0:
user_data.submit_ping(query.message.chat_id, query.message.id)
tags_text = "', '".join(tags)
text = f"☑️ Submitted '{tags_text}'."
reply_markup = None
elif tag == "submit":
text = "Select at least one tag before submitting."
elif tag == "clear":
tags.clear()
elif tag == "reload":
pass
else:
if tag not in tags:
tags.append(tag)
else:
tags.remove(tag)
if text is None:
if tags is not None:
selected = ", ".join(tags)
if selected:
text = f"Selected: {selected}."
else:
text = "Select tags."
else:
text = "No text or tags."
if query.message.text != text or query.message.reply_markup != reply_markup:
await query.edit_message_text(text=text, reply_markup=reply_markup)
async def start_command(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
assert update.message is not None
assert update.message.from_user is not None
assert context.user_data is not None
assert context.job_queue is not None
logging.info(f"Start from {update.message.from_user}.")
if not "chats" in context.bot_data:
context.bot_data["chats"] = {}
chat_id = update.message.chat_id
# Note it seems like chat_id == user_id but that's okay.
if not chat_id in context.bot_data["chats"]:
context.bot_data["chats"][chat_id] = update.message.from_user.id
user_data = UserData(context.user_data)
if not user_data.started:
user_data.started = True
text = ("TagTime has been started. You will receive pings per the official schedule.\n"
"\n"
"Run /help to see all commands.")
else:
text = "TagTimeBot is active. Run /help to see more commands."
await update.message.reply_text(text)
async def help_command(update: Update, _: ContextTypes.DEFAULT_TYPE) -> None:
"""Displays info on how to use the bot."""
assert update.message is not None
text = ("Commands:\n"
" /start to start receiving pings\n"
"\n"
" /list_tags to see see currently defined tags\n"
" /add_tags to add tags (provide tags separated by space)\n"
" /delete_tags to delete tags (will not affect past pings)\n"
"\n"
" /ping to receive a demo ping\n"
" /list_pings to list recently answered pings\n"
" /pending_pings to list pending pings\n"
"")
await update.message.reply_text(text)
async def add_tags_command(update: Update, context: ContextTypes.DEFAULT_TYPE):
assert update.message is not None
assert context.user_data is not None
user_data = UserData(context.user_data)
if context.args is None or len(context.args) == 0:
text = "No tags were added because none were provided."
else:
tags = context.args
if len(tags) == 1:
text = f"Tag '{tags[0]}' added."
else:
t = "', '".join(tags)
text = f"Tags '{t}' added."
for tag in tags:
user_data.add_tag(tag)
await context.bot.send_message(chat_id=update.message.chat.id, text=text)
async def list_tags_command(update: Update, context: ContextTypes.DEFAULT_TYPE):
assert update.message is not None
assert context.user_data is not None
tags_set = UserData(context.user_data).tags
tags = ', '.join(sorted(tags_set))
if tags != "":
text = f"Tags: {tags}"
else:
text = "No tags defined. Add with /add_tags."
await context.bot.send_message(chat_id=update.message.chat.id, text=text)
async def delete_tags_command(update: Update, context: ContextTypes.DEFAULT_TYPE):
assert update.message is not None
assert context.user_data is not None
user_data = UserData(context.user_data)
if context.args is None or len(context.args) == 0:
text = "No tags were deleted because none were provided."
else:
tags = context.args
tags_deleted, tags_ignored = [], []
for tag in tags:
if tag in user_data.tags:
user_data.delete_tag(tag)
tags_deleted.append(tag)
else:
tags_ignored.append(tag)
if tags_deleted:
tags_deleted = "', '".join(tags_deleted)
text = f"Deleted '{tags_deleted}'."
else:
text = "No tags were deleted."
if tags_ignored:
tags_ignored = "', '".join(tags_ignored)
text += f" Ignored non-existent '{tags_ignored}'."
await context.bot.send_message(chat_id=update.message.chat.id, text=text)
def main() -> None:
"""Run the bot."""
token = open("token.txt", "r").read().strip()
persistence = PicklePersistence(filepath='persistence.pkl')
application = Application.builder().token(token).persistence(persistence).build()
assert application.job_queue is not None
application.add_handler(CommandHandler("start", start_command))
application.add_handler(CommandHandler("help", help_command))
application.add_handler(CommandHandler("ping", ping_command))
application.add_handler(CommandHandler("list_pings", list_pings_command))
application.add_handler(CommandHandler("pending_pings", list_pending_pings_command))
application.add_handler(CallbackQueryHandler(callback_button))
application.add_handler(CommandHandler("add_tags", add_tags_command))
application.add_handler(CommandHandler("list_tags", list_tags_command))
application.add_handler(CommandHandler("delete_tags", delete_tags_command))
application.job_queue.run_repeating(callback_minute, interval=60, first=5)
application.run_polling(allowed_updates=Update.ALL_TYPES)
if __name__ == "__main__":
main()