Added polls and reconnect. Closes #1 & #7, I hope-

This commit is contained in:
Casey 2022-08-26 18:37:36 +03:00
parent 239957bb81
commit 8088cca8f0
Signed by: hkc
GPG Key ID: F0F6CFE11CDB0960
10 changed files with 147 additions and 47 deletions

2
.gitignore vendored
View File

@ -3,3 +3,5 @@ __pycache__
config-*.ini config-*.ini
venv venv
# :3
tmp.py

5
TODO
View File

@ -1,6 +1,7 @@
[integrations,core] Add database support so remote messages are stored and can be used to reply to them [integrations,core] Add database support so remote messages are stored and can be used to reply to them
[integrations,discord] Add Discord functionality
[core] Somehow find a way to get your user ID by token [core] Somehow find a way to get your user ID by token
[core] Maybe get rid of `main.list` field and create one automatically on a startup? [core] Maybe get rid of `main.list` field and create one automatically on a startup?
[integrations] Add support for shellscript integration [integrations] Add support for shellscript integration
[integrations,telegram] Add formatting option [integrations] Add formatting option
[integrations] Add filters
[integrations,vk] Add VK integration

View File

@ -28,6 +28,11 @@ user = 107914495779447227
; address bar while you have that list open) ; address bar while you have that list open)
list = 1 list = 1
; Should we automatically reconnect to the streaming socket?
; That option exists because it's not really a big deal when crossposter runs
; as a service and restarts automatically by the service manager.
auto-reconnect = yes
; Example Telegram integration. You can use it as a template ; Example Telegram integration. You can use it as a template
[module/telegram] [module/telegram]

View File

@ -0,0 +1,33 @@
from asyncio import gather
from configparser import ConfigParser
from typing import List, Optional
from mastoposter.integrations.base import BaseIntegration
from mastoposter.integrations import DiscordIntegration, TelegramIntegration
from mastoposter.types import Status
def load_integrations_from(config: ConfigParser) -> List[BaseIntegration]:
modules: List[BaseIntegration] = []
for module_name in config.get("main", "modules").split():
module = config[f"module/{module_name}"]
if module["type"] == "telegram":
modules.append(
TelegramIntegration(
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"])
return modules
async def execute_integrations(
status: Status, sinks: List[BaseIntegration]
) -> List[Optional[str]]:
return await gather(*[sink.post(status) for sink in sinks], return_exceptions=True)

View File

@ -1,7 +1,7 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
from asyncio import run from asyncio import run
from configparser import ConfigParser from configparser import ConfigParser
from mastoposter.integrations import DiscordIntegration, TelegramIntegration from mastoposter import execute_integrations, load_integrations_from
from mastoposter.sources import websocket_source from mastoposter.sources import websocket_source
from typing import AsyncGenerator, Callable, List from typing import AsyncGenerator, Callable, List
from mastoposter.integrations.base import BaseIntegration from mastoposter.integrations.base import BaseIntegration
@ -30,8 +30,7 @@ async def listen(
): ):
continue continue
for drain in drains: await execute_integrations(status, drains)
await drain.post(status)
def main(config_path: str): def main(config_path: str):
@ -49,22 +48,7 @@ def main(config_path: str):
for k in _remove: for k in _remove:
del conf[section][k] del conf[section][k]
modules: List[BaseIntegration] = [] modules = load_integrations_from(conf)
for module_name in conf.get("main", "modules").split():
module = conf[f"module/{module_name}"]
if module["type"] == "telegram":
modules.append(
TelegramIntegration(
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"])
url = "wss://{}/api/v1/streaming".format(conf["main"]["instance"]) url = "wss://{}/api/v1/streaming".format(conf["main"]["instance"])
run( run(
@ -73,6 +57,7 @@ def main(config_path: str):
modules, modules,
conf["main"]["user"], conf["main"]["user"],
url=url, url=url,
reconnect=conf["main"].getboolean("auto_reconnect", fallback=False),
list=conf["main"]["list"], list=conf["main"]["list"],
access_token=conf["main"]["token"], access_token=conf["main"]["token"],
) )

View File

@ -1,4 +1,5 @@
from abc import ABC, abstractmethod from abc import ABC, abstractmethod
from typing import Optional
from mastoposter.types import Status from mastoposter.types import Status
@ -8,5 +9,5 @@ class BaseIntegration(ABC):
pass pass
@abstractmethod @abstractmethod
async def post(self, status: Status) -> str: async def post(self, status: Status) -> Optional[str]:
raise NotImplemented raise NotImplemented

View File

@ -66,7 +66,7 @@ class DiscordIntegration(BaseIntegration):
) )
).json() ).json()
async def post(self, status: Status) -> str: async def post(self, status: Status) -> Optional[str]:
source = status.reblog or status source = status.reblog or status
embeds: List[DiscordEmbed] = [] embeds: List[DiscordEmbed] = []
@ -111,4 +111,4 @@ class DiscordIntegration(BaseIntegration):
embeds=embeds, embeds=embeds,
) )
return "" return None

View File

