forked from hkc/mastoposter
parent
239957bb81
commit
8088cca8f0
|
@ -3,3 +3,5 @@ __pycache__
|
||||||
config-*.ini
|
config-*.ini
|
||||||
venv
|
venv
|
||||||
|
|
||||||
|
# :3
|
||||||
|
tmp.py
|
||||||
|
|
5
TODO
5
TODO
|
@ -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
|
||||||
|
|
|
@ -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]
|
||||||
|
|
||||||
|
|
|
@ -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)
|
|
@ -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"],
|
||||||
)
|
)
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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 (
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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"),
|
||||||
|
|
Loading…
Reference in New Issue