base functionality complete many fixes expected

This commit is contained in:
2025-09-17 16:30:48 +03:00
parent 529da899b5
commit c653131e7f
10 changed files with 250 additions and 53 deletions
+3 -1
View File
@@ -1,9 +1,11 @@
### Telegram bot that uses [habr article api](https://git.frik.su/n0one/habr-article-API) for sending articles to user
You can get your chatID + userID using the `/start` command.
Commands:
- /start
- /help
- /articles
- /last
- url
To do:
+13
View File
@@ -0,0 +1,13 @@
services:
habr-bot:
image: habr-bot:latest
container_name: habr-bot
environment:
botToken: "your:telegram-token"
notifyStartStop: "False"
enableWhitelist: "True"
whitelist: "[chatid]"
habrApiUrl: "https://habr.frik.su/api/"
volumes:
- ./data:/app/data
restart: unless-stopped
+11
View File
@@ -0,0 +1,11 @@
FROM python:3.13-slim
WORKDIR /app
COPY requirements.txt ./
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
CMD ["python", "src/main.py"]
+3
View File
@@ -0,0 +1,3 @@
FIND_HEADING_REGEXP = r'^(.*)\n(=+)$'
MAX_MESSAGE_LENGTH = 4000
HABR_ARTICLE_PREFIX = "https://habr.com/articles/"
+2 -2
View File
@@ -1,3 +1,3 @@
from .utils import getArticles
from .utils import getArticleByUrl, getArticles, rateArticle
__all__ = ["getArticles"]
__all__ = ["getArticles", "getArticleByUrl", "rateArticle"]
+30 -2
View File
@@ -3,7 +3,7 @@ import json
import httpx
habrApiUrl="https://habr.frik.su/api/".removesuffix("/")
from app.settings import settings
async def getArticles(amount: int) -> list[tuple[str, str]]:
@@ -12,7 +12,7 @@ async def getArticles(amount: int) -> list[tuple[str, str]]:
payload = {
"amount": amount
}
response = await client.post(habrApiUrl + "/articles/get/md", json=payload)
response = await client.post(settings.habrApiUrl + "/articles/get/md", json=payload)
responseJson = json.loads(response.content)
@@ -20,3 +20,31 @@ async def getArticles(amount: int) -> list[tuple[str, str]]:
decoded = base64.b64decode(responseJson[key].encode('ascii')).decode('utf-8')
result.append((key, decoded))
return result
async def getArticleByUrl(url: str) -> tuple[str, str]:
result = ("", "")
async with httpx.AsyncClient() as client:
payload = {
"url": url
}
response = await client.post(settings.habrApiUrl + "/article/get/md", json=payload)
responseJson = json.loads(response.content)
for key in responseJson:
decoded = base64.b64decode(responseJson[key].encode('ascii')).decode('utf-8')
result = (key, decoded)
return result
async def rateArticle(username: str, url: str, rating: int) -> None:
async with httpx.AsyncClient() as client:
payload = {
"username": username,
"url": url,
"rating": rating
}
response = await client.post(settings.habrApiUrl + "/article/rate", json=payload)
if response.status_code != 200:
raise Exception
+10 -2
View File
@@ -1,14 +1,22 @@
from pydantic_settings import BaseSettings, SettingsConfigDict
def setup_settings():
settings.habrApiUrl = settings.habrApiUrl.removesuffix("/")
class Settings(BaseSettings):
botToken: str
notifyStartStop: bool
enableWhitelist: bool
whitelist: list
dataPath: str
jsonFilename: str
habrApiUrl: str
model_config = SettingsConfigDict(env_file = ".env", env_file_encoding="utf-8")
settings = Settings() # type: ignore
setup_settings()
+11 -3
View File
@@ -6,17 +6,25 @@ stopBot = "Bot stopped"
unexpectedError = "Unexpected error"
invalidUrl = "Please enter a valid url to the article"
# Commands
startCommand = "Hi. I'm open source bot for reading habr articles from telegram. Here is your chatid btw: "
startCommand = "Hi. I'm open source bot for reading habr articles from telegram.\nHere is your chatID: "
helpCommand = "Commands are obvious no help needed, hehe"
helpCommand = "Commands are obvious no help needed, but I'll give you one tip - try sending the link to the Habr article"
# Route status
askParam = "Enter url:"
showArticle = "Show the article"
askUrl = "Enter url:"
askRating = "Enter rating:"
ratedSuccessfully = "Set new rating for article"
# Data status
+129 -29
View File
@@ -1,25 +1,33 @@
from aiogram import Bot, Dispatcher, F, Router
from aiogram.client.default import DefaultBotProperties
from aiogram.enums import ParseMode
from aiogram.filters import Command
from aiogram.fsm.context import FSMContext
from aiogram.fsm.state import State, StatesGroup
from aiogram.fsm.storage.memory import MemoryStorage
from aiogram.types import BotCommand, BotCommandScopeDefault, Message
from aiogram.types import (
BotCommand,
BotCommandScopeDefault,
CallbackQuery,
LinkPreviewOptions,
Message,
)
from aiogram.utils.keyboard import InlineKeyboardBuilder
from loguru import logger
from app import httpxHandler
from app.consts import HABR_ARTICLE_PREFIX
from app.settings import settings
from app.telegram import strings
from app.telegram import strings, utils
async def setCommands():
commands = [
BotCommand(command='start', description='Start'),
BotCommand(command='help', description='Help'),
BotCommand(command='info', description='Info'),
BotCommand(command='last', description='last'),
BotCommand(command='remove', description='Remove')
BotCommand(command="start", description="Start"),
BotCommand(command="help", description="Help"),
BotCommand(command="last", description="Get latest article"),
BotCommand(command="rates", description="Article rates"),
BotCommand(command="rate", description="Rate article"),
BotCommand(command="remove_rate", description="Remove article rate"),
]
await bot.set_my_commands(commands, BotCommandScopeDefault())
@@ -28,63 +36,154 @@ class ArticleForm(StatesGroup):
url = State()
class removeForm(StatesGroup):
param = State()
url = State()
class RatingForm(StatesGroup):
url = State()
rate = State()
dp = Dispatcher(storage=MemoryStorage())
bot = Bot(
token=settings.botToken,
default=DefaultBotProperties(parse_mode=ParseMode.HTML),
default=DefaultBotProperties(parse_mode=None),
)
strategyRouter = Router()
ratingRouter = Router()
removeRouter = Router()
class get_whitelist_filter():
def __call__(self, message: Message) -> bool:
if settings.enableWhitelist:
return message.chat.id in settings.whitelist
else:
return True
whitelist_filter = get_whitelist_filter()
# Start command
@dp.message(Command("start"))
async def commandStart(message: Message) -> None:
await message.answer(strings.startCommand + str(message.chat.id))
await message.answer(strings.startCommand + str(message.chat.id) + "\nAnd your userID: " + str(message.from_user.id)) # type: ignore
@dp.message(Command("help"), F.chat.id.in_(settings.whitelist))
# Help command
@dp.message(whitelist_filter, Command("help"))
async def commandHelp(message: Message) -> None:
await message.answer(strings.helpCommand)
@dp.message(Command("info"), F.chat.id.in_(settings.whitelist))
async def commandInfo(message: Message) -> None:
msgText = ""
# Show all user rates
@dp.message(whitelist_filter, Command("rates"))
async def commandRates(message: Message) -> None:
msgText = "Not yet implemented.."
await message.answer(msgText)
@dp.message(Command("last"), F.chat.id.in_(settings.whitelist))
# Send latest article
@dp.message(whitelist_filter, Command("last"))
async def commandLast(message: Message):
try:
articles = await httpxHandler.getArticles(1)
if articles == []:
await message.answer(strings.unexpectedError)
else:
for (url, content) in articles:
print(url, content)
await message.answer("Url: " + url + "\n" + content)
heading = utils.getHeading(content)
articleID = utils.getArticleID(url)
builder = InlineKeyboardBuilder()
builder.button(text=strings.showArticle, callback_data=f"showArticle_id={articleID}")
await message.answer(url + "\n" + heading, reply_markup=builder.as_markup())
except Exception:
await message.answer(strings.unexpectedError)
@strategyRouter.message(Command("get"), F.chat.id.in_(settings.whitelist))
async def commandGet(message: Message, state: FSMContext):
await message.answer(strings.askParam)
# Rate article by user
@ratingRouter.message(Command("rate"), F.chat.id.in_(settings.whitelist))
async def commandRate(message: Message, state: FSMContext):
await message.answer(strings.askUrl)
await state.set_state(ArticleForm.url)
@strategyRouter.message(F.text, ArticleForm.url)
async def captureStartPair(message: Message, state: FSMContext):
await state.update_data(pair=message.text)
data = await state.get_data()
url = data.get("url")
msgText = ""
@ratingRouter.message(F.text, ArticleForm.url)
async def captureUrl(message: Message, state: FSMContext):
await state.update_data(url=message.text)
msgText = strings.askRating
await httpxHandler.getArticles(1)
await message.answer(msgText)
await state.clear()
@ratingRouter.message(F.text, ArticleForm.url)
async def captureRating(message: Message, state: FSMContext):
await state.update_data(rating=message.text)
data = await state.get_data()
username = str(message.from_user.id) # type: ignore
url = str(data.get("url", ""))
rating = int(data.get("rating", 0))
try:
await httpxHandler.rateArticle(username, url, rating)
await message.answer(strings.ratedSuccessfully)
except Exception:
await message.answer(strings.unexpectedError)
await state.clear()
# Send article from user url
@dp.message(whitelist_filter)
async def messageUrl(message: Message):
if message.text is None:
await message.answer(strings.unexpectedError)
return
if "https://" not in message.text and "http://" not in message.text:
await message.answer(strings.invalidUrl)
return
try:
url, content = await httpxHandler.getArticleByUrl(message.text)
heading = utils.getHeading(content)
articleID = utils.getArticleID(url)
builder = InlineKeyboardBuilder()
builder.button(text=strings.showArticle, callback_data=f"showArticle_id={articleID}")
await message.answer(url + "\n" + heading, reply_markup=builder.as_markup())
except Exception:
await message.answer(strings.unexpectedError)
# Handle button showArticle
@dp.callback_query(F.data.startswith("showArticle"))
async def showArticle(callback_query: CallbackQuery):
if callback_query.data is None or callback_query.message is None:
logger.error("callback_query content is None")
await callback_query.answer()
return
# TODO: fix this
linkPreviewOptions = LinkPreviewOptions(is_disabled=True)
try:
url = HABR_ARTICLE_PREFIX + callback_query.data.split("=")[1]
url, content = await httpxHandler.getArticleByUrl(url)
parts = utils.splitArticle(content)
for part in parts:
await callback_query.message.answer(part)
except Exception as e:
logger.error(e)
await callback_query.message.answer(
strings.unexpectedError,
link_preview_options=linkPreviewOptions
)
await callback_query.answer()
async def startBot():
await setCommands()
if settings.enableWhitelist and settings.notifyStartStop:
try:
for i in settings.whitelist:
await bot.send_message(chat_id=i, text=strings.startBot)
@@ -93,6 +192,7 @@ async def startBot():
logger.success("Started bot")
async def stopBot():
if settings.enableWhitelist and settings.notifyStartStop:
try:
for i in settings.whitelist:
await bot.send_message(chat_id=i, text=strings.stopBot)
@@ -103,7 +203,7 @@ async def stopBot():
async def start_bot() -> None:
dp.startup.register(startBot)
dp.include_router(strategyRouter)
dp.include_router(ratingRouter)
dp.include_router(removeRouter)
dp.shutdown.register(stopBot)
await dp.start_polling(bot)
+24
View File
@@ -0,0 +1,24 @@
import re
from app.consts import FIND_HEADING_REGEXP, MAX_MESSAGE_LENGTH
def getArticleID(url: str) -> str:
url = url.removesuffix("/")
return url[url.rfind("/")+1:]
def getHeading(text: str) -> str:
match = re.search(FIND_HEADING_REGEXP, text, re.MULTILINE)
if match is None:
return "Heading not found"
else:
return match.group(1)
def splitArticle(text: str) -> list[str]:
parts = []
for i in range(0, len(text), MAX_MESSAGE_LENGTH):
parts.append(text[i:i + MAX_MESSAGE_LENGTH])
return parts