@ -4,7 +4,7 @@ from typing import Any, List, Mapping, Optional, Union
from bs4 import BeautifulSoup, Tag, PageElement from bs4 import BeautifulSoup, Tag, PageElement
from httpx import AsyncClient from httpx import AsyncClient
from mastoposter.integrations.base import BaseIntegration from mastoposter.integrations.base import BaseIntegration
from mastoposter.types import Attachment, Status from mastoposter.types import Attachment, Poll, Status
@dataclass @dataclass
@ -111,6 +111,20 @@ class TelegramIntegration(BaseIntegration):
media=media_list, media=media_list,
) )
async def _post_poll(
self, poll: Poll, reply_to: Optional[str] = None
) -> TGResponse:
return await self._tg_request(
"sendPoll",
disable_notification=True,
disable_web_page_preview=True,
chat_id=self.chat_id,
question=f"Poll:{poll.id}",
reply_to_message_id=reply_to,
allow_multiple_answers=poll.multiple,
options=[opt.title for opt in poll.options],
)
@classmethod @classmethod
def node_to_text(cls, el: PageElement) -> str: def node_to_text(cls, el: PageElement) -> str:
if isinstance(el, Tag): if isinstance(el, Tag):
@ -126,7 +140,7 @@ class TelegramIntegration(BaseIntegration):
return str.join("", map(cls.node_to_text, el.children)) return str.join("", map(cls.node_to_text, el.children))
return escape(str(el)) return escape(str(el))
async def post(self, status: Status) -> str: async def post(self, status: Status) -> Optional[str]:
source = status.reblog or status source = status.reblog or status
text = self.node_to_text(BeautifulSoup(source.content, features="lxml")) text = self.node_to_text(BeautifulSoup(source.content, features="lxml"))
text = text.rstrip() text = text.rstrip()
@ -148,20 +162,33 @@ class TelegramIntegration(BaseIntegration):
+ text + text
) )
ids = []
if not source.media_attachments: if not source.media_attachments:
msg = await self._post_plaintext(text) if (res := await self._post_plaintext(text)).ok:
if res.result:
ids.append(res.result["message_id"])
elif len(source.media_attachments) == 1: elif len(source.media_attachments) == 1:
msg = await self._post_media(text, source.media_attachments[0]) if (
res := await self._post_media(text, source.media_attachments[0])
).ok and res.result is not None:
ids.append(res.result["message_id"])
else: else:
msg = await self._post_mediagroup(text, source.media_attachments) if (
res := await self._post_mediagroup(text, source.media_attachments)
).ok and res.result is not None:
ids.append(res.result["message_id"])
if not msg.ok: if source.poll:
# raise Exception(msg.error, msg.params) if (
return "" # XXX: silently ignore for now res := await self._post_poll(
source.poll, reply_to=ids[0] if ids else None
)
).ok and res.result:
ids.append(res.result["message_id"])
if msg.result: return str.join(",", map(str, ids))
return msg.result.get("message_id", "")
return ""
def __repr__(self) -> str: def __repr__(self) -> str:
return ( return (

View File

@ -2,18 +2,25 @@ from json import loads
from typing import AsyncGenerator from typing import AsyncGenerator
from urllib.parse import urlencode from urllib.parse import urlencode
from mastoposter.types import Status from mastoposter.types import Status
async def websocket_source(url: str, **params) -> AsyncGenerator[Status, None]: async def websocket_source(
url: str, reconnect: bool = False, **params
) -> AsyncGenerator[Status, None]:
from websockets.client import connect from websockets.client import connect
from websockets.exceptions import WebSocketException
url = f"{url}?" + urlencode({"stream": "list", **params}) url = f"{url}?" + urlencode({"stream": "list", **params})
async with connect(url) as ws: while True:
while (msg := await ws.recv()) != None: try:
event = loads(msg) async with connect(url) as ws:
if "error" in event: while (msg := await ws.recv()) != None:
raise Exception(event["error"]) event = loads(msg)
if event["event"] == "update": if "error" in event:
yield Status.from_dict(loads(event["payload"])) raise Exception(event["error"])
if event["event"] == "update":
yield Status.from_dict(loads(event["payload"]))
except WebSocketException:
if not reconnect:
raise

View File

@ -1,4 +1,4 @@
from dataclasses import dataclass from dataclasses import dataclass, field
from datetime import datetime from datetime import datetime
from typing import Optional, List, Literal from typing import Optional, List, Literal
@ -208,6 +208,43 @@ class Tag:
return cls(**data) return cls(**data)
@dataclass
class Poll:
@dataclass
class PollOption:
title: str
votes_count: Optional[int] = None
id: str
expires_at: Optional[datetime]
expired: bool
multiple: bool
votes_count: int
voters_count: Optional[int] = None
options: List[PollOption] = field(default_factory=list)
emojis: List[Emoji] = field(default_factory=list)
@classmethod
def from_dict(cls, data: dict) -> "Poll":
return cls(
id=data["id"],
expires_at=(
datetime.fromisoformat(data["expires_at"].rstrip("Z"))
if data.get("expires_at") is not None
else None
),
expired=data["expired"],
multiple=data["multiple"],
votes_count=data["votes_count"],
voters_count=(
int(data["voters_count"])
if data.get("voters_count") is not None
else None
),
options=[cls.PollOption(**opt) for opt in data["options"]],
)
@dataclass @dataclass
class Status: class Status:
id: str id: str
@ -227,7 +264,7 @@ class Status:
in_reply_to_id: Optional[str] = None in_reply_to_id: Optional[str] = None
in_reply_to_account_id: Optional[str] = None in_reply_to_account_id: Optional[str] = None
reblog: Optional["Status"] = None reblog: Optional["Status"] = None
poll: Optional[dict] = None poll: Optional[Poll] = None
card: Optional[dict] = None card: Optional[dict] = None
language: Optional[str] = None language: Optional[str] = None
text: Optional[str] = None text: Optional[str] = None
@ -262,7 +299,9 @@ class Status:
if data.get("reblog") is not None if data.get("reblog") is not None
else None else None
), ),
poll=data.get("poll"), poll=(
Poll.from_dict(data["poll"]) if data.get("poll") is not None else None
),
card=data.get("card"), card=data.get("card"),
language=data.get("language"), language=data.get("language"),
text=data.get("text"), text=data.get("text"),