Skip to content

Commit 7ce8b8e

Browse files
authored
Merge pull request #42 from mensch272/bots
Hostable discord bot
2 parents 7ad0cb8 + 47ab523 commit 7ce8b8e

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

57 files changed

+1568
-76
lines changed

CHANGELOG.md

+4
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,10 @@ and this project adheres to [Semantic Versioning](http://semver.org/).
1414
- Added pre-commit checks for consistent style
1515
- Added pre-commit badge to README.md
1616
- Added change log to database migrations README.md
17+
- Added discord bot with (most functions are only available in private):
18+
- Download novel to multiple formats
19+
- Search for novels using a query
20+
- Added alternative method to acquire package version if importlib fails
1721

1822
### Changed
1923

Procfile

+1
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
bot: python -m novelsave runbot discord

README.md

+29-2
Original file line numberDiff line numberDiff line change
@@ -2,20 +2,21 @@
22

33
![PyPI](https://img.shields.io/pypi/v/novelsave)
44
![Python Version](https://img.shields.io/badge/Python-v3.8-blue)
5-
![Repo Size](https://img.shields.io/github/repo-size/mensch272/novelsave)
6-
[![Contributors](https://img.shields.io/github/contributors/mensch272/novelsave)](https://github.com/mensch272/novelsave/graphs/contributors)
75
![Last Commit](https://img.shields.io/github/last-commit/mensch272/novelsave/main)
86
![Issues](https://img.shields.io/github/issues/mensch272/novelsave)
97
![Pull Requests](https://img.shields.io/github/issues-pr/mensch272/novelsave)
108
[![pre-commit.ci status](https://results.pre-commit.ci/badge/github/mensch272/novelsave/main.svg)](https://results.pre-commit.ci/latest/github/mensch272/novelsave/main)
119
[![License](https://img.shields.io/github/license/mensch272/novelsave)](LICENSE)
10+
![Discord](https://img.shields.io/discord/911120379341307904)
1211

1312
This is a tool to download and convert novels from popular sites to e-books.
1413

1514
> **v0.7.+ is not compatible with previous versions**
1615
1716
## Install
1817

18+
### Local
19+
1920
```bash
2021
pip install novelsave
2122
```
@@ -26,6 +27,32 @@ or
2627
pip install git+https://github.com/mensch272/novelsave.git
2728
```
2829

30+
### Chatbots
31+
32+
#### Discord
33+
34+
Join our server: https://discord.gg/eFgtrKTFt3
35+
36+
##### Environmental Variables
37+
38+
The default environmental variables are shown below. Modify them to your liking when deploying.
39+
40+
`DISCORD_TOKEN` is required, others are optional.
41+
42+
```shell
43+
DISCORD_TOKEN= # Required: discord bot token
44+
DISCORD_SESSION_TIMEOUT=10 # Minutes
45+
DISCORD_DOWNLOAD_THREADS=4
46+
DISCORD_SEARCH_LIMIT=20 # Maximum results to show
47+
DISCORD_SEARCH_DISABLED=no # Disable search functionality
48+
```
49+
50+
#### Heroku Deployment
51+
52+
Fill out the following form and set the environmental variables.
53+
54+
[![Deploy](https://www.herokucdn.com/deploy/button.png)](https://heroku.com/deploy)
55+
2956
## Usage
3057

3158
### Basic

novelsave/__init__.py

+13-2
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,15 @@
1-
from importlib.metadata import version
1+
from importlib.metadata import version, PackageNotFoundError
2+
3+
try:
4+
__version__ = version("novelsave")
5+
except PackageNotFoundError:
6+
import re
7+
from pathlib import Path
8+
9+
pyproject = Path(__file__).parent.parent / "pyproject.toml"
10+
with pyproject.open("r") as f:
11+
text = f.read()
12+
13+
__version__ = re.search(r'version = "(.+?)"', text).group(1)
214

3-
__version__ = version("novelsave")
415
__source__ = "https://github.com/mHaisham/novelsave"

novelsave/__main__.py

+8-2
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,11 @@
1-
from .cli import main
1+
import sys
22

3+
from novelsave.client import cli
34

45
if __name__ == "__main__":
5-
main()
6+
if sys.argv[1:] == ["runbot", "discord"]:
7+
from novelsave.client.bots import discord
8+
9+
discord.main()
10+
else:
11+
cli.main()

novelsave/client/__init__.py

Whitespace-only changes.

novelsave/client/bots/__init__.py

Whitespace-only changes.
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
from . import config
2+
from . import containers
3+
from . import bot
4+
from . import session
5+
from . import endpoints
6+
from .main import main

novelsave/client/bots/discord/bot.py

+16
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
import sys
2+
3+
from loguru import logger
4+
5+
from . import config
6+
7+
logger.configure(**config.logger_config())
8+
9+
try:
10+
from nextcord.ext import commands
11+
except ImportError as e:
12+
logger.exception(e)
13+
sys.exit(1)
14+
15+
16+
bot = commands.Bot("$")
+20
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
from nextcord.ext import commands
2+
3+
4+
async def direct_only(ctx: commands.Context):
5+
"""Custom direct only check
6+
7+
This check returns true when one of the below checks are true
8+
- The message author is bot owner and is invoked with help
9+
- The message is not from a guild
10+
"""
11+
if await ctx.bot.is_owner(ctx.author) and ctx.invoked_with == "help":
12+
return True
13+
14+
if ctx.guild is not None:
15+
raise commands.CheckFailure(
16+
f"You may not use this command inside a guild. "
17+
f"Use the '{ctx.clean_prefix}dm' to start a private session.",
18+
)
19+
20+
return True
+69
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
import functools
2+
import os
3+
import sys
4+
from datetime import timedelta
5+
6+
import dotenv
7+
from loguru import logger
8+
9+
from novelsave.settings import config, console_formatter
10+
11+
12+
@functools.lru_cache()
13+
def app() -> dict:
14+
"""Initialize and return the configuration used by the base application"""
15+
return config.copy()
16+
17+
18+
def logger_config() -> dict:
19+
return {
20+
"handlers": [
21+
{
22+
"sink": sys.stderr,
23+
"level": "TRACE",
24+
"format": console_formatter,
25+
"backtrace": True,
26+
"diagnose": True,
27+
},
28+
{
29+
"sink": config["config"]["dir"] / "logs" / "{time}.log",
30+
"level": "TRACE",
31+
"retention": "3 days",
32+
"encoding": "utf-8",
33+
},
34+
]
35+
}
36+
37+
38+
def intenv(key: str, default: int) -> int:
39+
try:
40+
return int(os.getenv(key))
41+
except (TypeError, ValueError):
42+
return default
43+
44+
45+
@functools.lru_cache()
46+
def discord() -> dict:
47+
"""Initialize and return discord configurations as a dict
48+
49+
The returned dict must contain 'DISCORD_TOKEN'
50+
"""
51+
dotenv.load_dotenv()
52+
53+
discord_token = os.getenv("DISCORD_TOKEN")
54+
if not discord_token:
55+
logger.error("Required environment variable 'DISCORD_TOKEN' is not set.")
56+
57+
return {
58+
"key": discord_token,
59+
"session": {
60+
"retain": timedelta(minutes=intenv("DISCORD_SESSION_TIMEOUT", 10)),
61+
},
62+
"download": {
63+
"threads": intenv("DISCORD_DOWNLOAD_THREADS", 4),
64+
},
65+
"search": {
66+
"limit": intenv("DISCORD_SEARCH_LIMIT", 20),
67+
"disabled": os.getenv("DISCORD_SEARCH_DISABLED", "no").lower(),
68+
},
69+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
from dependency_injector import containers, providers
2+
3+
from .endpoints import DownloadHandler, SearchHandler
4+
from .session import SessionHandler, Session
5+
6+
7+
class SessionContainer(containers.DeclarativeContainer):
8+
config = providers.Configuration(strict=True)
9+
10+
session_factory = providers.Selector(
11+
config.search.disabled,
12+
no=providers.Singleton(
13+
Session.factory,
14+
session_retain=config.session.retain,
15+
fragments=[DownloadHandler, SearchHandler],
16+
),
17+
yes=providers.Singleton(
18+
Session.factory,
19+
session_retain=config.session.retain,
20+
fragments=[DownloadHandler],
21+
),
22+
)
23+
24+
session_handler = providers.Singleton(
25+
SessionHandler, session_factory=session_factory
26+
)
27+
28+
29+
class DiscordApplication(containers.DeclarativeContainer):
30+
discord_config = providers.Configuration(strict=True)
31+
32+
session = providers.Container(
33+
SessionContainer,
34+
config=discord_config,
35+
)
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
import functools
2+
3+
from loguru import logger
4+
5+
from .session import Session
6+
7+
8+
def ensure_close(func):
9+
"""Ensures that when this method ends the session will be closed"""
10+
11+
@functools.wraps(func)
12+
def wrapped(*args, **kwargs):
13+
session: Session = args[0].session
14+
15+
result = None
16+
try:
17+
result = func(*args, **kwargs)
18+
except Exception as e:
19+
if not session.is_closed:
20+
session.send_sync(f"`❗ {str(e).strip()}`")
21+
22+
logger.exception(e)
23+
24+
if not session.is_closed:
25+
session.sync(session.close_and_inform)
26+
27+
return result
28+
29+
return wrapped
30+
31+
32+
def log_error(func):
33+
"""Catches and logs errors from the method and propagates the exception"""
34+
35+
@functools.wraps(func)
36+
def wrapped(*args, **kwargs):
37+
38+
try:
39+
return func(*args, **kwargs)
40+
except Exception as e:
41+
logger.exception(e)
42+
raise e
43+
44+
return wrapped
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
from .commands import dm, sources
2+
from .session import SessionCog
3+
from .download import Download, DownloadHandler
4+
from .search import Search, SearchHandler
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
from dependency_injector.wiring import inject, Provide
2+
from loguru import logger
3+
from nextcord.ext import commands
4+
5+
from novelsave.containers import Application
6+
from novelsave.core.services.source import BaseSourceService
7+
from .. import checks, mfmt
8+
from ..bot import bot
9+
10+
11+
@bot.command()
12+
async def dm(ctx: commands.Context):
13+
"""Send a direct message to you"""
14+
await ctx.author.send(
15+
f"Hello, {ctx.author.name}.\n"
16+
f" Send `{ctx.clean_prefix}help` to get usage instructions."
17+
)
18+
19+
20+
@bot.command()
21+
@commands.check(checks.direct_only)
22+
@inject
23+
async def sources(
24+
ctx: commands.Context,
25+
*args,
26+
source_service: BaseSourceService = Provide[Application.services.source_service],
27+
):
28+
"""List all the sources supported"""
29+
with ctx.typing():
30+
await ctx.send(
31+
f"The sources currently supported include (v{source_service.current_version}):"
32+
)
33+
34+
source_list = "\n".join(
35+
f"• `{'🔍' if gateway.is_search_capable else ' '}` <{gateway.base_url}>"
36+
for gateway in sorted(
37+
source_service.get_novel_sources(), key=lambda g: g.base_url
38+
)
39+
)
40+
41+
await ctx.send(source_list)
42+
await ctx.send(
43+
"You can request a new source by creating an issue at "
44+
"<https://github.com/mensch272/novelsave/issues/new/choose>"
45+
)
46+
47+
48+
@sources.error
49+
async def sources_error(ctx: commands.Context, error: Exception):
50+
if isinstance(error, commands.CommandError):
51+
await ctx.send(mfmt.error(str(error)))
52+
53+
logger.exception(repr(error))

0 commit comments

Comments
 (0)