forked from hkc/mastoposter
Initial commit :DDDDDD
This commit is contained in:
commit
7a7227f28d
|
@ -0,0 +1,4 @@
|
||||||
|
__pycache__
|
||||||
|
*.py[cow]
|
||||||
|
config-prod.ini
|
||||||
|
|
|
@ -0,0 +1,18 @@
|
||||||
|
[main]
|
||||||
|
modules = telegram
|
||||||
|
instance = mastodon.example.org
|
||||||
|
token = blahblah
|
||||||
|
user = 12345
|
||||||
|
list = 1
|
||||||
|
|
||||||
|
[module/telegram]
|
||||||
|
type = telegram
|
||||||
|
token = 12345:blahblah
|
||||||
|
chat = @username
|
||||||
|
show-post-link = yes
|
||||||
|
show-boost-from = yes
|
||||||
|
|
||||||
|
# TODO: add discord functionality
|
||||||
|
[module/discord]
|
||||||
|
type = discord
|
||||||
|
webhook = url
|
|
@ -0,0 +1,69 @@
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
from asyncio import run
|
||||||
|
from configparser import ConfigParser
|
||||||
|
|
||||||
|
from mastoreposter.integrations.telegram import TelegramIntegration
|
||||||
|
from mastoreposter.sources import websocket_source
|
||||||
|
from typing import AsyncGenerator, Callable, List
|
||||||
|
from mastoreposter.integrations.base import BaseIntegration
|
||||||
|
from mastoreposter.types import Status
|
||||||
|
|
||||||
|
|
||||||
|
async def listen(
|
||||||
|
source: Callable[..., AsyncGenerator[Status, None]],
|
||||||
|
drains: List[BaseIntegration],
|
||||||
|
user: str,
|
||||||
|
/,
|
||||||
|
**kwargs,
|
||||||
|
):
|
||||||
|
async for status in source(**kwargs):
|
||||||
|
if status.account.id != user:
|
||||||
|
continue
|
||||||
|
print(status)
|
||||||
|
if status.visibility == "direct":
|
||||||
|
continue
|
||||||
|
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)
|
||||||
|
|
||||||
|
|
||||||
|
def main(config_path: str):
|
||||||
|
conf = ConfigParser()
|
||||||
|
conf.read(config_path)
|
||||||
|
|
||||||
|
modules = []
|
||||||
|
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),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
raise ValueError("Invalid module type %r" % module["type"])
|
||||||
|
|
||||||
|
url = "wss://{}/api/v1/streaming".format(conf["main"]["instance"])
|
||||||
|
run(
|
||||||
|
listen(
|
||||||
|
websocket_source,
|
||||||
|
modules,
|
||||||
|
conf["main"]["user"],
|
||||||
|
url=url,
|
||||||
|
list=conf["main"]["list"],
|
||||||
|
access_token=conf["main"]["token"],
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
from sys import argv
|
||||||
|
|
||||||
|
main(argv[1])
|
|
@ -0,0 +1 @@
|
||||||
|
from .telegram import TelegramIntegration
|
|
@ -0,0 +1,12 @@
|
||||||
|
from abc import ABC, abstractmethod
|
||||||
|
|
||||||
|
from mastoreposter.types import Status
|
||||||
|
|
||||||
|
|
||||||
|
class BaseIntegration(ABC):
|
||||||
|
def __init__(self):
|
||||||
|
pass
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
async def post(self, status: Status) -> str:
|
||||||
|
raise NotImplemented
|
|
@ -0,0 +1,171 @@
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from html import escape
|
||||||
|
from typing import Any, List, Mapping, Optional, Union
|
||||||
|
from bs4 import BeautifulSoup, Tag, PageElement
|
||||||
|
from httpx import AsyncClient
|
||||||
|
from mastoreposter.integrations.base import BaseIntegration
|
||||||
|
from mastoreposter.types import Attachment, Status
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class TGResponse:
|
||||||
|
ok: bool
|
||||||
|
params: dict
|
||||||
|
result: Optional[Any] = None
|
||||||
|
error: Optional[str] = None
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_dict(cls, data: dict, params: dict) -> "TGResponse":
|
||||||
|
return cls(data["ok"], params, data.get("result"), data.get("error"))
|
||||||
|
|
||||||
|
|
||||||
|
class TelegramIntegration(BaseIntegration):
|
||||||
|
API_URL: str = "https://api.telegram.org/bot{}/{}"
|
||||||
|
MEDIA_COMPATIBILITY: Mapping[str, set] = {
|
||||||
|
"image": {"image", "video"},
|
||||||
|
"video": {"image", "video"},
|
||||||
|
"gifv": {"gifv"},
|
||||||
|
"audio": {"audio"},
|
||||||
|
"unknown": {"unknown"},
|
||||||
|
}
|
||||||
|
MEDIA_MAPPING: Mapping[str, str] = {
|
||||||
|
"image": "photo",
|
||||||
|
"video": "video",
|
||||||
|
"gifv": "animation",
|
||||||
|
"audio": "audio",
|
||||||
|
"unknown": "document",
|
||||||
|
}
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
token: str,
|
||||||
|
chat_id: Union[str, int],
|
||||||
|
show_post_link: bool = True,
|
||||||
|
show_boost_from: bool = True,
|
||||||
|
):
|
||||||
|
self.token = token
|
||||||
|
self.chat_id = chat_id
|
||||||
|
self.show_post_link = show_post_link
|
||||||
|
self.show_boost_from = show_boost_from
|
||||||
|
|
||||||
|
async def _tg_request(self, method: str, **kwargs) -> TGResponse:
|
||||||
|
url = self.API_URL.format(self.token, method)
|
||||||
|
async with AsyncClient() as client:
|
||||||
|
return TGResponse.from_dict(
|
||||||
|
(await client.post(url, json=kwargs)).json(), kwargs
|
||||||
|
)
|
||||||
|
|
||||||
|
async def _post_plaintext(self, text: str) -> TGResponse:
|
||||||
|
return await self._tg_request(
|
||||||
|
"sendMessage",
|
||||||
|
parse_mode="HTML",
|
||||||
|
disable_notification=True,
|
||||||
|
disable_web_page_preview=True,
|
||||||
|
chat_id=self.chat_id,
|
||||||
|
text=text,
|
||||||
|
)
|
||||||
|
|
||||||
|
async def _post_media(self, text: str, media: Attachment) -> TGResponse:
|
||||||
|
# Just to be safe
|
||||||
|
if media.type not in self.MEDIA_MAPPING:
|
||||||
|
return await self._post_plaintext(text)
|
||||||
|
|
||||||
|
return await self._tg_request(
|
||||||
|
"send%s" % self.MEDIA_MAPPING[media.type].title(),
|
||||||
|
parse_mode="HTML",
|
||||||
|
disable_notification=True,
|
||||||
|
disable_web_page_preview=True,
|
||||||
|
chat_id=self.chat_id,
|
||||||
|
caption=text,
|
||||||
|
**{self.MEDIA_MAPPING[media.type]: media.preview_url},
|
||||||
|
)
|
||||||
|
|
||||||
|
async def _post_mediagroup(self, text: str, media: List[Attachment]) -> TGResponse:
|
||||||
|
media_list: List[dict] = []
|
||||||
|
allowed_medias = {"image", "gifv", "video", "audio", "unknown"}
|
||||||
|
for attachment in media:
|
||||||
|
if attachment.type not in allowed_medias:
|
||||||
|
continue
|
||||||
|
if attachment.type not in self.MEDIA_COMPATIBILITY:
|
||||||
|
continue
|
||||||
|
allowed_medias &= self.MEDIA_COMPATIBILITY[attachment.type]
|
||||||
|
media_list.append(
|
||||||
|
{
|
||||||
|
"type": self.MEDIA_MAPPING[attachment.type],
|
||||||
|
"media": attachment.url,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
if len(media_list) == 1:
|
||||||
|
media_list[0].update(
|
||||||
|
{
|
||||||
|
"caption": text,
|
||||||
|
"parse_mode": "HTML",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
return await self._tg_request(
|
||||||
|
"sendMediaGroup",
|
||||||
|
disable_notification=True,
|
||||||
|
disable_web_page_preview=True,
|
||||||
|
chat_id=self.chat_id,
|
||||||
|
media=media_list,
|
||||||
|
)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def node_to_text(cls, el: PageElement) -> str:
|
||||||
|
if isinstance(el, Tag):
|
||||||
|
if el.name == "a":
|
||||||
|
return '<a href="{}">{}</a>'.format(
|
||||||
|
escape(el.attrs["href"]),
|
||||||
|
str.join("", map(cls.node_to_text, el.children)),
|
||||||
|
)
|
||||||
|
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 escape(str(el))
|
||||||
|
|
||||||
|
async def post(self, status: Status) -> str:
|
||||||
|
source = status.reblog or status
|
||||||
|
text = self.node_to_text(BeautifulSoup(source.content, features="lxml"))
|
||||||
|
|
||||||
|
if source.spoiler_text:
|
||||||
|
text = "Spoiler: {cw}\n<tg-spoiler>{text}</tg-spoiler>".format(
|
||||||
|
cw=source.spoiler_text, text=text
|
||||||
|
)
|
||||||
|
|
||||||
|
if self.show_post_link:
|
||||||
|
text += '\n\n<a href="%s">Link to post</a>' % status.link
|
||||||
|
|
||||||
|
if status.reblog and self.show_boost_from:
|
||||||
|
text = (
|
||||||
|
'Boosted post from <a href="{}">{}</a>'.format(
|
||||||
|
source.account.url, source.account.display_name
|
||||||
|
)
|
||||||
|
+ text
|
||||||
|
)
|
||||||
|
|
||||||
|
if not source.media_attachments:
|
||||||
|
msg = await self._post_plaintext(text)
|
||||||
|
elif len(source.media_attachments) == 1:
|
||||||
|
msg = await self._post_media(text, source.media_attachments[0])
|
||||||
|
else:
|
||||||
|
msg = await self._post_mediagroup(text, source.media_attachments)
|
||||||
|
|
||||||
|
if not msg.ok:
|
||||||
|
raise Exception(msg.error, msg.params)
|
||||||
|
|
||||||
|
return ""
|
||||||
|
|
||||||
|
def __repr__(self) -> str:
|
||||||
|
return (
|
||||||
|
"<TelegramIntegration "
|
||||||
|
"chat_id={chat!r} "
|
||||||
|
"show_post_link={show_post_link!r} "
|
||||||
|
"show_boost_from={show_boost_from!r} "
|
||||||
|
).format(
|
||||||
|
chat=self.chat_id,
|
||||||
|
show_post_link=self.show_post_link,
|
||||||
|
show_boost_from=self.show_boost_from,
|
||||||
|
)
|
|
@ -0,0 +1,19 @@
|
||||||
|
from json import loads
|
||||||
|
from typing import AsyncGenerator
|
||||||
|
from urllib.parse import urlencode
|
||||||
|
|
||||||
|
|
||||||
|
from mastoreposter.types import Status
|
||||||
|
|
||||||
|
|
||||||
|
async def websocket_source(url: str, **params) -> AsyncGenerator[Status, None]:
|
||||||
|
from websockets.client import connect
|
||||||
|
|
||||||
|
url = f"{url}?" + urlencode({"stream": "list", **params})
|
||||||
|
async with connect(url) as ws:
|
||||||
|
while (msg := await ws.recv()) != None:
|
||||||
|
event = loads(msg)
|
||||||
|
if "error" in event:
|
||||||
|
raise Exception(event["error"])
|
||||||
|
if event["event"] == "update":
|
||||||
|
yield Status.from_dict(loads(event["payload"]))
|
|
@ -0,0 +1,206 @@
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Optional, List, Literal
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Field:
|
||||||
|
name: str
|
||||||
|
value: str
|
||||||
|
verified_at: Optional[datetime] = None
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_dict(cls, data: dict) -> "Field":
|
||||||
|
return cls(
|
||||||
|
name=data["name"],
|
||||||
|
value=data["value"],
|
||||||
|
verified_at=(
|
||||||
|
datetime.fromisoformat(data["verified_at"].rstrip("Z"))
|
||||||
|
if data.get("verified_at") is not None
|
||||||
|
else None
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Emoji:
|
||||||
|
shortcode: str
|
||||||
|
url: str
|
||||||
|
static_url: str
|
||||||
|
visible_in_picker: bool
|
||||||
|
category: Optional[str] = None
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_dict(cls, data: dict) -> "Emoji":
|
||||||
|
return cls(**data)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Account:
|
||||||
|
id: str
|
||||||
|
username: str
|
||||||
|
acct: str
|
||||||
|
url: str
|
||||||
|
display_name: str
|
||||||
|
note: str
|
||||||
|
avatar: str
|
||||||
|
avatar_static: str
|
||||||
|
header: str
|
||||||
|
header_static: str
|
||||||
|
locked: bool
|
||||||
|
emojis: List[Emoji]
|
||||||
|
discoverable: bool
|
||||||
|
created_at: datetime
|
||||||
|
last_status_at: datetime
|
||||||
|
statuses_count: int
|
||||||
|
followers_count: int
|
||||||
|
following_count: int
|
||||||
|
moved: Optional["Account"] = None
|
||||||
|
fields: Optional[List[Field]] = None
|
||||||
|
bot: Optional[bool] = None
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_dict(cls, data: dict) -> "Account":
|
||||||
|
return cls(
|
||||||
|
id=data["id"],
|
||||||
|
username=data["username"],
|
||||||
|
acct=data["acct"],
|
||||||
|
url=data["url"],
|
||||||
|
display_name=data["display_name"],
|
||||||
|
note=data["note"],
|
||||||
|
avatar=data["avatar"],
|
||||||
|
avatar_static=data["avatar_static"],
|
||||||
|
header=data["header"],
|
||||||
|
header_static=data["header_static"],
|
||||||
|
locked=data["locked"],
|
||||||
|
emojis=list(map(Emoji.from_dict, data["emojis"])),
|
||||||
|
discoverable=data["discoverable"],
|
||||||
|
created_at=datetime.fromisoformat(data["created_at"].rstrip("Z")),
|
||||||
|
last_status_at=datetime.fromisoformat(data["last_status_at"].rstrip("Z")),
|
||||||
|
statuses_count=data["statuses_count"],
|
||||||
|
followers_count=data["followers_count"],
|
||||||
|
following_count=data["following_count"],
|
||||||
|
moved=(
|
||||||
|
Account.from_dict(data["moved"])
|
||||||
|
if data.get("moved") is not None
|
||||||
|
else None
|
||||||
|
),
|
||||||
|
fields=list(map(Field.from_dict, data.get("fields", []))),
|
||||||
|
bot=bool(data.get("bot")),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Attachment:
|
||||||
|
id: str
|
||||||
|
type: Literal["unknown", "image", "gifv", "video", "audio"]
|
||||||
|
url: str
|
||||||
|
preview_url: str
|
||||||
|
remote_url: Optional[str] = None
|
||||||
|
preview_remote_url: Optional[str] = None
|
||||||
|
meta: Optional[dict] = None
|
||||||
|
description: Optional[str] = None
|
||||||
|
blurhash: Optional[str] = None
|
||||||
|
text_url: Optional[str] = None # XXX: DEPRECATED
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_dict(cls, data: dict) -> "Attachment":
|
||||||
|
return cls(**data)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Application:
|
||||||
|
name: str
|
||||||
|
website: Optional[str] = None
|
||||||
|
vapid_key: Optional[str] = None
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_dict(cls, data: dict) -> "Application":
|
||||||
|
return cls(**data)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Mention:
|
||||||
|
id: str
|
||||||
|
username: str
|
||||||
|
acct: str
|
||||||
|
url: str
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_dict(cls, data: dict) -> "Mention":
|
||||||
|
return cls(**data)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Tag:
|
||||||
|
name: str
|
||||||
|
url: str
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_dict(cls, data: dict) -> "Tag":
|
||||||
|
return cls(**data)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Status:
|
||||||
|
id: str
|
||||||
|
uri: str
|
||||||
|
created_at: datetime
|
||||||
|
account: Account
|
||||||
|
content: str
|
||||||
|
visibility: Literal["public", "unlisted", "private", "direct"]
|
||||||
|
sensitive: bool
|
||||||
|
spoiler_text: str
|
||||||
|
media_attachments: List[Attachment]
|
||||||
|
reblogs_count: int
|
||||||
|
favourites_count: int
|
||||||
|
replies_count: int
|
||||||
|
application: Optional[Application] = None
|
||||||
|
url: Optional[str] = None
|
||||||
|
in_reply_to_id: Optional[str] = None
|
||||||
|
in_reply_to_account_id: Optional[str] = None
|
||||||
|
reblog: Optional["Status"] = None
|
||||||
|
poll: Optional[dict] = None
|
||||||
|
card: Optional[dict] = None
|
||||||
|
language: Optional[str] = None
|
||||||
|
text: Optional[str] = None
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_dict(cls, data: dict) -> "Status":
|
||||||
|
return cls(
|
||||||
|
id=data["id"],
|
||||||
|
uri=data["uri"],
|
||||||
|
created_at=datetime.fromisoformat(data["created_at"].rstrip("Z")),
|
||||||
|
account=Account.from_dict(data["account"]),
|
||||||
|
content=data["content"],
|
||||||
|
visibility=data["visibility"],
|
||||||
|
sensitive=data["sensitive"],
|
||||||
|
spoiler_text=data["spoiler_text"],
|
||||||
|
media_attachments=list(
|
||||||
|
map(Attachment.from_dict, data["media_attachments"])
|
||||||
|
),
|
||||||
|
application=(
|
||||||
|
Application.from_dict(data["application"])
|
||||||
|
if data.get("application") is not None
|
||||||
|
else None
|
||||||
|
),
|
||||||
|
reblogs_count=data["reblogs_count"],
|
||||||
|
favourites_count=data["favourites_count"],
|
||||||
|
replies_count=data["replies_count"],
|
||||||
|
url=data.get("url"),
|
||||||
|
in_reply_to_id=data.get("in_reply_to_id"),
|
||||||
|
in_reply_to_account_id=data.get("in_reply_to_account_id"),
|
||||||
|
reblog=(
|
||||||
|
Status.from_dict(data["reblog"])
|
||||||
|
if data.get("reblog") is not None
|
||||||
|
else None
|
||||||
|
),
|
||||||
|
poll=data.get("poll"),
|
||||||
|
card=data.get("card"),
|
||||||
|
language=data.get("language"),
|
||||||
|
text=data.get("text"),
|
||||||
|
)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def link(self) -> str:
|
||||||
|
return self.account.url + "/" + str(self.id)
|
Loading…
Reference in New Issue