generated from Beesquit/telegram-bot-template
base functionality complete many fixes expected
This commit is contained in:
@@ -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
@@ -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
@@ -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"]
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
FIND_HEADING_REGEXP = r'^(.*)\n(=+)$'
|
||||||
|
MAX_MESSAGE_LENGTH = 4000
|
||||||
|
HABR_ARTICLE_PREFIX = "https://habr.com/articles/"
|
||||||
@@ -1,3 +1,3 @@
|
|||||||
from .utils import getArticles
|
from .utils import getArticleByUrl, getArticles, rateArticle
|
||||||
|
|
||||||
__all__ = ["getArticles"]
|
__all__ = ["getArticles", "getArticleByUrl", "rateArticle"]
|
||||||
|
|||||||
@@ -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
@@ -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()
|
||||||
|
|||||||
@@ -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
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
Reference in New Issue
Block a user