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 ### 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: Commands:
- /start - /start
- /help - /help
- /articles - /last
- url - url
To do: 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 import httpx
habrApiUrl="https://habr.frik.su/api/".removesuffix("/") from app.settings import settings
async def getArticles(amount: int) -> list[tuple[str, str]]: async def getArticles(amount: int) -> list[tuple[str, str]]:
@@ -12,7 +12,7 @@ async def getArticles(amount: int) -> list[tuple[str, str]]:
payload = { payload = {
"amount": amount "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) 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') decoded = base64.b64decode(responseJson[key].encode('ascii')).decode('utf-8')
result.append((key, decoded)) result.append((key, decoded))
return result 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 from pydantic_settings import BaseSettings, SettingsConfigDict
def setup_settings():
settings.habrApiUrl = settings.habrApiUrl.removesuffix("/")
class Settings(BaseSettings): class Settings(BaseSettings):
botToken: str botToken: str
notifyStartStop: bool
enableWhitelist: bool
whitelist: list whitelist: list
dataPath: str habrApiUrl: str
jsonFilename: str
model_config = SettingsConfigDict(env_file = ".env", env_file_encoding="utf-8") model_config = SettingsConfigDict(env_file = ".env", env_file_encoding="utf-8")
settings = Settings() # type: ignore settings = Settings() # type: ignore
setup_settings()
+11 -3
View File
@@ -6,17 +6,25 @@ stopBot = "Bot stopped"
unexpectedError = "Unexpected error" unexpectedError = "Unexpected error"
invalidUrl = "Please enter a valid url to the article"
# Commands # 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 # Route status
askParam = "Enter url:" showArticle = "Show the article"
askUrl = "Enter url:"
askRating = "Enter rating:"
ratedSuccessfully = "Set new rating for article"
# Data status # Data status
+143 -43
View File
@@ -1,25 +1,33 @@
from aiogram import Bot, Dispatcher, F, Router from aiogram import Bot, Dispatcher, F, Router
from aiogram.client.default import DefaultBotProperties from aiogram.client.default import DefaultBotProperties
from aiogram.enums import ParseMode
from aiogram.filters import Command from aiogram.filters import Command
from aiogram.fsm.context import FSMContext from aiogram.fsm.context import FSMContext
from aiogram.fsm.state import State, StatesGroup from aiogram.fsm.state import State, StatesGroup
from aiogram.fsm.storage.memory import MemoryStorage 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 loguru import logger
from app import httpxHandler from app import httpxHandler
from app.consts import HABR_ARTICLE_PREFIX
from app.settings import settings from app.settings import settings
from app.telegram import strings from app.telegram import strings, utils
async def setCommands(): async def setCommands():
commands = [ commands = [
BotCommand(command='start', description='Start'), BotCommand(command="start", description="Start"),
BotCommand(command='help', description='Help'), BotCommand(command="help", description="Help"),
BotCommand(command='info', description='Info'), BotCommand(command="last", description="Get latest article"),
BotCommand(command='last', description='last'), BotCommand(command="rates", description="Article rates"),
BotCommand(command='remove', description='Remove') BotCommand(command="rate", description="Rate article"),
BotCommand(command="remove_rate", description="Remove article rate"),
] ]
await bot.set_my_commands(commands, BotCommandScopeDefault()) await bot.set_my_commands(commands, BotCommandScopeDefault())
@@ -28,82 +36,174 @@ class ArticleForm(StatesGroup):
url = State() url = State()
class removeForm(StatesGroup): class removeForm(StatesGroup):
param = State() url = State()
class RatingForm(StatesGroup):
url = State()
rate = State()
dp = Dispatcher(storage=MemoryStorage()) dp = Dispatcher(storage=MemoryStorage())
bot = Bot( bot = Bot(
token=settings.botToken, token=settings.botToken,
default=DefaultBotProperties(parse_mode=ParseMode.HTML), default=DefaultBotProperties(parse_mode=None),
) )
strategyRouter = Router() ratingRouter = Router()
removeRouter = 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")) @dp.message(Command("start"))
async def commandStart(message: Message) -> None: 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: async def commandHelp(message: Message) -> None:
await message.answer(strings.helpCommand) await message.answer(strings.helpCommand)
@dp.message(Command("info"), F.chat.id.in_(settings.whitelist)) # Show all user rates
async def commandInfo(message: Message) -> None: @dp.message(whitelist_filter, Command("rates"))
msgText = "" async def commandRates(message: Message) -> None:
msgText = "Not yet implemented.."
await message.answer(msgText) 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): async def commandLast(message: Message):
articles = await httpxHandler.getArticles(1) try:
if articles == []: articles = await httpxHandler.getArticles(1)
if articles == []:
await message.answer(strings.unexpectedError)
else:
for (url, content) in articles:
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) await message.answer(strings.unexpectedError)
else:
for (url, content) in articles:
print(url, content)
await message.answer("Url: " + url + "\n" + content)
@strategyRouter.message(Command("get"), F.chat.id.in_(settings.whitelist)) # Rate article by user
async def commandGet(message: Message, state: FSMContext): @ratingRouter.message(Command("rate"), F.chat.id.in_(settings.whitelist))
await message.answer(strings.askParam) async def commandRate(message: Message, state: FSMContext):
await message.answer(strings.askUrl)
await state.set_state(ArticleForm.url) await state.set_state(ArticleForm.url)
@strategyRouter.message(F.text, ArticleForm.url) @ratingRouter.message(F.text, ArticleForm.url)
async def captureStartPair(message: Message, state: FSMContext): async def captureUrl(message: Message, state: FSMContext):
await state.update_data(pair=message.text) await state.update_data(url=message.text)
data = await state.get_data() msgText = strings.askRating
url = data.get("url")
msgText = ""
await httpxHandler.getArticles(1) await httpxHandler.getArticles(1)
await message.answer(msgText) await message.answer(msgText)
await state.clear() 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(): async def startBot():
await setCommands() await setCommands()
try: if settings.enableWhitelist and settings.notifyStartStop:
for i in settings.whitelist: try:
await bot.send_message(chat_id=i, text=strings.startBot) for i in settings.whitelist:
except Exception as e: await bot.send_message(chat_id=i, text=strings.startBot)
logger.error(e) except Exception as e:
logger.error(e)
logger.success("Started bot") logger.success("Started bot")
async def stopBot(): async def stopBot():
try: if settings.enableWhitelist and settings.notifyStartStop:
for i in settings.whitelist: try:
await bot.send_message(chat_id=i, text=strings.stopBot) for i in settings.whitelist:
except Exception as e: await bot.send_message(chat_id=i, text=strings.stopBot)
logger.error(e) except Exception as e:
logger.error(e)
logger.success("Stopped bot") logger.success("Stopped bot")
async def start_bot() -> None: async def start_bot() -> None:
dp.startup.register(startBot) dp.startup.register(startBot)
dp.include_router(strategyRouter) dp.include_router(ratingRouter)
dp.include_router(removeRouter) dp.include_router(removeRouter)
dp.shutdown.register(stopBot) dp.shutdown.register(stopBot)
await dp.start_polling(bot) 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