diff --git a/config.ini b/config.ini index b878651..b981b13 100644 --- a/config.ini +++ b/config.ini @@ -49,7 +49,9 @@ show-post-link = yes ; Should we show link to original author before post content? show-boost-from = yes -# TODO: add discord functionality +; Discord integration [module/discord] type = discord + +; Webhook URL with the `?wait=true` webhook = url diff --git a/mastoposter/__main__.py b/mastoposter/__main__.py index 90f778d..fce4613 100644 --- a/mastoposter/__main__.py +++ b/mastoposter/__main__.py @@ -1,10 +1,11 @@ #!/usr/bin/env python3 from asyncio import run from configparser import ConfigParser +from mastoposter.integrations.discord import DiscordIntegration from mastoposter.integrations.telegram import TelegramIntegration from mastoposter.sources import websocket_source -from typing import AsyncGenerator, Callable, List +from typing import Any, AsyncGenerator, Callable, Dict, List from mastoposter.integrations.base import BaseIntegration from mastoposter.types import Status @@ -19,14 +20,18 @@ async def listen( async for status in source(**kwargs): if status.account.id != user: continue - print(status) - if status.visibility == "direct": + + # TODO: add option/filter to handle that + if status.visibility in ("direct", "private"): continue + + # TODO: find a better way to handle threads if ( status.in_reply_to_account_id is not None and status.in_reply_to_account_id != user ): continue + for drain in drains: await drain.post(status) @@ -35,18 +40,31 @@ def main(config_path: str): conf = ConfigParser() conf.read(config_path) - modules = [] + for section in conf.sections(): + _remove = set() + for k, v in conf[section].items(): + normalized_key = k.replace(" ", "_").replace("-", "_") + if k == normalized_key: + continue + conf[section][normalized_key] = v + _remove.add(k) + for k in _remove: + del conf[section][k] + + modules: List[BaseIntegration] = [] for module_name in conf.get("main", "modules").split(): module = conf[f"module/{module_name}"] if module["type"] == "telegram": modules.append( TelegramIntegration( - token=module.get("token"), - chat_id=module.get("chat"), - show_post_link=module.getboolean("show-post-link", fallback=True), - show_boost_from=module.getboolean("show-boost-from", fallback=True), + token=module["token"], + chat_id=module["chat"], + show_post_link=module.getboolean("show_post_link", fallback=True), + show_boost_from=module.getboolean("show_boost_from", fallback=True), ) ) + elif module["type"] == "discord": + modules.append(DiscordIntegration(webhook=module["webhook"])) else: raise ValueError("Invalid module type %r" % module["type"]) diff --git a/mastoposter/integrations/discord/__init__.py b/mastoposter/integrations/discord/__init__.py new file mode 100644 index 0000000..0cc21ed --- /dev/null +++ b/mastoposter/integrations/discord/__init__.py @@ -0,0 +1,116 @@ +from json import dumps +from typing import Dict, List, Optional +from bs4 import BeautifulSoup, PageElement, Tag +from httpx import AsyncClient +from zlib import crc32 +from mastoposter.integrations.base import BaseIntegration +from mastoposter.integrations.discord.types import ( + DiscordEmbed, + DiscordEmbedAuthor, + DiscordEmbedField, + DiscordEmbedImage, +) +from mastoposter.types import Status + + +class DiscordIntegration(BaseIntegration): + def __init__(self, webhook: str): + self.webhook = webhook + + @staticmethod + def md_escape(text: str) -> str: + return ( + text.replace("\\", "\\\\") + .replace("*", "\\*") + .replace("[", "\\[") + .replace("]", "\\]") + .replace("_", "\\_") + .replace("~", "\\~") + .replace("|", "\\|") + .replace("`", "\\`") + ) + + @classmethod + def node_to_text(cls, el: PageElement) -> str: + if isinstance(el, Tag): + if el.name == "a": + return "[%s](%s)" % ( + cls.md_escape(str.join("", map(cls.node_to_text, el.children))), + el.attrs["href"], + ) + elif el.name == "p": + return str.join("", map(cls.node_to_text, el.children)) + "\n\n" + elif el.name == "br": + return "\n" + return str.join("", map(cls.node_to_text, el.children)) + return cls.md_escape(str(el)) + + async def execute_webhook( + self, + content: Optional[str] = None, + username: Optional[str] = None, + avatar_url: Optional[str] = None, + embeds: Optional[List[DiscordEmbed]] = None, + ) -> dict: + async with AsyncClient() as c: + json = { + "content": content, + "username": username, + "avatar_url": avatar_url, + "embeds": [embed.asdict() for embed in embeds] + if embeds is not None + else [], + } + return ( + await c.post( + self.webhook, + json=json, + ) + ).json() + + async def post(self, status: Status) -> str: + source = status.reblog or status + embeds: List[DiscordEmbed] = [] + + text = self.node_to_text(BeautifulSoup(source.content, features="lxml")) + if source.spoiler_text: + text = f"CW: {source.spoiler_text}\n||{text}||" + + if status.reblog is not None: + title = f"{status.account.acct} boosted from {source.account.acct}" + else: + title = f"{status.account.acct} posted" + + embeds.append( + DiscordEmbed( + title=title, + description=text, + url=status.link, + timestamp=source.created_at, + author=DiscordEmbedAuthor( + name=source.account.display_name, + url=source.account.url, + icon_url=source.account.avatar_static, + ), + color=crc32(source.account.id.encode("utf-8")) & 0xFFFFFF, + ) + ) + + for attachment in source.media_attachments: + if attachment.type == "image": + embeds.append( + DiscordEmbed( + url=status.link, + image=DiscordEmbedImage( + url=attachment.url, + ), + ) + ) + + await self.execute_webhook( + username=status.account.acct, + avatar_url=status.account.avatar_static, + embeds=embeds, + ) + + return "" diff --git a/mastoposter/integrations/discord/types.py b/mastoposter/integrations/discord/types.py new file mode 100644 index 0000000..0bb91a3 --- /dev/null +++ b/mastoposter/integrations/discord/types.py @@ -0,0 +1,78 @@ +from dataclasses import asdict, dataclass +from datetime import datetime +from typing import Any, Callable, Dict, List, Optional + + +def _f(func: Callable, v: Optional[Any], *a, **kw) -> Any: + return func(v, *a, **kw) if v is not None else None + + +__all__ = ( + "DiscordEmbed", + "DiscordEmbedFooter", + "DiscordEmbedImage", + "DiscordEmbedThumbnail", + "DiscordEmbedAuthor", + "DiscordEmbedField", +) + + +@dataclass +class DiscordEmbedFooter: + text: str + icon_url: Optional[str] + + +@dataclass +class DiscordEmbedImage: + url: str + width: int = 0 + height: int = 0 + + +@dataclass +class DiscordEmbedThumbnail: + url: str + + +@dataclass +class DiscordEmbedAuthor: + name: str + url: Optional[str] = None + icon_url: Optional[str] = None + + +@dataclass +class DiscordEmbedField: + name: str + value: str + inline: Optional[bool] + + +@dataclass +class DiscordEmbed: + title: Optional[str] = None + description: Optional[str] = None + url: Optional[str] = None + timestamp: Optional[datetime] = None + color: Optional[int] = None + footer: Optional[DiscordEmbedFooter] = None + image: Optional[DiscordEmbedImage] = None + thumbnail: Optional[DiscordEmbedThumbnail] = None + author: Optional[DiscordEmbedAuthor] = None + fields: Optional[List[DiscordEmbedField]] = None + + def asdict(self) -> Dict[str, Any]: + return { + "type": "rich", + "title": self.title, + "description": self.description, + "url": self.url, + "timestamp": _f(datetime.isoformat, self.timestamp, "T", "seconds"), + "color": self.color, + "footer": _f(asdict, self.footer), + "image": _f(asdict, self.image), + "thumbnail": _f(asdict, self.thumbnail), + "author": _f(asdict, self.author), + "fields": _f(lambda v: list(map(asdict, v)), self.fields), + } diff --git a/mastoposter/integrations/telegram.py b/mastoposter/integrations/telegram.py index d7c2d71..daae0b6 100644 --- a/mastoposter/integrations/telegram.py +++ b/mastoposter/integrations/telegram.py @@ -16,7 +16,7 @@ class TGResponse: @classmethod def from_dict(cls, data: dict, params: dict) -> "TGResponse": - return cls(data["ok"], params, data.get("result"), data.get("error")) + return cls(data["ok"], params, data.get("result"), data.get("description")) class TelegramIntegration(BaseIntegration): @@ -129,6 +129,7 @@ class TelegramIntegration(BaseIntegration): async def post(self, status: Status) -> str: source = status.reblog or status text = self.node_to_text(BeautifulSoup(source.content, features="lxml")) + text = text.rstrip() if source.spoiler_text: text = "Spoiler: {cw}\n{text}".format( @@ -154,8 +155,11 @@ class TelegramIntegration(BaseIntegration): msg = await self._post_mediagroup(text, source.media_attachments) if not msg.ok: - raise Exception(msg.error, msg.params) + # raise Exception(msg.error, msg.params) + return "" # XXX: silently ignore for now + if msg.result: + return msg.result.get("message_id", "") return "" def __repr__(self) -> str: diff --git a/mastoposter/types.py b/mastoposter/types.py index 5e01834..0bcb088 100644 --- a/mastoposter/types.py +++ b/mastoposter/types.py @@ -90,6 +90,73 @@ class Account: ) +@dataclass +class AttachmentMetaImage: + @dataclass + class Vec2F: + x: float + y: float + + @dataclass + class AttachmentMetaImageDimensions: + width: int + height: int + size: str + aspect: float + + original: AttachmentMetaImageDimensions + small: AttachmentMetaImageDimensions + focus: Vec2F + + @classmethod + def from_dict(cls, data: dict) -> "AttachmentMetaImage": + return cls( + **data, + original=cls.AttachmentMetaImageDimensions(**data["original"]), + small=cls.AttachmentMetaImageDimensions(**data["small"]), + focus=cls.Vec2F(**data["focus"]) + ) + + +@dataclass +class AttachmentMetaVideo: + @dataclass + class AttachmentMetaVideoOriginal: + width: int + height: int + duration: float + bitrate: int + frame_rate: Optional[str] # XXX Gargron wtf? + + @dataclass + class AttachmentMetaVideoSmall: + width: int + height: int + size: str + aspect: float + + length: str + duration: float + fps: int + size: str + width: int + height: int + aspect: float + audio_encode: str + audio_bitrate: str # XXX GARGROOOOONNNNNN!!!!!!! + audio_channels: str # XXX I HATE YOU + original: AttachmentMetaVideoOriginal + small: AttachmentMetaVideoSmall + + @classmethod + def from_dict(cls, data: dict) -> "AttachmentMetaVideo": + return cls( + **data, + original=cls.AttachmentMetaVideoOriginal(**data["original"]), + small=cls.AttachmentMetaVideoSmall(**data["small"]) + ) + + @dataclass class Attachment: id: str