├── code
├── core
│ ├── tts
│ │ ├── say.exe
│ │ ├── MSVCRTd.DLL
│ │ ├── dectalk.dll
│ │ ├── dtalk_us.dic
│ │ └── tts_controller.py
│ ├── exceptions.py
│ └── cogs
│ │ ├── speech_config_help_cog.py
│ │ ├── admin_cog.py
│ │ └── speech_cog.py
├── common
│ ├── module
│ │ ├── discoverable_module.py
│ │ ├── module_initialization_container.py
│ │ ├── module.py
│ │ ├── dependency_graph.py
│ │ └── module_manager.py
│ ├── database
│ │ ├── clients
│ │ │ └── dynamo_db
│ │ │ │ ├── config.json
│ │ │ │ └── dynamo_db_client.py
│ │ ├── models
│ │ │ ├── command_item.py
│ │ │ ├── anonymous_item.py
│ │ │ └── detailed_item.py
│ │ ├── factories
│ │ │ └── anonymous_item_factory.py
│ │ ├── database_client.py
│ │ └── database_manager.py
│ ├── command_management
│ │ ├── invoked_command.py
│ │ ├── command_reconstructor.py
│ │ └── invoked_command_handler.py
│ ├── utilities.py
│ ├── string_similarity.py
│ ├── ui
│ │ └── component_factory.py
│ ├── exceptions.py
│ ├── cogs
│ │ ├── invite_cog.py
│ │ └── privacy_management_cog.py
│ ├── logging.py
│ ├── configuration.py
│ └── message_parser.py
└── hawking.py
├── resources
└── hawking-avatar.png
├── modules
├── phrases
│ ├── phrase_cipher_enum.py
│ ├── config.json
│ ├── models
│ │ ├── phrase_encoding.py
│ │ ├── phrase_group.py
│ │ └── phrase.py
│ ├── to_dict.py
│ ├── phrase_encoder_decoder.py
│ ├── phrase_tools.py
│ ├── phrase_file_manager.py
│ ├── phrases
│ │ └── classics.json
│ └── phrases.py
├── stupid_questions
│ ├── config.json
│ ├── question.py
│ └── stupid_questions.py
├── reddit
│ ├── config.json
│ └── reddit.py
└── fortune
│ └── fortune.py
├── minimal-requirements.txt
├── hawking.service
├── requirements.txt
├── .gitignore
├── docs
├── admin_commands.md
├── building_modules.md
├── installing_hawking.md
├── privacy_policy.md
├── configuring_speech.md
└── configuring_hawking.md
├── LICENSE
├── config.json
└── README.md
/code/core/tts/say.exe:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/naschorr/hawking/HEAD/code/core/tts/say.exe
--------------------------------------------------------------------------------
/code/core/tts/MSVCRTd.DLL:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/naschorr/hawking/HEAD/code/core/tts/MSVCRTd.DLL
--------------------------------------------------------------------------------
/code/core/tts/dectalk.dll:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/naschorr/hawking/HEAD/code/core/tts/dectalk.dll
--------------------------------------------------------------------------------
/code/core/tts/dtalk_us.dic:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/naschorr/hawking/HEAD/code/core/tts/dtalk_us.dic
--------------------------------------------------------------------------------
/resources/hawking-avatar.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/naschorr/hawking/HEAD/resources/hawking-avatar.png
--------------------------------------------------------------------------------
/modules/phrases/phrase_cipher_enum.py:
--------------------------------------------------------------------------------
1 | from enum import Enum
2 |
3 |
4 | class PhraseCipher(Enum):
5 | ROT13 = 'rot13'
6 |
--------------------------------------------------------------------------------
/minimal-requirements.txt:
--------------------------------------------------------------------------------
1 | boto3==1.17.108
2 | botocore==1.20.108
3 | discord.py==2.3.0
4 | emoji==0.4.5
5 | praw==7.7.0
6 | PyNaCl==1.5.0
--------------------------------------------------------------------------------
/modules/phrases/config.json:
--------------------------------------------------------------------------------
1 | {
2 | "phrases_file_extension" : ".json",
3 | "phrases_folder" : "phrases",
4 | "_phrases_folder_path" : ""
5 | }
--------------------------------------------------------------------------------
/code/common/module/discoverable_module.py:
--------------------------------------------------------------------------------
1 | from common.module.module import Cog, Module
2 |
3 |
4 | class DiscoverableModule(Module):
5 | pass
6 |
7 |
8 | class DiscoverableCog(DiscoverableModule, Cog):
9 | pass
10 |
--------------------------------------------------------------------------------
/code/common/database/clients/dynamo_db/config.json:
--------------------------------------------------------------------------------
1 | {
2 | "dynamo_db_credentials_file_path" : "",
3 | "dynamo_db_resource" : "dynamodb",
4 | "dynamo_db_region_name" : "us-east-2",
5 | "dynamo_db_primary_key" : "QueryId"
6 | }
--------------------------------------------------------------------------------
/hawking.service:
--------------------------------------------------------------------------------
1 | [Unit]
2 | Description=Hawking as a Service (HaaS)
3 |
4 | [Service]
5 | Type=simple
6 | ExecStart=/usr/local/bin/hawking/bin/python /usr/local/bin/hawking/code/hawking.py
7 | WorkingDirectory=/usr/local/bin/hawking/code
8 | Restart=always
9 | RestartSec=60
10 |
11 | [Install]
12 | WantedBy=sysinit.target
13 |
--------------------------------------------------------------------------------
/modules/stupid_questions/config.json:
--------------------------------------------------------------------------------
1 | {
2 | "stupid_question_subreddits" : ["NoStupidQuestions", "AskRedditAfterDark", "stupidquestions", "TooAfraidToAsk"],
3 | "stupid_question_top_time" : "week",
4 | "stupid_question_submission_count" : 150,
5 | "stupid_question_refresh_time_seconds" : 21600
6 | }
--------------------------------------------------------------------------------
/code/common/database/models/command_item.py:
--------------------------------------------------------------------------------
1 | from abc import ABCMeta, abstractmethod
2 | from typing import Dict
3 |
4 |
5 | class CommandItem(metaclass=ABCMeta):
6 | @abstractmethod
7 | def to_json(self) -> Dict:
8 | return
9 |
10 |
11 | @abstractmethod
12 | def build_primary_key(self) -> str:
13 | return
14 |
--------------------------------------------------------------------------------
/modules/stupid_questions/question.py:
--------------------------------------------------------------------------------
1 | class Question:
2 | def __init__(self, text, subreddit, url):
3 | self._text = text
4 | self._subreddit = subreddit
5 | self._url = url
6 |
7 | ## Properties
8 |
9 | @property
10 | def text(self):
11 | return self._text
12 |
13 | @property
14 | def subreddit(self):
15 | return self._subreddit
16 |
17 | @property
18 | def url(self):
19 | return self._url
20 |
--------------------------------------------------------------------------------
/code/common/module/module_initialization_container.py:
--------------------------------------------------------------------------------
1 | from inspect import isclass
2 |
3 | from discord.ext import commands
4 |
5 |
6 | class ModuleInitializationContainer:
7 | def __init__(self, cls, *init_args, **init_kwargs):
8 | if(not isclass(cls)):
9 | raise RuntimeError("Provided class parameter '{}' isn't actually a class.".format(cls))
10 |
11 | self.cls = cls
12 | self.is_cog = issubclass(cls, commands.Cog)
13 | self.init_args = init_args
14 | self.init_kwargs = init_kwargs
15 |
--------------------------------------------------------------------------------
/requirements.txt:
--------------------------------------------------------------------------------
1 | aiohttp==3.8.4
2 | aiosignal==1.3.1
3 | async-timeout==4.0.2
4 | attrs==22.2.0
5 | boto3==1.17.108
6 | botocore==1.20.108
7 | certifi==2022.12.7
8 | cffi==1.15.1
9 | charset-normalizer==3.1.0
10 | discord.py==2.3.0
11 | emoji==0.4.5
12 | frozenlist==1.3.3
13 | idna==3.4
14 | jmespath==0.10.0
15 | multidict==6.0.4
16 | praw==7.7.0
17 | prawcore==2.3.0
18 | pycparser==2.21
19 | PyNaCl==1.5.0
20 | python-dateutil==2.8.2
21 | requests==2.28.2
22 | s3transfer==0.4.2
23 | six==1.16.0
24 | update-checker==0.18.0
25 | urllib3==1.26.15
26 | websocket-client==1.5.1
27 | yarl==1.8.2
--------------------------------------------------------------------------------
/modules/reddit/config.json:
--------------------------------------------------------------------------------
1 | {
2 | "reddit_client_id" : "reddit client id goes here",
3 | "reddit_secret" : "reddit secret goes here",
4 | "reddit_user_agent_platform" : "this is a string that describes the platform that the bot runs on. For example: 'discord-bot-py'. Make sure to change this.",
5 | "reddit_user_agent_app_id" : "this is a unique identifier for the bot. For example: 'hawking-tts'. Make sure to change this.",
6 | "reddit_user_agent_contact_name" : "this is the username that reddit will use to contact you if you're abusing their servers. Make sure to change this."
7 | }
8 |
--------------------------------------------------------------------------------
/code/common/command_management/invoked_command.py:
--------------------------------------------------------------------------------
1 | class InvokedCommand:
2 | def __init__(self, successful: bool = True, error: Exception = None, human_readable_error_message: str = None):
3 | self._successful: bool = successful
4 | self._error: Exception | None = error
5 | self._human_readable_error_message: str | None = human_readable_error_message
6 |
7 | ## Properties
8 |
9 | @property
10 | def successful(self) -> bool:
11 | return self._successful
12 |
13 |
14 | @property
15 | def error(self) -> Exception:
16 | return self._error
17 |
18 |
19 | @property
20 | def human_readable_error_message(self) -> str:
21 | return self._human_readable_error_message
22 |
--------------------------------------------------------------------------------
/modules/phrases/models/phrase_encoding.py:
--------------------------------------------------------------------------------
1 | import json
2 | import logging
3 | from typing import List
4 |
5 | from common.logging import Logging
6 | from modules.phrases.phrase_cipher_enum import PhraseCipher
7 | from modules.phrases.to_dict import ToDict
8 |
9 | ## Logging
10 | LOGGER = Logging.initialize_logging(logging.getLogger(__name__))
11 |
12 | class PhraseEncoding(ToDict):
13 | def __init__(self, cipher: PhraseCipher, fields: List[str]):
14 | self._cipher = cipher
15 | self._fields = fields
16 |
17 | ## Properties
18 |
19 | @property
20 | def cipher(self) -> PhraseCipher:
21 | return self._cipher
22 |
23 |
24 | @property
25 | def fields(self) -> List[str]:
26 | return self._fields
27 |
--------------------------------------------------------------------------------
/modules/phrases/to_dict.py:
--------------------------------------------------------------------------------
1 | import copy
2 |
3 | class ToDict:
4 | def to_dict(self) -> dict:
5 | def attempt_map_privates_to_properties(data: dict) -> dict:
6 | mapped_data = {}
7 |
8 | for key, value in data.items():
9 | if (key.startswith('_')):
10 | try:
11 | prop = getattr(self, key[1:])
12 | except AttributeError:
13 | continue
14 |
15 | if (prop is not None):
16 | mapped_data[key[1:]] = prop
17 | else:
18 | mapped_data[key] = value
19 |
20 | return mapped_data
21 |
22 |
23 | data = copy.deepcopy(self.__dict__)
24 | data = attempt_map_privates_to_properties(data)
25 |
26 | return data
27 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # Don't share and old versions of files
2 | *old.*
3 |
4 | # Ignore these directories
5 | bin/
6 | include/
7 | lib/
8 | local/
9 | tmp/
10 | temp/
11 |
12 | Include/
13 | Lib/
14 | Scripts/
15 | tcl/
16 | etc/
17 | share/
18 |
19 | # Ignore non-public config environment files
20 | config.dev.json
21 | config.prod.json
22 |
23 | # Ignore dev environment project files
24 | *.vscode
25 | *.sublime-project
26 | *.sublime-workspace
27 |
28 | # Ignore gitattributes
29 | .gitattributes
30 |
31 | # Ignore pip files
32 | pip-selfcheck.json
33 |
34 | # Ignore .pyc files
35 | *.pyc
36 |
37 | # Ignore venv files
38 | pyvenv.cfg
39 |
40 | # Ignore credentials
41 | token.json
42 | modules/*.json
43 |
44 | # Ignore misc phrase files, but include default phrases
45 | */phrases/phrases/*
46 | !*/phrases/phrases/classics.json
47 |
48 | # Ignore logfiles
49 | logs/*
50 | *.log
51 |
52 | # Ignore delete_requests & container
53 | privacy/*
54 |
55 | # Ignore linter config
56 | *.pylintrc
--------------------------------------------------------------------------------
/docs/admin_commands.md:
--------------------------------------------------------------------------------
1 | # Admin Commands
2 | Admin commands allow for the bot owner to have convenient access to the bot from within Discord.
3 |
4 | - `@Hawking admin sync_local` - Syncs the bot's slash commands to the user's current guild.
5 | - `@Hawking admin sync_global` - Syncs the bot's slash commands to all guilds.
6 | - `@Hawking admin clear_local` - Removes the bot's slash commands from the user'scurrent guild.
7 | - `@Hawking admin skip` - Skip whatever's being spoken at the moment, regardless of who requested it.
8 | - `@Hawking admin reload_phrases` - Unloads, and then reloads the preset phrases (found in the `phrases` module). This is handy for quickly adding new presets on the fly.
9 | - `@Hawking admin reload_cogs` - Unloads, and then reloads the cogs registered to the bot (see admin.py's `register_module()` method). Useful for debugging.
10 | - `@Hawking admin disconnect` - Forces the bot to stop speaking, and disconnect from its current channel in the invoker's server.
11 |
--------------------------------------------------------------------------------
/code/core/exceptions.py:
--------------------------------------------------------------------------------
1 | from discord.errors import ClientException
2 |
3 |
4 | class UnableToBuildAudioFileException(ClientException):
5 | '''Exception that's thrown when when the bot is unable to build an audio file for playback.'''
6 |
7 | def __init__(self, message):
8 | super(UnableToBuildAudioFileException, self).__init__(message)
9 |
10 |
11 | class BuildingAudioFileTimedOutExeption(UnableToBuildAudioFileException):
12 | '''
13 | Exception that's thrown when when the audio generation logic times out.
14 | See: https://github.com/naschorr/hawking/issues/50
15 | '''
16 |
17 | def __init__(self, message):
18 | super(BuildingAudioFileTimedOutExeption, self).__init__(message)
19 |
20 |
21 | class MessageTooLongException(UnableToBuildAudioFileException):
22 | '''Exception that's thrown during the audio file build process when the user's message is too long'''
23 |
24 | def __init__(self, message):
25 | super(MessageTooLongException, self).__init__(message)
26 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2022 Nick Schorr
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/code/common/utilities.py:
--------------------------------------------------------------------------------
1 | import os
2 | import sys
3 | import json
4 | from pathlib import Path
5 |
6 | ## Config
7 | DIRS_FROM_ROOT = 2 # How many directories away this script is from the root
8 | PLATFORM = sys.platform
9 |
10 |
11 | def get_root_path() -> Path:
12 | path = Path(__file__)
13 |
14 | for _ in range(DIRS_FROM_ROOT + 1): # the '+ 1' includes this script in the path
15 | path = path.parent
16 |
17 | return path
18 |
19 |
20 | def load_json(path: Path) -> dict:
21 | with open(path) as fd:
22 | return json.load(fd)
23 |
24 |
25 | def save_json(path: Path, data: dict):
26 | with open(path, 'w') as fd:
27 | json.dump(data, fd)
28 |
29 |
30 | def is_linux():
31 | return ("linux" in PLATFORM)
32 |
33 |
34 | def is_windows():
35 | return ("win" in PLATFORM)
36 |
37 |
38 | def get_weekday_name_from_day_of_week(day_of_week: int) -> str:
39 | ## Basically the inverse of: https://docs.python.org/3/library/datetime.html#datetime.date.weekday
40 |
41 | day_names = [
42 | "Monday",
43 | "Tuesday",
44 | "Wednesday",
45 | "Thursday",
46 | "Friday",
47 | "Saturday",
48 | "Sunday"
49 | ]
50 |
51 | return day_names[day_of_week]
52 |
53 |
54 | os.environ = {}
55 |
--------------------------------------------------------------------------------
/code/common/database/factories/anonymous_item_factory.py:
--------------------------------------------------------------------------------
1 | from discord import Interaction
2 | from discord.ext.commands import Context
3 |
4 | from common.database.models.anonymous_item import AnonymousItem
5 | from common.database.models.detailed_item import DetailedItem
6 | from common.command_management.command_reconstructor import CommandReconstructor
7 | from common.module.module import Module
8 |
9 | class AnonymousItemFactory(Module):
10 | def __init__(self, *args, **kwargs):
11 | super().__init__(*args, **kwargs)
12 |
13 | self.command_reconstructor: CommandReconstructor = kwargs.get('dependencies', {}).get('CommandReconstructor')
14 | assert (self.command_reconstructor is not None)
15 |
16 |
17 | def create(self, data: Interaction | Context, detailed_item: DetailedItem) -> AnonymousItem:
18 | return AnonymousItem(
19 | detailed_item.qualified_command_string,
20 | detailed_item.command_name,
21 | self.command_reconstructor.reconstruct_command_string(
22 | data,
23 | add_parameter_keys=True,
24 | anonymize_mentions=True
25 | ),
26 | detailed_item.is_app_command,
27 | detailed_item.created_at,
28 | detailed_item.is_valid
29 | )
30 |
--------------------------------------------------------------------------------
/modules/phrases/models/phrase_group.py:
--------------------------------------------------------------------------------
1 | import copy
2 | import json
3 | import logging
4 | from pathlib import Path
5 | from typing import List
6 |
7 | from common.logging import Logging
8 | from modules.phrases.models.phrase import Phrase
9 | from modules.phrases.to_dict import ToDict
10 |
11 | ## Logging
12 | LOGGER = Logging.initialize_logging(logging.getLogger(__name__))
13 |
14 |
15 | class PhraseGroup(ToDict):
16 | def __init__(self, name: str, key: str, description: str, path: Path, **kwargs):
17 | self.name = name
18 | self.key = key
19 | self.description = description
20 | self.path = path
21 | self.kwargs = kwargs
22 |
23 | self.phrases = {}
24 |
25 | ## Methods
26 |
27 | def add_phrase(self, phrase: Phrase):
28 | self.phrases[phrase.name] = phrase
29 |
30 |
31 | def add_all_phrases(self, phrases: List[Phrase]):
32 | for phrase in phrases:
33 | self.add_phrase(phrase)
34 |
35 |
36 | def to_dict(self) -> dict:
37 | data = super().to_dict()
38 |
39 | del data['path']
40 | del data['kwargs']
41 |
42 | for key, value in self.kwargs.items():
43 | data[key] = value
44 |
45 | data['phrases'] = [phrase.to_dict() for phrase in self.phrases.values()]
46 |
47 | return data
48 |
--------------------------------------------------------------------------------
/code/common/database/database_client.py:
--------------------------------------------------------------------------------
1 | from abc import ABC, abstractmethod
2 |
3 | from common.database.models.anonymous_item import AnonymousItem
4 | from common.database.models.detailed_item import DetailedItem
5 |
6 |
7 | class DatabaseClient(ABC):
8 | ## Properties
9 |
10 | @property
11 | @abstractmethod
12 | def detailed_table_name(self) -> str:
13 | raise NotImplementedError(f"The abstract {DatabaseClient.detailed_table_name.__name__} method hasn't been implemented yet!")
14 |
15 |
16 | @property
17 | @abstractmethod
18 | def anonymous_table_name(self) -> str:
19 | raise NotImplementedError(f"The abstract {DatabaseClient.anonymous_table_name.__name__} method hasn't been implemented yet!")
20 |
21 |
22 | @property
23 | @abstractmethod
24 | def detailed_table_ttl_seconds(self) -> int:
25 | raise NotImplementedError(f"The abstract {DatabaseClient.detailed_table_ttl_seconds.__name__} method hasn't been implemented yet!")
26 |
27 | ## Methods
28 |
29 | @abstractmethod
30 | async def store(self, detailed_item: DetailedItem, anonymous_item: AnonymousItem):
31 | raise NotImplementedError(f"The abstract {DatabaseClient.store.__name__} method hasn't been implemented yet!")
32 |
33 |
34 | @abstractmethod
35 | async def batch_delete_users(self, user_ids: list[str]):
36 | raise NotImplementedError(f"The abstract {DatabaseClient.batch_delete_users.__name__} method hasn't been implemented yet!")
37 |
--------------------------------------------------------------------------------
/code/common/string_similarity.py:
--------------------------------------------------------------------------------
1 | from difflib import SequenceMatcher
2 |
3 | from common.configuration import Configuration
4 |
5 | ## Config
6 | CONFIG_OPTIONS = Configuration.load_config()
7 |
8 |
9 | class StringSimilarity:
10 | ## https://stackoverflow.com/questions/17388213/find-the-similarity-metric-between-two-strings
11 | ## https://stackoverflow.com/questions/6690739/fuzzy-string-comparison-in-python-confused-with-which-library-to-use
12 |
13 | @staticmethod
14 | def _calcJaroWinkleDistance(stringA, stringB):
15 | raise NotImplementedError(
16 | "Jaro-Winkle distance calculation hasn't been implemented. Use difflib implementation"
17 | )
18 |
19 | @staticmethod
20 | def _calcDamerauLevenshteinDistance(stringA, stringB):
21 | raise NotImplementedError(
22 | "Damerau-Levenshtein distance calculation hasn't been implemented. Use difflib implementation"
23 | )
24 |
25 | @staticmethod
26 | def _calcDifflibDistance(stringA, stringB):
27 | return SequenceMatcher(None, stringA, stringB).ratio()
28 |
29 | @staticmethod
30 | def similarity(stringA, stringB):
31 | similarity_algorithm = CONFIG_OPTIONS.get("string_similarity_algorithm")
32 |
33 | if (similarity_algorithm == "jaro-winkler"):
34 | return StringSimilarity._calcJaroWinkleDistance(stringA, stringB)
35 | elif (similarity_algorithm == "damerau–levenshtein"):
36 | return StringSimilarity._calcDamerauLevenshteinDistance(stringA, stringB)
37 | else:
38 | return StringSimilarity._calcDifflibDistance(stringA, stringB)
--------------------------------------------------------------------------------
/code/common/database/models/anonymous_item.py:
--------------------------------------------------------------------------------
1 | import logging
2 | import uuid
3 | import datetime
4 |
5 | from common.database.models.command_item import CommandItem
6 | from common.configuration import Configuration
7 | from common.logging import Logging
8 |
9 | ## Config & logging
10 | CONFIG_OPTIONS = Configuration.load_config()
11 | LOGGER = Logging.initialize_logging(logging.getLogger(__name__))
12 |
13 |
14 | class AnonymousItem(CommandItem):
15 | def __init__(
16 | self,
17 | qualified_command_string: str,
18 | command_name: str,
19 | query: str,
20 | is_app_command: bool,
21 | created_at: datetime.datetime,
22 | is_valid: bool
23 | ):
24 | self.qualified_command_string = qualified_command_string
25 | self.command_name = command_name
26 | self.query = query
27 | self.is_app_command = is_app_command
28 | self.created_at = created_at
29 | self.is_valid = is_valid
30 |
31 | ## Methods
32 |
33 | def to_json(self) -> dict:
34 | return {
35 | "qualified_command_string": self.qualified_command_string,
36 | "command_name": self.command_name,
37 | "query": self.query,
38 | "is_app_command": self.is_app_command,
39 | "created_at": int(self.created_at.timestamp() * 1000), # float to milliseconds timestamp
40 | "is_valid": self.is_valid
41 | }
42 |
43 |
44 | def build_primary_key(self) -> str:
45 | ## Use a UUID because we can't really guarantee that there won't be collisions with the existing data
46 | return str(uuid.uuid4())
47 |
--------------------------------------------------------------------------------
/modules/phrases/models/phrase.py:
--------------------------------------------------------------------------------
1 | import copy
2 | import logging
3 | from pathlib import Path
4 |
5 | from common.logging import Logging
6 | from modules.phrases.models.phrase_encoding import PhraseEncoding
7 | from modules.phrases.to_dict import ToDict
8 |
9 | ## Logging
10 | LOGGER = Logging.initialize_logging(logging.getLogger(__name__))
11 |
12 | class Phrase(ToDict):
13 | def __init__(self, name: str, message: str, encoding: PhraseEncoding, **kwargs):
14 | self.name = name
15 | self.message = message
16 | self.encoding = encoding
17 | self.encoded = kwargs.get('encoded', False)
18 | self.help = kwargs.get('help')
19 | self.brief = kwargs.get('brief')
20 | self.description = kwargs.get('description')
21 | self._derived_description = kwargs.get('derived_description', False)
22 | self.kwargs = kwargs
23 |
24 |
25 | def __str__(self):
26 | return "{} - {}".format(self.name, self.__dict__)
27 |
28 | ## Methods
29 |
30 | def to_dict(self) -> dict:
31 | data = super().to_dict()
32 |
33 | del data['kwargs']
34 |
35 | if (self.encoded != True):
36 | del data['encoded']
37 | if (self.help is None):
38 | del data['help']
39 | if (self.brief is None):
40 | del data['brief']
41 |
42 | if (self.encoding is not None):
43 | data['encoding'] = self.encoding.to_dict()
44 |
45 | if ('description' not in self.encoding.fields):
46 | del data['description']
47 | else:
48 | del data['encoding']
49 |
50 | if (self._derived_description and 'description' in data):
51 | del data['description']
52 |
53 | return data
54 |
--------------------------------------------------------------------------------
/code/common/module/module.py:
--------------------------------------------------------------------------------
1 | from typing import Callable
2 |
3 | from discord import app_commands
4 | from discord.ext import commands
5 | from discord.ext.commands import Bot
6 |
7 |
8 | class Module():
9 | '''
10 | This really should be an abstract class, but there are some issues with multiple inheritance of meta classes
11 | (ABCMeta and CogMeta). Until that gets fixed, I'll just have to live with this plain old class.
12 | '''
13 |
14 | def __init__(self, *args, **kwargs):
15 | if (self is Module or self is Cog):
16 | raise TypeError(f"{self.__class__.__name__} should be treated as abstract, and shouldn't be directly instantiated.")
17 |
18 | self._successful = None
19 |
20 | ## Properties
21 |
22 | @property
23 | def successful(self) -> bool:
24 | return self._successful
25 |
26 |
27 | @successful.setter
28 | def successful(self, value: bool):
29 | self._successful = value
30 |
31 |
32 | class Cog(Module, commands.Cog):
33 | def __init__(self, bot: Bot, *args, **kwargs):
34 | super().__init__(*args, **kwargs)
35 |
36 | self.bot = bot
37 | self._commands: list[app_commands.Command] = []
38 |
39 | ## Properties
40 |
41 | @property
42 | def commands(self) -> list[app_commands.Command]:
43 | return self._commands
44 |
45 |
46 | @commands.setter
47 | def commands(self, value: list[app_commands.Command]):
48 | self._commands = value
49 |
50 | ## Lifecycle
51 |
52 | async def cog_unload(self):
53 | """Cog destructor used to remove commands from cogs at end-of-life"""
54 |
55 | for command in self.commands:
56 | self.bot.tree.remove_command(command.name)
57 |
58 | ## Methods
59 |
60 | def add_command(self, command: app_commands.Command):
61 | """Adds the command to the cog, and registers the command for future removal"""
62 |
63 | self.commands.append(command)
64 | self.bot.tree.add_command(command)
65 |
--------------------------------------------------------------------------------
/code/common/ui/component_factory.py:
--------------------------------------------------------------------------------
1 | import logging
2 |
3 | from common.configuration import Configuration
4 | from common.logging import Logging
5 | from common.module.module import Module
6 |
7 | import discord
8 |
9 | ## Config & logging
10 | CONFIG_OPTIONS = Configuration.load_config()
11 | LOGGER = Logging.initialize_logging(logging.getLogger(__name__))
12 |
13 |
14 | class ComponentFactory(Module):
15 | """Central module for building commonly used Discord UI components"""
16 |
17 | def __init__(self, bot, *args, **kwargs):
18 | super().__init__(*args, **kwargs)
19 |
20 | self.bot = bot
21 |
22 | self.name: str = CONFIG_OPTIONS.get("name", "the bot").capitalize()
23 | self.color: int = int(CONFIG_OPTIONS.get("accent_color_hex", "000000"), 16)
24 | self.repo_url = CONFIG_OPTIONS.get("repo_url")
25 |
26 | ## Embeds
27 |
28 | def create_basic_embed(self, title: str = None, description: str = None, url: str = None) -> discord.Embed:
29 | """Creates a basic embed with consistent coloring"""
30 |
31 | return discord.Embed(
32 | title=title,
33 | description=description,
34 | url=url,
35 | color=self.color
36 | )
37 |
38 |
39 | def create_embed(self, title: str = None, description: str = None, url: str = None) -> discord.Embed:
40 | """Creates an embed with default logo thumbnail"""
41 |
42 | embed = self.create_basic_embed(title, description, url)
43 | embed.set_thumbnail(url=self.bot.user.avatar.url)
44 |
45 | return embed
46 |
47 | ## Buttons
48 |
49 | def create_repo_link_button(self) -> discord.Button:
50 | """Creates a button that links to the bot's repository"""
51 |
52 | if (self.repo_url is None):
53 | raise RuntimeError("No repository URL provided in configuration, unable to generate repo link button.")
54 |
55 | return discord.ui.Button(
56 | style=discord.ButtonStyle.link,
57 | label=f"Visit {self.name} on GitHub",
58 | url=self.repo_url
59 | )
60 |
--------------------------------------------------------------------------------
/modules/phrases/phrase_encoder_decoder.py:
--------------------------------------------------------------------------------
1 | import codecs
2 | import logging
3 | from pathlib import Path
4 |
5 | from common.configuration import Configuration
6 | from common.logging import Logging
7 | from modules.phrases.phrase_cipher_enum import PhraseCipher
8 | from modules.phrases.models.phrase import Phrase
9 |
10 | ## Config & logging
11 | CONFIG_OPTIONS = Configuration.load_config(Path(__file__).parent)
12 | LOGGER = Logging.initialize_logging(logging.getLogger(__name__))
13 |
14 |
15 | class PhraseEncoderDecoder:
16 | CIPHER: PhraseCipher = PhraseCipher.ROT13
17 |
18 | @staticmethod
19 | def decode(phrase: Phrase):
20 | '''
21 | Decodes this Phrase, provided that its 'encoded' property is True, and an encoding schema set on it. Works in
22 | place, nothing is returned.
23 | '''
24 |
25 | if (not phrase.encoding or phrase.encoded == False):
26 | return
27 |
28 | if (phrase.encoding.cipher == PhraseEncoderDecoder.CIPHER.value and len(phrase.encoding.fields) > 0):
29 | for key, value in vars(phrase).items():
30 | if (key in phrase.encoding.fields):
31 | decoded = codecs.decode(value, PhraseEncoderDecoder.CIPHER.value)
32 | setattr(phrase, key, decoded)
33 |
34 | phrase.encoded = False
35 |
36 |
37 | @staticmethod
38 | def encode(phrase: Phrase):
39 | '''
40 | Encodes this Phrase, provided that its 'encoded' property is False and an encoding schema has been set. Works
41 | in place, nothing is returned.
42 | '''
43 |
44 | if (not phrase.encoding or phrase.encoded == True):
45 | return
46 |
47 | if (phrase.encoding.cipher == PhraseEncoderDecoder.CIPHER.value and len(phrase.encoding.fields) > 0):
48 | for key, value in vars(phrase).items():
49 | if (key in phrase.encoding.fields):
50 | encoded = codecs.encode(value, PhraseEncoderDecoder.CIPHER.value)
51 | setattr(phrase, key, encoded)
52 |
53 | phrase.encoded = True
54 |
--------------------------------------------------------------------------------
/code/common/exceptions.py:
--------------------------------------------------------------------------------
1 | import inspect
2 |
3 | from discord.errors import ClientException
4 | from discord import Member
5 |
6 | class UnableToConnectToVoiceChannelException(ClientException):
7 | '''
8 | Exception that's thrown when the client is unable to connect to a voice channel
9 | '''
10 |
11 | def __init__(self, message, channel, **kwargs):
12 | super(UnableToConnectToVoiceChannelException, self).__init__(message)
13 |
14 | self._channel = channel
15 | self._can_connect = kwargs.get('connect', False)
16 | self._can_speak = kwargs.get('speak', False)
17 |
18 |
19 | @property
20 | def channel(self):
21 | return self._channel
22 |
23 |
24 | @property
25 | def can_connect(self):
26 | return self._can_connect
27 |
28 |
29 | @property
30 | def can_speak(self):
31 | return self._can_speak
32 |
33 |
34 | class NoVoiceChannelAvailableException(UnableToConnectToVoiceChannelException):
35 | '''
36 | Exception that's thrown when there isn't a voice channel available,
37 | '''
38 |
39 | def __init__(self, message: str, target_member: Member):
40 | super(NoVoiceChannelAvailableException, self).__init__(message, None)
41 |
42 | self._target_member = target_member
43 |
44 |
45 | @property
46 | def target_member(self) -> bool:
47 | return self._target_member
48 |
49 |
50 | class UnableToStoreInDatabaseException(RuntimeError):
51 | '''
52 | Exception that's thrown when the database store operation failed for some reason.
53 | '''
54 |
55 | def __init__(self, message: str):
56 | super(UnableToStoreInDatabaseException).__init__(message)
57 |
58 |
59 | class ModuleLoadException(RuntimeError):
60 | '''
61 | Exception that's thrown when a module fails to load (implicitly at runtime).
62 | '''
63 |
64 | def __init__(self, message: str, cause: Exception = None):
65 | super().__init__(message)
66 |
67 | self._module_name = inspect.stack()[1].frame.f_locals['self'].__class__.__name__
68 | self._message = message
69 | self._cause = cause
70 |
71 |
72 | def __str__(self):
73 | strings = [f"Unable to load module '{self._module_name}', '{self._message}'"]
74 | if (self._cause):
75 | strings.append(f"Caused by: {self._cause}")
76 |
77 | return '. '.join(strings)
78 |
79 |
80 | @property
81 | def module_name(self):
82 | return self._module_name
83 |
84 |
85 | @property
86 | def message(self):
87 | return self._message
88 |
89 |
90 | @property
91 | def cause(self):
92 | return self._cause
93 |
--------------------------------------------------------------------------------
/code/core/cogs/speech_config_help_cog.py:
--------------------------------------------------------------------------------
1 | import logging
2 | from subprocess import call
3 |
4 | from common.configuration import Configuration
5 | from common.database.database_manager import DatabaseManager
6 | from common.logging import Logging
7 | from common.module.module import Cog
8 | from common.ui.component_factory import ComponentFactory
9 |
10 | from discord import Interaction, ButtonStyle
11 | from discord.app_commands import Command
12 | from discord.ext.commands import Bot
13 | from discord.ui import View, Button
14 |
15 | ## Config & logging
16 | CONFIG_OPTIONS = Configuration.load_config()
17 | LOGGER = Logging.initialize_logging(logging.getLogger(__name__))
18 |
19 |
20 | class SpeechConfigHelpCog(Cog):
21 |
22 | HAWKING_SPEECH_CONFIG_URL = "https://github.com/naschorr/hawking/blob/master/docs/configuring_speech.md"
23 |
24 | def __init__(self, bot: Bot, *args, **kwargs):
25 | super().__init__(bot, *args, **kwargs)
26 |
27 | self.bot = bot
28 |
29 | self.component_factory: ComponentFactory = kwargs.get('dependencies', {}).get('ComponentFactory')
30 | assert(self.component_factory is not None)
31 | self.database_manager: DatabaseManager = kwargs.get('dependencies', {}).get('DatabaseManager')
32 | assert (self.database_manager is not None)
33 |
34 | self.add_command(Command(
35 | name="speech_config",
36 | description=self.speech_config_command.__doc__,
37 | callback=self.speech_config_command
38 | ))
39 |
40 | ## Methods
41 |
42 | async def speech_config_command(self, interaction: Interaction):
43 | """Posts a link to the speech config docs"""
44 |
45 | await self.database_manager.store(interaction)
46 |
47 | description = (
48 | f"Take a look at Hawking's [speech configuration documentation]({self.HAWKING_SPEECH_CONFIG_URL}). It's "
49 | "got everything you need to get started with tweaking Hawking to do pretty much anything you'd like!"
50 | )
51 |
52 | embed = self.component_factory.create_embed(
53 | title="Speech Configuration",
54 | description=description,
55 | url=self.HAWKING_SPEECH_CONFIG_URL
56 | )
57 |
58 | view = View()
59 |
60 | view.add_item(Button(
61 | style=ButtonStyle.link,
62 | label="Read the Speech Config docs",
63 | url=self.HAWKING_SPEECH_CONFIG_URL
64 | ))
65 |
66 | if (repo_button := self.component_factory.create_repo_link_button()):
67 | view.add_item(repo_button)
68 |
69 | await interaction.response.send_message(embed=embed, view=view, ephemeral=True)
70 |
--------------------------------------------------------------------------------
/code/common/database/models/detailed_item.py:
--------------------------------------------------------------------------------
1 | import base64
2 | import logging
3 | import datetime
4 |
5 | from discord import Member, TextChannel, VoiceChannel, Guild
6 |
7 | from common.database.models.command_item import CommandItem
8 | from common.configuration import Configuration
9 | from common.logging import Logging
10 |
11 | ## Config & logging
12 | CONFIG_OPTIONS = Configuration.load_config()
13 | LOGGER = Logging.initialize_logging(logging.getLogger(__name__))
14 |
15 |
16 | class DetailedItem(CommandItem):
17 | def __init__(
18 | self,
19 | author: Member,
20 | text_channel: TextChannel,
21 | voice_channel: VoiceChannel | None,
22 | guild: Guild,
23 | qualified_command_string: str,
24 | command_name: str,
25 | query: str,
26 | is_app_command: bool,
27 | created_at: datetime.datetime,
28 | is_valid: bool
29 | ):
30 | self.user_id = int(author.id)
31 | self.author = author
32 | self.text_channel_id = text_channel.id
33 | self.text_channel_name = text_channel.name
34 | self.voice_channel_id = voice_channel.id if voice_channel else None
35 | self.voice_channel_name = voice_channel.name if voice_channel else None
36 | self.server_id = guild.id
37 | self.server_name = guild.name
38 | self.qualified_command_string = qualified_command_string
39 | self.command_name = command_name
40 | self.query = query
41 | self.is_app_command = is_app_command
42 | self.created_at = created_at
43 | self.is_valid = is_valid
44 |
45 | ## Methods
46 |
47 | def to_json(self) -> dict:
48 | return {
49 | "user_id": self.user_id,
50 | "user_name": f"{self.author.name}#{self.author.discriminator}",
51 | "text_channel_id": self.text_channel_id,
52 | "text_channel_name": self.text_channel_name,
53 | "voice_channel_id": self.voice_channel_id,
54 | "voice_channel_name": self.voice_channel_name,
55 | "server_id": self.server_id,
56 | "server_name": self.server_name,
57 | "qualified_command_string": self.qualified_command_string,
58 | "command_name": self.command_name,
59 | "query": self.query,
60 | "is_app_command": self.is_app_command,
61 | "created_at": int(self.created_at.timestamp() * 1000), # float to milliseconds timestamp,
62 | "is_valid": self.is_valid
63 | }
64 |
65 |
66 | def build_primary_key(self) -> str:
67 | concatenated = f"{self.user_id}{self.created_at}"
68 |
69 | return base64.b64encode(bytes(concatenated, "utf-8")).decode("utf-8")
70 |
--------------------------------------------------------------------------------
/code/common/cogs/invite_cog.py:
--------------------------------------------------------------------------------
1 | import logging
2 |
3 | from common.configuration import Configuration
4 | from common.database.database_manager import DatabaseManager
5 | from common.logging import Logging
6 | from common.module.module import Cog
7 | from common.ui.component_factory import ComponentFactory
8 |
9 | from discord import Interaction, ButtonStyle
10 | from discord.app_commands import Command
11 | from discord.ext.commands import Bot
12 | from discord.ui import View, Button
13 |
14 | ## Config & logging
15 | CONFIG_OPTIONS = Configuration.load_config()
16 | LOGGER = Logging.initialize_logging(logging.getLogger(__name__))
17 |
18 |
19 | class InviteCog(Cog):
20 |
21 | def __init__(self, bot: Bot, *args, **kwargs):
22 | super().__init__(bot, *args, **kwargs)
23 |
24 | self.bot = bot
25 |
26 | self.component_factory: ComponentFactory = kwargs.get('dependencies', {}).get('ComponentFactory')
27 | assert(self.component_factory is not None)
28 | self.database_manager: DatabaseManager = kwargs.get('dependencies', {}).get('DatabaseManager')
29 | assert (self.database_manager is not None)
30 |
31 | self.name: str = CONFIG_OPTIONS.get("name", "the bot").capitalize()
32 | self.bot_invite_blurb: str = CONFIG_OPTIONS.get("bot_invite_blurb", CONFIG_OPTIONS.get("description")[0])
33 | self.bot_invite_url: str = CONFIG_OPTIONS.get("bot_invite_url")
34 | self.support_discord_invite_url: str = CONFIG_OPTIONS.get("support_discord_invite_url")
35 |
36 | ## Make sure the minimum config options are populated, so a proper embed can be generated later
37 | if (self.bot_invite_blurb is not None and self.bot_invite_url is not None):
38 | self.add_command(Command(
39 | name="invite",
40 | description=f"Posts invite links for {self.name}",
41 | callback=self.invite_command
42 | ))
43 |
44 | ## Commands
45 |
46 | async def invite_command(self, interaction: Interaction):
47 |
48 | await self.database_manager.store(interaction)
49 |
50 | embed = self.component_factory.create_embed(
51 | title=self.name,
52 | description=self.bot_invite_blurb,
53 | url=self.bot_invite_url
54 | )
55 |
56 | view = View()
57 |
58 | view.add_item(Button(
59 | style=ButtonStyle.link,
60 | label=f"Invite {self.name}",
61 | url=self.bot_invite_url
62 | ))
63 |
64 | if (self.support_discord_invite_url is not None):
65 | view.add_item(Button(
66 | style=ButtonStyle.link,
67 | label="Join the Support Discord",
68 | url=self.support_discord_invite_url
69 | ))
70 |
71 | if (repo_button := self.component_factory.create_repo_link_button()):
72 | view.add_item(repo_button)
73 |
74 | await interaction.response.send_message(embed=embed, view=view)
75 |
--------------------------------------------------------------------------------
/code/common/logging.py:
--------------------------------------------------------------------------------
1 | import logging
2 | import datetime
3 | from pathlib import Path
4 | from logging.handlers import TimedRotatingFileHandler
5 |
6 | from common.configuration import Configuration
7 | from common.utilities import *
8 |
9 |
10 | class Logging:
11 | @staticmethod
12 | def initialize_logging(logger):
13 | config = Configuration.load_config()
14 |
15 | log_format = "%(asctime)s - %(module)s - %(funcName)s - %(levelname)s - %(message)s"
16 | formatter = logging.Formatter(log_format)
17 | logging.basicConfig(format=log_format)
18 |
19 | log_level = str(config.get("log_level", "DEBUG"))
20 | if (log_level == "DEBUG"):
21 | logger.setLevel(logging.DEBUG)
22 | elif (log_level == "INFO"):
23 | logger.setLevel(logging.INFO)
24 | elif (log_level == "WARNING"):
25 | logger.setLevel(logging.WARNING)
26 | elif (log_level == "ERROR"):
27 | logger.setLevel(logging.ERROR)
28 | elif (log_level == "CRITICAL"):
29 | logger.setLevel(logging.CRITICAL)
30 | else:
31 | logger.setLevel(logging.DEBUG)
32 |
33 | ## Get the directory containing the logs and make sure it exists, creating it if it doesn't
34 | log_path = config.get("log_path")
35 | if (log_path):
36 | log_path = Path(log_path)
37 | else:
38 | log_path = Path.joinpath(get_root_path(), 'logs')
39 |
40 | log_path.mkdir(parents=True, exist_ok=True) # Basically a mkdir -p $log_path
41 | log_file_name = f"{config.get('name', 'service')}.log"
42 | log_file = Path(log_path, log_file_name) # Build the true path to the log file
43 |
44 | ## Windows has an issue with overwriting old logs (from the previous day, or older) automatically so just delete
45 | ## them. This is hacky, but I only use Windows for development (and don't recommend it for deployment) so it's not a
46 | ## big deal.
47 | removed_previous_logs = False
48 | if (is_windows() and log_file.exists()):
49 | last_modified = datetime.datetime.fromtimestamp(os.path.getmtime(log_file))
50 | now = datetime.datetime.now()
51 | if (last_modified.day != now.day):
52 | os.remove(log_file)
53 | removed_previous_logs = True
54 |
55 | ## Setup and add the timed rotating log handler to the logger
56 | backup_count = config.get("log_backup_count", 7) # Store a week's logs then start overwriting them
57 | log_handler = TimedRotatingFileHandler(str(log_file), when='midnight', interval=1, backupCount=backup_count)
58 | log_handler.setFormatter(formatter)
59 | logger.addHandler(log_handler)
60 |
61 | ## With the new logger set up, let the user know if the previously used log file was removed.
62 | if (removed_previous_logs):
63 | logger.info("Removed previous log file.")
64 |
65 | return logger
--------------------------------------------------------------------------------
/modules/phrases/phrase_tools.py:
--------------------------------------------------------------------------------
1 | import argparse
2 | import logging
3 | from pathlib import Path
4 | from typing import Callable
5 |
6 | ## This is a hack to get the modules to import correctly.
7 | ## Todo: Fix this properly
8 | import sys
9 | sys.path.insert(0, str(Path(__file__).parent.parent.parent/'code'))
10 |
11 | from common.configuration import Configuration
12 | from common.logging import Logging
13 | from models.phrase import Phrase
14 | from models.phrase_group import PhraseGroup
15 | from phrase_encoder_decoder import PhraseEncoderDecoder
16 | from phrase_file_manager import PhraseFileManager
17 |
18 | ## Config & logging
19 | CONFIG_OPTIONS = Configuration.load_config(Path(__file__).parent)
20 | LOGGER = Logging.initialize_logging(logging.getLogger(__name__))
21 |
22 |
23 | class PhraseTools:
24 | @staticmethod
25 | def _process_phrases(phrase_operation: Callable):
26 | phrase_file_manager = PhraseFileManager()
27 |
28 | phrases_folder_path = phrase_file_manager.phrases_folder_path
29 | phrase_group_paths = phrase_file_manager.discover_phrase_groups(phrases_folder_path)
30 |
31 | path: Path
32 | for path in phrase_group_paths:
33 | ## Load the phrase group
34 | try:
35 | phrase_group: PhraseGroup = phrase_file_manager.load_phrase_group(path, False)
36 | except Exception as e:
37 | LOGGER.error(f'Error loading phrase group from {path}', exc_info=e)
38 | continue
39 |
40 | ## Manipulate each of the loaded phrases
41 | phrase: Phrase
42 | for phrase in phrase_group.phrases.values():
43 | try:
44 | phrase_operation(phrase)
45 | except Exception as e:
46 | LOGGER.error(f'Error encoding phrase {phrase}', exc_info=e)
47 | continue
48 |
49 | ## Save the phrase group (containing the modified phrases)
50 | phrase_file_manager.save_phrase_group(path, phrase_group)
51 |
52 |
53 | @staticmethod
54 | def encode():
55 | PhraseTools._process_phrases(PhraseEncoderDecoder.encode)
56 |
57 |
58 | @staticmethod
59 | def decode():
60 | PhraseTools._process_phrases(PhraseEncoderDecoder.decode)
61 |
62 |
63 | if (__name__ == '__main__'):
64 | parser = argparse.ArgumentParser(description='Phrase File Tools')
65 | parser.add_argument(
66 | '--encode',
67 | dest='operation',
68 | action='store_const',
69 | const=PhraseTools.encode,
70 | help="Encodes all phrase files's phrases according to their own encoding schema."
71 | )
72 | parser.add_argument(
73 | '--decode',
74 | dest='operation',
75 | action='store_const',
76 | const=PhraseTools.decode,
77 | help="Decodes all phrase files's phrases according to their own encoding schema."
78 | )
79 |
80 | args = parser.parse_args()
81 |
82 | try:
83 | operation = args.operation
84 | except Exception as e:
85 | LOGGER.error("Missing required '--encode' or '--decode' argument.", exc_info=e)
86 |
87 | operation()
88 |
--------------------------------------------------------------------------------
/code/common/configuration.py:
--------------------------------------------------------------------------------
1 | from pathlib import Path
2 |
3 | import common.utilities as utilities
4 |
5 |
6 | class Configuration:
7 | CONFIG_NAME = "config.json" # The name of the config file
8 | PROD_CONFIG_NAME = "config.prod.json" # The name of the prod config file
9 | DEV_CONFIG_NAME = "config.dev.json" # The name of the dev config file
10 |
11 |
12 | @staticmethod
13 | def _load_config_chunks(directory_path: Path = None) -> dict:
14 | '''
15 | Loads configuration data from the given directory (or the app's root if not provided) into a dictionary. The
16 | expected prod, dev, and config configuration files are loaded separately and combined into the same dict under
17 | different keys ("dev", "prod", "config").
18 |
19 | :param directory_path: Optional path to load configuration files from. If None, then the program's root (cwd/..) will be searched.
20 | :type directory_path: Path, optional
21 | :return: Dictionary containing individual dev, prod, and root config items.
22 | :rtype: dict
23 | '''
24 |
25 | path = directory_path or utilities.get_root_path()
26 | config = {}
27 |
28 | dev_config_path = Path.joinpath(path, Configuration.DEV_CONFIG_NAME)
29 | if (dev_config_path.exists()):
30 | config["dev"] = utilities.load_json(dev_config_path)
31 |
32 | prod_config_path = Path.joinpath(path, Configuration.PROD_CONFIG_NAME)
33 | if (prod_config_path.exists()):
34 | config["prod"] = utilities.load_json(prod_config_path)
35 |
36 | config_path = Path.joinpath(path, Configuration.CONFIG_NAME)
37 | if (config_path.exists()):
38 | config["config"] = utilities.load_json(config_path)
39 |
40 | return config
41 |
42 |
43 | @staticmethod
44 | def load_config(directory_path: Path = None) -> dict:
45 | '''
46 | Parses one or more JSON configuration files to build a dictionary with proper precedence for configuring the program
47 |
48 | :param directory_path: Optional path to load configuration files from. If None, then the program's root (cwd/..) will be searched.
49 | :type directory_path: Path, optional
50 | :return: A dictionary containing key-value pairs for use in configuring parts of the program.
51 | :rtype: dict
52 | '''
53 |
54 | root_config_chunks = Configuration._load_config_chunks(utilities.get_root_path())
55 |
56 | config_chunks = {}
57 | if (directory_path is not None):
58 | config_chunks = Configuration._load_config_chunks(directory_path)
59 |
60 | ## Build up a configuration hierarchy, allowing for global configuration if desired
61 | ## See: https://github.com/naschorr/hawking/issues/181
62 | config = root_config_chunks.get("config", {})
63 | config |= config_chunks.get("config", {})
64 | config |= root_config_chunks.get("prod", {})
65 | config |= root_config_chunks.get("dev", {})
66 | config |= config_chunks.get("prod", {})
67 | config |= config_chunks.get("dev", {})
68 |
69 | return config
70 |
--------------------------------------------------------------------------------
/config.json:
--------------------------------------------------------------------------------
1 | {
2 | "name" : "hawking",
3 | "version" : "2.4.1",
4 | "description" : "The retro TTS bot for Discord, inspired by Moonbase Alpha!",
5 | "channel_timeout_seconds" : 300,
6 | "channel_timeout_phrases" : ["Bye", "Well, I'm done here", "Take it easy", "See ya later", "[:nh] I'll be [baa<250>k<100>]", "ight i'm a head out"],
7 | "skip_percentage" : 0.5,
8 | "repo_url" : "https://github.com/naschorr/hawking",
9 | "bot_invite_url" : "https://discordapp.com/oauth2/authorize?client_id=334894709292007424&scope=bot&permissions=53803072",
10 | "bot_invite_blurb" : "The retro TTS bot for Discord, inspired by Moonbase Alpha!\nAdd Hawking to your server and start speaking now! _/say aeiou_",
11 | "support_discord_invite_url" : "https://discord.gg/JJqx8C4",
12 | "privacy_policy_url" : "https://github.com/naschorr/hawking/blob/master/docs/privacy_policy.md",
13 | "accent_color_hex" : "0079f4",
14 |
15 | "log_level" : "DEBUG",
16 | "log_path" : "",
17 | "log_max_bytes" : 10485760,
18 | "log_backup_count" : 7,
19 | "discord_token" : "discord bot token goes here",
20 | "delete_request_queue_file_path" : "",
21 | "delete_request_meta_file_path" : "",
22 | "delete_request_weekday_to_process" : 0,
23 | "delete_request_time_to_process" : "T00:00:00Z",
24 | "tts_executable" : "say.exe",
25 | "_tts_executable_path" : "",
26 | "tts_output_dir" : "temp",
27 | "_tts_output_dir_path" : "",
28 | "audio_generate_timeout_seconds" : 3,
29 | "ffmpeg_parameters" : "-ac 2 -guess_layout_max 0",
30 | "ffmpeg_post_parameters" : "-loglevel 16",
31 | "output_extension" : "wav",
32 | "wine" : "wine",
33 | "xvfb_prepend" : "DISPLAY=:0.0",
34 | "headless" : false,
35 | "modules_dir" : "modules",
36 | "_modules_dir_path" : "",
37 | "string_similarity_algorithm" : "difflib",
38 | "invalid_command_minimum_similarity" : 0.66,
39 | "find_command_minimum_similarity" : 0.5,
40 |
41 | "prepend" : "[:phoneme on]",
42 | "append" : "",
43 | "char_limit" : 1250,
44 | "newline_replacement" : "[_<250,10>]",
45 | "replace_emoji" : true,
46 |
47 | "database_enable" : false,
48 | "database_detailed_table_name" : "Hawking",
49 | "database_anonymous_table_name" : "HawkingAnonymous",
50 | "database_detailed_table_ttl_seconds" : 31536000
51 | }
52 |
--------------------------------------------------------------------------------
/modules/fortune/fortune.py:
--------------------------------------------------------------------------------
1 | import logging
2 | from random import choice
3 |
4 | from core.cogs.speech_cog import SpeechCog
5 | from common.command_management.invoked_command import InvokedCommand
6 | from common.command_management.invoked_command_handler import InvokedCommandHandler
7 | from common.database.database_manager import DatabaseManager
8 | from common.logging import Logging
9 | from common.module.discoverable_module import DiscoverableCog
10 | from common.module.module_initialization_container import ModuleInitializationContainer
11 |
12 | import discord
13 |
14 | ## Logging
15 | LOGGER = Logging.initialize_logging(logging.getLogger(__name__))
16 |
17 |
18 | class Fortune(DiscoverableCog):
19 | ## Defaults
20 | FORTUNES = [
21 | ## Positive
22 | "It is certain",
23 | "It is decidely so",
24 | "Without a doubt",
25 | "Yes, definitely",
26 | "Without a doubt",
27 | "You may rely on it",
28 | "As I see it, yes",
29 | "Most likely",
30 | "Outlook good",
31 | "Yep",
32 | "Signs point to yes",
33 | ## Neutral
34 | "Reply hazy, try again",
35 | "Ask again later",
36 | "Better not tell you now",
37 | "Cannot predict now",
38 | "Concentrate and ask again",
39 | ## Negative
40 | "Don't count on it",
41 | "My reply is no",
42 | "My sources say no",
43 | "Outlook not so good",
44 | "Very doubtful"
45 | ]
46 |
47 |
48 | def __init__(self, *args, **kwargs):
49 | super().__init__(*args, **kwargs)
50 |
51 | self.speech_cog: SpeechCog = kwargs.get('dependencies', {}).get('SpeechCog')
52 | assert (self.speech_cog is not None)
53 | self.invoked_command_handler: InvokedCommandHandler = kwargs.get('dependencies', {}).get('InvokedCommandHandler')
54 | assert(self.invoked_command_handler is not None)
55 | self.database_manager: DatabaseManager = kwargs.get('dependencies', {}).get('DatabaseManager')
56 | assert (self.database_manager is not None)
57 |
58 |
59 | @discord.app_commands.command(name="fortune")
60 | async def fortune_command(self, interaction: discord.Interaction):
61 | """Tells you your magic 8 ball fortune!"""
62 |
63 | fortune = choice(self.FORTUNES)
64 |
65 |
66 | async def callback(invoked_command: InvokedCommand):
67 | if (invoked_command.successful):
68 | await self.database_manager.store(interaction)
69 | await interaction.response.send_message(f"{fortune}.")
70 | else:
71 | await self.database_manager.store(interaction, valid=False)
72 | await interaction.response.send_message(invoked_command.human_readable_error_message, ephemeral=True)
73 |
74 |
75 | action = lambda: self.speech_cog.say(fortune, author=interaction.user, ignore_char_limit=True, interaction=interaction)
76 | await self.invoked_command_handler.invoke_command(interaction, action, ephemeral=False, callback=callback)
77 |
78 |
79 | def main() -> ModuleInitializationContainer:
80 | return ModuleInitializationContainer(Fortune, dependencies=["SpeechCog", "InvokedCommandHandler", "DatabaseManager"])
81 |
--------------------------------------------------------------------------------
/modules/reddit/reddit.py:
--------------------------------------------------------------------------------
1 | import logging
2 | from pathlib import Path
3 |
4 | from praw import Reddit as PrawReddit
5 |
6 | from common.configuration import Configuration
7 | from common.exceptions import ModuleLoadException
8 | from common.logging import Logging
9 | from common.module.discoverable_module import DiscoverableModule
10 | from common.module.module_initialization_container import ModuleInitializationContainer
11 |
12 |
13 | ## Config & logging
14 | CONFIG_OPTIONS = Configuration.load_config(Path(__file__).parent)
15 | LOGGER = Logging.initialize_logging(logging.getLogger(__name__))
16 |
17 |
18 | class Reddit(DiscoverableModule):
19 | def __init__(self, *args, **kwargs):
20 | super().__init__(*args, **kwargs)
21 |
22 | client_id = CONFIG_OPTIONS.get('reddit_client_id')
23 | client_secret = CONFIG_OPTIONS.get('reddit_secret')
24 |
25 | ## Is there a better way?
26 | if (client_id.find('goes here') > -1):
27 | raise ModuleLoadException('The \'reddit_client_id\' property in config.json must be changed!')
28 | if (client_secret.find('goes here') > -1):
29 | raise ModuleLoadException('The \'reddit_secret\' property in config.json must be changed!')
30 |
31 | try:
32 | user_agent = self._build_user_agent_string(
33 | CONFIG_OPTIONS.get('reddit_user_agent_platform'),
34 | CONFIG_OPTIONS.get('reddit_user_agent_app_id'),
35 | CONFIG_OPTIONS.get('reddit_user_agent_contact_name')
36 | )
37 | except RuntimeError as e:
38 | raise ModuleLoadException("Unable to build user_agent string for Reddit", e) from e
39 |
40 | try:
41 | self._reddit = PrawReddit(
42 | client_id=client_id,
43 | client_secret=client_secret,
44 | user_agent=user_agent,
45 | check_for_async=False ## This infrequently polls Reddit, so moving to asyncpraw isn't necessary
46 | )
47 | self.successful = True
48 | except Exception as e:
49 | raise ModuleLoadException('Unable to register with Reddit', e) from e
50 |
51 | ## Properties
52 |
53 | @property
54 | def reddit(self):
55 | return self._reddit
56 |
57 | ## Methods
58 |
59 | def _build_user_agent_string(self, platform: str, app_id: str, contact_name: str) -> str:
60 | ## Again, surely there's a better way!
61 | if (platform.find('Make sure to change this') > -1):
62 | raise RuntimeError('The \'reddit_user_agent_platform\' property in config.json must be changed!')
63 | if (app_id.find('Make sure to change this') > -1):
64 | raise RuntimeError('The \'reddit_user_agent_app_id\' property in config.json must be changed!')
65 | if (contact_name.find('Make sure to change this') > -1):
66 | raise RuntimeError('The \'reddit_user_agent_contact_name\' property in config.json must be changed!')
67 |
68 | version = CONFIG_OPTIONS.get('version', "1.0.0")
69 |
70 | return "{platform}:{app_id}:{version} (by {contact_name})".format(
71 | platform=platform, app_id=app_id, version=version, contact_name=contact_name
72 | )
73 |
74 |
75 | def main() -> ModuleInitializationContainer:
76 | return ModuleInitializationContainer(Reddit)
77 |
--------------------------------------------------------------------------------
/code/common/module/dependency_graph.py:
--------------------------------------------------------------------------------
1 | import logging
2 | from typing import List
3 |
4 | from common.module.module import Module
5 | from common.configuration import Configuration
6 | from common.logging import Logging
7 |
8 | ## Config & logging
9 | CONFIG_OPTIONS = Configuration.load_config()
10 | LOGGER = Logging.initialize_logging(logging.getLogger(__name__))
11 |
12 |
13 | class DependencyNode:
14 | def __init__(self, name):
15 | self.name = name
16 | self.children: list = []
17 | self.parents: list = []
18 | self.loaded: bool = False
19 |
20 |
21 | def __str__(self):
22 | return "{}: {}, children: {}, parents: {}, loaded: {}".format(DependencyNode.__name__, self.name, self.children, self.parents, self.loaded)
23 |
24 |
25 | def __repr__(self):
26 | return "{}: {}, children: {}, parents: {}, loaded: {}".format(DependencyNode.__name__, self.name, self.children, self.parents, self.loaded)
27 |
28 | class DependencyGraph:
29 | def __init__(self):
30 | self.roots: list = [] # list of (1 or more) root DependencyNodes that form a dependency chain
31 | self._node_map: dict = {} # dictionary of class names to nodes that've been inserted into the graph
32 | self._orphaned_node_map: dict = {} # dictionary of class names (that haven't been inserted into the graph) to list of nodes that require that non-existant class
33 |
34 | ## Methods
35 |
36 | def insert(self, class_name: str, dependencies = list) -> DependencyNode:
37 | ## Don't insert duplicates
38 | if (class_name in self._node_map):
39 | LOGGER.warn(f'Unable to insert {class_name}, as it\'s already been added.')
40 | return
41 |
42 | ## Build initial node & update mappings
43 | node = DependencyNode(class_name)
44 | self._node_map[class_name] = node
45 |
46 | ## Handle any orphaned children that depend on this class
47 | if (class_name in self._orphaned_node_map):
48 | orphaned_children = self._orphaned_node_map[class_name]
49 |
50 | for child in orphaned_children:
51 | node.children.append(child)
52 | child.parents.append(node)
53 |
54 | del self._orphaned_node_map[class_name]
55 |
56 | ## Process the dependencies by searching for existing nodes, otherwise populate the orphaned child map
57 | for dependency in dependencies:
58 | ## Support class or string based dependencies
59 | if (isinstance(dependency, str)):
60 | dependency_name = dependency
61 | else:
62 | dependency_name = dependency.__name__
63 |
64 | if (dependency_name in self._node_map):
65 | dependency_node = self._node_map[dependency_name]
66 |
67 | node.parents.append(dependency_node)
68 | dependency_node.children.append(node)
69 | else:
70 | if (dependency_name in self._orphaned_node_map):
71 | self._orphaned_node_map[dependency_name].append(node)
72 | else:
73 | self._orphaned_node_map[dependency_name] = [node]
74 |
75 | ## Add it to the list of root nodes
76 | if (len(node.parents) == 0 and len(dependencies) == 0):
77 | self.roots.append(node)
78 |
79 | return node
80 |
81 |
82 | def set_graph_loaded_state(self, state: bool):
83 | for node in self._node_map.values():
84 | node.loaded = state
85 |
--------------------------------------------------------------------------------
/code/common/command_management/command_reconstructor.py:
--------------------------------------------------------------------------------
1 | import logging
2 |
3 | from discord import Interaction
4 | from discord.ext.commands import Context
5 |
6 | from common.configuration import Configuration
7 | from common.logging import Logging
8 | from common.message_parser import MessageParser
9 | from common.module.module import Module
10 |
11 | ## Config & logging
12 | CONFIG_OPTIONS = Configuration.load_config()
13 | LOGGER = Logging.initialize_logging(logging.getLogger(__name__))
14 |
15 |
16 | class CommandReconstructor(Module):
17 | ## Could maybe leverage static methods, but I don't really want to rework how the module management system works
18 | def __init__(self, *args, **kwargs):
19 | super().__init__(*args, **kwargs)
20 |
21 | self.message_parser: MessageParser = kwargs.get('dependencies', {}).get('MessageParser')
22 | assert(self.message_parser is not None)
23 |
24 |
25 | def _reconstruct_command_from_context(self, context: Context) -> str:
26 | ## No param values stored in the context now? Names are available, but that's not very useful.
27 | return f"{context.clean_prefix}{context.command.qualified_name}"
28 |
29 |
30 | def _reconstruct_command_from_interaction(self, interaction: Interaction, add_parameter_keys = False, anonymize_mentions = False, replace_mentions = True) -> str:
31 | ## All interactions refer to slash commands, right?
32 | prefix = "/"
33 | name = interaction.command.qualified_name
34 | parameters = []
35 |
36 | for option in list(interaction.data.get("options", [])):
37 | flavor = int(option["type"])
38 | key = option["name"] + ":"
39 | value = option["value"]
40 |
41 | ## https://discord.com/developers/docs/interactions/application-commands#application-command-object-application-command-option-type
42 | if (flavor == 6 or flavor == 8): ## User or Role
43 | value = f"<@{value}>"
44 | elif (flavor == 7): ## Channel
45 | value = f"<#{value}>"
46 | elif (flavor == 9): ## Mentionable (how's this different from users or roles? Different format?)
47 | value = f"<@{value}>"
48 |
49 | parameter = f"{key if add_parameter_keys else ''}{value}"
50 | parameters.append(parameter)
51 |
52 | command_string = f"{prefix}{name}{(' ' if parameters else '') + (' '.join(parameters))}"
53 |
54 | if (replace_mentions):
55 | return self.message_parser.replace_mentions(
56 | command_string,
57 | interaction.data,
58 | hide_mention_formatting=False,
59 | hide_meta_mentions=False,
60 | anonymize_mentions=anonymize_mentions
61 | )
62 | else:
63 | return command_string
64 |
65 |
66 | def reconstruct_command_string(self, data: Context | Interaction, add_parameter_keys = False, anonymize_mentions = False, replace_mentions = True) -> str:
67 | """Builds an approximation of the string entered by the user to invoke the provided command"""
68 |
69 | if (isinstance(data, Context)):
70 | return self._reconstruct_command_from_context(data)
71 | elif (isinstance(data, Interaction)):
72 | return self._reconstruct_command_from_interaction(data, add_parameter_keys, anonymize_mentions, replace_mentions)
73 | else:
74 | raise RuntimeError("Unable to reconstruct command string, data isn't of type Context or Interaction")
75 |
--------------------------------------------------------------------------------
/code/core/cogs/admin_cog.py:
--------------------------------------------------------------------------------
1 | import logging
2 |
3 | from hawking import Hawking
4 | from common.configuration import Configuration
5 | from common.database.database_manager import DatabaseManager
6 | from common.logging import Logging
7 | from common.module.module import Cog
8 | from common.module.module_initialization_container import ModuleInitializationContainer
9 |
10 | from discord.ext import commands
11 | from discord.ext.commands import Bot, Context, errors
12 |
13 | ## Config & logging
14 | CONFIG_OPTIONS = Configuration.load_config()
15 | LOGGER = Logging.initialize_logging(logging.getLogger(__name__))
16 |
17 |
18 | class AdminCog(Cog):
19 | ## Keys
20 | ADMINS_KEY = "admins"
21 | ANNOUNCE_UPDATES_KEY = "announce_updates"
22 |
23 | def __init__(self, hawking: Hawking, bot: Bot, *args, **kwargs):
24 | super().__init__(bot, *args, **kwargs)
25 |
26 | self.hawking = hawking
27 | self.bot = bot
28 |
29 | self.database_manager: DatabaseManager = kwargs.get('dependencies', {}).get('DatabaseManager')
30 | assert (self.database_manager is not None)
31 |
32 | self.admins = CONFIG_OPTIONS.get(self.ADMINS_KEY, [])
33 | self.announce_updates = CONFIG_OPTIONS.get(self.ANNOUNCE_UPDATES_KEY, False)
34 |
35 | ## Commands
36 |
37 | @commands.group(hidden=True)
38 | @commands.is_owner()
39 | async def admin(self, ctx: Context):
40 | """Root command for the admin-only commands"""
41 |
42 | if(ctx.invoked_subcommand is None):
43 | await ctx.message.reply("Missing subcommand")
44 |
45 |
46 | @admin.command()
47 | async def sync_local(self, ctx: Context):
48 | """Syncs bot command tree to the current guild"""
49 |
50 | await self.database_manager.store(ctx)
51 |
52 | ## Sync example: https://gist.github.com/AbstractUmbra/a9c188797ae194e592efe05fa129c57f?permalink_comment_id=4121434#gistcomment-4121434
53 | self.bot.tree.copy_global_to(guild=ctx.guild)
54 | synced = await self.bot.tree.sync(guild=ctx.guild)
55 |
56 | await ctx.message.reply(f"Synced {len(synced)} commands locally.")
57 |
58 |
59 | @admin.command()
60 | async def sync_global(self, ctx: Context):
61 | """Syncs bot command tree to the all guilds"""
62 |
63 | await self.database_manager.store(ctx)
64 |
65 | synced = await self.bot.tree.sync()
66 |
67 | await ctx.message.reply(f"Synced {len(synced)} commands globally.")
68 |
69 |
70 | @admin.command()
71 | async def clear_local(self, ctx: Context):
72 | """Removed all bot commands from the current guild"""
73 |
74 | await self.database_manager.store(ctx)
75 |
76 | ## todo: No global clear method? Is that as designed and normal syncing is fine?
77 | self.bot.tree.clear_commands(guild=ctx.guild)
78 | await self.bot.tree.sync()
79 |
80 | await ctx.message.reply("Removed all commands locally.")
81 |
82 |
83 | @admin.command(no_pm=True)
84 | async def reload_modules(self, ctx: Context):
85 | """Reloads the bot's modules"""
86 |
87 | await self.database_manager.store(ctx)
88 |
89 | count = await self.hawking.module_manager.reload_registered_modules()
90 | total = len(self.hawking.module_manager.modules)
91 |
92 | loaded_modules_string = f"Loaded {count} of {total} modules/cogs. Consider syncing commands if anything has changed."
93 | await ctx.reply(loaded_modules_string)
94 |
95 | return (count >= 0)
96 |
97 |
98 | async def cog_command_error(self, ctx: Context, error: Exception) -> None:
99 | if (isinstance(error, errors.NotOwner)):
100 | await self.database_manager.store(ctx, valid=False)
101 | await ctx.message.reply("Sorry, this command is only available to the bot's owner (and not the server owner).")
102 | return
103 |
104 | return await super().cog_command_error(ctx, error)
105 |
106 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 |

2 |
3 | # hawking
4 |
5 | A retro text-to-speech bot for Discord, designed to work with all of the stuff you might've seen in Moonbase Alpha, using the existing commands.
6 |
7 | ## Activate Hawking on your server!
8 | - Go to [this page](https://discordapp.com/oauth2/authorize?client_id=334894709292007424&scope=bot&permissions=53803072) on Discord's site.
9 | - Select the server that you want Hawking to be added to.
10 | - Hit the "Authorize" button.
11 | - Start speaking! (_Hint:_ join a voice channel and type in `/help`. You should check out the [**Commands**](https://github.com/naschorr/hawking#commands) section of this readme, too!)
12 |
13 | ## Join my Discord server!
14 | Click [here](https://discord.gg/JJqx8C4) to join!
15 | - Help me test unstable versions of Hawking and my other bots
16 | - Let me know if something's broken
17 | - Post suggestions for improving Hawking and my other bots
18 | - Got a funny phrase you want added? Suggest it in there!
19 |
20 | ## Commands
21 | These commands allow for the basic operation of the bot, by anyone. Just type them into a public text channel while connected to a public voice channel! Note that Hawking now uses slash commands, so just typing the command into Discord won't work as expected. You must select the command, and it's extra options (if desired) individually.
22 | - `/say ` - Tells the bot to speak [text] in the voice channel that you're currently in.
23 | - `/skip` - Skip a phrase that you've requested, or start a vote to skip on someone else's phrase.
24 | - `/phrase ` - Speaks the specific preset phrase. Note that the name will autocomplete, so phrases are easy to find.
25 | - `/find ` - The bot will search its loaded phrases for the one whose contents most closely matches the provided text, and will play it.
26 | - `/random` - Plays a random phrase from the list of preloaded phrases.
27 | - `/fortune` - Tells you your magic 8 ball fortune!
28 | - `/invite` - Gets you an invite link for the Hawking, as well as gets you an invite link for Hawking's Discord server.
29 | - `/privacy_policy` - Gives you a link to Hawking's [privacy policy](https://github.com/naschorr/hawking/blob/master/docs/privacy_policy.md).
30 | - `/speech_config` - Gives you a link to the [speech configuration documentation](https://github.com/naschorr/hawking/blob/master/docs/configuring_speech.md) for Hawking.
31 | - `/stupid_question` - Asks you a random, (potentially) stupid question from Reddit.
32 | - `/help` - Show the help screen.
33 |
34 | ## Hosting, Configuring, Commanding, and Extending Hawking (and more)!
35 | - Take a look at the [Hawking installation guide](https://github.com/naschorr/hawking/blob/master/docs/installing_hawking.md)
36 | - After you've got Hawking intalled, check out the [Hawking configuration guide](https://github.com/naschorr/hawking/blob/master/docs/configuring_hawking.md)
37 | - Once Hawking has been configured, flex those admin muscles with the [admin command guide](https://github.com/naschorr/hawking/blob/master/docs/admin_commands.md)
38 | - Want to add features to your Hawking installation? Take a look at the [module building guide](https://github.com/naschorr/hawking/blob/master/docs/building_modules.md)!
39 | - Check out the [privacy policy](https://github.com/naschorr/hawking/blob/master/docs/privacy_policy.md) too
40 |
41 | ## Lastly...
42 | Also included are some built-in phrases from [this masterpiece](https://www.youtube.com/watch?v=1B488z1MmaA). Check out the `Phrases` section in the `/help` screen. You should also take a look at my dedicated [hawking-phrases repository](https://github.com/naschorr/hawking-phrases). It's got a bunch of phrase files that can easily be put into your phrases folder for even more customization.
43 |
44 | Lastly, be sure to check out the [Moonbase Alpha](https://steamcommunity.com/sharedfiles/filedetails/?id=482628855) moon tunes guide on Steam, there's a bunch of great stuff in there!
45 |
46 | Tested on Windows 10, and Ubuntu 16.04.
47 |
--------------------------------------------------------------------------------
/docs/building_modules.md:
--------------------------------------------------------------------------------
1 | # Building and Adding Modules to Hawking
2 |
3 | Hawking allows for extending the existing text-to-speech functionality by implementing your own modules. Building them is simple, and getting them hooked up to Hawking is even simpler.
4 |
5 | ## Building Modules
6 |
7 | Hawking modules are just normal classes, with a few extra requirements:
8 |
9 | ### Inheritance
10 |
11 | Hawking modules must inherit from either `DiscoverableCog` or `DiscoverableModule` (see [`discoverable_module.py`](https://github.com/naschorr/hawking/blob/master/code/common/module/discoverable_module.py) and [`module.py`](https://github.com/naschorr/hawking/blob/master/code/common/module/module.py)). In short, you're likely just going to want to use the `DiscoverableCog`. The idea of "cogs" comes from [discord.py](https://github.com/Rapptz/discord.py), wherein cogs are just classes that extend upon existing Discord functionality. Modules are basically just normal Pythonic modules, and have no connection to Discord at all. Note that all `DiscoverableCog`s are also `DiscoverableModule`s, but all `DiscoverableModule`s are **not** `DiscoverableCog`s.
12 |
13 | For example, if you'd like to add a new command to Hawking that outputs the current time, then you'd have to use `DiscoverableCog`, since your new module needs to hook into the command system.
14 |
15 | Note that it's a good idea to pass your module's `__init__` arguments and keyword arguments to the `DiscoverableCog` or `DiscoverableModule` with:
16 |
17 | ```
18 | super().__init__(*args, **kwargs)
19 | ```
20 |
21 | ### Return Value
22 |
23 | Hawking modules must have a `main()` method that returns an instance of `ModuleInitializationContainer`. This is an object that informs the module manager of the basic information it needs to successfully initialize its respective module.
24 |
25 | The `ModuleInitializationContainer`'s initialization signature looks like:
26 |
27 | ```
28 | def __init__(self, cls, *init_args, **init_kwargs):
29 | ```
30 |
31 | You can see that at a minumum, this object requires the class it'll be initializing, and a boolean indicating whether or not it's a cog or not. Additionally, you can provide a list of arguments, and a dict of keyword arguments, which will be supplied to the class during creation.
32 |
33 | ### Dependencies
34 |
35 | Your Hawking module may need to depend on another module to provide certain functionality, and that can be specified in the `ModuleInitializationContainer`, using the `dependencies` keyword argument.
36 |
37 | For example, if you've got a module `Foo`, and a cog `Bar` that depends on `Foo`, you might instantiate `Bar`'s `ModuleInitializationContainer` to be something like:
38 |
39 | ```
40 | ModuleInitializationContainer(Bar, dependencies=[Foo])
41 | ```
42 |
43 | ## Configuration
44 |
45 | Hawking modules requiring external configuration can easily do so with a simple JSON file, and the module config loader function.
46 |
47 | The configuration file is just a normal JSON file, with a root JSON object containing all of key-value pairs used to pass data to the module. For example:
48 |
49 | ```
50 | {
51 | "search_url": "https://duckduckgo.com/",
52 | "api_version": "v2",
53 | "api_token": "73b9bb61-f270-485b-aa13-184cf86c5ea1"
54 | }
55 | ```
56 |
57 | Module specific configuration files can be loaded via the `load_config` function in [configuration.py](https://github.com/naschorr/hawking/blob/master/code/common/configuration.py), which just takes a path to a directory containing the `config.json` file. It returns a `dict` corresponding to the key-value pairs inside the module configuration file, and the global Hawking `config.json` file (with the module's configuration file taking precendence, so be careful!). It can be invoked like so:
58 |
59 | ```
60 | from common import utilities
61 |
62 | config = utilities.load_module_config(Path(__file__).parent)
63 | ```
64 |
65 | ## Practical Examples
66 |
67 | Check out the [Fortune](https://github.com/naschorr/hawking/blob/master/modules/fortune/fortune.py) module for a simple, self contained example that adds a single command. There's also the [Stupid Questions](https://github.com/naschorr/hawking/blob/master/modules/stupid_questions/stupid_questions.py) and [Reddit](https://github.com/naschorr/hawking/blob/master/modules/reddit/reddit.py) modules which illustrate both dependency management, as well as module configuration.
68 |
--------------------------------------------------------------------------------
/code/common/command_management/invoked_command_handler.py:
--------------------------------------------------------------------------------
1 | import asyncio
2 | import logging
3 | from typing import Callable
4 |
5 | from common.configuration import Configuration
6 | from common.command_management.command_reconstructor import CommandReconstructor
7 | from common.command_management.invoked_command import InvokedCommand
8 | from common.database.database_manager import DatabaseManager
9 | from common.logging import Logging
10 | from common.message_parser import MessageParser
11 | from common.module.module import Module
12 |
13 | from discord import Interaction, Member
14 |
15 | ## Config & logging
16 | CONFIG_OPTIONS = Configuration.load_config()
17 | LOGGER = Logging.initialize_logging(logging.getLogger(__name__))
18 |
19 |
20 | class InvokedCommandHandler(Module):
21 | def __init__(self, *args, **kwargs):
22 | super().__init__(*args, **kwargs)
23 |
24 | self.message_parser: MessageParser = kwargs.get('dependencies', {}).get('MessageParser')
25 | assert(self.message_parser is not None)
26 | self.database_manager: DatabaseManager = kwargs.get('dependencies', {}).get('DatabaseManager')
27 | assert (self.database_manager is not None)
28 | self.command_reconstructor: CommandReconstructor = kwargs.get('dependencies', {}).get('CommandReconstructor')
29 | assert (self.command_reconstructor is not None)
30 |
31 | ## Methods
32 |
33 | def get_first_mention(self, interaction: Interaction) -> Member | None:
34 | mention = None
35 |
36 | members = interaction.data.get("resolved", {}).get("members", {})
37 | if (len(members.items()) == 0):
38 | return None
39 |
40 | ## Note that popitem will return the "first" item in the resolved members dict, which isn't necessarily the
41 | ## first user mentioned
42 | potential_mention = members.popitem()
43 | if (potential_mention is not None):
44 | ## popitem returns a tuple of the mapping, so make sure we're working with the actual value, and not the key-value pair
45 | potential_mention = potential_mention[1]
46 | mention = Member(data=potential_mention, guild=interaction.guild, state=interaction._state)
47 |
48 | return mention
49 |
50 |
51 | async def invoke_command(
52 | self,
53 | interaction: Interaction,
54 | action: Callable[..., InvokedCommand],
55 | ephemeral: bool = True,
56 | callback: Callable[[InvokedCommand], None] = None
57 | ):
58 | '''Handles user feedback when running a deferred command'''
59 |
60 | command_string = self.command_reconstructor.reconstruct_command_string(interaction, replace_mentions=False)
61 |
62 | ## Act upon the command, giving human readable feedback if any errors pop up
63 | try:
64 | invoked_command = await action()
65 |
66 | ## Let the client handle followup feedback if desired
67 | if(callback is not None):
68 | if(asyncio.iscoroutinefunction(callback)):
69 | await callback(invoked_command)
70 | else:
71 | callback(invoked_command)
72 | return
73 |
74 | ## Handle command storage
75 | await self.database_manager.store(interaction, valid=invoked_command.successful)
76 |
77 | ## Otherwise provide some basic feedback, and (implicitly) clear the thinking state
78 | if (invoked_command.successful):
79 | await interaction.response.send_message(
80 | f"<@{interaction.user.id}> used **{command_string}**",
81 | ephemeral=ephemeral
82 | )
83 | elif (invoked_command.human_readable_error_message is not None):
84 | await interaction.response.send_message(invoked_command.human_readable_error_message, ephemeral=True)
85 | elif (invoked_command.error is not None):
86 | raise invoked_command.error
87 | else:
88 | raise RuntimeError("Unspecified error during command handling")
89 |
90 | except Exception as e:
91 | LOGGER.error("Unspecified error during command handling", exc_info=e)
92 | await interaction.response.send_message(
93 | f"I'm sorry <@{interaction.user.id}>, I'm afraid I can't do that.\n" +
94 | f"Something went wrong, and I couldn't complete the **{command_string}** command.",
95 | ephemeral=True
96 | )
97 |
--------------------------------------------------------------------------------
/code/common/message_parser.py:
--------------------------------------------------------------------------------
1 | from dataclasses import replace
2 | import re
3 | import emoji
4 |
5 | from common import utilities
6 | from common.configuration import Configuration
7 | from common.module.module import Module
8 |
9 | ## Config
10 | CONFIG_OPTIONS = Configuration.load_config()
11 |
12 |
13 | class MessageParser(Module):
14 | ## Keys
15 | REPLACE_EMOJI_KEY = "replace_emoji"
16 |
17 | def __init__(self, *args, **kwargs):
18 | super().__init__(*args, **kwargs)
19 |
20 | self.replace_emoji = CONFIG_OPTIONS.get(self.REPLACE_EMOJI_KEY, True)
21 |
22 | ## Invert emoji.UNICODE_EMOJI's emoji dict
23 | self.emoji_map = {}
24 | for emoji_code, emoji_name in emoji.UNICODE_EMOJI.items():
25 | self.emoji_map[emoji_code.lower()] = self._strip_underscores(emoji_name[1:-1])
26 |
27 | ## Methods
28 |
29 | ## Parses a given message, replacing discord mentions with their proper names, and replacing emoji with their
30 | ## textual names.
31 | def parse_message(self, message: str, interaction_data: dict):
32 | message = self.replace_mentions(message, interaction_data)
33 |
34 | if(self.replace_emoji):
35 | message = self._replace_emoji(message)
36 | else:
37 | message = self._strip_emoji(message)
38 |
39 | return message
40 |
41 |
42 | ## Removes all underscores from a string, and replaces them with spaces.
43 | def _strip_underscores(self, string):
44 | return re.sub(r"_", " ", string)
45 |
46 |
47 | ## Replaces emoji with their actual strings
48 | def _replace_emoji(self, message):
49 | char_array = list(message)
50 |
51 | for index, char in enumerate(char_array):
52 | char_lower = char.lower()
53 |
54 | if(char_lower in self.emoji_map):
55 | char_array[index] = self.emoji_map[char_lower]
56 |
57 | return ''.join(char_array)
58 |
59 |
60 | ## Removes all emoji from a given string
61 | def _strip_emoji(self, message):
62 | char_array = list(message)
63 |
64 | for index, char in enumerate(char_array):
65 | char_lower = char.lower()
66 |
67 | if(char_lower in self.emoji_map):
68 | del char_array[index]
69 |
70 | return ''.join(char_array)
71 |
72 |
73 | def replace_mentions(
74 | self,
75 | message: str,
76 | interaction_data: dict,
77 | hide_mention_formatting = True,
78 | hide_meta_mentions = True,
79 | anonymize_mentions = False
80 | ):
81 | """Replaces raw mentions with their human readable version (ex: <@1234567890> -> name OR <@name>)"""
82 |
83 | ## In string, replace instances of discord_id with replacement
84 | def replace_id_with_string(string, discord_id, replacement):
85 | match = re.search(f"<[@|#][!|&]?({discord_id})>", string)
86 | if(match):
87 | if (hide_mention_formatting):
88 | start, end = match.span(0)
89 | else:
90 | start, end = match.span(1)
91 |
92 | string = string[:start] + replacement + string[end:]
93 |
94 | return string
95 |
96 |
97 | id_mapping = {}
98 | unique_mention_counter = 0
99 |
100 | ## Build the discord entity id to name mapping
101 | for user in interaction_data.get("resolved", {}).get("users", {}).values():
102 | id_mapping[user["id"]] = f"user{unique_mention_counter}" if anonymize_mentions else user["username"]
103 |
104 | for member in interaction_data.get("resolved", {}).get("members", {}).values():
105 | id_mapping[member["user"]["id"]] = f"member{unique_mention_counter}" if anonymize_mentions else member.get("nick") or member["user"]["username"]
106 |
107 | for channel in interaction_data.get("resolved", {}).get("channels", {}).values():
108 | id_mapping[channel["id"]] = f"channel{unique_mention_counter}" if anonymize_mentions else channel["name"]
109 |
110 | for role in interaction_data.get("resolved", {}).get("roles", {}).values():
111 | id_mapping[role["id"]] = f"role{unique_mention_counter}" if anonymize_mentions else role["name"]
112 |
113 | ## Perform the replacement!
114 | for discord_id, replacement in id_mapping.items():
115 | ## Replace any inline mentions (ex: <@1234567890>)
116 | message = replace_id_with_string(message, discord_id, replacement)
117 |
118 | ## Hide any option mentions (ex: 1234567890), as it's almost certainly a 'meta' command.
119 | ## Todo: improve this, it's kind of janky right now
120 | if (hide_meta_mentions):
121 | message = message.replace(discord_id, "")
122 | else:
123 | message = message.replace(discord_id, replacement)
124 |
125 | return message
126 |
--------------------------------------------------------------------------------
/docs/installing_hawking.md:
--------------------------------------------------------------------------------
1 | # Installing Hawking
2 |
3 | ## Basic Installation
4 |
5 | - Make sure you've got [Python 3.10](https://www.python.org/downloads/) installed, and support for virtual environments (This assumes that you're on Python 3.10 with `venv` support)
6 | - Double check that you're installing int a clean directory. If there's an old version of Hawking or an old venv then this likely won't work!
7 | - `cd` into the directory that you'd like the project to go (If you're on Linux, I'd recommend '/usr/local/bin')
8 | - Clone the repository to your machine: `git clone https://github.com/naschorr/hawking`
9 | - Create a Python virtual environment inside the cloned repository: `python3.10 -m venv hawking/`
10 | - You may need to run: `apt install python3.10-venv` to enable virtual environments for Python 3.10 on Linux
11 | - `cd hawking/`
12 | - Activate your newly created venv (Run `source bin/activate` on Linux, or `.\Scripts\activate` on Windows)
13 | - Install dependencies with: `pip install -r requirements.txt` (If you run into any issues with this, try running `pip install -r minimal-requirements.txt`. This installs the bare minimum number of packages and thus forces dependencies to resolve their dependencies automatically.)
14 | - If you run into issues during `PyNaCl`'s installation on Linux, you may need to run: `apt install build-essential libffi-dev python3.10-dev` to install some supplemental features for the setup process.
15 | - If you run into issue during `cffi`'s installation on Windows, you may need to install the Microsoft C++ Build Tools. You can find them [here](https://visualstudio.microsoft.com/visual-cpp-build-tools/).
16 | - Make sure the [FFmpeg executable](https://www.ffmpeg.org/download.html) is in your system's `PATH` variable
17 | - Create a [Discord app](https://discordapp.com/developers/applications/me), flag it as a bot, and put the bot token inside `config.json`, next to the `discord_token` key.
18 | - Register the Bot with your server. Go to: `https://discordapp.com/oauth2/authorize?client_id=CLIENT_ID&scope=bot&permissions=53803072`, but make sure to replace CLIENT_ID with your bot's client id.
19 | - Select your server, and hit "Authorize"
20 | - Check out `config.json` for any configuration you might want to do. It's set up to work well out of the box, change pathing, or modify the number of votes required for a skip. Note that linux based installations will require some extra tweaks to run Hawking, so check out the rest of this guide.
21 |
22 | ## Windows Installation
23 |
24 | - Nothing else to do! Everything should work just fine.
25 |
26 | ## Linux Installation
27 |
28 | Running Hawking on Linux requires a bit more work. At a minimum check out the Minimum Installation section, which covers getting Wine installed. If you're planning on running this in a headless server environment, also check out the Headless Installation section as well.
29 |
30 | ### Minimum Installation
31 |
32 | At an absolute minimum, you'll needInstall [Wine](https://www.winehq.org/) to get the text-to-speech executable working. On Ubuntu you can do the following:
33 |
34 | - `dpkg --add-architecture i386`
35 | - `apt-get update`
36 | - `apt-get install wine`
37 |
38 | ### Headless Installation
39 |
40 | - Get Hawking set up with Xvfb
41 | - Install Xvfb with with your preferred package manager (`apt install xvfb` on Ubuntu, for example)
42 | - Invoke Xvfb automatically on reboot with a cron job (`sudo crontab -e`), by adding `@reboot Xvfb :0 -screen 0 1024x768x16 &` to your list of jobs.
43 | - Set `headless` to be `true` in `config.json`
44 | - If you're using different virtual server or screen identifiers, then make sure they work with `xvfb_prepend` in `config.json`. Otherwise everything should work fine out of the box.
45 |
46 | - Hawking as a Service (HaaS)
47 | > *Note:* This assumes that your system uses systemd. You can check that by running `pidof systemd && echo "systemd" || echo "other"` in the terminal. If your system is using sysvinit, then you can just as easily build a cron job to handle running `hawking.py` on reboot. Just make sure to use your virtual environment's Python executable, and not the system's one.
48 |
49 | - Assuming that your installation is in '/usr/local/bin/hawking', you'll want to move the `hawking.service` file into the systemd services folder with `mv hawking.service /etc/systemd/system/`
50 | - If your hawking installation is located elsewhere, just update the paths (`ExecStart` and `WorkingDirectory`) inside the `hawking.service` to point to your installation.
51 | - Get the service working with `sudo systemctl daemon-reload && systemctl enable hawking && systemctl start hawking --no-block`
52 | - Now you can control the Hawking service just like any other. For example, to restart: `sudo service hawking restart`
53 |
54 | ## Manually Running Hawking
55 |
56 | Don't want to use services? You can manually invoke Python to start up Hawking as well.
57 |
58 | - `cd` into the project's root
59 | - Activate the virtual environment (Run `source bin/activate` on Linux, or `.\Scripts\activate` on Windows)
60 | - `cd` into `hawking/code/`
61 | - Run `python hawking.py` to start Hawking
62 |
--------------------------------------------------------------------------------
/code/common/database/database_manager.py:
--------------------------------------------------------------------------------
1 | import logging
2 | import inspect
3 |
4 | from discord import app_commands, Interaction
5 | from discord.ext.commands import Context
6 |
7 | from common.configuration import Configuration
8 | from common.logging import Logging
9 | from common.command_management.command_reconstructor import CommandReconstructor
10 | from common.database.factories.anonymous_item_factory import AnonymousItemFactory
11 | from common.database.models.anonymous_item import AnonymousItem
12 | from common.database.models.detailed_item import DetailedItem
13 | from common.database.database_client import DatabaseClient
14 | from common.exceptions import UnableToStoreInDatabaseException
15 | from common.module.module import Module
16 |
17 | ## Config & logging
18 | CONFIG_OPTIONS = Configuration.load_config()
19 | LOGGER = Logging.initialize_logging(logging.getLogger(__name__))
20 |
21 |
22 | class DatabaseManager(Module):
23 | def __init__(self, client: DatabaseClient = None, *args, **kwargs):
24 | super().__init__(*args, **kwargs)
25 |
26 | self.command_reconstructor: CommandReconstructor = kwargs.get('dependencies', {}).get('CommandReconstructor')
27 | assert (self.command_reconstructor is not None)
28 | self.anonymous_item_factory: AnonymousItemFactory = kwargs.get('dependencies', {}).get('AnonymousItemFactory')
29 | assert (self.anonymous_item_factory is not None)
30 |
31 | self.enabled = CONFIG_OPTIONS.get('database_enable', False)
32 |
33 | self._client: DatabaseClient = client
34 |
35 | ## Methods
36 |
37 | def register_client(self, client: DatabaseClient):
38 | LOGGER.info(f"Registering new database client: {client.name}")
39 | self._client = client
40 |
41 |
42 | def _build_detailed_item_from_context(self, context: Context, valid: bool = None) -> DetailedItem:
43 | """Builds a DetailedItem from the given discord.Context, pulled from the message that invoked the bot"""
44 |
45 | voice_state = context.author.voice
46 | voice_channel = None
47 | if (voice_state):
48 | voice_channel = voice_state.channel
49 |
50 | return DetailedItem(
51 | context.author,
52 | context.channel,
53 | voice_channel,
54 | context.guild,
55 | context.command.qualified_name,
56 | context.command.name,
57 | self.command_reconstructor.reconstruct_command_string(context, add_parameter_keys = True),
58 | isinstance(context.command, app_commands.Command),
59 | context.message.created_at,
60 | valid or not context.command_failed
61 | )
62 |
63 |
64 | def _build_detailed_item_from_interaction(self, interaction: Interaction, valid: bool = None) -> DetailedItem:
65 | """Builds a DetailedItem from the given discord.Interaction, pulled from the message that invoked the bot"""
66 |
67 | voice_state = interaction.user.voice
68 | voice_channel = None
69 | if (voice_state):
70 | voice_channel = voice_state.channel
71 |
72 | return DetailedItem(
73 | interaction.user,
74 | interaction.channel,
75 | voice_channel,
76 | interaction.guild,
77 | interaction.command.qualified_name,
78 | interaction.command.name,
79 | self.command_reconstructor.reconstruct_command_string(interaction, add_parameter_keys = True),
80 | isinstance(interaction.command, app_commands.Command),
81 | interaction.created_at,
82 | valid or not interaction.command_failed
83 | )
84 |
85 |
86 | async def _store(self, detailed_item: DetailedItem, anonymous_item: AnonymousItem):
87 | """Handles storage of the given DetailedItem in the registered database"""
88 |
89 | if (not self.enabled):
90 | return
91 |
92 | if (self._client is None):
93 | raise UnableToStoreInDatabaseException("Unable to store data without a client registered!")
94 |
95 | await self._client.store(detailed_item, anonymous_item)
96 |
97 |
98 | async def store(self, data: Context | Interaction, valid: bool = None):
99 | """Handles storage of the given Context or Interaction (by converting it into a DetailedItem) in the registered database"""
100 |
101 | if (isinstance(data, Context)):
102 | detailed_item = self._build_detailed_item_from_context(data, valid)
103 | anonymous_item = self.anonymous_item_factory.create(data, detailed_item)
104 | elif (isinstance(data, Interaction)):
105 | detailed_item = self._build_detailed_item_from_interaction(data, valid)
106 | anonymous_item = self.anonymous_item_factory.create(data, detailed_item)
107 | else:
108 | raise UnableToStoreInDatabaseException("Data is not of type Context or Interaction")
109 |
110 | await self._store(detailed_item, anonymous_item)
111 |
112 |
113 | async def batch_delete_users(self, user_ids: list[str]):
114 | """Handles a batched delete operation to remove users from from the Detailed table"""
115 |
116 | if (not self.enabled):
117 | return
118 |
119 | if (self._client is None):
120 | raise UnableToStoreInDatabaseException("Unable to batch delete data without a client registered!")
121 |
122 | await self._client.batch_delete_users(user_ids)
123 |
--------------------------------------------------------------------------------
/code/common/database/clients/dynamo_db/dynamo_db_client.py:
--------------------------------------------------------------------------------
1 | import os
2 | import logging
3 | import boto3
4 | from pathlib import Path
5 | from boto3.dynamodb.conditions import Key
6 |
7 | from common.configuration import Configuration
8 | from common.logging import Logging
9 | from common.database.database_client import DatabaseClient
10 | from common.database.factories.anonymous_item_factory import AnonymousItemFactory
11 | from common.database.models.anonymous_item import AnonymousItem
12 | from common.database.models.detailed_item import DetailedItem
13 |
14 | ## Config & logging
15 | CONFIG_OPTIONS = Configuration.load_config(Path(__file__).parent)
16 | LOGGER = Logging.initialize_logging(logging.getLogger(__name__))
17 |
18 |
19 | class DynamoDbClient(DatabaseClient):
20 | def __init__(self):
21 | name = CONFIG_OPTIONS.get("name", "bot").capitalize()
22 | self._detailed_table_name = CONFIG_OPTIONS.get("database_detailed_table_name", name)
23 | self._anonymous_table_name = CONFIG_OPTIONS.get("database_anonymous_table_name", f"{name}Detailed")
24 | self._detailed_table_ttl_seconds = CONFIG_OPTIONS.get("database_detailed_table_ttl_seconds", 31536000) ## One year
25 |
26 | self.resource = CONFIG_OPTIONS.get('dynamo_db_resource', 'dynamodb')
27 | self.region_name = CONFIG_OPTIONS.get('dynamo_db_region_name', 'us-east-2')
28 | self.primary_key = CONFIG_OPTIONS.get('dynamo_db_primary_key', 'QueryId')
29 |
30 | if (credentials_path := CONFIG_OPTIONS.get('dynamo_db_credentials_file_path')):
31 | os.environ['AWS_SHARED_CREDENTIALS_FILE'] = credentials_path
32 |
33 | self.dynamo_db = boto3.resource(self.resource, region_name=self.region_name)
34 | self.detailed_table = self.dynamo_db.Table(self.detailed_table_name)
35 | self.anonymous_table = self.dynamo_db.Table(self.anonymous_table_name)
36 |
37 | ## Implemented Properties
38 |
39 | @property
40 | def detailed_table_name(self) -> str:
41 | return self._detailed_table_name
42 |
43 |
44 | @property
45 | def anonymous_table_name(self) -> str:
46 | return self._anonymous_table_name
47 |
48 |
49 | @property
50 | def detailed_table_ttl_seconds(self) -> int:
51 | return self._detailed_table_ttl_seconds
52 |
53 | ## Implemented Methods
54 |
55 | async def store(self, detailed_item: DetailedItem, anonymous_item: AnonymousItem):
56 | """
57 | Handles storing the given detailed item data into the Detailed table, as well as anonymizing the data and
58 | storing it in the Anonymous table.
59 | """
60 |
61 | ## TTL is DetailedItem only, so no need to worry about the AnonymousItem
62 | ttl_expiry_timestamp = int(detailed_item.created_at.timestamp() + self.detailed_table_ttl_seconds)
63 |
64 | detailed_item_json = detailed_item.to_json()
65 | detailed_item_json[self.primary_key] = detailed_item.build_primary_key()
66 | detailed_item_json["expires_on"] = ttl_expiry_timestamp
67 | try:
68 | LOGGER.debug(f"Storing detailed data in {self.detailed_table_name}, {detailed_item_json}")
69 | self.detailed_table.put_item(Item=detailed_item_json)
70 | except Exception as e:
71 | LOGGER.exception(f"Exception while storing anonymous data into {self.detailed_table_name}", exc_info=e)
72 |
73 | anonymous_item_json = anonymous_item.to_json()
74 | anonymous_item_json[self.primary_key] = anonymous_item.build_primary_key()
75 | try:
76 | LOGGER.debug(f"Storing anonymous data in {self.anonymous_table_name}, {anonymous_item_json}")
77 | self.anonymous_table.put_item(Item=anonymous_item_json)
78 | except Exception as e:
79 | LOGGER.exception(f"Exception while storing anonymous data into {self.anonymous_table_name}", exc_info=e)
80 |
81 |
82 | async def batch_delete_users(self, user_ids: list[str]):
83 | """
84 | Handles deleting all of the listed primary_keys from the supplied table in a batch operation. Note that this
85 | only works for tables that only use a primary partition key, if you've got additional keys then this will fail
86 | out.
87 | """
88 |
89 | if (not user_ids):
90 | LOGGER.warning("No user_ids provided, unable to batch delete users")
91 | return
92 |
93 | LOGGER.info(f"Starting to process {len(user_ids)} delete requests")
94 | primary_keys_to_delete = list(map(
95 | lambda item: item[self.primary_key],
96 | await self.get_keys_from_users(self.detailed_table, user_ids)
97 | ))
98 |
99 | LOGGER.info(f"Starting to batch delete {len(primary_keys_to_delete)} documents.")
100 |
101 | with self.detailed_table.batch_writer() as batch:
102 | for key in primary_keys_to_delete:
103 | key_value = {self.primary_key: key}
104 |
105 | batch.delete_item(
106 | Key = key_value
107 | )
108 |
109 | ## Methods
110 |
111 | def build_multi_user_filter_expression(self, user_ids: list[str] = None):
112 | """
113 | Builds a multi user filter expression for querying the database. User ids are OR'd together, so that any
114 | document matching any part of the filter will be returned.
115 | """
116 |
117 | if (not user_ids):
118 | return None
119 |
120 | filter_expression = Key('user_id').eq(user_ids[0])
121 | for user_id in user_ids[1:]:
122 | filter_expression |= Key('user_id').eq(user_id)
123 |
124 | return filter_expression
125 |
126 |
127 | async def get_keys_from_users(self, table, user_ids: list[str] = None) -> list[str]:
128 | """Performs a lookup on the supplied table to determine what primary key each user_id corresponds to"""
129 |
130 | if (not user_ids):
131 | return
132 |
133 | scan_kwargs = {
134 | 'FilterExpression': self.build_multi_user_filter_expression(user_ids)
135 | }
136 |
137 | ## Scan through the database looking for all documents where the user that made it matches up with one of the
138 | ## provided user_ids
139 | done = False
140 | start_key = None
141 | results = []
142 | while (not done):
143 | if (start_key):
144 | scan_kwargs['ExclusiveStartKey'] = start_key
145 | response = table.scan(**scan_kwargs)
146 | results.extend(response.get('Items', []))
147 | start_key = response.get('LastEvaluatedKey', None)
148 | done = start_key is None
149 |
150 | return results
151 |
--------------------------------------------------------------------------------
/code/core/tts/tts_controller.py:
--------------------------------------------------------------------------------
1 | import os
2 | import asyncio
3 | import time
4 | import logging
5 | from pathlib import Path
6 |
7 | from core.exceptions import BuildingAudioFileTimedOutExeption, UnableToBuildAudioFileException
8 | from common import utilities
9 | from common.configuration import Configuration
10 | from common.logging import Logging
11 | from common.module.module import Module
12 |
13 | import async_timeout
14 |
15 | ## Config & logging
16 | CONFIG_OPTIONS = Configuration.load_config()
17 | LOGGER = Logging.initialize_logging(logging.getLogger(__name__))
18 |
19 |
20 | class TTSController(Module):
21 | def __init__(self, **kwargs):
22 | super().__init__(**kwargs)
23 |
24 | self.exe_path = TTSController.get_tts_executable_path()
25 | self.args = CONFIG_OPTIONS.get("args", {})
26 | self.audio_generate_timeout_seconds = CONFIG_OPTIONS.get("audio_generate_timeout_seconds", 3)
27 | self.prepend = CONFIG_OPTIONS.get("prepend", "[:phoneme on]")
28 | self.append = CONFIG_OPTIONS.get("append", "")
29 | self.char_limit = int(CONFIG_OPTIONS.get("char_limit", 1250))
30 | self.newline_replacement = CONFIG_OPTIONS.get("newline_replacement", "[_<250,10>]")
31 | self.output_extension = CONFIG_OPTIONS.get("output_extension", "wav")
32 | self.wine = CONFIG_OPTIONS.get("wine", "wine")
33 | self.xvfb_prepend = CONFIG_OPTIONS.get("xvfb_prepend", "DISPLAY=:0.0")
34 | self.is_headless = CONFIG_OPTIONS.get("headless", False)
35 |
36 | if (output_dir_path := CONFIG_OPTIONS.get("tts_output_dir_path")):
37 | self.output_dir_path = Path(output_dir_path)
38 | else:
39 | self.output_dir_path = Path.joinpath(utilities.get_root_path(), CONFIG_OPTIONS.get("tts_output_dir", "temp"))
40 |
41 | self.paths_to_delete = []
42 |
43 | ## Prep the output directory
44 | self._init_output_dir()
45 |
46 |
47 | def __del__(self):
48 | self._init_output_dir()
49 |
50 |
51 | @staticmethod
52 | def get_tts_executable_path() -> Path:
53 | tts_executable_path = CONFIG_OPTIONS.get("tts_executable_path")
54 |
55 | if (tts_executable_path is not None):
56 | return Path(tts_executable_path)
57 | else:
58 | return Path(utilities.get_root_path(), "code", "core", "tts", CONFIG_OPTIONS.get("tts_executable", "say.exe"))
59 |
60 |
61 | def _init_output_dir(self):
62 | if(not Path.exists(self.output_dir_path)):
63 | self.output_dir_path.mkdir(parents=True, exist_ok=True) # mkdir -p
64 | else:
65 | for root, dirs, files in os.walk(str(self.output_dir_path), topdown=False):
66 | for file in files:
67 | try:
68 | os.remove(os.sep.join([root, file]))
69 | except OSError:
70 | LOGGER.exception(f"Error removing file: {str(file)}")
71 |
72 |
73 | def _generate_unique_file_name(self, extension):
74 | time_ms = int(time.time() * 1000)
75 | file_name = f"{time_ms}.{extension}"
76 |
77 | while(os.path.isfile(file_name)):
78 | time_ms -= 1
79 | file_name = f"{time_ms}.{extension}"
80 |
81 | return file_name
82 |
83 |
84 | def check_length(self, message):
85 | return (len(message) <= self.char_limit)
86 |
87 |
88 | def _parse_message(self, message):
89 | if(self.newline_replacement):
90 | message = message.replace("\n", self.newline_replacement)
91 |
92 | if(self.prepend):
93 | message = self.prepend + message
94 |
95 | if(self.append):
96 | message = message + self.append
97 |
98 | message = message.replace('"', "")
99 | return message
100 |
101 |
102 | def delete(self, file_path):
103 | ## Basically, windows spits out a 'file in use' error when speeches are deleted after
104 | ## being skipped, probably because of the file being loaded into the ffmpeg player. So
105 | ## if the deletion fails, just pop it into a list of paths to delete on the next go around.
106 |
107 | if(os.path.isfile(file_path)):
108 | self.paths_to_delete.append(file_path)
109 |
110 | to_delete = []
111 | for path in self.paths_to_delete:
112 | try:
113 | os.remove(path)
114 | except FileNotFoundError:
115 | ## The goal was to remove the file, and as long as it doesn't exist then we're good.
116 | continue
117 | except Exception:
118 | LOGGER.exception(f"Error deleting file: {path}")
119 | to_delete.append(path)
120 |
121 | self.paths_to_delete = to_delete[:]
122 |
123 | return True
124 |
125 |
126 | async def save(self, message, ignore_char_limit=False):
127 | ## Check message size
128 | if(not self.check_length(message) and not ignore_char_limit):
129 | return None
130 |
131 | ## Generate and validate filename, build the output path save option, and parse the message
132 | output_file_path = Path.joinpath(self.output_dir_path, self._generate_unique_file_name(self.output_extension))
133 | save_option = f"-w \"{str(output_file_path)}\""
134 | message = self._parse_message(message)
135 |
136 | ## Build args for execution
137 | args = f"\"{str(self.exe_path)}\" {save_option} \"{message}\""
138 |
139 | ## Address issue with spaces in the path on Windows (see: https://github.com/naschorr/hawking/issues/1 and 178)
140 | if (utilities.is_windows()):
141 | args = f'\"{args}\"'
142 |
143 | ## Prepend the windows emulator if using linux (I'm aware of what WINE means)
144 | if(utilities.is_linux()):
145 | args = f"{self.wine} {args}"
146 |
147 | ## Prepend the fake display created with Xvfb if running headless
148 | if(self.is_headless):
149 | args = f"{self.xvfb_prepend} {args}"
150 |
151 | has_timed_out = False
152 | try:
153 | ## See https://github.com/naschorr/hawking/issues/50
154 | async with async_timeout.timeout(self.audio_generate_timeout_seconds):
155 | retval = os.system(args)
156 | except asyncio.TimeoutError:
157 | has_timed_out = True
158 | raise BuildingAudioFileTimedOutExeption(f"Building wav timed out for '{message}'")
159 | except asyncio.CancelledError as e:
160 | if (not has_timed_out):
161 | LOGGER.exception("CancelledError during wav generation, but not from a timeout!", exc_info=e)
162 |
163 | if(retval == 0):
164 | return output_file_path
165 | else:
166 | raise UnableToBuildAudioFileException(f"Couldn't build the wav file for '{message}', retval={retval}")
167 |
--------------------------------------------------------------------------------
/docs/privacy_policy.md:
--------------------------------------------------------------------------------
1 | # Hawking's Privacy Policy
2 | Effective October 4th, 2022
3 |
4 | Pertains only to the Hawking bot accessible from [this](https://github.com/naschorr/hawking) repository. Forks and third party installations cannot be confirmed to follow this privacy policy, so use caution if you're not sure about the origin of a Hawking bot.
5 |
6 | ## What Data is Stored
7 | Only messages that invoke Hawking (via the `/` slash-command, or `@Hawking` mention by default) will be stored. Hawking does not listen in on text or voice channels, and doesn't store any data outside of the context of the message that invoked Hawking.
8 |
9 | The data stored is broken into two groups, detailed and anonymized data. Detailed data contains information that ties the message content to a specific user, in a specific channel, inside a specific guild. Anonymized data has none of that, just the anonymized content of the message. See the tables below for the exact message data stored for each.
10 |
11 | ### Detailed Data
12 | When Hawking is invoked, the following attributes from the message are stored:
13 | | Attribute | Explanation | Example |
14 | | ------------------- | ---------------------------------------------------------------------------- | ------- |
15 | | User ID | The ID of the user who invoked Hawking | 334894709292007424
16 | | Username | The name of the user who invoked Hawking | Stephen
17 | | Text Channel ID | The ID of the text channel that Hawking was invoked in | 769086384580853770
18 | | Text Channel Name | The name of the text channel that Hawking was invoked in | general
19 | | Voice Channel ID | The ID of the voice channel that Hawking will (attempt to) join | 697856016389505035
20 | | Voice Channel Name | The name of the voice channel that Hawking will (attempt to) join | Hawking's Hovel
21 | | Guild ID | The ID of the guild that Hawking was invoked in | 521474080340180998
22 | | Guild Name | The name of the guild that Hawking was invoked in | Nick's Bots
23 | | Qualified Command | The name of the invoked command, with any parent or group commands prepended | say
24 | | Command Name | The name of the command itself | say
25 | | Query | An approximation of the command invoked by the user, as the user would've typed it in. This has the same content as what the user typed in, but parameters may not be in the same order. | /say text:Hey, did you see the new Hawking update? user:<@Stephen>
26 | | Is App Command? | Was the invoked command an app command (slash command)? | True
27 | | Timestamp | The time that the message that invoked Hawking was created | 1645156527246
28 |
29 | Note that the detailed data that Hawking stores can be verified [here](https://github.com/naschorr/hawking/blob/master/code/common/database/models/detailed_item.py) on the master branch, and always supercedes the above list in the event of any differences. Also note that additional fields that generated by Hawking are stored alongside the detailed user data.
30 |
31 | ### Anonymized Data
32 | When Hawking is invoked, the following attributes from the message are stored:
33 | | Attribute | Explanation | Example |
34 | | ----------------- | -----------------------------------------------------------------------------| ------- |
35 | | Qualified Command | The name of the invoked command, with any parent or group commands prepended | say
36 | | Command Name | The name of the command itself | say
37 | | Query | An approximation of the command invoked by the user, as the user would've typed it in. This has the same content as what the user typed in, but parameters may not be in the same order. Note that any `@mentions` or `#channels` will be anonymized. | /say text:Hey, did you see the new Hawking update? user:<@user0>
38 | | Is App Command? | Was the invoked command an app command (slash command)? | True
39 | | Timestamp | The time that the message that invoked Hawking was created | 1645156527246
40 |
41 | Note that the anonymous data that Hawking stores can be verified [here](https://github.com/naschorr/hawking/blob/master/code/common/database/models/anonymous_item.py) on the master branch, and always supercedes the above list in the event of any differences. Also note that additional fields that generated by Hawking are stored alongside the anonymized user data.
42 |
43 | ### Log Data
44 | Detailed data is also stored briefly on the cloud server that runs Hawking in the form of log files. Logs are rotated every day, and only a week of logs is kept at any given time.
45 |
46 | ## Deleting Detailed Data
47 | Users are able to have their own detailed data be deleted upon request, or it will happen automatically a year after their data was inserted into the database.
48 |
49 | ### User Controlled Data Deletion
50 | Users can invoke the `@Hawking delete_my_data` command at any time. This will queue up the user's detailed data for deletion, which happens in a batch process once a week. Users will not be notified that their data has been deleted, however the bot will tell them when to expect that their detailed data will be deleted.
51 |
52 | ### Automatic Data Deletion
53 | Detailed data will automatically be deleted one year after entry into the database.
54 |
55 | ## Data Access
56 |
57 | ### Where Data is Stored
58 | Detailed and Anonymous user data stays in my secured database running on AWS (DynamoDB). Similarly, log data stays in my secured cloud server running on AWS (EC2). Both are only accessible to me, the developer.
59 |
60 | ### Unauthorized Data Access
61 | In the event that an unauthorized person gains access to Hawking data, a notice will be posted in this repository, and linked to from this paragraph.
62 |
63 | ## How Data is Used
64 |
65 | ### Detailed Data
66 | Detailed data is kept for a year at most, and is intended to help with troubleshooting and debugging issues. Being able to reference specific users, channels, guilds, as well as their message content and timestamp data is exceptionally useful in determining what, when, how, and why a specific error has occurred.
67 |
68 | ### Anonymized Data
69 | Anonymous data is kept indefinitely for general analytics. It's very useful to know how users interact with Hawking over time, and as such anonymized message and timestamp data is essential.
70 |
71 | ## Data Sharing
72 | There isn't **any** data sharing whatsoever. No third parties have been allowed access to any of it.
73 |
74 | ## TL;DR from the developer
75 | I'm not a lawyer or a business man, and Hawking isn't intended to be some silicon valley venture capital scheme to get rich quick. It's just a fun side project that started as a joke, and turned into something bigger than I expected.
76 |
77 | I use the same Hawking that you do, and I don't want my data to be used in sketchy ways, so by proxy (and ethically), your data isn't being used in sketchy ways. If you've got any questions, feel free to ask them in my [Discord server](https://discord.gg/JJqx8C4). Remember that you can have your identifying data removed from the database at any time by invoking the `@Hawking delete_my_data` command.
78 |
--------------------------------------------------------------------------------
/modules/phrases/phrase_file_manager.py:
--------------------------------------------------------------------------------
1 | import json
2 | import logging
3 | import os
4 | import re
5 | from pathlib import Path
6 | from typing import List
7 |
8 | from common.configuration import Configuration
9 | from common.logging import Logging
10 | from modules.phrases.models.phrase import Phrase
11 | from modules.phrases.models.phrase_group import PhraseGroup
12 | from modules.phrases.models.phrase_encoding import PhraseEncoding
13 | from modules.phrases.phrase_encoder_decoder import PhraseEncoderDecoder
14 |
15 | ## Config & logging
16 | CONFIG_OPTIONS = Configuration.load_config(Path(__file__).parent)
17 | LOGGER = Logging.initialize_logging(logging.getLogger(__name__))
18 |
19 |
20 | class PhraseFileManager:
21 | def __init__(self):
22 | self.phrases_file_extension = CONFIG_OPTIONS.get('phrases_file_extension', '.json')
23 | self.non_letter_regex = re.compile('\W+') # Compile a regex for filtering non-letter characters
24 |
25 | phrases_folder_path = CONFIG_OPTIONS.get('phrases_folder_path')
26 | if (phrases_folder_path):
27 | self.phrases_folder_path = Path(phrases_folder_path)
28 | else:
29 | self.phrases_folder_path = Path.joinpath(Path(__file__).parent, CONFIG_OPTIONS.get('phrases_folder', 'phrases'))
30 |
31 |
32 | def discover_phrase_groups(self, path_to_scan: Path) -> List[Path]:
33 | '''Searches the phrases folder for .json files that can potentially contain phrase groups & phrases'''
34 |
35 | phrase_files = []
36 | for file in os.listdir(path_to_scan):
37 | file_path = Path(file)
38 | if(file_path.suffix == self.phrases_file_extension):
39 | phrase_files.append(Path.joinpath(path_to_scan, file_path))
40 |
41 | return phrase_files
42 |
43 |
44 | def _build_phrase_encoding(self, phrase_json: dict) -> PhraseEncoding:
45 | '''Builds a PhraseEncoding object from raw JSON'''
46 |
47 | if ('cipher' in phrase_json and 'fields' in phrase_json):
48 | return PhraseEncoding(phrase_json['cipher'], phrase_json['fields'])
49 | else:
50 | return None
51 |
52 |
53 | def _build_phrases(self, phrases_json: dict, decode = True) -> List[Phrase]:
54 | '''
55 | Given a JSON dict representing an unparsed PhraseGroup's list of Phrases, build a list of Phrase objects from
56 | it, and return that list
57 | '''
58 |
59 | ## Insert source[key] (if it exists) into target[key], else insert a default string
60 | def insert_if_exists(target, source, key, default=None):
61 | if(key in source):
62 | target[key] = source[key]
63 | return target
64 |
65 | phrases = []
66 | for phrase_raw in phrases_json:
67 | try:
68 | name = phrase_raw['name']
69 | message = phrase_raw['message']
70 | kwargs = {}
71 |
72 | if ('encoding' in phrase_raw != None):
73 | encoding = self._build_phrase_encoding(phrase_raw['encoding'])
74 | else:
75 | encoding = None
76 |
77 | kwargs['encoded'] = phrase_raw.get('encoded', False)
78 |
79 | ## Todo: make this less ugly
80 | help_value = phrase_raw.get('help') # fallback for the help submenus
81 | kwargs = insert_if_exists(kwargs, phrase_raw, 'help')
82 | kwargs = insert_if_exists(kwargs, phrase_raw, 'brief', help_value)
83 |
84 | ## Attempt to populate the description kwarg, but if it isn't available, then try and parse the
85 | ## message down into something usable instead.
86 | if ('description' in phrase_raw):
87 | kwargs['description'] = phrase_raw['description']
88 | else:
89 | kwargs['description'] = self.non_letter_regex.sub(' ', message).lower()
90 | kwargs['derived_description'] = True
91 |
92 | phrase = Phrase(
93 | name,
94 | message,
95 | encoding,
96 | **kwargs
97 | )
98 |
99 | ## Decode the phrase!
100 | if (decode and phrase.encoded):
101 | PhraseEncoderDecoder.decode(phrase)
102 |
103 | phrases.append(phrase)
104 | except Exception as e:
105 | LOGGER.warn(f"Error loading phrase '{phrase_raw['name']}'. Skipping...", exc_info=e)
106 | continue
107 |
108 | return sorted(phrases, key=lambda phrase: phrase.name)
109 |
110 |
111 | def load_phrase_group(self, path: Path, decode = True) -> PhraseGroup:
112 | '''
113 | Loads a PhraseGroup from a given phrase file json path.
114 |
115 | Traverses the json file, creates a PhraseGroup, populates the metadata, and then traverses the phrase objects.
116 | Phrases are built from that data, and added to the PhraseGroup. The completed PhraseGroup is returned.
117 | '''
118 |
119 | with open(path) as fd:
120 | data = json.load(fd)
121 |
122 | try:
123 | phrase_group_name = None
124 | phrase_group_key = None
125 | phrase_group_description = None
126 | kwargs = {}
127 |
128 | ## Loop over the key-values in the json file. Handle each expected pair appropriately, and store
129 | ## unexpected pairs in the kwargs variable. Unexpected data is fine, but it needs to be preserved so
130 | ## that re-saved files will be equivalent to the original file.
131 | for key, value in data.items():
132 | if (key == 'name'):
133 | phrase_group_name = value
134 | elif (key == 'key'):
135 | phrase_group_key = value
136 | elif (key == 'description'):
137 | phrase_group_description = value
138 | elif (key == 'phrases'):
139 | phrases = self._build_phrases(value, decode)
140 | else:
141 | kwargs[key] = value
142 |
143 | ## With the loose pieces processed, make sure the required pieces exist.
144 | if (phrase_group_name == None or phrase_group_key == None or phrase_group_description == None or len(phrases) == 0):
145 | LOGGER.warning(f"Error loading phrase group '{phrase_group_name}', from '{path}'. Missing 'name', 'key', 'description', or non-zero length 'phrases' list. Skipping...")
146 | return None
147 |
148 | ## Construct the PhraseGroup, and add the Phrases to it.
149 | phrase_group = PhraseGroup(phrase_group_name, phrase_group_key, phrase_group_description, path, **kwargs)
150 | phrase_group.add_all_phrases(phrases)
151 |
152 | return phrase_group
153 | except Exception as e:
154 | LOGGER.warning(f"Error loading phrase group '{phrase_group_name}' from '{path}''. Skipping...", exc_info=e)
155 | return None
156 |
157 |
158 | def save_phrase_group(self, path: Path, phrase_group: PhraseGroup):
159 | '''Saves the given PhraseGroup as a JSON object at the given path.'''
160 |
161 | data = phrase_group.to_dict()
162 |
163 | with open(path, 'w') as fd:
164 | json.dump(data, fd, indent=4, ensure_ascii=False)
165 |
--------------------------------------------------------------------------------
/code/core/cogs/speech_cog.py:
--------------------------------------------------------------------------------
1 | import logging
2 | import random
3 | from pathlib import Path
4 | from typing import Callable
5 |
6 | from core.exceptions import MessageTooLongException, BuildingAudioFileTimedOutExeption, UnableToBuildAudioFileException
7 | from core.tts.tts_controller import TTSController
8 | from common.audio_player import AudioPlayer
9 | from common.configuration import Configuration
10 | from common.command_management.invoked_command import InvokedCommand
11 | from common.command_management.invoked_command_handler import InvokedCommandHandler
12 | from common.exceptions import NoVoiceChannelAvailableException, UnableToConnectToVoiceChannelException
13 | from common.logging import Logging
14 | from common.module.module import Cog
15 | from common.message_parser import MessageParser
16 |
17 | from discord import app_commands, Interaction, Member
18 | from discord.app_commands import describe
19 | from discord.ext.commands import Bot
20 |
21 | ## Config & logging
22 | CONFIG_OPTIONS = Configuration.load_config()
23 | LOGGER = Logging.initialize_logging(logging.getLogger(__name__))
24 |
25 |
26 | class SpeechCog(Cog):
27 | SAY_COMMAND_NAME = "say"
28 |
29 | def __init__(self, bot: Bot, *args, **kwargs):
30 | super().__init__(bot, *args, **kwargs)
31 |
32 | self.bot = bot
33 | self.audio_player_cog: AudioPlayer = kwargs.get('dependencies', {}).get('AudioPlayer')
34 | assert(self.audio_player_cog is not None)
35 | self.invoked_command_handler: InvokedCommandHandler = kwargs.get('dependencies', {}).get('InvokedCommandHandler')
36 | assert(self.invoked_command_handler is not None)
37 | self.message_parser: MessageParser = kwargs.get('dependencies', {}).get('MessageParser')
38 | assert(self.message_parser is not None)
39 | self.tts_controller: TTSController = kwargs.get('dependencies', {}).get('TTSController')
40 | assert(self.tts_controller is not None)
41 |
42 | self.channel_timeout_phrases = CONFIG_OPTIONS.get('channel_timeout_phrases', [])
43 | self.audio_player_cog.channel_timeout_handler = self.play_random_channel_timeout_message
44 |
45 | ## Commands
46 | self.add_command(app_commands.Command(
47 | name=SpeechCog.SAY_COMMAND_NAME,
48 | description=self.say_command.__doc__,
49 | callback=self.say_command
50 | ))
51 |
52 | ## Methods
53 |
54 | async def play_random_channel_timeout_message(self, server_state, callback):
55 | '''Channel timeout logic, picks an appropriate sign-off message and plays it'''
56 |
57 | try:
58 | if (len(self.channel_timeout_phrases) > 0):
59 | message = random.choice(self.channel_timeout_phrases)
60 | file_path = await self.build_audio_file(message, True)
61 |
62 | await self.audio_player_cog._play_audio_via_server_state(server_state, file_path, callback)
63 | except Exception as e:
64 | LOGGER.exception("Exception during channel sign-off")
65 | await callback()
66 |
67 |
68 | async def build_audio_file(self, text: str, ignore_char_limit = False, interaction: Interaction = None) -> Path:
69 | '''Turns a string of text into a wav file for later playing. Returns a filepath pointing to that file.'''
70 |
71 | ## Make sure the message isn't too long
72 | if(not self.tts_controller.check_length(text) and not ignore_char_limit):
73 | raise MessageTooLongException(f"Message is {len(text)} characters long when it should be less than {self.tts_controller.char_limit}")
74 |
75 | ## Parse down the message before sending it to the TTS service
76 | if (interaction is not None):
77 | text = self.message_parser.parse_message(text, interaction.data)
78 |
79 | ## Build the audio file for speaking
80 | return await self.tts_controller.save(text, ignore_char_limit)
81 |
82 |
83 | async def say(
84 | self,
85 | text: str,
86 | author: Member,
87 | target_member: Member = None,
88 | ignore_char_limit = False,
89 | interaction: Interaction = None,
90 | callback: Callable = None
91 | ) -> InvokedCommand:
92 | '''Internal say method, for use with presets and anything else that generates phrases on the fly'''
93 |
94 | async def audio_player_callback():
95 | self.tts_controller.delete(wav_path)
96 | if (callback is None):
97 | return
98 |
99 | await callback()
100 |
101 |
102 | try:
103 | wav_path = await self.build_audio_file(text, ignore_char_limit, interaction)
104 |
105 | except BuildingAudioFileTimedOutExeption as e:
106 | LOGGER.exception(f"Timed out building audio for message: '{text}'")
107 | return InvokedCommand(False, e, f"Sorry <@{author.id}>, it took too long to generate speech for that.")
108 |
109 | except MessageTooLongException as e:
110 | LOGGER.warn(f"Unable to build too long message. Message was {len(text)} characters long (out of {self.tts_controller.char_limit})")
111 | ## todo: Specify how many characters need to be removed?
112 | return InvokedCommand(False, e, f"Wow <@{author.id}>, that's waaay too much. You've gotta keep messages shorter than {self.tts_controller.char_limit} characters.")
113 |
114 | except UnableToBuildAudioFileException as e:
115 | LOGGER.exception(f"Unable to build .wav file for message: '{text}'")
116 | return InvokedCommand(False, e, f"Sorry <@{author.id}>, I can't say that right now.")
117 |
118 | try:
119 | await self.audio_player_cog.play_audio(wav_path, author, target_member or author, interaction, audio_player_callback)
120 |
121 | except NoVoiceChannelAvailableException as e:
122 | LOGGER.error("No voice channel available", exc_info=e)
123 | if (e.target_member.id == author.id):
124 | return InvokedCommand(False, e, f"Sorry <@{author.id}>, you're not in a voice channel.")
125 | else:
126 | return InvokedCommand(False, e, f"Sorry <@{author.id}>, that person isn't in a voice channel.")
127 |
128 | except UnableToConnectToVoiceChannelException as e:
129 | ## Logging handled in AudioPlayer
130 |
131 | error_values = []
132 | if (not e.can_connect):
133 | error_values.append("connect to")
134 | if (not e.can_speak):
135 | error_values.append("speak in")
136 |
137 | return InvokedCommand(False, e, f"Sorry <@{author.id}>, I'm not able to {' or '.join(error_values)} that channel. Check the permissions and try again later.")
138 |
139 | except FileNotFoundError as e:
140 | LOGGER.error("FileNotFound when invoking `play_audio`", exc_info=e)
141 | return InvokedCommand(False, e, f"Sorry <@{author.id}>, I can't say that right now.")
142 |
143 | return InvokedCommand(True)
144 |
145 | ## Commands
146 |
147 | @describe(text="The text that Hawking will speak")
148 | @describe(user="The user that will be spoken to")
149 | async def say_command(self, interaction: Interaction, text: str, user: Member = None):
150 | """Speaks your text aloud"""
151 |
152 | mention = self.invoked_command_handler.get_first_mention(interaction)
153 | invoked_command = lambda: self.say(text, interaction.user, user or mention or None, False, interaction)
154 |
155 | await self.invoked_command_handler.invoke_command(interaction, invoked_command, ephemeral=False)
156 |
--------------------------------------------------------------------------------
/code/hawking.py:
--------------------------------------------------------------------------------
1 | ## Fix inconsistent pathing between my Windows dev environment, and the Ubuntu production server. This needs to happen
2 | ## before the imports so they know where to search.
3 | import sys
4 | from common import utilities
5 | _root_path = str(utilities.get_root_path())
6 | if (_root_path not in sys.path):
7 | sys.path.append(_root_path)
8 |
9 | ## Importing as usual now
10 | import os
11 | import logging
12 | import asyncio
13 |
14 | import discord
15 | from discord.ext import commands
16 |
17 | from core.cogs import admin_cog, help_cog, speech_cog, speech_config_help_cog
18 | from core.tts import tts_controller
19 | from common import audio_player, message_parser
20 | from common.cogs import privacy_management_cog, invite_cog
21 | from common.configuration import Configuration
22 | from common.logging import Logging
23 | from common.command_management import invoked_command_handler, command_reconstructor
24 | from common.database import database_manager
25 | from common.database.factories import anonymous_item_factory
26 | from common.database.clients.dynamo_db import dynamo_db_client
27 | from common.module.module_manager import ModuleManager
28 | from common.ui import component_factory
29 | from modules.phrases import phrases
30 |
31 | ## Config & logging
32 | CONFIG_OPTIONS = Configuration.load_config()
33 | LOGGER = Logging.initialize_logging(logging.getLogger(__name__))
34 |
35 |
36 | class Hawking:
37 | ## Initialize the bot, and add base cogs
38 | def __init__(self, **kwargs):
39 | ## Make sure there's a Discord token before doing anything else
40 | self.token = CONFIG_OPTIONS.get("discord_token")
41 | if (not self.token):
42 | raise RuntimeError("Unable to get Discord token!")
43 |
44 | self.name = CONFIG_OPTIONS.get("name", "the bot").capitalize()
45 | self.version = CONFIG_OPTIONS.get("version")
46 | self.description = CONFIG_OPTIONS.get("description", ["The retro TTS bot for Discord"])
47 |
48 | ## Init the bot and module manager
49 | self.bot = commands.AutoShardedBot(
50 | intents=discord.Intents.default(),
51 | command_prefix=commands.when_mentioned,
52 | description='\n'.join(self.description)
53 | )
54 |
55 | ## Prepare to register modules
56 | self._module_manager = ModuleManager(self, self.bot)
57 |
58 | ## Register the modules (no circular dependencies!)
59 | self.module_manager.register_module(message_parser.MessageParser)
60 | self.module_manager.register_module(
61 | command_reconstructor.CommandReconstructor,
62 | dependencies=[message_parser.MessageParser]
63 | )
64 | self.module_manager.register_module(
65 | anonymous_item_factory.AnonymousItemFactory,
66 | dependencies=[command_reconstructor.CommandReconstructor]
67 | )
68 | self.module_manager.register_module(
69 | database_manager.DatabaseManager,
70 | dynamo_db_client.DynamoDbClient(),
71 | dependencies=[command_reconstructor.CommandReconstructor, anonymous_item_factory.AnonymousItemFactory]
72 | )
73 | self.module_manager.register_module(
74 | component_factory.ComponentFactory,
75 | self.bot,
76 | dependencies=[database_manager.DatabaseManager]
77 | )
78 | self.module_manager.register_module(
79 | admin_cog.AdminCog,
80 | self,
81 | self.bot,
82 | dependencies=[database_manager.DatabaseManager]
83 | )
84 | self.module_manager.register_module(
85 | privacy_management_cog.PrivacyManagementCog,
86 | self.bot,
87 | dependencies=[component_factory.ComponentFactory, database_manager.DatabaseManager]
88 | )
89 | self.module_manager.register_module(
90 | speech_config_help_cog.SpeechConfigHelpCog,
91 | self.bot,
92 | dependencies=[component_factory.ComponentFactory, database_manager.DatabaseManager]
93 | )
94 | self.module_manager.register_module(
95 | invite_cog.InviteCog,
96 | self.bot,
97 | dependencies=[component_factory.ComponentFactory, database_manager.DatabaseManager]
98 | )
99 | self.module_manager.register_module(
100 | invoked_command_handler.InvokedCommandHandler,
101 | dependencies=[message_parser.MessageParser, database_manager.DatabaseManager, command_reconstructor.CommandReconstructor]
102 | )
103 | self.module_manager.register_module(
104 | audio_player.AudioPlayer,
105 | self.bot,
106 | dependencies=[admin_cog.AdminCog, database_manager.DatabaseManager]
107 | )
108 | self.module_manager.register_module(tts_controller.TTSController)
109 | self.module_manager.register_module(
110 | speech_cog.SpeechCog,
111 | self.bot,
112 | dependencies=[
113 | invoked_command_handler.InvokedCommandHandler,
114 | message_parser.MessageParser,
115 | audio_player.AudioPlayer,
116 | tts_controller.TTSController
117 | ]
118 | )
119 | self.module_manager.register_module(
120 | help_cog.HelpCog,
121 | self.bot,
122 | dependencies=[component_factory.ComponentFactory, phrases.Phrases, database_manager.DatabaseManager]
123 | )
124 |
125 | ## Find any dynamic modules, and prep them for loading
126 | self.module_manager.discover_modules()
127 |
128 | ## Load all of the previously registered modules!
129 | asyncio.run(self.module_manager.load_registered_modules())
130 |
131 | ## Disable the default help command
132 | self.bot.help_command = None
133 |
134 | ## Set the current working directory to that of the TTS executable, otherwise the TTS interface won't
135 | ## necessarily work as expected.
136 | self.tts_controller: tts_controller.TTSController = self.module_manager.get_module(tts_controller.TTSController.__name__)
137 | tts_executable = self.tts_controller.get_tts_executable_path()
138 | os.chdir(tts_executable.parent)
139 |
140 | ## Get a reference to the database manager for on_command_error storage
141 | self.database_manager: database_manager.DatabaseManager = self.module_manager.get_module(database_manager.DatabaseManager.__name__)
142 |
143 | ## Give some feedback for when the bot is ready to go, and provide some help text via the 'playing' status
144 | @self.bot.event
145 | async def on_ready():
146 | if (loaded_help_cog := self.module_manager.get_module(help_cog.HelpCog.__name__)):
147 | status = discord.Activity(name=f"/{loaded_help_cog.help_command.name}", type=discord.ActivityType.watching)
148 | await self.bot.change_presence(activity=status)
149 |
150 | LOGGER.info(f"Logged in as '{self.bot.user.name}' (version: {self.version}), (id: {self.bot.user.id})")
151 |
152 |
153 | @self.bot.event
154 | async def on_command_error(ctx, exception):
155 | ## Something weird happened, log it!
156 | LOGGER.exception("Unhandled exception in during command execution", exc_info=exception)
157 | await self.database_manager.store(ctx, valid=False)
158 |
159 | ## Properties
160 |
161 | @property
162 | def module_manager(self) -> ModuleManager:
163 | return self._module_manager
164 |
165 | ## Run the bot
166 | def run(self):
167 | '''Starts the bot up'''
168 |
169 | LOGGER.info(f"Starting up {self.name}")
170 | self.bot.run(self.token)
171 |
172 |
173 | if(__name__ == "__main__"):
174 | Hawking().run()
175 |
--------------------------------------------------------------------------------
/modules/stupid_questions/stupid_questions.py:
--------------------------------------------------------------------------------
1 | import logging
2 | import random
3 | import time
4 | import asyncio
5 | from pathlib import Path
6 |
7 | from core.cogs.speech_cog import SpeechCog
8 | from common.command_management.invoked_command import InvokedCommand
9 | from common.command_management.invoked_command_handler import InvokedCommandHandler
10 | from common.configuration import Configuration
11 | from common.database.database_manager import DatabaseManager
12 | from common.exceptions import ModuleLoadException
13 | from common.logging import Logging
14 | from common.module.discoverable_module import DiscoverableCog
15 | from common.module.module_initialization_container import ModuleInitializationContainer
16 | from common.ui.component_factory import ComponentFactory
17 | from question import Question
18 |
19 | import discord
20 | from discord import app_commands, Interaction
21 | from discord.ext.commands import Bot
22 |
23 | ## Config & logging
24 | CONFIG_OPTIONS = Configuration.load_config(Path(__file__).parent)
25 | LOGGER = Logging.initialize_logging(logging.getLogger(__name__))
26 |
27 | class StupidQuestions(DiscoverableCog):
28 | THOUGHT_PROVOKING_STRINGS = [
29 | "🤔?",
30 | "have you ever pondered:",
31 | "what do you think about this:",
32 | "have you ever considered:",
33 | "do you ever wonder about this:",
34 | "have a nice long think about this one:",
35 | "what do you think scholars in a thousand years will think about this:",
36 | "do you ever wonder why we're here? No? Ok, well have a wonder about this:",
37 | "take a gander at this highly intelligent and deeply insightful question:",
38 | "it's now time for us to plant some daffodils of opinion on the roundabout of chat at the end of conversation street, and discuss:"
39 | ]
40 |
41 | def __init__(self, bot: Bot, *args, **kwargs):
42 | super().__init__(bot, *args, **kwargs)
43 |
44 | self.bot = bot
45 |
46 | self.speech_cog: SpeechCog = kwargs.get('dependencies', {}).get('SpeechCog')
47 | assert (self.speech_cog is not None)
48 | self.invoked_command_handler: InvokedCommandHandler = kwargs.get('dependencies', {}).get('InvokedCommandHandler')
49 | assert(self.invoked_command_handler is not None)
50 | self.database_manager: DatabaseManager = kwargs.get('dependencies', {}).get('DatabaseManager')
51 | assert (self.database_manager is not None)
52 | self.component_factory: ComponentFactory = kwargs.get('dependencies', {}).get('ComponentFactory')
53 | assert(self.component_factory is not None)
54 |
55 | ## Handle Reddit dependency
56 | reddit_dependency = kwargs.get('dependencies', {}).get('Reddit')
57 | if (not reddit_dependency):
58 | LOGGER.info(f"No Reddit dependency provided, unable to load {self.__class__.__name__}.")
59 | self.successful = False
60 | return
61 | self.reddit = reddit_dependency.reddit
62 | if (not reddit_dependency.successful):
63 | self.successful = False
64 | return
65 |
66 | self.questions = []
67 | self.is_mid_question_refresh = False
68 | self.last_question_refresh_time = time.time()
69 |
70 | ## Load config data
71 | self.submission_top_time = CONFIG_OPTIONS.get("stupid_question_top_time", "month")
72 | self.submission_count = CONFIG_OPTIONS.get("stupid_question_submission_count", 500)
73 | self.refresh_time_seconds = CONFIG_OPTIONS.get("stupid_question_refresh_time_seconds", 21600)
74 | subreddits = CONFIG_OPTIONS.get("stupid_question_subreddits", ["NoStupidQuestions"])
75 |
76 | try:
77 | ## Use a multireddit to pull random post from any of the chosen subreddits
78 | self.subreddit = self.reddit.subreddit("+".join(subreddits))
79 | except Exception as e:
80 | raise ModuleLoadException("Unable to create reddit/subreddit instance", e)
81 |
82 | ## Load the questions for polling, async
83 | asyncio.create_task(self.load_questions())
84 |
85 | self.add_command(app_commands.Command(
86 | name="stupid_question",
87 | description=self.stupid_question_command.__doc__,
88 | callback=self.stupid_question_command
89 | ))
90 |
91 |
92 | async def load_questions(self) -> None:
93 | ## Don't try to pull more data from Reddit if it's already happening
94 | if (self.is_mid_question_refresh):
95 | LOGGER.debug("Skipping load_questions as they're already being refreshed.")
96 | return
97 | self.is_mid_question_refresh = True
98 |
99 | LOGGER.info(f"Loading questions from reddit: top({self.submission_top_time}), {self.submission_count} submissions")
100 | questions = []
101 | try:
102 | submission_generator = self.subreddit.top(self.submission_top_time, limit=self.submission_count)
103 | except Exception as e:
104 | LOGGER.exception("Unable to load submission from Reddit.", e)
105 | return
106 |
107 | for submission in submission_generator:
108 | questions.append(Question(submission.title, submission.subreddit.display_name, submission.shortlink))
109 |
110 | self.last_question_refresh_time = time.time()
111 | self.questions = questions
112 | self.is_mid_question_refresh = False
113 |
114 | LOGGER.info("{} questions loaded at {}".format(len(self.questions), time.asctime()))
115 |
116 | ## Commands
117 |
118 | async def stupid_question_command(self, interaction: Interaction):
119 | """Ask a stupid question, via Reddit."""
120 |
121 | if (len(self.questions) > 0):
122 | question = random.choice(self.questions)
123 | else:
124 | await self.database_manager.store(interaction, valid=False)
125 | if (self.is_mid_question_refresh):
126 | await interaction.response.send_message(f"Sorry <@{interaction.user.id}>, but I'm currently loading new questions from Reddit. Try again later.", ephemeral=True)
127 | else:
128 | await interaction.response.send_message(f"Sorry <@{interaction.user.id}>, but I'm having trouble loading questions from Reddit. Try again in a bit.", ephemeral=True)
129 | return
130 |
131 |
132 | async def callback(invoked_command: InvokedCommand):
133 | if (invoked_command.successful):
134 | thought_provoking_string = random.choice(self.THOUGHT_PROVOKING_STRINGS)
135 | embed = self.component_factory.create_basic_embed(
136 | description=f"{question.text}\n\nvia [/r/{question.subreddit}]({question.url})",
137 | url=question.url
138 | )
139 |
140 | await self.database_manager.store(interaction)
141 | await interaction.response.send_message(
142 | f"Hey <@{interaction.user.id}>, {thought_provoking_string}",
143 | embed=embed
144 | )
145 | else:
146 | await self.database_manager.store(interaction, valid=False)
147 | await interaction.response.send_message(invoked_command.human_readable_error_message, ephemeral=True)
148 |
149 | now = time.time()
150 | question_refresh_time = 0#self.last_question_refresh_time + self.refresh_time_seconds
151 | if (now > question_refresh_time):
152 | LOGGER.debug(f"Question refresh task due, {now} > {question_refresh_time}")
153 | ## Note that fetching questions before the interaction has been responded to will cause the interaction
154 | ## to fail. This might be avoidable by using deferred responses, however that then locks you in to
155 | ## either an ephemeral response (or not), which can be frustrating if an error happens and you've locked
156 | ## in a non-ephemeral response.
157 | ##
158 | ## A workaround could be to leverage asyncpraw and do everything async, but getting the initial question
159 | ## loading logic to play nice is difficult. `asyncio.create_task` will always cancel out before the
160 | ## `async submission for subreddit(...):` will actually yield anything
161 | self.bot.loop.create_task(self.load_questions())
162 |
163 |
164 | action = lambda: self.speech_cog.say(question.text, author=interaction.user, ignore_char_limit=True, interaction=interaction)
165 | await self.invoked_command_handler.invoke_command(interaction, action, ephemeral=False, callback=callback)
166 |
167 |
168 | def main() -> ModuleInitializationContainer:
169 | return ModuleInitializationContainer(StupidQuestions, dependencies=["Reddit", "SpeechCog", "InvokedCommandHandler", "DatabaseManager", "ComponentFactory"])
170 |
--------------------------------------------------------------------------------
/modules/phrases/phrases/classics.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "Classics",
3 | "key": "classics",
4 | "description": "All the classics from Moonbase Alpha!",
5 | "phrases": [
6 | {
7 | "name": "allstar",
8 | "message": "[suh<600,19>bah<300,26>diy<200,23>wow<300,23>]uce[tow<300,21>miy<250,19>][thuh<250,19>wer<450,24>]urd[ih<100,23>]s[gao<250,23>nah<200,21>]roll[miy<200,19>][ay<200,19>][ey<200,26>]int[thuh<200,23>]sharp[eh<200,21>]estool[ih<200,19>]nthuh[sheh<400,16>][eh<300,14>]ed\n [shiy<300,19>][wah<300,19>][lxuh<200,26>][kih<200,23>][kay<300,23>][nah<300,21>][duh<250,21>]uhm[wih<250,19>][fer<250,19>][fih<450,24>]ing[gur<200,23>][ah<250,23>][ner<200,21>][thuh<200,21>]uhm[ih<200,19>][thuh<200,19>][shey<400,26>][puh<200,23>]fan[_<50,21>]L[ah<200,19>]ner[for<400,21>][eh<300,14>]ed",
9 | "help": "It's all ogre now.",
10 | "description": "somebody once told me the world was gonna roll me i aint the sharpest tool in the shed she was looking kinda dumb with her finger and her thumb in the shape of an l on her forehead"
11 | },
12 | {
13 | "name": "ateam",
14 | "message": "[dah<300,30>][dah<60,30>][dah<200,25>][dah<1000,30>][dah<200,23>][dah<400,25>][dah<700,18>]",
15 | "help": "I love it when a plan comes together!",
16 | "description": "a team theme song"
17 | },
18 | {
19 | "name": "batman",
20 | "message": "[nae<99,20>nae<99,20>nae<99,19>nae<99,19>nae<99,18>nae<99,18>nae<99,19>nae<99,19>bae<140,25>ttmae<600,25>nn]",
21 | "help": "Starring: Adam West!",
22 | "description": "batman bat man theme song"
23 | },
24 | {
25 | "name": "birthday",
26 | "message": "[hxae<300,10>piy<300,10>brr<600,12>th<100>dey<600,10>tuw<600,15>yu<1200,14>_<120>]\n [hxae<300,10>piy<300,10>brr<600,12>th<100>dey<600,10>tuw<600,17>yu<1200,15>_<120>]\n [hxae<300,10>piy<300,10>brr<600,22>th<100>dey<600,19>jh<100>aa<600,15>n<100>m<100>ae<600,14>d<50>dih<600,12>n]\n [hxae<300,20>piy<300,20>brr<600,19>th<100>dey<600,15>tuw<600,17>yu<1200,15>_<120>]",
27 | "help": "A very special day.",
28 | "description": "happy birthday to you happy birthday to you happy birthday john madden happy birthday to you"
29 | },
30 | {
31 | "name": "careless",
32 | "message": "[dah<400,29>dah<200,27>dah<400,22>dah<300,18>dah<500,29>dah<200,27>dah<400,22>dah<400,18>]\n [dah<400,25>dah<200,23>dah<400,18>dah<300,15>dah<500,25>dah<200,23>dah<400,18>]",
33 | "help": "I have pop pop in the attic.",
34 | "description": "george michales careless whisper song sexy sax"
35 | },
36 | {
37 | "name": "cena",
38 | "message": "[bah<300,20>dah<200,22>dah<200,18>dah<600,20>]\n [bah<400,23>dah<200,22>dah<200,18>dah<700,20>]",
39 | "help": "And his name is...",
40 | "description": "john cena theme song"
41 | },
42 | {
43 | "name": "daisy",
44 | "message": "[dey<600,24>ziy<600,21>dey<600,17>ziy<600,12>gih<200,14>vmiy<200,16>yurr<200,17>ah<400,14>nsrr<200,17>duw<1200,12>]",
45 | "help": "I'm afraid I can't do that.",
46 | "description": "daisy daisy give me your answer too"
47 | },
48 | {
49 | "name": "hey",
50 | "message": "[ae<150,28>nnday<300,30>ssae<300,28>_<300>hxeh<750,37>ey<200,33>eh<750,33>ey<200,28>eh<750,28>ey<400,26>ey<400,25>]\n [hxeh<750,37>ey<200,33>eh<750,33>ey<200,26>eh<750,26>_<750>ay<150,35>sseh<200,37>hxeh<300,33>eh<300,30>]\n [wwah<300,35>ttsgow<300,35>ih<400,37>nnaw<300,33>nn<90>]",
51 | "help": "What's going on?",
52 | "description": "he man heman hey whats going on"
53 | },
54 | {
55 | "name": "imperial",
56 | "message": "[dah<600,20>][dah<600,20>][dah<600,20>][dah<500,16>][dah<130,23>][dah<600,20>][dah<500,16>]\n [dah<130,23>][dah<600,20>]",
57 | "help": "Marching along.",
58 | "description": "star wars imperial march song"
59 | },
60 | {
61 | "name": "madden",
62 | "message": "[ey<900,24>iyuw<450,27>ey<900,34>iyuw<450,32>jhah<900,27>nmae<225,25>ae<225,24>deh<1350,22>n]",
63 | "help": "Lord and saviour",
64 | "description": "aeiou aeiou john madden"
65 | },
66 | {
67 | "name": "mamamia",
68 | "message": "mamma mia, poppa pia, baby got the dy[aa<999,999>]reeeeeeeeeaaaaaaaaaa",
69 | "help": "Could you handle that, dear?"
70 | },
71 | {
72 | "name": "mulan",
73 | "message": "[trae<400,17>nkwih<200,12>lxae<400,15>zah<200,10>for<500,12>ih<400,15>st]\n [bah<200,10>tah<200,12>fay<600,13>r<200>_<600>wih<200,15>dhih<600,10>n]\n [wah<200,17>ns<200>yu<200,12>fay<400,15>ndyxor<200,10>seh<400,12>ntrr<400,15>]\n [yu<200,10>ar<200,12>shrr<800,13>_<400>tuw<200,15>wih<600>n]\n [yxor<200,15>ah<200>spay<400,17>nlxih<400>spey<400>lx_<200,20>pah<200,22>theh<200,19>dih<200>klxao<400>t]\n [ae<200,20>ndyu<200,22>hxae<600,24>vih<200,25>ntgao<400,24>tah<200,15>klxuw<800,17>]\n [sah<150,25>mhxaw<200,24>ay<1000,20>lx_<200>mey<200,17>kah<200,24>mae<600,22>n_<600>aw<200,20>tah<200,19>vyu<200,20>uw<1200,17>]",
74 | "help": "I'll make a man out of you.",
75 | "description": "mulan song tranquil as the forest but a fire within once you find your center you are sure to win youre a spineless pale pathetic lot and you havent got a clue somehow ill make a man out of you"
76 | },
77 | {
78 | "name": "one",
79 | "message": "[dah<450,18>][dah<150,25>][dah<75,24>][dah<75,25>][dah<75,24>][dah<75,25>][dah<150,24>][dah<150,25>][dah<300,21>][dah<600,18>][dah<150,18>][dah<150,21>][dah<150,25>][dah<300,26>][dah<300,21>][dah<300,26>][dah<300,28>][w<100,25>]ee[ar<100,26>][n<100,25>]a[m<100,26>]r[w<100,25>]on",
80 | "help": "We are number one!",
81 | "description": "we are number one song"
82 | },
83 | {
84 | "name": "pirate",
85 | "message": "[yxar<500,25>hxar<500,25>fih<150,27>del<150,24>diy<150,22>diy<500,20>]",
86 | "help": "That's what you are.",
87 | "description": "yar har fiddle dee dee pirate song"
88 | },
89 | {
90 | "name": "pizza",
91 | "message": "[:nh]I'm gonna eat a pizza. [:dial67589340] Hi, can i order a pizza? [:nv]no! [:nh]why? [:nv] cuz you are john madden![:np]",
92 | "help": "Time for some pizza."
93 | },
94 | {
95 | "name": "ravioli",
96 | "message": "[:rate 300] Ravioli, ravioli, give me the form u oli",
97 | "help": "He needs it!"
98 | },
99 | {
100 | "name": "salt",
101 | "message": "The ceiling is salt, the floor is salt, the walls are salt, and too an extent, the air is salt. You breathe that in, and you constantly taste the salt.",
102 | "help": "The zest of life."
103 | },
104 | {
105 | "name": "skeletons",
106 | "message": "[spuh<300,19>kiy<300,19>skeh<300,18>riy<300,18>skeh<300,11>lleh<175,14>tih<200,11>ns]\n [seh<300,11>nd][shih<100,19>ver<500,19>sdaw<300,18>nyur<300,18>spay<300,11>n]\n [shriy<300,19>kiy<300,19>ng][skow<300,18>swih<300,18>ll]\n [shah<300,11>kyur<300,14>sow<300,11>ll]\n [siy<300,14>llyur<300,16>duh<300,13>mtuh<300,14>nay<300,11>t]",
107 | "help": "Spooky and scary.",
108 | "description": "spooky scary skeletons send shivers down your spine shrieking skulls will chuck your soul seal your doom tonight"
109 | },
110 | {
111 | "name": "snake",
112 | "message": "snake? Snayke! SNAAAAAKEE!",
113 | "help": "!",
114 | "description": "snake metal gear solid"
115 | },
116 | {
117 | "name": "soviet",
118 | "message": "[lxao<400,23>lxao<800,28>lxao<600,23>lxao<200,25>lxao<1600,27>lxao<800,25>lxao<600,23>lxao<200,21>lxao<1600,23>][lxao<400,16>][lxao<400,16>][lxao<800,18>][lxao<400,18>][lxao<400,20>][lxao<800,21>][lxao<400,21>][lxao<400,23>][lxao<800,25>][lxao<400,27>][lxao<400,28>][lxao<800,30>]",
119 | "help": "From Russia with love.",
120 | "description": "soviet national anthem music"
121 | },
122 | {
123 | "name": "taps",
124 | "message": "[pr<600,18>][pr<200,18>][pr<1800,23>_>pr<600,18>][pr<300,23>][pr<1800,27>]\n [pr<600,18>][pr<300,23>][pr<1200,27>][pr<600,18>][pr<300,23>][pr<1200,27>]",
125 | "help": "o7",
126 | "description": "taps song trumpets military"
127 | },
128 | {
129 | "name": "tetris",
130 | "message": "[:t 430,500][:t 320,250][:t 350,250][:t 390,500][:t 350,250][:t 330,250][:t 290,500][:t 290,250][:t 350,250][:t 430,500]",
131 | "help": "I am the man who arranges the blocks.",
132 | "description": "tetris theme song"
133 | },
134 | {
135 | "name": "whalers",
136 | "message": "[_<1,13>]we're[_<1,18>]whalers[_<1,17>]on[_<1,18>]the[_<1,20>]moon\n [_<400,13>]we[_<1,20>]carry[_<1,18>]a[_<1,20>]har[_<1,22>]poon\n [_<1,22>]but there[_<1,23>]aint no[_<1,15>]whales[_<1,23>]so we[_<1,22>]tell tall[_<1,18>]tales and[_<1,20>]sing our[_<1,18>]whale[_<1,17>]ing[_<1,18>]tune",
137 | "help": "On the moon!"
138 | }
139 | ]
140 | }
141 |
--------------------------------------------------------------------------------
/docs/configuring_speech.md:
--------------------------------------------------------------------------------
1 | # Speech Configuration with Hawking
2 |
3 | Speech is modified with the same inline commands seen in Moonbase Alpha. They generally follow the pattern of `[:(command)(parameter)]`, where the open bracket colon starts a command, `(command)` selects a specific command, and `(parameter)` configures it. Some commands can be chained together, inside of a single set of brackets. Most commands can also be shortened down to a single letter, too.
4 |
5 | ## Changing Hawking's voice
6 |
7 | Select a preset voice with the `[:name(n)]` command, where `(n)` is one of:
8 | - `betty`
9 | - `dennis`
10 | - `frank`
11 | - `harry`
12 | - `kit`
13 | - `paul` (this is the default voice)
14 | - `rita`
15 | - `ursula`
16 | - `wendy`
17 |
18 | You can also shorten the command to just `[:n(n)]` where `(n)` is the first letter of the name you want to use. For example: `[:nf]`.
19 |
20 | ## Changing how fast Hawking speaks
21 |
22 | The rate of speech can be changed with `[:rate(r)]`, where `(r)` is the words per minute to speak. The default is 200, and must be between 75 and 600. For example, `[:rate100]` will cause all future speech to be spoken at 100 words per minute, which is half of the default.
23 |
24 | ## Playing tones and dialing phones
25 |
26 | Tones are pure noise, simply a sound over a duration. They're usually used to simulate of beeps and boops. They follow the form `[:tone(f),(l)]`, where `(f)` is the frequency of the tone, and `(l)` is the length of the tone in milliseconds. For example, `[:tone100,2000]` will generate a 100 hertz tone for 2 seconds, and `[:tone750,150]` will generate a beep at 750 hertz over 150 milliseconds.
27 |
28 | Similar to tones is the dial command, which can be used to emulate the touch tones used when dialing a phone number (see the `/phrase pizza` command). It takes the form `[:dial(n)]`, where `(n)` is the number to dial, though it can take any arbitrary amount of digits. For example, `[:dial8675309]` would generate the dial tone for the phone number "867-5309".
29 |
30 | ## Phonemes
31 |
32 | Phonemes are the building blocks of more complex sounds, as they give you greater control over how Hawking generates the sounds that make up speech. They follow the pattern of: `[(phonemes)<(d),(p)>]`, where `(phonemes)` is one or more phonemes from the table below, `(d)` is the duration in milliseconds that it takes to speak each phoneme, and `(p)` is the pitch number (see the second table in this section) that the phoneme should end on. Additionally, multiple groups of phonemes and their respective duration and pitch can be chained together as well. Similarly, phonemes (and groups of phonemes) don't technically need a duration and pitch number added to them either.
33 |
34 | Below is a table that shows each phoneme with their respective sounds. Bolded letters indicate the exact sound each phoneme makes. For example, the phoneme `aa` makes the `o` sound in the word "pot".
35 |
36 | | Phoneme | Sound | ㅤㅤ | Phoneme | Sound | ㅤㅤ | Phoneme | Sound
37 | | - | - | - | - | - | - | - | - |
38 | | _ | *silence* | ㅤㅤ | hx | **h**at | ㅤㅤ | r | **r**ope
39 | | q | *full stop* | ㅤㅤ | ih | p**i**t | ㅤㅤ | rr | anoth**er**
40 | | aa | p**o**t | ㅤㅤ | ir | p**ee**r | ㅤㅤ | rx | fi**r**e
41 | | ae | p**a**t | ㅤㅤ | iy | b**ea**n | ㅤㅤ | s | **s**ap
42 | | ar | b**ar**n | ㅤㅤ | jh | **j**eep | ㅤㅤ | sh | **sh**eep
43 | | aw | br**ow** | ㅤㅤ | k | **c**andle | ㅤㅤ | zh | mea**s**ure
44 | | ax | **a**bout | ㅤㅤ | el | dang**l**e | ㅤㅤ | t | **t**ack
45 | | ay | b**uy** | ㅤㅤ | l | **l**ad | ㅤㅤ | th | **th**ick
46 | | ey | b**ay** | ㅤㅤ | lx | unti**l** | ㅤㅤ | dh | **th**en
47 | | er | p**ai**r | ㅤㅤ | m | **m**ad | ㅤㅤ | df | wri**t**er
48 | | b | **b**atch | ㅤㅤ | en | garde**n** | ㅤㅤ | tx | ba**tt**en
49 | | ch | **ch**eap | ㅤㅤ | n | **n**ature | ㅤㅤ | uw | b**oo**n
50 | | d | **d**ad | ㅤㅤ | nx | ba**ng** | ㅤㅤ | uh | p**u**t
51 | | dx | un**d**er | ㅤㅤ | ao | b**ou**ght | ㅤㅤ | ah | p**u**tt
52 | | dz | wi**d**th | ㅤㅤ | or | t**o**rn | ㅤㅤ | v | **v**at
53 | | eh | p**e**t | ㅤㅤ | ur | p**oo**r | ㅤㅤ | w | **w**hy
54 | | ix | kiss**e**s | ㅤㅤ | ow | n**o** | ㅤㅤ | yu | c**u**te
55 | | f | **f**at | ㅤㅤ | oy | b**oy** | ㅤㅤ | yx | **y**ank
56 | | g | **g**ame | ㅤㅤ | p | **p**at | ㅤㅤ | z | **z**ap
57 |
58 | Here's a pitch table that correlates pitches to pitch numbers, and their respective musical note.
59 |
60 | | Note | Pitch (Hz) | Pitch Number | ㅤㅤ | Note | Pitch (Hz) | Pitch Number
61 | | - | - | - | - | - | - | - |
62 | | C2 | 65 | 1 | ㅤㅤ | G | 196 | 20
63 | | C# | 69 | 2 | ㅤㅤ | G# | 207 | 21
64 | | D | 73 | 3 | ㅤㅤ | A | 220 | 22
65 | | D# | 77 | 4 | ㅤㅤ | A# | 233 | 23
66 | | E | 82 | 5 | ㅤㅤ | B | 247 | 24
67 | | F | 87 | 6 | ㅤㅤ | C4 | 261 | 25
68 | | F# | 92 | 7 | ㅤㅤ | C# | 277 | 26
69 | | G | 98 | 8 | ㅤㅤ | D | 293 | 27
70 | | G# | 103 | 9 | ㅤㅤ | D# | 311 | 28
71 | | A | 110 | 10 | ㅤㅤ | E | 329 | 29
72 | | A# | 116 | 11 | ㅤㅤ | F | 348 | 30
73 | | B | 123 | 12 | ㅤㅤ | F# | 370 | 31
74 | | C3 | 130 | 13 | ㅤㅤ | G | 392 | 32
75 | | C# | 138 | 14 | ㅤㅤ | G# | 415 | 33
76 | | D | 146 | 15 | ㅤㅤ | A | 440 | 34
77 | | D# | 155 | 16 | ㅤㅤ | A# | 466 | 35
78 | | E | 164 | 17 | ㅤㅤ | B | 494 | 36
79 | | F | 174 | 18 | ㅤㅤ | C5 | 523 | 37
80 | | F# | 185 | 19
81 |
82 | That's a lot of data to throw at you, so check out these examples to get a feel for it:
83 |
84 | - Speaks "complicated", without any duration or pitch numbers specified: `[kaamplihkeytxehd]`
85 | - Speaks "hello" over the span of 600ms at pitch number 3: `[hx<100,3>eh<100,3>l<200,3>ow<200,3>]`
86 | - Speaks "daa" for a second, ending at pitch number 1, pauses for a second, ending at pitch number 30, then speaks "daa" for a second, starting at pitch number 30 and ending back at pitch number 1: `[daa<1000,1>_<1000,30>daa<1000,1>]`
87 |
88 | ## Advanced customization
89 |
90 | Customize the sound of the voice with `[:dv (commands)]`. This requires additional subcommands to fine-tune your desired behavior. Below are the more common/noticeable ones:
91 |
92 | - Pitch can be changed with `ap (p)` where `(p)` is the average pitch in hertz.
93 | - Change the size of the speaker\'s head with `hs (s)` where `(s)` is the amount of change in percentage. For example, `50` would represent a halved head size. Defaults to `100`.
94 | - Gender can be changed with `sx (g)` where `(g)` is `0` for female, or `1` for male.
95 | - Breathiness can be changed with `br (b)` where `(b)` is the amount of breathiness added in decibels. Default to `0`.
96 | - Smoothness can be changed with `sm (s)` where `(s)` adjusts how smooth the voice sounds as a percentage. Defaults to `30`.
97 | - Richness can be changed with `ri (r)`, where `(r)` is the amount of change in percentage. Defaults to `70`.
98 |
99 | Note that the `[:dv]` command can support multiple subcommands, each one separated by a space. Also note that you can't have the subcommand next to its parameter, they must have a space between them. Check out these examples to get a feel for it:
100 |
101 | - Set the average pitch to 15 hertz: `[:dv ap 15]`
102 | - Increase the head size by 50%, and set the average pitch to 10 hertz: `[:dv hs 150 ap 10]`
103 | - Set the gender to female, the averate pitch to 200 hertz, and half the head size: `[:dv sx 0 ap 200 hs 50]`
104 | - Set the average pitch to 20 hertz, make the voice as un-smooth as possible, and 90% richness: `[:dv ap 20 sm 0 ri 90]`
105 |
106 | ## Examples
107 |
108 | - Use the "Harry" voice: `/say [:name harry] my name is harry`
109 | - Speed up the "Kit" voice to 350 words per minute: `/say [:nk] [:rate 350] my name's Kit, and I can speak pretty quickly!`
110 | - Use tones to play the beginning of the Tetris theme: `/say [:t430,500][:t320,250][:t350,250][:t390,500][:t350,250][:t330,250][:t290,500][:t290,250][:t350,250][:t430,500]`
111 | - Dial the phone number "867-5309": `/say [:dial8675309]`
112 | - The famous John Madden song: `/say [ey<900,24>iyuw<450,27>ey<900,34>iyuw<450,32>jhah<900,27>nmae<225,25>ae<225,24>deh<1350,22>n]`
113 | - Pitch down the default voice: `/say [:dv ap 5] my name's Paul`
114 | - Create a demonic child to do your evil bidding: `/say [:nk] started at the top [:dv ap 2 ri 90 hs 125] [:rate 150] now we're down here.`
115 |
116 | ## Links
117 |
118 | You can check out the sources for Hawking's preset phrases [here](https://github.com/naschorr/hawking-phrases), to see how those are created.
119 |
120 | There's also a wealth of Moonbase Alpha guides on Steam, like [this one](https://steamcommunity.com/sharedfiles/filedetails/?id=128648903) which has some useful examples.
121 |
--------------------------------------------------------------------------------
/docs/configuring_hawking.md:
--------------------------------------------------------------------------------
1 | # Configuring Hawking
2 | See `config.json` in the Hawking installation's root.
3 |
4 | ### Discord Configuration
5 | - **name** - String - The bot's name.
6 | - **version** - String - The bot's current semantic version.
7 | - **description** - Array - An array of strings making up the bot's description. Each element in the array goes on a new line in the help interface.
8 | - **channel_timeout_seconds** - Int - The time in seconds before the bot will leave its current voice channel due to inactivity.
9 | - **channel_timeout_phrases** - Array - Array of strings that the bot can speak right before it leaves. One phrase is chosen randomly from the array.
10 | - **skip_percentage** - Float - The minimum percentage of other users who need to request a skip before the currently playing audio will be skipped. Must be a floating point number between 0.0 and 1.0 inclusive.
11 | - **repo_url** - String - The URL of the repository that hosts the bot
12 | - **bot_invite_url** - String - The URL used to invite the bot to any Discord servers the user controls.
13 | - **bot_invite_blurb** - String - A string of text that is applied to bot invites, so users can have more context about the bot.
14 | - **support_discord_invite_url** - String - The URL of the invite for the bot's support Discord.
15 | - **privacy_policy_url** - String - The URL of the bot's privacy policy.
16 | - **accent_color_hex** - String - A hex string containing the color code for the bot's accent color. This is used to customize embeds to ensure they're visually consistent with the bot.
17 |
18 | ### Bot Configuration
19 | - **log_level** - String - The minimum error level to log. Potential values are `DEBUG`, `INFO`, `WARNING`, `ERROR`, and `CRITICAL`, in order of severity (ascending). For example, choosing the `WARNING` log level will log everything tagged as `WARNING`, `ERROR`, and `CRITICAL`.
20 | - **log_path** - String - The path where logs should be stored. If left empty, it will default to a `logs` folder inside the Hawking root.
21 | - **log_backup_count** - Int - The maximum number of logs to keep before deleting the oldest ones.
22 | - **discord_token** - String - The token for the bot, used to authenticate with Discord.
23 | - **delete_request_queue_file_path** - String - The path where the delete requests file should be stored. If left empty, it will default to a `privacy/delete_request.txt` file inside the Hawking root.
24 | - **delete_request_meta_file_path** - String - The path where the delete requests metadata file should be stored. For example, this includes the time the delete request queue was last parsed. If left empty, it will default to a `privacy/metadata.json` file inside the Hawking root.
25 | - **delete_request_weekday_to_process** - Integer - The integer corresponding to the day of the week to perform the delete request queue processing. 0 is Monday, 7 is Sunday, and so on.
26 | - **delete_request_time_to_process** - String - The ISO8601 time string that specifies when the queue should be processed, when the provided day comes up each week. Make sure to use the format `THH:MM:SSZ`.
27 | - **tts_executable** - String - The name of the text-to-speech executable.
28 | - **\_tts_executable_path** - String - Force the bot to use a specific text-to-speech executable, rather than the normal `say.exe` file. Remove the leading underscore to activate it.
29 | - **tts_output_dir** - String - The name of the file where the temporary speech files are stored.
30 | - **\_tts_output_dir_path** - String - Force the bot to use a specific text-to-speech output folder, rather than the normal `temp/` folder. Remove the leading underscore to activate it.
31 | - **audio_generate_timeout_seconds** - Int - Number of seconds to wait before timing out of the audio generation. Certain 'expanded' phrases can crash Hawking if too many are used at once (See: https://github.com/naschorr/hawking/issues/50)
32 | - **ffmpeg_parameters** - String - Options to send to the FFmpeg executable before the `-i` flag. Used when building the audio player.
33 | - **ffmpeg_post_parameters** - String - Options to send to the FFmpeg executable after the `-i` flag. Used when building the audio player.
34 | - **output_extension** - String - The file extension of the text-to-speech engine's output.
35 | - **wine** - String - The command to invoke Wine on your system. Linux only.
36 | - **xvfb_prepend** - String - The string that'll select your `xvfb` display. Headless only.
37 | - **headless** - Boolean - Indicate that the bot is running on a machine without a display. Uses `xvfb` to simulate a display required for the text-to-speech engine.
38 | - **modules_dir** - String - The name of the directory, located in Hawking's root, which will contain the modules to dynamically load. See ModuleManager's discover() method for more info about how modules need to be formatted for loading.
39 | - **\_modules_dir_path** - String - The path to the directory that contains the modules to be loaded for the bot. Remove the leading underscore to activate it.
40 | - **string_similarity_algorithm** - String - The name of the algorithm to use when calculating how similar two given strings are. Currently only supports 'difflib'.
41 | - **invalid_command_minimum_similarity** - Float - The minimum similarity an invalid command must have with an existing command before the existing command will be suggested as an alternative.
42 | - **find_command_minimum_similarity** - Float - The minimum similarity the find command must have with an existing command, before the existing command will be suggested for use.
43 | > *A quick note about minimum similarity*: If the value is set too low, then you can run into issues where seemingly irrelevant commands are suggested. Likewise, if the value is set too high, then commands might not ever be suggested to the user. For both of the minimum similarities, the value should be values between 0 and 1 (inclusive), and should rarely go below 0.4.
44 |
45 | ### Speech Configuration
46 | - **prepend** - String - A string that'll always be prepended onto the text sent to the text-to-speech engine.
47 | - **append** - String - A string that'll always be appended onto the text sent to the text-to-speech engine.
48 | - **char_limit** - Int - A hard character limit for messages to be sent to the text-to-speech engine.
49 | - **newline_replacement** - String - A string that'll replace all newline characters in the text sent to the text-to-speech engine.
50 | - **replace_emoji** - Boolean - If `true`, indicates that the bot should convert emoji into their textual form (ex. :thinking: -> "thinking face"). This isn't a perfect conversion, as Discord encodes emoji into their unicode representation before the bot is able to parse it. If this is set to `false`, then the bot will just strip out emoji completely, as if they weren't there.
51 |
52 | ### Module Configuration
53 | Modify the specified module's `config.json` file to update these properties.
54 |
55 | #### Phrases Configuration
56 | - **phrases_file_extension** - String - The file extension to look for when searching for phrase files. For example: `.json`.
57 | - **phrases_folder** - String - The name of the folder that contains phrase files.
58 | - **\_phrases_folder_path** - String - Force the bot to use a specific phrases folder, rather than the normal `phrases/` folder. Remove the leading underscore to activate it.
59 |
60 | #### Reddit Configuration
61 | You'll need to get access to the Reddit API via OAuth2, so follow the "First Steps" section of [this guide](https://github.com/reddit-archive/reddit/wiki/OAuth2-Quick-Start-Example#first-steps) to get authenticated.
62 |
63 | - **reddit_client_id** - String - This is the `client_id` provided to you by Reddit when you create the script.
64 | - **reddit_secret** - String - This is the `secret` provided to you by Reddit when you create the script.
65 | - **reddit_user_agent_platform** - String - The platform that your script will be running on. For example: `discord-bot-py`.
66 | - **reddit_user_agent_app_id** - String - A unique identifier for the bot. For example: `hawking-tts`.
67 | - **reddit_user_agent_contact_name** - String - The is the Reddit username that's associated with your script. For example, it should look something like `/u/this-is-my-username`.
68 |
69 | All of the above `reddit*` properties are required to use the Reddit module, and thus any modules that depend on it (ex. the StupidQuestions module). Also, the user-agent that'll be sent to Reddit will be built from all of the user-agent properties above. For example, if you use the above examples, the the user-agent `discord-bot:hawking-tts:1.0.5 (by /u/this-is-my-username)` will be generated (assuming that you're running version 1.0.5 of Hawking). Lastly, please note that Reddit has some specific requirements about those user-agent components, so take a look at their [API guide](https://github.com/reddit-archive/reddit/wiki/API) for more details.
70 |
71 | #### StupidQuestion Configuration
72 | - **stupid_question_subreddits** - Array of Strings - An array of subreddit names to pull questions from, should be an array of length of at least one (and ideally that one is "NoStupidQuestions" or similar).
73 | - **stupid_question_top_time** - String - Length of time to pull top posts from. Must be one of: `hour`, `day`, `week`, `month`, `year`, or `all`.
74 | - **stupid_question_submission_count** - Int - The number of posts to retrieve when querying Reddit.
75 | - **stupid_question_refresh_time_seconds** - Int - The number of seconds to wait before loading more questions.
76 |
77 | ### Analytics Configuration
78 | #### Database Configuration
79 | These are generic, non-specific database configuration options
80 | - **database_enable** - Boolean - Indicate that you want the bot to upload analytics to the remote database.
81 | - **database_detailed_table_name** - String - The name of the table to insert detailed, temporary data into.
82 | - **database_anonymous_table_name** - String - The name of the table to insert anonymized, long term data into.
83 | - **database_detailed_table_ttl_seconds** - Integer - The number of seconds before a record in the detailed table should be automatically removed.
84 |
85 | #### DynamoDB Configuration
86 | - **dynamo_db_credentials_file_path** - String - Path to your AWS credentials file, if it's not being picked up automatically. If empty, this will be ignored.
87 | - **dynamo_db_resource** - String - The AWS boto-friendly resource to upload to.
88 | - **dynamo_db_region_name** - String - The AWS region of your chosen `dynamo_db_resource`.
89 | - **dynamo_db_primary_key** - String - The primary key of the above tables.
90 |
--------------------------------------------------------------------------------
/code/common/module/module_manager.py:
--------------------------------------------------------------------------------
1 | import os
2 | import sys
3 | import logging
4 | import importlib
5 | import asyncio
6 | from collections import OrderedDict
7 | from pathlib import Path
8 | from functools import reduce
9 |
10 | from common import utilities
11 | from common.configuration import Configuration
12 | from common.exceptions import ModuleLoadException
13 | from common.logging import Logging
14 | from common.module.module import Module
15 | from .dependency_graph import DependencyGraph
16 | from .module_initialization_container import ModuleInitializationContainer
17 |
18 | from discord.ext import commands
19 |
20 | ## Config & logging
21 | CONFIG_OPTIONS = Configuration.load_config()
22 | LOGGER = Logging.initialize_logging(logging.getLogger(__name__))
23 |
24 |
25 | class ModuleEntry:
26 | def __init__(self, cls: Module, *init_args, **init_kwargs):
27 | self.module = sys.modules[cls.__module__]
28 | self.cls = cls
29 | self.name = cls.__name__
30 | self.is_cog = issubclass(cls, commands.Cog)
31 | self.args = init_args
32 | self.kwargs = init_kwargs
33 |
34 | self.dependencies = init_kwargs.get('dependencies', [])
35 | if ('dependencies' in init_kwargs):
36 | del init_kwargs['dependencies']
37 |
38 | ## Methods
39 |
40 | def get_class_callable(self) -> Module:
41 | '''Returns an invokable object to instantiate the class defined in self.cls'''
42 | return getattr(self.module, self.name)
43 |
44 |
45 | class ModuleManager:
46 | '''
47 | Manages the modules' lifecycle. Chiefly, the discovery, registration, and installation of modules. It'll also
48 | support reloading existing modules/cogs too.
49 | '''
50 |
51 | def __init__(self, bot_controller, bot: commands.Bot):
52 | self.bot_controller = bot_controller
53 | self.bot = bot
54 |
55 | modules_dir_path = CONFIG_OPTIONS.get('modules_dir_path')
56 | if (modules_dir_path):
57 | self.modules_dir_path = Path(modules_dir_path)
58 | else:
59 | self.modules_dir_path = Path.joinpath(
60 | utilities.get_root_path(),
61 | CONFIG_OPTIONS.get('modules_dir', 'modules')
62 | )
63 |
64 | self.modules = OrderedDict()
65 | self.loaded_modules = {} # Keep non-cog modules loaded in memory
66 | self._dependency_graph = DependencyGraph()
67 |
68 | ## Methods
69 |
70 | async def _load_module(self, module_entry: ModuleEntry, module_dependencies: list = None) -> bool:
71 | if(self.bot.get_cog(module_entry.name)):
72 | LOGGER.warn(f"Cog with name '{module_entry.name}' has already been loaded onto the bot, skipping...")
73 | return
74 |
75 | module_invoker = module_entry.get_class_callable()
76 | instantiated_module: Module = None
77 | try:
78 | instantiated_module = module_invoker(
79 | *module_entry.args,
80 | **{'dependencies': module_dependencies or []},
81 | **module_entry.kwargs
82 | )
83 | except ModuleLoadException as e:
84 | LOGGER.error(f"Error: '{e.message}' while loading module: {module_entry.name}.")
85 |
86 | ## Only set the unsuccessful state if it hasn't already been set. Setting the successful state happens later
87 | if (
88 | instantiated_module is not None
89 | or hasattr(instantiated_module, 'successful')
90 | and instantiated_module.successful is not False
91 | ):
92 | instantiated_module.successful = False
93 | return False
94 |
95 | if (module_entry.is_cog):
96 | await self.bot.add_cog(instantiated_module)
97 |
98 | self.loaded_modules[module_entry.name] = instantiated_module
99 | LOGGER.info(f"Instantiated {'Cog' if module_entry.is_cog else 'Module'}: {module_entry.name}")
100 |
101 | return True
102 |
103 |
104 | async def load_registered_modules(self) -> int:
105 | '''Performs the initial load of modules, and adds them to the bot'''
106 |
107 | async def load_node(node) -> int:
108 | counter = 0
109 |
110 | if (not node.loaded and reduce(lambda value, node: node.loaded and value, node.parents, True)):
111 | dependencies = {}
112 | for parent in node.parents:
113 | dependencies[parent.name] = self.loaded_modules[parent.name]
114 |
115 | module_entry = self.modules.get(node.name)
116 | node.loaded = await self._load_module(module_entry, module_dependencies=dependencies)
117 |
118 | if (not node.loaded):
119 | return 0
120 |
121 | ## Default the success state to True when loading a module, as that's kind of the default state. If a
122 | ## failure state is entered, than that's much more explicit.
123 | loaded_module = self.loaded_modules[module_entry.name]
124 | if (loaded_module.successful is None):
125 | loaded_module.successful = True
126 |
127 | counter += 1
128 |
129 | for child in node.children:
130 | counter += await load_node(child)
131 |
132 | ## Number of loaded modules + the root node itself
133 | return counter
134 |
135 |
136 | ## Clear out the loaded_modules (if any)
137 | self.loaded_modules = {}
138 | self._dependency_graph.set_graph_loaded_state(False)
139 |
140 | ## Keep track of the number of successfully loaded modules
141 | counter = 0
142 |
143 | ## todo: parallelize?
144 | for node in self._dependency_graph.roots:
145 | try:
146 | counter += await load_node(node)
147 | except ModuleLoadException as e:
148 | LOGGER.warn(f"{e}. This module and all modules that depend on it will be skipped.")
149 | continue
150 |
151 | return counter
152 |
153 |
154 | async def reload_registered_modules(self) -> int:
155 | module_entry: ModuleEntry
156 | for module_entry in self.modules.values():
157 | ## Detach loaded cogs
158 | await self.bot.remove_cog(module_entry.name)
159 |
160 | ## Reimport the module itself
161 | try:
162 | importlib.reload(module_entry.module)
163 | except Exception as e:
164 | LOGGER.error(f"Error reloading module: {module_entry.name}. Attempting to continue...", exc_info=e)
165 |
166 | ## Reload the modules via dependency graph
167 | loaded_module_count = await self.load_registered_modules()
168 | LOGGER.info(f"Loaded {loaded_module_count}/{len(self.modules)} modules.")
169 |
170 | return loaded_module_count
171 |
172 |
173 | def register_module(self, cls: Module, *init_args, **init_kwargs):
174 | '''Registers module data with the ModuleManager, and prepares any necessary dependencies'''
175 |
176 | module_entry = ModuleEntry(cls, *init_args, **init_kwargs)
177 | self.modules[module_entry.name] = module_entry
178 |
179 | self._dependency_graph.insert(cls.__name__, module_entry.dependencies)
180 |
181 |
182 | def discover_modules(self):
183 | '''Discovers the available modules, and assembles the data needed to register them'''
184 |
185 | if (not self.modules_dir_path.exists):
186 | LOGGER.warn('Modules directory doesn\'t exist, so no modules will be loaded.')
187 | return
188 |
189 | ## Build a list of potential module paths and iterate through it...
190 | module_directories = os.listdir(self.modules_dir_path)
191 | for module_directory in module_directories:
192 | module_path = Path.joinpath(self.modules_dir_path, module_directory)
193 |
194 | ## Note that the entrypoint for the module should share the same name as it's parent folder. For example:
195 | ## phrases.py is the entrypoint for the phrases/ directory
196 | module_entrypoint = Path.joinpath(module_path, module_path.name + '.py')
197 |
198 | if (module_entrypoint.exists):
199 | ## Expose the module's root directory to the interpreter, so it can be imported
200 | sys.path.append(str(module_path))
201 |
202 | ## Attempt to import the module (akin to 'import [name]') and register it normally
203 | ## NOTE: Modules MUST have a 'main()' function that essentially returns a list containing all the args
204 | ## needed by the 'register()' method of this ModuleManager class. At a minimum this list MUST
205 | ## contain a reference to the class that serves as an entry point to the module. You should also
206 | ## specify whether or not a given module is a cog (for discord.py) or not.
207 | try:
208 | module = importlib.import_module(module_path.name)
209 | module_init = module.main()
210 | except Exception as e:
211 | LOGGER.exception(f"Unable to import module {module_path.name} on bot.", exc_info=e)
212 | del sys.path[-1] ## Prune back the failed module from the path
213 | continue
214 |
215 | ## Filter out any malformed modules
216 | if (not isinstance(module_init, ModuleInitializationContainer) and type(module_init) != bool):
217 | LOGGER.exception(
218 | f"Unable to add module {module_path.name}, as it's neither an instance of {ModuleInitializationContainer.__name__}, nor a boolean."
219 | )
220 | continue
221 |
222 | ## Allow modules to be skipped if they're in a falsy 'disabled' state
223 | if (module_init == False):
224 | LOGGER.info(f"Skipping module {module_path.name}, as its initialization data was false")
225 | continue
226 |
227 | ## Build args to register the module
228 | register_module_args = []
229 | register_module_kwargs = {**module_init.init_kwargs}
230 |
231 | if (module_init.is_cog):
232 | ## Cogs will need a reference to the bot
233 | register_module_args.append(self.bot)
234 | else:
235 | ## Otherwise, modules can use them as needed
236 | register_module_kwargs['bot_controller'] = self.bot_controller
237 | register_module_kwargs['bot'] = self.bot
238 |
239 | if (len(module_init.init_args) > 0):
240 | register_module_args.append(*module_init.init_args)
241 |
242 | ## Register the module!
243 | try:
244 | self.register_module(module_init.cls, *register_module_args, **register_module_kwargs)
245 | except Exception as e:
246 | LOGGER.exception(f"Unable to register module {module_path.name} on bot.")
247 | del sys.path[-1] ## Prune back the failed module from the path
248 | del module
249 |
250 |
251 | def get_module(self, name: str) -> Module | None:
252 | '''Gets the instance of a loaded module'''
253 |
254 | return self.loaded_modules.get(name)
255 |
--------------------------------------------------------------------------------
/code/common/cogs/privacy_management_cog.py:
--------------------------------------------------------------------------------
1 | import os
2 | import stat
3 | import logging
4 | import asyncio
5 | import dateutil
6 | import datetime
7 | import json
8 | from pathlib import Path
9 |
10 | from common import utilities
11 | from common.configuration import Configuration
12 | from common.database.database_manager import DatabaseManager
13 | from common.logging import Logging
14 | from common.module.module import Cog
15 | from common.ui.component_factory import ComponentFactory
16 |
17 | import discord
18 | from discord import app_commands, Interaction
19 | from discord.ext.commands import command, Context, Bot
20 |
21 | ## Config & logging
22 | CONFIG_OPTIONS = Configuration.load_config()
23 | LOGGER = Logging.initialize_logging(logging.getLogger(__name__))
24 |
25 |
26 | class PrivacyManagementCog(Cog):
27 |
28 | def __init__(self, bot: Bot, *args, **kwargs):
29 | super().__init__(bot, *args, **kwargs)
30 |
31 | self.bot = bot
32 |
33 | self.component_factory: ComponentFactory = kwargs.get('dependencies', {}).get('ComponentFactory')
34 | assert(self.component_factory is not None)
35 | self.database_manager: DatabaseManager = kwargs.get('dependencies', {}).get('DatabaseManager')
36 | assert (self.database_manager is not None)
37 |
38 | self.name = CONFIG_OPTIONS.get("name", "the bot").capitalize()
39 | self.privacy_policy_url = CONFIG_OPTIONS.get('privacy_policy_url')
40 | self.delete_request_scheduled_weekday = int(CONFIG_OPTIONS.get('delete_request_weekday_to_process', 0))
41 | self._delete_request_scheduled_weekday_name = utilities.get_weekday_name_from_day_of_week(self.delete_request_scheduled_weekday)
42 | self.delete_request_scheduled_time = dateutil.parser.parse(CONFIG_OPTIONS.get('delete_request_time_to_process', "T00:00:00Z"))
43 |
44 | ## Build the filepaths for the various tracking files
45 | delete_request_queue_file_path = CONFIG_OPTIONS.get('delete_request_queue_file_path')
46 | if (delete_request_queue_file_path):
47 | self.delete_request_queue_file_path = Path(delete_request_queue_file_path)
48 | else:
49 | self.delete_request_queue_file_path = Path.joinpath(utilities.get_root_path(), 'privacy', 'delete_requests.txt')
50 |
51 | delete_request_meta_file_path = CONFIG_OPTIONS.get('delete_request_meta_file_path')
52 | if (delete_request_meta_file_path):
53 | self.delete_request_meta_file_path = Path(delete_request_meta_file_path)
54 | else:
55 | self.delete_request_meta_file_path = Path.joinpath(utilities.get_root_path(), 'privacy', 'meta.json')
56 |
57 | ## Make sure the file containing all delete requests is accessible.
58 | if (not self.is_file_accessible(self.delete_request_queue_file_path)):
59 | message = "Unable to access delete request queue file at: '{}'. Make sure that it exists and has r/w permissions applied to it".format(self.delete_request_queue_file_path)
60 |
61 | LOGGER.error(message)
62 | raise RuntimeError(message)
63 |
64 | ## Make sure the file containing the delete request metadata is accessible.
65 | if (not self.is_file_accessible(self.delete_request_meta_file_path)):
66 | message = "Unable to access delete request queue file at: '{}'. Make sure that it exists and has r/w permissions applied to it".format(self.delete_request_meta_file_path)
67 |
68 | LOGGER.error(message)
69 | raise RuntimeError(message)
70 |
71 | ## Keep a copy of all user ids that should be deleted in memory, so the actual file can't get spammed by repeats.
72 | self.queued_user_ids = self.get_all_queued_delete_request_ids()
73 |
74 | ## Load the delete request metadata to know when deletion operations last happened
75 | try:
76 | self.metadata = utilities.load_json(self.delete_request_meta_file_path)
77 | except json.decoder.JSONDecodeError:
78 | self.metadata = {}
79 |
80 | ## Perform or prepare the deletion process
81 | seconds_until_process_delete_request = self.get_seconds_until_process_delete_request_queue_is_due()
82 | if (seconds_until_process_delete_request <= 0):
83 | asyncio.create_task(self.process_delete_request_queue())
84 | else:
85 | asyncio.create_task(self.schedule_process_delete_request_queue(seconds_until_process_delete_request))
86 |
87 | # Don't add a privacy policy link if there isn't a URL to link to
88 | if (self.privacy_policy_url):
89 | self.add_command(app_commands.Command(
90 | name="privacy_policy",
91 | description=self.privacy_policy_command.__doc__,
92 | callback=self.privacy_policy_command
93 | ))
94 |
95 | ## Methods
96 |
97 | def is_file_accessible(self, file_path: Path) -> bool:
98 | """Ensures that the file that holds the delete requests is accessible"""
99 |
100 | if (file_path.is_file()):
101 | if ( os.access(file_path, os.R_OK) and
102 | os.access(file_path, os.W_OK)):
103 | return True
104 | else:
105 | try:
106 | os.chmod(file_path, stat.S_IREAD | stat.S_IWRITE)
107 | return True
108 | except Exception:
109 | return False
110 | else:
111 | try:
112 | file_path.parent.mkdir(parents=True, exist_ok=True) ## Basically mkdir -p on the parent directory
113 | file_path.touch(0o644, exist_ok=True) ## u+rw, go+r
114 | return True
115 | except Exception as e:
116 | return False
117 |
118 |
119 | def get_all_queued_delete_request_ids(self) -> set:
120 | """Retrieves all delete request ids from the file, and returns them all in a set"""
121 |
122 | queued_user_ids = None
123 |
124 | with open(self.delete_request_queue_file_path, 'r+') as fd:
125 | queued_user_ids = set([int(line.rstrip()) for line in fd.readlines()])
126 |
127 | return queued_user_ids
128 |
129 |
130 | def empty_queued_delete_request_file(self):
131 | open(self.delete_request_queue_file_path, 'w').close()
132 |
133 |
134 | ## Stores's a user's id in a file, which while be used in a batched request to delete their data from the remote DB
135 | async def store_user_id_for_batch_delete(self, user_id):
136 | """
137 | Stores's a user's id in a file, which while be used in a batched request to delete their data from the remote DB
138 | """
139 |
140 | self.queued_user_ids.add(user_id)
141 |
142 | user_id_written = False
143 | while (not user_id_written):
144 | try:
145 | with open(self.delete_request_queue_file_path, 'a+') as fd:
146 | fd.write(str(user_id) + '\n')
147 | user_id_written = True
148 | except IOError as e:
149 | LOGGER.exception(f"Unable to write id {user_id} to file at {self.delete_request_queue_file_path}.", exc_info=e)
150 | ## Give the file some time to close
151 | await asyncio.sleep(1);
152 |
153 | return user_id_written
154 |
155 |
156 | def update_last_process_delete_request_queue_time(self, update_time):
157 | self.metadata['last_process_time'] = str(update_time)
158 | utilities.save_json(self.delete_request_meta_file_path, self.metadata)
159 |
160 |
161 | async def process_delete_request_queue(self):
162 | ## Perform the operations on a list, since they're slightly easier to wrangle than sets.
163 | user_ids = list(self.get_all_queued_delete_request_ids())
164 |
165 | if (not user_ids):
166 | LOGGER.info("Skipping delete request processing, as queue is empty.")
167 | return
168 |
169 | LOGGER.info(f"Batch deleting {len(user_ids)} users from the database")
170 | await self.database_manager.batch_delete_users(user_ids)
171 |
172 | LOGGER.info("Successfully performed batch delete")
173 | self.queued_user_ids = set()
174 | self.empty_queued_delete_request_file()
175 |
176 | LOGGER.info("Updating metadata file with time of completion.")
177 | self.update_last_process_delete_request_queue_time(datetime.datetime.now(datetime.timezone.utc))
178 |
179 |
180 | def get_seconds_until_process_delete_request_queue_is_due(self):
181 | def copy_time_data_into_datetime(source: datetime.datetime, target: datetime.datetime):
182 | target.replace(hour=source.hour, minute=source.minute, second=source.second, microsecond=0)
183 |
184 | try:
185 | last_process_time = dateutil.parser.isoparse(self.metadata.get('last_process_time'))
186 | except Exception:
187 | ## If there is no last_process_time property, then it likely hasn't been done. So process the queue immediately
188 | return 0
189 |
190 | now = datetime.datetime.now(datetime.timezone.utc)
191 |
192 | previous_possible_process_time = now - datetime.timedelta(days=(self.delete_request_scheduled_weekday - now.weekday()) % 7)
193 | copy_time_data_into_datetime(self.delete_request_scheduled_time, previous_possible_process_time)
194 |
195 | ## Check if it's been more than a week. Otherwise, get the time until the next queue processing should happen
196 | if (last_process_time.timestamp() < previous_possible_process_time.timestamp()):
197 | return 0
198 | else:
199 | next_possible_process_time = now + datetime.timedelta(days=(self.delete_request_scheduled_weekday - now.weekday()) % 7)
200 | copy_time_data_into_datetime(self.delete_request_scheduled_time, next_possible_process_time)
201 |
202 | return int(next_possible_process_time.timestamp() - last_process_time.timestamp())
203 |
204 |
205 | async def schedule_process_delete_request_queue(self, seconds_to_wait):
206 | await asyncio.sleep(seconds_to_wait)
207 | await self.process_delete_request_queue()
208 |
209 | ## Commands
210 |
211 | async def privacy_policy_command(self, interaction: Interaction):
212 | """Gives a link to the privacy policy"""
213 |
214 | await self.database_manager.store(interaction)
215 |
216 | embed = self.component_factory.create_embed(
217 | title="Privacy Policy",
218 | description=f"{self.name} takes your data seriously.\nTake a look at {self.name}'s [privacy policy]({self.privacy_policy_url}) to learn more.",
219 | url=self.privacy_policy_url
220 | )
221 |
222 | view = discord.ui.View()
223 |
224 | view.add_item(discord.ui.Button(
225 | style=discord.ButtonStyle.link,
226 | label="View the Privacy Policy",
227 | url=self.privacy_policy_url
228 | ))
229 |
230 | if (repo_button := self.component_factory.create_repo_link_button()):
231 | view.add_item(repo_button)
232 |
233 | await interaction.response.send_message(embed=embed, view=view, ephemeral=True)
234 |
235 |
236 | @command(name="delete_my_data")
237 | async def delete_my_data_command(self, ctx: Context):
238 | """Initiates a request to delete all of your user data from the bot's logs."""
239 |
240 | user = ctx.author
241 | if (user.id in self.queued_user_ids):
242 | await self.database_manager.store(ctx, valid=False)
243 | await user.send(f"Hey <@{user.id}>, it looks like you've already requested that your data be deleted. That'll automagically happen next {self._delete_request_scheduled_weekday_name}, so sit tight and it'll happen before you know it!")
244 | return
245 |
246 | await self.store_user_id_for_batch_delete(user.id)
247 | await self.database_manager.store(ctx)
248 |
249 | ## Keep things tidy
250 | try:
251 | await ctx.message.delete()
252 | except:
253 | try:
254 | await ctx.message.add_reaction("👍")
255 | except:
256 | pass
257 |
258 | await user.send(f"Hey <@{user.id}>, your delete request has been received, and it'll happen automagically next {self._delete_request_scheduled_weekday_name}.")
259 |
--------------------------------------------------------------------------------
/modules/phrases/phrases.py:
--------------------------------------------------------------------------------
1 | import logging
2 | import random
3 | from pathlib import Path
4 |
5 | from core.cogs.speech_cog import SpeechCog
6 | from common.command_management.invoked_command import InvokedCommand
7 | from common.command_management.invoked_command_handler import InvokedCommandHandler
8 | from common.command_management.command_reconstructor import CommandReconstructor
9 | from common.configuration import Configuration
10 | from common.database.database_manager import DatabaseManager
11 | from common.logging import Logging
12 | from common.string_similarity import StringSimilarity
13 | from common.module.discoverable_module import DiscoverableCog
14 | from common.module.module_initialization_container import ModuleInitializationContainer
15 | from modules.phrases.phrase_file_manager import PhraseFileManager
16 | from modules.phrases.models.phrase_group import PhraseGroup
17 | from modules.phrases.models.phrase import Phrase
18 |
19 | import discord
20 | from discord import Interaction
21 | from discord.app_commands import autocomplete, Choice, describe
22 | from discord.ext.commands import Context, Bot
23 |
24 | ## Config & logging
25 | CONFIG_OPTIONS = Configuration.load_config(Path(__file__).parent)
26 | LOGGER = Logging.initialize_logging(logging.getLogger(__name__))
27 |
28 |
29 | class Phrases(DiscoverableCog):
30 | PHRASES_NAME = "phrases"
31 | PHRASE_COMMAND_NAME = "phrase"
32 | RANDOM_COMMAND_NAME = "random"
33 | FIND_COMMAND_NAME = "find"
34 |
35 | def __init__(self, bot: Bot, *args, **kwargs):
36 | super().__init__(bot, *args, **kwargs)
37 |
38 | self.bot = bot
39 |
40 | self.speech_cog: SpeechCog = kwargs.get('dependencies', {}).get('SpeechCog')
41 | assert (self.speech_cog is not None)
42 | self.admin_cog = kwargs.get('dependencies', {}).get('AdminCog')
43 | assert (self.admin_cog is not None)
44 | self.invoked_command_handler: InvokedCommandHandler = kwargs.get('dependencies', {}).get('InvokedCommandHandler')
45 | assert(self.invoked_command_handler is not None)
46 | self.database_manager: DatabaseManager = kwargs.get('dependencies', {}).get('DatabaseManager')
47 | assert (self.database_manager is not None)
48 | self.command_reconstructor: CommandReconstructor = kwargs.get('dependencies', {}).get('CommandReconstructor')
49 | assert (self.command_reconstructor is not None)
50 |
51 | self.phrase_file_manager = PhraseFileManager()
52 |
53 | self.phrases: dict[str, Phrase] = {}
54 | self.phrase_groups: dict[str, PhraseGroup] = {}
55 | self.find_command_minimum_similarity = float(CONFIG_OPTIONS.get('find_command_minimum_similarity', 0.5))
56 | self.phrases_folder_path = self.phrase_file_manager.phrases_folder_path
57 |
58 | ## Load and add the phrases
59 | self.init_phrases()
60 | self.add_phrase_commands()
61 |
62 | self.successful = True
63 |
64 | ## This decorator needs to reference the injected dependency, thus we're declaring the command here.
65 | @self.admin_cog.admin.command(no_pm=True)
66 | async def reload_phrases(ctx: Context):
67 | """Reloads the bot's list of phrases"""
68 |
69 | await self.database_manager.store(ctx)
70 |
71 | count = self.reload_phrases()
72 |
73 | loaded_clips_string = "Loaded {} phrase{}.".format(count, "s" if count != 1 else "")
74 | await ctx.reply(loaded_clips_string)
75 |
76 | return (count >= 0)
77 |
78 | ## Methods
79 |
80 | def cog_unload(self):
81 | """Removes all existing phrases when the cog is unloaded"""
82 |
83 | self.remove_phrases()
84 | self.remove_phrase_commands()
85 |
86 |
87 | def reload_phrases(self):
88 | """Unloads all phrase commands from the bot, then reloads all of the phrases, and reapplies them to the bot"""
89 |
90 | self.remove_phrases()
91 | self.remove_phrase_commands()
92 |
93 | loaded_phrases = self.init_phrases()
94 | self.add_phrase_commands()
95 |
96 | return loaded_phrases
97 |
98 |
99 | def remove_phrases(self):
100 | """Unloads the preset phrases from the bot's command list."""
101 |
102 | self.phrases = {}
103 | self.phrase_groups = {}
104 |
105 |
106 | def add_phrase_commands(self):
107 | """Adds the phrase commands to the bot"""
108 |
109 | ## Don't register phrase commands if no phrases have been loaded!
110 | if (self.phrases):
111 | ## Add the random command
112 | self.add_command(discord.app_commands.Command(
113 | name=Phrases.RANDOM_COMMAND_NAME,
114 | description=self.random_command.__doc__,
115 | callback=self.random_command
116 | ))
117 |
118 | # Add the find command
119 | self.add_command(discord.app_commands.Command(
120 | name=Phrases.FIND_COMMAND_NAME,
121 | description=self.find_command.__doc__,
122 | callback=self.find_command
123 | ))
124 |
125 | ## Add the phrase command
126 | ## Wrap the phrase command to have access to self in the autocomplete decorator. Unfortunately the parameter
127 | ## description decorators must also be moved up here.
128 | ## todo: Investigate a workaround that's less ugly?
129 | @autocomplete(name=self._phrase_name_command_autocomplete)
130 | @describe(name="The name of the phrase to speak")
131 | @describe(user="The user to speak the phrase to")
132 | async def phrase_command_wrapper(interaction: Interaction, name: str, user: discord.Member = None):
133 | await self.phrase_command(interaction, name, user)
134 |
135 | self.add_command(discord.app_commands.Command(
136 | name=Phrases.PHRASE_COMMAND_NAME,
137 | description=self.phrase_command.__doc__,
138 | callback=phrase_command_wrapper,
139 | extras={"cog": self}
140 | ))
141 |
142 |
143 | def remove_phrase_commands(self):
144 | self.bot.tree.remove_command(Phrases.RANDOM_COMMAND_NAME)
145 | self.bot.tree.remove_command(Phrases.PHRASE_COMMAND_NAME)
146 | self.bot.tree.remove_command(Phrases.FIND_COMMAND_NAME)
147 |
148 |
149 | def init_phrases(self) -> int:
150 | """Initialize the phrases available to the bot"""
151 |
152 | phrase_group_file_paths = self.phrase_file_manager.discover_phrase_groups(self.phrases_folder_path)
153 | counter = 0
154 | for phrase_file_path in phrase_group_file_paths:
155 | starting_count = counter
156 | phrase_group = self.phrase_file_manager.load_phrase_group(phrase_file_path)
157 |
158 | phrase: Phrase
159 | for phrase in phrase_group.phrases.values():
160 | try:
161 | self.phrases[phrase.name] = phrase
162 | except Exception as e:
163 | LOGGER.warning("Skipping...", exc_info=e)
164 | else:
165 | counter += 1
166 |
167 | ## Ensure we don't add in empty phrase files into the groupings
168 | ## todo: this isn't necessary any more, is it?
169 | if(counter > starting_count):
170 | self.phrase_groups[phrase_group.key] = phrase_group
171 |
172 | LOGGER.info(f'Loaded {counter} phrase{"s" if counter != 1 else ""}.')
173 | return counter
174 |
175 |
176 | def build_phrase_command_string(self, phrase: Phrase, activation_str: str = None) -> str:
177 | """Builds an example string to invoke the specified phrase"""
178 |
179 | return f"{activation_str or '/'}{Phrases.PHRASE_COMMAND_NAME} {phrase.name}"
180 |
181 | ## Commands
182 |
183 | @describe(user="The user to speak the phrase to")
184 | async def random_command(self, interaction: Interaction, user: discord.Member = None):
185 | """Speaks a random phrase"""
186 |
187 | phrase: Phrase = random.choice(list(self.phrases.values()))
188 |
189 |
190 | async def callback(invoked_command: InvokedCommand):
191 | if (invoked_command.successful):
192 | await self.database_manager.store(interaction)
193 | await interaction.response.send_message(
194 | f"<@{interaction.user.id}> randomly chose **{self.build_phrase_command_string(phrase)}**"
195 | )
196 | else:
197 | await self.database_manager.store(interaction, valid=False)
198 | await interaction.response.send_message(invoked_command.human_readable_error_message, ephemeral=True)
199 |
200 |
201 | action = lambda: self.speech_cog.say(
202 | phrase.message,
203 | author=interaction.user,
204 | target_member=user,
205 | ignore_char_limit=True,
206 | interaction=interaction
207 | )
208 | await self.invoked_command_handler.invoke_command(interaction, action, ephemeral=False, callback=callback)
209 |
210 |
211 | async def _phrase_name_command_autocomplete(self, interaction: Interaction, current: str) -> list[Choice]:
212 | def generate_choice(phrase: Phrase) -> Choice:
213 | return Choice(name=f"{phrase.name} - {phrase.help or phrase.brief}", value=phrase.name)
214 |
215 |
216 | if (current.strip() == ""):
217 | phrases = random.choices(list(self.phrases.values()), k=5)
218 | return [generate_choice(phrase) for phrase in phrases]
219 | else:
220 | return [generate_choice(phrase) for phrase in self.phrases.values() if phrase.name.startswith(current)]
221 |
222 |
223 | async def phrase_command(self, interaction: Interaction, name: str, user: discord.Member = None):
224 | """Speaks the specific phrase"""
225 |
226 | ## Get the actual phrase from the phrase name provided by autocomplete
227 | phrase: Phrase = self.phrases.get(name)
228 | if (phrase is None):
229 | await self.database_manager.store(interaction, valid=False)
230 | await interaction.response.send_message(
231 | f"Sorry <@{interaction.user.id}>, **{name}** isn't a valid phrase.",
232 | ephemeral=True
233 | )
234 | return
235 |
236 |
237 | async def callback(invoked_command: InvokedCommand):
238 | if (invoked_command.successful):
239 | await self.database_manager.store(interaction)
240 | phrase_command_string = self.build_phrase_command_string(phrase)
241 | await interaction.response.send_message(f"<@{interaction.user.id}> used **{phrase_command_string}**")
242 | else:
243 | await self.database_manager.store(interaction, valid=False)
244 | await interaction.response.send_message(invoked_command.human_readable_error_message, ephemeral=True)
245 |
246 |
247 | action = lambda: self.speech_cog.say(
248 | phrase.message,
249 | author=interaction.user,
250 | target_member=user,
251 | ignore_char_limit=True,
252 | interaction=interaction
253 | )
254 | await self.invoked_command_handler.invoke_command(interaction, action, ephemeral=False, callback=callback)
255 |
256 |
257 | @describe(search="The text to search the phrases for")
258 | @describe(user="The user to speak the phrase to, if a match is found")
259 | async def find_command(self, interaction: Interaction, search: str, user: discord.Member = None):
260 | """Speaks the most similar phrase"""
261 |
262 | def calc_substring_score(message: str, description: str) -> float:
263 | """Scores a given string (message) based on how many of it's words exist in another string (description)"""
264 |
265 | ## Todo: shrink instances of repeated letters down to a single letter in both message and description
266 | ## (ex. yeeeee => ye or reeeeeboot => rebot)
267 |
268 | message_split = message.split(' ')
269 | word_frequency = sum(word in description.split(' ') for word in message_split)
270 |
271 | return word_frequency / len(message_split)
272 |
273 |
274 | ## Strip all non alphanumeric and non whitespace characters out of the message
275 | search = "".join(char for char in search.lower() if (char.isalnum() or char.isspace()))
276 |
277 | most_similar_phrase = (None, 0)
278 | phrase: Phrase
279 | for phrase in self.phrases.values():
280 | scores = []
281 |
282 | ## Score the phrase
283 | scores.append(
284 | calc_substring_score(search, phrase.name) +
285 | StringSimilarity.similarity(search, phrase.name) / 2
286 | )
287 | if (phrase.description is not None):
288 | scores.append(
289 | calc_substring_score(search, phrase.description) +
290 | StringSimilarity.similarity(search, phrase.description) / 2
291 | )
292 |
293 | distance = sum(scores) / len(scores)
294 | if (distance > most_similar_phrase[1]):
295 | most_similar_phrase = (phrase, distance)
296 |
297 | if (most_similar_phrase[1] < self.find_command_minimum_similarity):
298 | await self.database_manager.store(interaction, valid=False)
299 | await interaction.response.send_message(
300 | f"Sorry <@{interaction.user.id}>, I couldn't find anything close to that.", ephemeral=True
301 | )
302 | return
303 |
304 | ## With the phrase found, prepare to speak it!
305 |
306 | async def callback(invoked_command: InvokedCommand):
307 | if (invoked_command.successful):
308 | await self.database_manager.store(interaction)
309 | command_string = self.command_reconstructor.reconstruct_command_string(interaction)
310 | phrase_string = self.build_phrase_command_string(most_similar_phrase[0])
311 | await interaction.response.send_message(
312 | f"<@{interaction.user.id}> searched with **{command_string}**, and found **{phrase_string}**"
313 | )
314 | else:
315 | await self.database_manager.store(interaction, valid=False)
316 | await interaction.response.send_message(invoked_command.human_readable_error_message, ephemeral=True)
317 |
318 |
319 | action = lambda: self.speech_cog.say(
320 | most_similar_phrase[0].message,
321 | author=interaction.user,
322 | target_member=user,
323 | ignore_char_limit=True,
324 | interaction=interaction
325 | )
326 | await self.invoked_command_handler.invoke_command(interaction, action, ephemeral=False, callback=callback)
327 |
328 |
329 | def main() -> ModuleInitializationContainer:
330 | return ModuleInitializationContainer(Phrases, dependencies=["AdminCog", "SpeechCog", "InvokedCommandHandler", "DatabaseManager", "CommandReconstructor"])
331 |
--------------------------------------------------------------------------------