Build custom container images / build (map[base_image:php:8-fpm-alpine build_args:PHP_VERSION=8
context:php8-pgsql fingerprint_command:{ apk info -v | LC_ALL=C sort; find /usr/local/lib/php/extensions /usr/local/etc/php/conf.d -type f -exec sha256sum {} + | LC_ALL=C sort; }
name:ph… (push) Successful in 48s
Build custom container images / build (map[base_image:postgres:18 build_args:PG_VERSION=18
POSTGIS_VERSION=3
VCHORD_VERSION=0.5.3
context:postgres fingerprint_command:{ dpkg-query -W -f='${binary:Package}=${Version}\n' | LC_ALL=C sort; find /usr/lib/postgresql -type f -exec sha256su… (push) Successful in 55s
Build custom container images / build (map[base_image:python:3 build_args:PYTHON_VERSION=3
context:python-tools fingerprint_command:{ dpkg-query -W -f='${binary:Package}=${Version}\n' | LC_ALL=C sort; pip freeze | LC_ALL=C sort; }
name:python-tools oci_labels:org.opencontainers.ima… (push) Successful in 54s
Build custom container images / build (map[base_image:python:3.12-slim build_args:PYTHON_VERSION=3.12-slim
context:linkki-tiedotus fingerprint_command:{ dpkg-query -W -f='${binary:Package}=${Version}\n' | LC_ALL=C sort; pip freeze | LC_ALL=C sort; }
name:linkki-tiedotus oci_labels:… (push) Successful in 39s
187 lines
6.4 KiB
Python
187 lines
6.4 KiB
Python
import json
|
|
from contextlib import ExitStack
|
|
from pathlib import Path
|
|
|
|
import requests
|
|
|
|
|
|
class TelegramApiError(RuntimeError):
|
|
def __init__(self, description: str, error_code: int | None = None):
|
|
super().__init__(description)
|
|
self.description = description
|
|
self.error_code = error_code
|
|
|
|
|
|
class TelegramAPI:
|
|
def __init__(self, bot_token: str):
|
|
self.base_url = f"https://api.telegram.org/bot{bot_token}"
|
|
|
|
def send_media_group(
|
|
self,
|
|
chat_id: str,
|
|
image_paths: list[Path],
|
|
caption: str | None = None,
|
|
parse_mode: str | None = None,
|
|
) -> list[dict]:
|
|
if not image_paths:
|
|
raise ValueError("send_media_group requires at least one image.")
|
|
|
|
media_items = []
|
|
for index, _ in enumerate(image_paths):
|
|
media_item = {
|
|
"type": "photo",
|
|
"media": f"attach://file{index}",
|
|
}
|
|
if index == 0 and caption is not None:
|
|
media_item["caption"] = caption
|
|
if parse_mode is not None:
|
|
media_item["parse_mode"] = parse_mode
|
|
media_items.append(media_item)
|
|
|
|
payload = {"chat_id": chat_id, "media": json.dumps(media_items, ensure_ascii=False)}
|
|
|
|
with ExitStack() as stack:
|
|
files = {}
|
|
for index, image_path in enumerate(image_paths):
|
|
files[f"file{index}"] = stack.enter_context(image_path.open("rb"))
|
|
|
|
return self._post("sendMediaGroup", data=payload, files=files, timeout=60)
|
|
|
|
def get_updates(self, offset: int | None = None, limit: int = 100, timeout: int = 0) -> list[dict]:
|
|
payload = {"limit": limit, "timeout": timeout}
|
|
if offset is not None:
|
|
payload["offset"] = offset
|
|
return self._post("getUpdates", data=payload, timeout=timeout + 10)
|
|
|
|
def send_message(
|
|
self,
|
|
chat_id: str,
|
|
text: str,
|
|
parse_mode: str | None = None,
|
|
disable_link_preview: bool = False,
|
|
) -> dict:
|
|
payload = {"chat_id": chat_id, "text": text}
|
|
if parse_mode is not None:
|
|
payload["parse_mode"] = parse_mode
|
|
if disable_link_preview:
|
|
payload["link_preview_options"] = json.dumps({"is_disabled": True})
|
|
return self._post("sendMessage", data=payload, timeout=30)
|
|
|
|
def edit_message_text(
|
|
self,
|
|
chat_id: str,
|
|
message_id: int,
|
|
text: str,
|
|
parse_mode: str | None = None,
|
|
disable_link_preview: bool = False,
|
|
) -> dict:
|
|
payload = {
|
|
"chat_id": chat_id,
|
|
"message_id": message_id,
|
|
"text": text,
|
|
}
|
|
if parse_mode is not None:
|
|
payload["parse_mode"] = parse_mode
|
|
if disable_link_preview:
|
|
payload["link_preview_options"] = json.dumps({"is_disabled": True})
|
|
return self._post("editMessageText", data=payload, timeout=30)
|
|
|
|
def edit_message_caption(
|
|
self,
|
|
chat_id: str,
|
|
message_id: int,
|
|
caption: str,
|
|
parse_mode: str | None = None,
|
|
) -> dict:
|
|
payload = {
|
|
"chat_id": chat_id,
|
|
"message_id": message_id,
|
|
"caption": caption,
|
|
}
|
|
if parse_mode is not None:
|
|
payload["parse_mode"] = parse_mode
|
|
return self._post("editMessageCaption", data=payload, timeout=30)
|
|
|
|
def delete_message(self, chat_id: str, message_id: int) -> bool:
|
|
payload = {
|
|
"chat_id": chat_id,
|
|
"message_id": message_id,
|
|
}
|
|
return bool(self._post("deleteMessage", data=payload, timeout=30))
|
|
|
|
def pin_chat_message(self, chat_id: str, message_id: int, disable_notification: bool = True) -> bool:
|
|
payload = {
|
|
"chat_id": chat_id,
|
|
"message_id": message_id,
|
|
}
|
|
if disable_notification:
|
|
payload["disable_notification"] = "true"
|
|
return bool(self._post("pinChatMessage", data=payload, timeout=30))
|
|
|
|
def unpin_chat_message(self, chat_id: str, message_id: int) -> bool:
|
|
payload = {
|
|
"chat_id": chat_id,
|
|
"message_id": message_id,
|
|
}
|
|
return bool(self._post("unpinChatMessage", data=payload, timeout=30))
|
|
|
|
def _post(self, endpoint: str, data: dict, files: dict | None = None, timeout: int = 30):
|
|
response = requests.post(f"{self.base_url}/{endpoint}", data=data, files=files, timeout=timeout)
|
|
try:
|
|
response.raise_for_status()
|
|
except requests.HTTPError as error:
|
|
description = _extract_error_description(response) or str(error)
|
|
error_code = _extract_error_code(response)
|
|
raise TelegramApiError(description=description, error_code=error_code) from error
|
|
|
|
payload = response.json()
|
|
if not payload.get("ok", False):
|
|
raise TelegramApiError(
|
|
description=str(payload.get("description", "Telegram API request failed.")),
|
|
error_code=_to_int_or_none(payload.get("error_code")),
|
|
)
|
|
return payload["result"]
|
|
|
|
|
|
def _extract_error_description(response: requests.Response) -> str | None:
|
|
try:
|
|
payload = response.json()
|
|
except ValueError:
|
|
return None
|
|
description = payload.get("description")
|
|
if description is None:
|
|
return None
|
|
return str(description)
|
|
|
|
|
|
def _extract_error_code(response: requests.Response) -> int | None:
|
|
try:
|
|
payload = response.json()
|
|
except ValueError:
|
|
return None
|
|
return _to_int_or_none(payload.get("error_code"))
|
|
|
|
|
|
def _to_int_or_none(value) -> int | None:
|
|
if isinstance(value, int):
|
|
return value
|
|
if isinstance(value, str) and value.isdigit():
|
|
return int(value)
|
|
return None
|
|
|
|
|
|
def is_message_missing_error(error: TelegramApiError | str) -> bool:
|
|
description = error.description if isinstance(error, TelegramApiError) else str(error)
|
|
lowered = description.lower()
|
|
return "not found" in lowered or "message to delete not found" in lowered or "message to edit not found" in lowered
|
|
|
|
|
|
def is_message_not_modified_error(error: TelegramApiError | str) -> bool:
|
|
description = error.description if isinstance(error, TelegramApiError) else str(error)
|
|
return "message is not modified" in description.lower()
|
|
|
|
|
|
def is_message_not_deletable_error(error: TelegramApiError | str) -> bool:
|
|
description = error.description if isinstance(error, TelegramApiError) else str(error)
|
|
return "can't be deleted" in description.lower()
|