Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Channel post manager #21

Merged
merged 4 commits into from
Sep 25, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -158,3 +158,29 @@ The same behavior is supported in sulguk. Otherwise, you can set the language on
#### Tags which contents is ignored:

`<head>`, `<link>`, `<meta>`, `<script>`, `<style>`, `<template>`, `<title>`


## Command line utility for channel management

1. Install with addons
```shell
pip install 'sulguk[cli]'
```

2. Set environment variable `BOT_TOKEN`

```shell
export BOT_TOKEN="your telegram token"
```

3. Send HTML file as a message to your channel. Additional files will be sent as comments to the first one. You can provide a channel name or a public link

```shell
sulguk send @chat_id file.html
```

4. If you want to, edit using the link from shell or from your tg client. Edition of comments is supported as well.

```shell
sulguk edit 'https://t.me/channel/1?comment=42' file.html
```
6 changes: 6 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,12 @@ classifiers = [
"Operating System :: OS Independent",
]
dependencies = []
[project.optional-dependencies]
cli = [
"aiogram",
]
[project.scripts]
sulguk = "sulguk.post_manager.cli:cli"

[project.urls]
"Homepage" = "https://github.com/tishka17/sulguk"
Expand Down
Empty file.
19 changes: 19 additions & 0 deletions src/sulguk/post_manager/chat_info.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
from logging import getLogger
from typing import Union

from aiogram import Bot
from aiogram.exceptions import TelegramBadRequest

from .exceptions import ChatNotFound

logger = getLogger(__name__)


async def get_chat(bot: Bot, chat_id: Union[str, int]):
try:
return await bot.get_chat(chat_id)
except TelegramBadRequest as e:
if "chat not found" in e.message:
logger.error("Chat %s not found", chat_id)
raise ChatNotFound
raise
40 changes: 40 additions & 0 deletions src/sulguk/post_manager/cli.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import asyncio
import logging
import os

from aiogram import Bot

from .editor import edit
from .exceptions import ManagerError
from .params import parse_args
from .sender import send


async def main():
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s - %(levelname)s - %(name)s - %(message)s",
)
logging.getLogger("aiogram").setLevel(logging.WARNING)
bot = Bot(token=os.getenv("BOT_TOKEN"))
args = parse_args()
try:
if args.command == "edit":
await edit(bot, args)
else:
await send(bot, args)
except ManagerError:
logging.error("There were errors during execution. See above")
finally:
await bot.session.close()


def cli():
try:
asyncio.run(main())
except (KeyboardInterrupt, SystemExit):
pass


if __name__ == '__main__':
cli()
36 changes: 36 additions & 0 deletions src/sulguk/post_manager/editor.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import logging

from aiogram import Bot
from aiogram.exceptions import TelegramBadRequest

from .chat_info import get_chat
from .file import load_file
from .params import EditArgs

logger = logging.getLogger(__name__)


async def edit(bot: Bot, args: EditArgs):
chat = await get_chat(bot, args.destination.group_id)
if not args.destination.post_id:
raise ValueError("No post provided to edit")
if args.destination.comment_id:
chat_id = chat.linked_chat_id
message_id = args.destination.comment_id
else:
chat_id = chat.id
message_id = args.destination.post_id

data = load_file(args.file)
try:
await bot.edit_message_text(
chat_id=chat_id,
message_id=message_id,
text=data.text,
entities=data.entities,
)
except TelegramBadRequest as e:
if "message is not modified" in e.message:
logger.debug("Nothing changed")
return
raise
10 changes: 10 additions & 0 deletions src/sulguk/post_manager/exceptions.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
class ManagerError(Exception):
pass


class LinkedMessageNotFound(ManagerError):
pass


class ChatNotFound(ManagerError):
pass
15 changes: 15 additions & 0 deletions src/sulguk/post_manager/file.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import logging

from sulguk import transform_html, RenderResult
from .exceptions import ManagerError

logger = logging.getLogger(__name__)


def load_file(filename) -> RenderResult:
try:
with open(filename) as f:
return transform_html(f.read())
except FileNotFoundError:
logger.error("File `%s` not found", filename)
raise ManagerError
89 changes: 89 additions & 0 deletions src/sulguk/post_manager/links.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
from dataclasses import dataclass
from typing import Optional, Union
from urllib.parse import urlparse, parse_qs

from aiogram.types import Message, Chat


@dataclass
class Link:
group_id: Union[str, int]
post_id: Optional[int] = None
comment_id: Optional[int] = None


class LinkParseError(ValueError):
pass


def parse_group_id(group_id: str) -> Union[int, str]:
try:
return int(group_id)
except ValueError:
if group_id.startswith("@"):
return group_id
return "@" + group_id


def parse_link(link: str) -> Link:
if not link.startswith("https://"):
return Link(group_id=parse_group_id(link))
parsed = urlparse(link)
if parsed.scheme != "https":
raise LinkParseError(f"Invalid scheme: {parsed.scheme}")
if parsed.hostname != "t.me":
raise LinkParseError(f"Invalid hostname: {parsed.hostname}")
path = parsed.path[1:].split("/") # remove starting /
if len(path) == 1:
return Link(parse_group_id(path[0]))
elif len(path) == 2:
params = parse_qs(parsed.query)
try:
post_id = int(path[1])
except ValueError:
raise LinkParseError(f"Invalid post id: {path[1]}")
comment_id_raw = params.get("comment")
if not comment_id_raw:
comment_id = None
elif len(comment_id_raw) == 1:
try:
comment_id = int(comment_id_raw[0])
except ValueError:
raise LinkParseError(f"Invalid comment id: {path[1]}")
else:
raise LinkParseError(f"Cannot parse comment id: {parsed.query}")
return Link(
group_id=parse_group_id(path[0]),
post_id=post_id,
comment_id=comment_id,
)
else:
raise LinkParseError(f"Invalid path: {parsed.path}")


def unparse_link(link: Link) -> str:
if isinstance(link.group_id, str):
group_id = link.group_id.lstrip("@")
else:
group_id = link.group_id
result = f"https://t.me/{group_id}/"
if link.post_id:
result += f"{link.post_id}"
if link.comment_id:
result += f"?comment={link.comment_id}"
return result


def make_link(
chat: Chat,
message: Optional[Message] = None,
comment: Optional[Message] = None,
) -> Link:
link = Link(
group_id=chat.username,
)
if message:
link.post_id = message.message_id
if comment:
link.comment_id = comment.message_id
return link
46 changes: 46 additions & 0 deletions src/sulguk/post_manager/params.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
from argparse import ArgumentParser
from typing import List, Literal, Union

from .links import Link, parse_link


class SendArgs:
command: Literal["send"]
mode: Literal["poll", "getChat"]
destination: Link
file: List[str]


class EditArgs:
command: Literal["edit"]
destination: Link
file: str


def init_parser():
root = ArgumentParser(prog='Sulguk message manager')
subparsers = root.add_subparsers(dest="command")
sender = subparsers.add_parser("send")
sender.add_argument(
"destination", type=parse_link
)
sender.add_argument(
"file", nargs='+'
)
sender.add_argument(
"-m", "--mode", choices=["poll", "getChat"],
default="poll",
)
editor = subparsers.add_parser("edit")
editor.add_argument(
"destination", type=parse_link
)
editor.add_argument(
"file",
)
return root


def parse_args() -> Union[SendArgs, EditArgs]:
parser = init_parser()
return parser.parse_args()
Loading