Skip to content

Commit

Permalink
feat: add support for other third-party content providers
Browse files Browse the repository at this point in the history
  • Loading branch information
huenique committed Oct 20, 2021
1 parent 51adf5c commit f12250f
Show file tree
Hide file tree
Showing 11 changed files with 346 additions and 156 deletions.
2 changes: 1 addition & 1 deletion config.json
@@ -1,7 +1,7 @@
{
"guild_id": 790101969413865472,
"bot_prefix": ".",
"imap_host": "imap.gmail.com",
"imap_domain_name": "imap.gmail.com",
"embeddings": {
"new_member_greetings": {
"readme_channel_id": 790110106809401344,
Expand Down
2 changes: 1 addition & 1 deletion dayong/bot.py
Expand Up @@ -11,8 +11,8 @@
import tanjun

from dayong.configs import DayongConfig, DayongDynamicLoader
from dayong.impls import MessageDBImpl
from dayong.interfaces import MessageDBProto
from dayong.operations import MessageDBImpl
from dayong.settings import BASE_DIR


Expand Down
136 changes: 105 additions & 31 deletions dayong/components/task_component.py
Expand Up @@ -11,55 +11,121 @@
import tanjun

from dayong.configs import DayongConfig
from dayong.exts.apis import RESTClient
from dayong.exts.emails import EmailClient
from dayong.settings import CONTENT_PROVIDER
from dayong.tasks.manager import TaskManager

component = tanjun.Component()
task_manager = TaskManager()
ext_instance: dict[str, Any] = {}

# Available 3rd party content providers.
CONTENT_PROVIDER = {
"medium",
}
RESPONSE_INTVL = 30
RESPONSE_MESSG = {False: "Sorry, I got nothing for today 😔"}


async def _set_response_intvl() -> int:
"""Set interval according to number of tasks running which should prevent
rate-limiting.
Returns:
int: RESPONSE_INTVL
"""
tasks = len(ext_instance)
if tasks >= 2:
return RESPONSE_INTVL + tasks
return RESPONSE_INTVL


async def _set_ext_instance(ext_class: str, ext_class_instance: Any, *args: Any) -> Any:
"""Save and track instances which may be used by other scheduled tasks.
Args:
ext_class (str): The name of the class.
ext_class_instance (Any): Any instantiable class.
Returns:
Any: The same instance passed as an argument to this function.
"""
if ext_class not in ext_instance:
if args:
instance = ext_class_instance(*args)
else:
instance = ext_class_instance()
ext_instance[ext_class] = instance
else:
instance = ext_instance[ext_class]

return instance


async def _set_ext_loop(
ctx: tanjun.abc.SlashContext, content: Union[list[Any], Any], response_intvl: int
) -> None:
"""Respond with retrieved content.
Args:
ctx (tanjun.abc.SlashContext): Slash command specific context.
content (Union[list[Any], Any]): The fetched content.
response_intvl (int): Interval between each response.
"""
if content:
for article in content:
await ctx.respond(article)
await asyncio.sleep(response_intvl)
else:
await ctx.respond(f"dev.to: {RESPONSE_MESSG[False]}")


async def _devto_article(ctx: tanjun.abc.SlashContext, *args: Any) -> NoReturn:
"""Async wrapper for `dayong.tasks.get_devto_article()`
This coroutine is tasked to retrieve dev.to content on REST API endpoints and
deliver fetched content every `RESPONSE_INTVL` seconds.
Args:
ctx (tanjun.abc.SlashContext): Slash command specific context.
"""
response_intvl = await _set_response_intvl()
client = await _set_ext_instance(RESTClient.__name__, RESTClient)
assert isinstance(client, RESTClient)

while True:
articles = await client.get_devto_article()
content = articles.content
await _set_ext_loop(ctx, content, response_intvl)


async def _medium_daily_digest(
ctx: tanjun.abc.SlashContext, config: DayongConfig
) -> NoReturn:
"""Extend `medium_daily_digest` and execute
`dayong.tasks.get_medium_daily_digest` as a coro.
"""Async wrapper for `dayong.tasks.get_medium_daily_digest()`.
This coroutine is tasked to retrieve medium content on email subscription and
deliver fetched content every 30 seconds. 30 seconds is set to avoid rate-limiting.
deliver fetched content every `RESPONSE_INTVL` seconds.
Args:
ctx (tanjun.abc.SlashContext): Slash command specific context.
host (str): The URL of the email provider's IMAP server.
config (DayongConfig): Instance of `dayong.configs.DayongConfig`.
"""
ext_class = EmailClient.__name__

if ext_class not in ext_instance:
email = EmailClient.client(
config.imap_host, config.email, config.email_password
)
ext_instance[ext_class] = email
else:
email: EmailClient = ext_instance[ext_class]
response_intvl = await _set_response_intvl()
client = await _set_ext_instance(
EmailClient.__name__,
EmailClient,
config.imap_domain_name,
config.email,
config.email_password,
)
assert isinstance(client, EmailClient)

while True:
articles = await email.get_medium_daily_digest()
if articles:
for article in articles:
await ctx.respond(article)
await asyncio.sleep(30)
else:
await ctx.respond("Sorry, I got nothing for today 😔")
articles = await client.get_medium_daily_digest()
content = articles.content
await _set_ext_loop(ctx, content, response_intvl)


async def assign_task(
source: str,
interval: Union[int, float],
source: str, interval: Union[int, float]
) -> tuple[str, Callable[..., Coroutine[Any, Any, Any]], float]:
"""Get the coroutine for the given task specified by source.
Expand All @@ -74,11 +140,17 @@ async def assign_task(
tuple[str, Callable[..., Coroutine[Any, Any, Any]]]: A tuple containing the
task name and the callable for the task.
"""
# interval = interval if interval >= 86400.0 else 86400.0
task_cstr = {
"medium": (
_medium_daily_digest.__name__,
_medium_daily_digest,
interval if interval >= 86400.0 else 86400.0,
interval,
),
"dev": (
_devto_article.__name__,
_devto_article,
interval,
),
}

Expand All @@ -101,15 +173,15 @@ async def assign_task(
),
converters=float,
)
@tanjun.with_str_slash_option("source", "e.g. medium or dev.to")
@tanjun.with_str_slash_option("source", "e.g. medium or dev")
@tanjun.as_slash_command(
"content", "fetch content on email subscription, from a microservice, or API"
)
async def share_content(
ctx: tanjun.abc.Context,
source: str,
action: str,
interval: float,
action: str,
config: DayongConfig = tanjun.injected(type=DayongConfig),
) -> None:
"""Fetch content on email subscription, from a microservice, or API.
Expand Down Expand Up @@ -146,11 +218,13 @@ async def share_content(
ctx,
config,
)
await ctx.respond("I'll comeback here to deliver articles and blog posts 📰")
await ctx.respond(
f"I'll comeback here to deliver content from `{source}` 📰"
)
except RuntimeError:
await ctx.respond("I'm already doing that 👌")
elif action == "stop":
task_manager.get_task(task_nm).cancel()
task_manager.stop_task(task_nm)
else:
await ctx.respond(
f"This doesn't seem to be a valid command argument: `{action}` 🤔"
Expand Down
6 changes: 3 additions & 3 deletions dayong/configs.py
Expand Up @@ -21,7 +21,7 @@ class ConfigFile(BaseModel):
bot_prefix: str
embeddings: dict[str, Union[str, dict[str, Any]]]
guild_id: int
imap_host: str
imap_domain_name: str


class EnvironVariables(BaseModel):
Expand Down Expand Up @@ -54,7 +54,7 @@ def load(
email=kwargs["email"],
email_password=kwargs["email_password"],
guild_id=kwargs["guild_id"],
imap_host=kwargs["imap_host"],
imap_domain_name=kwargs["imap_domain_name"],
)


Expand Down Expand Up @@ -86,7 +86,7 @@ def load_cfg(self) -> None:
self.bot_prefix = config["bot_prefix"]
self.guild_id = config["guild_id"]
self.embeddings = config["embeddings"]
self.imap_host = config["imap_host"]
self.imap_domain_name = config["imap_domain_name"]


class DayongDynamicLoader:
Expand Down
41 changes: 41 additions & 0 deletions dayong/exts/apis.py
Expand Up @@ -4,3 +4,44 @@
Module in charge of retrieving content from API endpoints.
"""
import asyncio
import json
import urllib.request
from typing import Any

from dayong.exts.contents import ThirdPartyContent
from dayong.interfaces import Client
from dayong.settings import CONTENT_PROVIDER


class RESTClient(Client):
"""Represents a client for interacting with REST APIs."""

_headers = {"User-Agent": "Mozilla/5.0"}
_request = urllib.request.Request("http://127.0.0.1", headers=_headers)

@staticmethod
async def get_content(*args: Any, **kwargs: Any) -> ThirdPartyContent:
loop = asyncio.get_running_loop()
resp = await loop.run_in_executor(None, urllib.request.urlopen, args[0])
data = await loop.run_in_executor(None, json.loads, resp.read())
return ThirdPartyContent(data, list(kwargs.values())[0])

async def get_devto_article(self, sort_by_date: bool = False) -> ThirdPartyContent:
"""Retrieve URLs of dev.to articles.
Args:
sort_by_date (bool, optional): Whether to order articles by descending
publish date. Defaults to False.
Returns:
list[str]: List of article URLs.
"""
request = self._request

if sort_by_date:
request.full_url = f"""{CONTENT_PROVIDER["dev"]}/api/articles/latest/"""
else:
request.full_url = f"""{CONTENT_PROVIDER["dev"]}/api/articles/"""

return await RESTClient.get_content(request, constraint="canonical_url")
38 changes: 38 additions & 0 deletions dayong/exts/contents.py
@@ -0,0 +1,38 @@
# pylint: disable=R0903
"""
dayong.exts.contents
~~~~~~~~~~~~~~~~~~~~
"""
from typing import Any, Optional


class ThirdPartyContent:
"""Represents content from third-party service/content providers."""

def __init__(self, content: Any, constraint: Optional[Any] = None) -> None:
if isinstance(content, list):
self.content = ThirdPartyContent.collect(content, constraint)
else:
self.content = content

@staticmethod
def collect(content_list: Any, constraint: Any) -> list[Any]:
"""Parse response data for specific parts.
Args:
content_list (Any): The sequence to be processed.
constraint (Any): Any immutable type that can be used as a dictionary key.
Returns:
list[Any]: A list of parsed response data.
"""
parts: list[Any] = []

if constraint:
for content in content_list:
parts.append(content[constraint])
else:
parts = content_list

return parts

0 comments on commit f12250f

Please sign in to comment.