Skip to content

Commit

Permalink
Add Twitter Post Client (#42)
Browse files Browse the repository at this point in the history
Reworking the project in a way to incorporate an interface "PostClient" which supports slack and twitter.
  • Loading branch information
bh2smith authored Mar 14, 2023
1 parent e571844 commit 1df1247
Show file tree
Hide file tree
Showing 13 changed files with 163 additions and 28 deletions.
9 changes: 8 additions & 1 deletion .env.sample
Original file line number Diff line number Diff line change
@@ -1,4 +1,11 @@
DUNE_API_KEY=

# Slack Credentials
SLACK_TOKEN=
SLACK_ALERT_CHANNEL=

DUNE_API_KEY=
# Twitter Credentials
CONSUMER_KEY=
CONSUMER_SECRET=
ACCESS_TOKEN=
ACCESS_TOKEN_SECRET=
37 changes: 37 additions & 0 deletions Makefile
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
VENV = venv
PYTHON = $(VENV)/bin/python3
PIP = $(VENV)/bin/pip
PROJECT_ROOT = src


$(VENV)/bin/activate: requirements/dev.txt
python3 -m venv $(VENV)
$(PIP) install --upgrade pip
$(PIP) install -r requirements/dev.txt


install:
make $(VENV)/bin/activate

clean:
rm -rf __pycache__

fmt:
black ./

lint:
pylint ${PROJECT_ROOT}/

types:
mypy ${PROJECT_ROOT}/ --strict

check:
make fmt
make lint
make types

test-unit:
python -m pytest tests/unit

test-e2e:
python -m pytest tests/e2e
3 changes: 2 additions & 1 deletion requirements/prod.txt
Original file line number Diff line number Diff line change
Expand Up @@ -5,4 +5,5 @@ types-python-dateutil==2.8.19
types-PyYAML==6.0.11
python-dateutil==2.8.2
python-dotenv==0.21.0
certifi==2022.12.7
certifi==2022.12.7
tweepy==4.13.0
Empty file added src/post/__init__.py
Empty file.
12 changes: 12 additions & 0 deletions src/post/base.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
"""Abstraction for posting alerts"""
from abc import ABC, abstractmethod


class PostClient(ABC):
"""
Basic Post Client with message post functionality
"""

@abstractmethod
def post(self, message: str) -> None:
"""Posts `message` to `self.channel` excluding link previews."""
24 changes: 24 additions & 0 deletions src/post/twitter.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
"""
Twitter Alert Client
"""
import tweepy # type:ignore

from src.post.base import PostClient


class TwitterClient(PostClient):
"""Forwards alerts to Twitter"""

def __init__(self, credentials: dict[str, str]) -> None:
auth = tweepy.OAuthHandler(
consumer_key=credentials["consumer_key"],
consumer_secret=credentials["consumer_secret"],
)
auth.set_access_token(
key=credentials["access_token"],
secret=credentials["access_token_secret"],
)
self.api = tweepy.API(auth)

def post(self, message: str) -> None:
self.api.update_status(status=message)
26 changes: 20 additions & 6 deletions src/query_monitor/factory.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,14 @@
Factory method to load QueryMonitor object from yaml configuration files
"""
from __future__ import annotations
import os
from dataclasses import dataclass

import logging.config
from dataclasses import dataclass
from enum import Enum

import yaml
from dune_client.types import QueryParameter
from dune_client.query import Query
from dune_client.types import QueryParameter

from src.models import TimeWindow, LeftBound
from src.query_monitor.base import QueryBase
Expand All @@ -17,11 +18,22 @@
from src.query_monitor.result_threshold import ResultThresholdQuery
from src.query_monitor.windowed import WindowedQueryMonitor


log = logging.getLogger(__name__)
logging.config.fileConfig(fname="logging.conf", disable_existing_loggers=False)


class AlertType(Enum):
"""Supported Alert Frameworks."""

SLACK = "slack"
TWITTER = "twitter"

@classmethod
def from_str(cls, val: str) -> AlertType:
"""From string constructor"""
return cls(val.lower())


@dataclass
class Config:
"""
Expand All @@ -31,6 +43,7 @@ class Config:
query: QueryBase
ping_frequency: int
alert_channel: str
alert_type: AlertType


def load_config(config_yaml: str) -> Config:
Expand Down Expand Up @@ -66,10 +79,11 @@ def load_config(config_yaml: str) -> Config:

config_obj = Config(
query=base_query,
# Use specified channel, or default to "global config"
alert_channel=cfg.get("alert_channel", os.environ["SLACK_ALERT_CHANNEL"]),
alert_channel=cfg.get("alert_channel"),
# This is 4x the DuneClient default of 5 seconds
ping_frequency=cfg.get("ping_frequency", 20),
# Slack is the default alert type.
alert_type=AlertType.from_str(cfg.get("alert_type", "slack")),
)
log.debug(f"config parsed as {config_obj}")
return config_obj
8 changes: 4 additions & 4 deletions src/runner.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,8 @@
from dune_client.client import DuneClient

from src.alert import AlertLevel
from src.post.base import PostClient
from src.query_monitor.base import QueryBase
from src.slack_client import BasicSlackClient

log = logging.getLogger(__name__)
logging.config.fileConfig(fname="logging.conf", disable_existing_loggers=False)
Expand All @@ -26,12 +26,12 @@ def __init__(
self,
query: QueryBase,
dune: DuneClient,
slack_client: BasicSlackClient,
alerter: PostClient,
ping_frequency: int,
):
self.query = query
self.dune = dune
self.slack_client = slack_client
self.alerter = alerter
self.ping_frequency = ping_frequency

def run_loop(self) -> None:
Expand All @@ -44,6 +44,6 @@ def run_loop(self) -> None:
alert = query.get_alert(results)
if alert.level == AlertLevel.SLACK:
log.warning(alert.message)
self.slack_client.post(alert.message)
self.alerter.post(alert.message)
elif alert.level == AlertLevel.LOG:
log.info(alert.message)
4 changes: 3 additions & 1 deletion src/slack_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,11 +10,13 @@
from slack.errors import SlackApiError
from slack.web.client import WebClient

from src.post.base import PostClient

log = logging.getLogger(__name__)
logging.config.fileConfig(fname="logging.conf", disable_existing_loggers=False)


class BasicSlackClient:
class BasicSlackClient(PostClient):
"""
Basic Slack Client with message post functionality
constructed from an API token and channel
Expand Down
32 changes: 26 additions & 6 deletions src/slackbot.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,23 +8,25 @@

from dune_client.client import DuneClient

from src.post.base import PostClient
from src.post.twitter import TwitterClient
from src.query_monitor.base import QueryBase
from src.query_monitor.factory import load_config
from src.query_monitor.factory import load_config, AlertType
from src.runner import QueryRunner
from src.slack_client import BasicSlackClient


def run_slackbot(
query: QueryBase,
dune: DuneClient,
slack_client: BasicSlackClient,
alert_client: PostClient,
ping_frequency: int,
) -> None:
"""
This is the main method of the program.
Instantiate a query runner, and execute its run_loop
"""
query_runner = QueryRunner(query, dune, slack_client, ping_frequency)
query_runner = QueryRunner(query, dune, alert_client, ping_frequency)
query_runner.run_loop()


Expand All @@ -39,11 +41,29 @@ def run_slackbot(
args = parser.parse_args()
dotenv.load_dotenv()
config = load_config(args.query_config)

alerter: PostClient
if config.alert_type == AlertType.SLACK:
alerter = BasicSlackClient(
token=os.environ["SLACK_TOKEN"],
# Use specified channel, or default to "global config"
channel=config.alert_channel or os.environ["SLACK_ALERT_CHANNEL"],
)
elif config.alert_type == AlertType.TWITTER:
alerter = TwitterClient(
credentials={
"consumer_key": os.environ["CONSUMER_KEY"],
"consumer_secret": os.environ["CONSUMER_SECRET"],
"access_token": os.environ["ACCESS_TOKEN"],
"access_token_secret": os.environ["ACCESS_TOKEN_SECRET"],
}
)
else:
raise ValueError(f"Invalid or unsupported AlertType {config.alert_type}")

run_slackbot(
query=config.query,
dune=DuneClient(os.environ["DUNE_API_KEY"]),
slack_client=BasicSlackClient(
token=os.environ["SLACK_TOKEN"], channel=config.alert_channel
),
alert_client=alerter,
ping_frequency=config.ping_frequency,
)
26 changes: 26 additions & 0 deletions tests/e2e/test_twitter_post.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import os
import unittest

import dotenv
import pytest

from src.post.twitter import TwitterClient


class TestTwitterPost(unittest.TestCase):
@pytest.mark.skip(reason="Don't want to make a post all the time.")
def test_twitter_post(self):
dotenv.load_dotenv()
client = TwitterClient(
credentials={
"consumer_key": os.environ["CONSUMER_KEY"],
"consumer_secret": os.environ["CONSUMER_SECRET"],
"access_token": os.environ["ACCESS_TOKEN"],
"access_token_secret": os.environ["ACCESS_TOKEN_SECRET"],
}
)
client.post("Hi Mom!")


if __name__ == "__main__":
unittest.main()
4 changes: 0 additions & 4 deletions tests/unit/test_implementations.py
Original file line number Diff line number Diff line change
Expand Up @@ -99,10 +99,6 @@ def test_load_from_config(self):
self.assertTrue(isinstance(left_bounded_monitor, LeftBoundedQueryMonitor))
del os.environ["SLACK_ALERT_CHANNEL"]

def test_load_config_error(self):
with self.assertRaises(KeyError):
load_config(filepath("no-params.yaml"))


if __name__ == "__main__":
unittest.main()
6 changes: 1 addition & 5 deletions tests/unit/test_load_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,14 +6,10 @@


class TestConfigLoading(unittest.TestCase):
def setUp(self) -> None:
self.fallback_alert_channel = "Default"
os.environ["SLACK_ALERT_CHANNEL"] = self.fallback_alert_channel

def test_default_config(self):

config = load_config(filepath("counter.yaml"))
self.assertEqual(config.alert_channel, self.fallback_alert_channel)
self.assertEqual(config.alert_channel, None)

def test_specified_channel(self):
config = load_config(filepath("alert-channel.yaml"))
Expand Down

0 comments on commit 1df1247

Please sign in to comment.