diff --git a/README.md b/README.md
index 4c77cb3..130ff1e 100644
--- a/README.md
+++ b/README.md
@@ -175,15 +175,13 @@ expression to match your spoiler text.
Filter to match post content against either a regular expression, or a list of
tags. Matching is done on the plaintext version of the post.
-`mode` property determines the type of operation. Can be either `regexp`,
-`tag` or `hashtag` (two last ones are the same).
+You can have one of two properties (but not both because fuck you): `tags` or
+`regexp`. It's obvious what does what: if you have `regexp` set, plaintext
+version of status is checked against that regular expression, and if you have
+`tags` set, then only statuses that have those tags will be allowed.
-In `mode = regexp` you have to specify the `regexp` option with a regular
-expression to match against.
-
-In `mode = tag` or `mode = hashtag`, `tags` option should contain a
-space-separated list of tags. If any of the tags are present in that list,
-filter will be triggered.
+Please note that in case of tags, you should NOT use `#` symbol in front of
+them.
#### `type = visibility`
Simple filter that just checks for post visibility.
diff --git a/mastoposter/__init__.py b/mastoposter/__init__.py
index a096cb5..1c4c717 100644
--- a/mastoposter/__init__.py
+++ b/mastoposter/__init__.py
@@ -1,5 +1,6 @@
from asyncio import gather
from configparser import ConfigParser
+from logging import getLogger
from typing import Dict, List, Optional
from mastoposter.filters import run_filters
from mastoposter.filters.base import BaseFilter, FilterInstance
@@ -11,33 +12,49 @@ from mastoposter.integrations import (
)
from mastoposter.types import Status
+logger = getLogger(__name__)
+
def load_integrations_from(config: ConfigParser) -> List[FilteredIntegration]:
modules: List[FilteredIntegration] = []
for module_name in config.get("main", "modules").split():
mod = config[f"module/{module_name}"]
+ logger.info(
+ "Configuring %s integration (type=%s)", module_name, mod["type"]
+ )
filters: Dict[str, FilterInstance] = {}
for filter_name in mod.get("filters", "").split():
filter_basename = filter_name.lstrip("~!")
+ logger.info(
+ "Loading filter %s for integration %s",
+ filter_basename,
+ module_name,
+ )
+
filters[filter_basename] = BaseFilter.new_instance(
filter_name, config[f"filter/{filter_basename}"]
)
for finst in list(filters.values()):
+ logger.info("Running post-initialization hook for %r", finst)
finst.filter.post_init(filters, config)
+ # TODO: make a registry of integrations
+ # INFO: mastoposter/integrations/base.py@__init__
if mod["type"] == "telegram":
modules.append(
FilteredIntegration(
- TelegramIntegration(mod), list(filters.values())
+ TelegramIntegration.from_section(mod),
+ list(filters.values()),
)
)
elif mod["type"] == "discord":
modules.append(
FilteredIntegration(
- DiscordIntegration(mod), list(filters.values())
+ DiscordIntegration.from_section(mod),
+ list(filters.values()),
)
)
else:
@@ -48,6 +65,7 @@ def load_integrations_from(config: ConfigParser) -> List[FilteredIntegration]:
async def execute_integrations(
status: Status, sinks: List[FilteredIntegration]
) -> List[Optional[str]]:
+ logger.debug("Executing integrations...")
return await gather(
*[sink[0](status) for sink in sinks if run_filters(sink[1], status)],
return_exceptions=True,
diff --git a/mastoposter/__main__.py b/mastoposter/__main__.py
index e95aa8f..083588d 100644
--- a/mastoposter/__main__.py
+++ b/mastoposter/__main__.py
@@ -1,6 +1,7 @@
#!/usr/bin/env python3
from asyncio import run
from configparser import ConfigParser, ExtendedInterpolation
+from logging import getLogger
from mastoposter import execute_integrations, load_integrations_from
from mastoposter.integrations import FilteredIntegration
from mastoposter.sources import websocket_source
@@ -8,10 +9,14 @@ from typing import AsyncGenerator, Callable, List
from mastoposter.types import Account, Status
from httpx import Client
+from mastoposter.utils import normalize_config
+
WSOCK_TEMPLATE = "wss://{instance}/api/v1/streaming"
VERIFY_CREDS_TEMPLATE = "https://{instance}/api/v1/accounts/verify_credentials"
+logger = getLogger()
+
async def listen(
source: Callable[..., AsyncGenerator[Status, None]],
@@ -20,12 +25,19 @@ async def listen(
/,
**kwargs,
):
+ logger.info("Starting listening...")
async for status in source(**kwargs):
+ logger.debug("Got status: %r", status)
if status.account.id != user:
continue
# TODO: add option/filter to handle that
if status.visibility in ("direct",):
+ logger.info(
+ "Skipping post %s (status.visibility=%r)",
+ status.uri,
+ status.visibility,
+ )
continue
# TODO: find a better way to handle threads
@@ -33,6 +45,10 @@ async def listen(
status.in_reply_to_account_id is not None
and status.in_reply_to_account_id != user
):
+ logger.info(
+ "Skipping post %s because it's a reply to another person",
+ status.uri,
+ )
continue
await execute_integrations(status, drains)
@@ -42,21 +58,15 @@ def main(config_path: str):
conf = ConfigParser(interpolation=ExtendedInterpolation())
conf.read(config_path)
- 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]
+ normalize_config(conf)
modules: List[FilteredIntegration] = load_integrations_from(conf)
+ logger.info("Loaded %d integrations", len(modules))
+
user_id: str = conf["main"]["user"]
if user_id == "auto":
+ logger.info("config.main.user is set to auto, getting user ID")
with Client() as c:
rq = c.get(
VERIFY_CREDS_TEMPLATE.format(**conf["main"]),
@@ -64,8 +74,10 @@ def main(config_path: str):
)
account = Account.from_dict(rq.json())
user_id = account.id
+ logger.info("account.id=%s", user_id)
url = "wss://{}/api/v1/streaming".format(conf["main"]["instance"])
+
run(
listen(
websocket_source,
diff --git a/mastoposter/filters/__init__.py b/mastoposter/filters/__init__.py
index 45d14d0..21398bd 100644
--- a/mastoposter/filters/__init__.py
+++ b/mastoposter/filters/__init__.py
@@ -1,3 +1,4 @@
+from logging import getLogger
from typing import List
from mastoposter.types import Status
@@ -10,8 +11,26 @@ from mastoposter.filters.text import TextFilter # NOQA
from mastoposter.filters.spoiler import SpoilerFilter # NOQA
from mastoposter.filters.visibility import VisibilityFilter # NOQA
+logger = getLogger(__name__)
+
def run_filters(filters: List[FilterInstance], status: Status) -> bool:
+ logger.debug("Running filters on %r", status)
+
if not filters:
+ logger.debug("No filters, returning True")
return True
- return all((fil.filter(status) ^ fil.inverse for fil in filters))
+
+ results: List[bool] = []
+ for fil in filters:
+ result = fil.filter(status)
+ logger.debug(
+ "%r -> %r ^ %r -> %r",
+ fil.filter,
+ result,
+ fil.inverse,
+ result ^ fil.inverse,
+ )
+ results.append(result ^ fil.inverse)
+ logger.debug("Result: %r", all(results))
+ return all(results)
diff --git a/mastoposter/filters/base.py b/mastoposter/filters/base.py
index 9513790..350d844 100644
--- a/mastoposter/filters/base.py
+++ b/mastoposter/filters/base.py
@@ -23,8 +23,8 @@ class BaseFilter(ABC):
filter_name: ClassVar[str] = "_base"
- def __init__(self, section: SectionProxy):
- UNUSED(section)
+ def __init__(self):
+ pass
def __init_subclass__(cls, filter_name: str, **kwargs):
super().__init_subclass__(**kwargs)
@@ -51,7 +51,11 @@ class BaseFilter(ABC):
def load_filter(cls, name: str, section: SectionProxy) -> "BaseFilter":
if name not in cls.FILTER_REGISTRY:
raise KeyError(f"no filter with name {name!r} was found")
- return cls.FILTER_REGISTRY[name](section)
+ return cls.FILTER_REGISTRY[name].from_section(section)
+
+ @classmethod
+ def from_section(cls, section: SectionProxy) -> "BaseFilter":
+ raise NotImplementedError
@classmethod
def new_instance(cls, name: str, section: SectionProxy) -> FilterInstance:
diff --git a/mastoposter/filters/boost.py b/mastoposter/filters/boost.py
index 687b60b..448e2b3 100644
--- a/mastoposter/filters/boost.py
+++ b/mastoposter/filters/boost.py
@@ -1,13 +1,18 @@
from configparser import SectionProxy
from fnmatch import fnmatch
+from typing import List
from mastoposter.filters.base import BaseFilter
from mastoposter.types import Status
class BoostFilter(BaseFilter, filter_name="boost"):
- def __init__(self, section: SectionProxy):
- super().__init__(section)
- self.list = section.get("list", "").split()
+ def __init__(self, accounts: List[str]):
+ super().__init__()
+ self.list = accounts
+
+ @classmethod
+ def from_section(cls, section: SectionProxy) -> "BoostFilter":
+ return cls(section["list"].split())
@classmethod
def check_account(cls, acct: str, mask: str) -> bool:
diff --git a/mastoposter/filters/combined.py b/mastoposter/filters/combined.py
index 7bcf899..0700cd4 100644
--- a/mastoposter/filters/combined.py
+++ b/mastoposter/filters/combined.py
@@ -11,12 +11,19 @@ class CombinedFilter(BaseFilter, filter_name="combined"):
"single": lambda d: sum(d) == 1,
}
- def __init__(self, section: SectionProxy):
- self.filter_names = section.get("filters", "").split()
- self.operator = self.OPERATORS[section.get("operator", "all")]
- self._operator_name = section.get("operator", "all")
+ def __init__(self, filter_names: List[str], operator: str):
+ self.filter_names = filter_names
+ self._operator_name = operator
+ self.operator = self.OPERATORS[self._operator_name]
self.filters: List[FilterInstance] = []
+ @classmethod
+ def from_section(cls, section: SectionProxy) -> "CombinedFilter":
+ return cls(
+ filter_names=section["filters"].split(),
+ operator=section.get("operator", "all"),
+ )
+
def post_init(
self, filters: Dict[str, FilterInstance], config: ConfigParser
):
diff --git a/mastoposter/filters/media.py b/mastoposter/filters/media.py
index 39c6a39..9f01ba9 100644
--- a/mastoposter/filters/media.py
+++ b/mastoposter/filters/media.py
@@ -1,16 +1,26 @@
from configparser import SectionProxy
-from typing import Set
+from typing import Literal, Set
from mastoposter.filters.base import BaseFilter
from mastoposter.types import Status
class MediaFilter(BaseFilter, filter_name="media"):
- def __init__(self, section: SectionProxy):
- super().__init__(section)
- self.valid_media: Set[str] = set(section.get("valid_media").split())
- self.mode = section.get("mode", "include")
- if self.mode not in ("include", "exclude", "only"):
- raise ValueError(f"{self.mode=} is not valid")
+ def __init__(
+ self,
+ valid_media: Set[str],
+ mode: Literal["include", "exclude", "only"],
+ ):
+ super().__init__()
+ self.valid_media: Set[str] = valid_media
+ self.mode = mode
+ assert self.mode in ("include", "exclude", "only")
+
+ @classmethod
+ def from_section(cls, section: SectionProxy) -> "MediaFilter":
+ return cls(
+ valid_media=set(section["valid_media"].split()),
+ mode=section.get("mode", "include"), # type: ignore
+ )
def __call__(self, status: Status) -> bool:
if not status.media_attachments:
diff --git a/mastoposter/filters/mention.py b/mastoposter/filters/mention.py
index 8ba70e4..4046d95 100644
--- a/mastoposter/filters/mention.py
+++ b/mastoposter/filters/mention.py
@@ -1,6 +1,6 @@
from configparser import SectionProxy
from re import Pattern, compile as regexp
-from typing import ClassVar
+from typing import ClassVar, Set
from fnmatch import fnmatch
from mastoposter.filters.base import BaseFilter
from mastoposter.types import Status
@@ -9,22 +9,27 @@ from mastoposter.types import Status
class MentionFilter(BaseFilter, filter_name="mention"):
MENTION_REGEX: ClassVar[Pattern] = regexp(r"@([^@]+)(@([^@]+))?")
- def __init__(self, section: SectionProxy):
- super().__init__(section)
- self.list = section.get("list", "").split()
+ def __init__(self, accounts: Set[str]):
+ super().__init__()
+ self.accounts = accounts
+
+ @classmethod
+ def from_section(cls, section: SectionProxy) -> "MentionFilter":
+ return cls(set(section.get("list", "").split()))
@classmethod
def check_account(cls, acct: str, mask: str) -> bool:
return fnmatch("@" + acct, mask)
def __call__(self, status: Status) -> bool:
- if not self.list and status.mentions:
+ if not self.accounts and status.mentions:
return True
+ # XXX: make it better somehow. and faster. and stronger.
return any(
(
any(
self.check_account(mention.acct, mask)
- for mask in self.list
+ for mask in self.accounts
)
for mention in status.mentions
)
@@ -32,5 +37,7 @@ class MentionFilter(BaseFilter, filter_name="mention"):
def __repr__(self):
return str.format(
- "Filter:{name}({list!r})", name=self.filter_name, list=self.list
+ "Filter:{name}({list!r})",
+ name=self.filter_name,
+ list=self.accounts,
)
diff --git a/mastoposter/filters/spoiler.py b/mastoposter/filters/spoiler.py
index 6e23fcd..0d862e0 100644
--- a/mastoposter/filters/spoiler.py
+++ b/mastoposter/filters/spoiler.py
@@ -5,9 +5,13 @@ from mastoposter.types import Status
class SpoilerFilter(BaseFilter, filter_name="spoiler"):
- def __init__(self, section: SectionProxy):
- super().__init__(section)
- self.regexp: Pattern = regexp(section.get("regexp", "^.*$"))
+ def __init__(self, regex: str = "^.*$"):
+ super().__init__()
+ self.regexp: Pattern = regexp(regex)
+
+ @classmethod
+ def from_section(cls, section: SectionProxy) -> "SpoilerFilter":
+ return cls(section.get("regexp", section.get("regex", "^.*$")))
def __call__(self, status: Status) -> bool:
return self.regexp.match(status.spoiler_text) is not None
diff --git a/mastoposter/filters/text.py b/mastoposter/filters/text.py
index eb7738f..56bc28d 100644
--- a/mastoposter/filters/text.py
+++ b/mastoposter/filters/text.py
@@ -8,18 +8,24 @@ from mastoposter.types import Status
class TextFilter(BaseFilter, filter_name="content"):
- def __init__(self, section: SectionProxy):
- super().__init__(section)
- self.mode = section["mode"]
- self.tags: Set[str] = set()
- self.regexp: Optional[Pattern] = None
+ def __init__(
+ self, regex: Optional[str] = None, tags: Optional[Set[str]] = None
+ ):
+ super().__init__()
+ assert regex is not None or tags
- if self.mode == "regexp":
- self.regexp = regexp(section["regexp"])
- elif self.mode in ("hashtag", "tag"):
- self.tags = set(map(str.lower, section["tags"].split()))
- else:
- raise ValueError(f"Invalid filter mode {self.mode}")
+ self.tags: Optional[Set[str]] = tags
+ self.regexp: Optional[Pattern] = regexp(regex) if regex else None
+
+ @classmethod
+ def from_section(cls, section: SectionProxy) -> "TextFilter":
+ if "regexp" in section and "tags" in section:
+ raise AssertionError("you can't use both tags and regexp")
+ elif "regexp" in section:
+ return cls(regex=section["regexp"])
+ elif "tags" in section:
+ return cls(tags=set(section["tags"].split()))
+ raise AssertionError("neither regexp or tags were set")
@classmethod
def node_to_text(cls, el: PageElement) -> str:
@@ -63,3 +69,4 @@ class TextFilter(BaseFilter, filter_name="content"):
name=self.filter_name,
tags=self.tags,
)
+ return "Filter:{name}(invalid)".format(name=self.filter_name)
diff --git a/mastoposter/filters/visibility.py b/mastoposter/filters/visibility.py
index 70fda56..40b3b8a 100644
--- a/mastoposter/filters/visibility.py
+++ b/mastoposter/filters/visibility.py
@@ -1,12 +1,17 @@
from configparser import SectionProxy
+from typing import Set
from mastoposter.filters.base import BaseFilter
from mastoposter.types import Status
class VisibilityFilter(BaseFilter, filter_name="visibility"):
- def __init__(self, section: SectionProxy):
- super().__init__(section)
- self.options = set(section["options"].split())
+ def __init__(self, options: Set[str]):
+ super().__init__()
+ self.options = options
+
+ @classmethod
+ def from_section(cls, section: SectionProxy) -> "BaseFilter":
+ return cls(set(section["options"].split()))
def __call__(self, status: Status) -> bool:
return status.visibility in self.options
diff --git a/mastoposter/integrations/base.py b/mastoposter/integrations/base.py
index 1b6765c..815263a 100644
--- a/mastoposter/integrations/base.py
+++ b/mastoposter/integrations/base.py
@@ -6,9 +6,14 @@ from mastoposter.types import Status
class BaseIntegration(ABC):
- def __init__(self, section: SectionProxy):
+ # TODO: make a registry of integrations
+ def __init__(self):
pass
+ @classmethod
+ def from_section(cls, section: SectionProxy) -> "BaseIntegration":
+ raise NotImplementedError
+
@abstractmethod
async def __call__(self, status: Status) -> Optional[str]:
raise NotImplementedError
diff --git a/mastoposter/integrations/discord/__init__.py b/mastoposter/integrations/discord/__init__.py
index 38b96bb..417c9dd 100644
--- a/mastoposter/integrations/discord/__init__.py
+++ b/mastoposter/integrations/discord/__init__.py
@@ -12,8 +12,12 @@ from mastoposter.types import Status
class DiscordIntegration(BaseIntegration):
- def __init__(self, section: SectionProxy):
- self.webhook = section.get("webhook", "")
+ def __init__(self, webhook: str):
+ self.webhook = webhook
+
+ @classmethod
+ def from_section(cls, section: SectionProxy) -> "DiscordIntegration":
+ return cls(section["webhook"])
async def execute_webhook(
self,
diff --git a/mastoposter/integrations/telegram.py b/mastoposter/integrations/telegram.py
index a67bfde..fb81b07 100644
--- a/mastoposter/integrations/telegram.py
+++ b/mastoposter/integrations/telegram.py
@@ -54,12 +54,31 @@ Boost from \
class TelegramIntegration(BaseIntegration):
- def __init__(self, sect: SectionProxy):
- self.token = sect.get("token", "")
- self.chat_id = sect.get("chat", "")
- self.silent = sect.getboolean("silent", True)
- self.template: Template = Template(
- emojize(sect.get("template", DEFAULT_TEMPLATE))
+ def __init__(
+ self,
+ token: str,
+ chat_id: str,
+ template: Optional[Template] = None,
+ silent: bool = True,
+ ):
+ self.token = token
+ self.chat_id = chat_id
+ self.silent = silent
+
+ if template is None:
+ self.template = Template(emojize(DEFAULT_TEMPLATE))
+ else:
+ self.template = template
+
+ @classmethod
+ def from_section(cls, section: SectionProxy) -> "TelegramIntegration":
+ return cls(
+ token=section["token"],
+ chat_id=section["chat"],
+ silent=section.getboolean("silent", True),
+ template=Template(
+ emojize(section.get("template", DEFAULT_TEMPLATE))
+ ),
)
async def _tg_request(self, method: str, **kwargs) -> TGResponse:
diff --git a/mastoposter/utils.py b/mastoposter/utils.py
index 30e0352..14e6bcc 100644
--- a/mastoposter/utils.py
+++ b/mastoposter/utils.py
@@ -1,3 +1,4 @@
+from configparser import ConfigParser
from html import escape
from typing import Callable, Dict
from bs4.element import Tag, PageElement
@@ -16,6 +17,19 @@ def md_escape(text: str) -> str:
)
+def normalize_config(conf: ConfigParser):
+ 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]
+
+
def node_to_html(el: PageElement) -> str:
TAG_TRANSFORMS: Dict[str, Callable[[Tag,], str]] = {
"a": lambda tag: '{}'.format(