Boilerplate code and refactoring
This commit is contained in:
parent
4b1962b49a
commit
4a0303f71b
@ -5,72 +5,11 @@ import sys
|
|||||||
import yaml
|
import yaml
|
||||||
import logging
|
import logging
|
||||||
import argparse
|
import argparse
|
||||||
import bluebubbles_bot
|
import persona
|
||||||
import inspect
|
from typing import List
|
||||||
import importlib
|
from dataclasses import dataclass
|
||||||
# Import your custom libraries here
|
import datetime
|
||||||
|
import socketio
|
||||||
_plugin_map = {}
|
|
||||||
_delegate_map = {}
|
|
||||||
_initialized_plugins = []
|
|
||||||
|
|
||||||
# Plugin loading code
|
|
||||||
plugin_class = 'bluebubbles_bot'.strip('-').capitalize() + 'Plugin'
|
|
||||||
def add_plugin(plugin,reload) :
|
|
||||||
"""Adds a given plugin and instance, reinitializing one if it already exists and such is specified."""
|
|
||||||
plugin_name = plugin.__module__.split('.')[-1]
|
|
||||||
if not reload and plugin_name in _plugin_map.keys():
|
|
||||||
pass
|
|
||||||
else :
|
|
||||||
# We can't startup the plugin here because it hasn't been configured. We'll handle that at runtime.
|
|
||||||
try:
|
|
||||||
# Remove any intialized objects of the same name, forcing a reinitialization
|
|
||||||
_initialized_plugins.remove(_plugin_map[plugin_name])
|
|
||||||
except:
|
|
||||||
pass
|
|
||||||
_plugin_map.update({plugin_name:plugin})
|
|
||||||
|
|
||||||
def use_plugins(plugins,reload=False) :
|
|
||||||
"""Defines plugins that should be used in a lookup, optionally forcing them to reload."""
|
|
||||||
# Verify data
|
|
||||||
if type(plugins) != list :
|
|
||||||
raise ValueError('argument \'plugins\' should be of type list')
|
|
||||||
for plugin in plugins :
|
|
||||||
# Check if the plugin is a string or a descendent of a bluebubbles_bot-plugin class
|
|
||||||
if type(plugin) != str and plugin_class not in [x.__name__ for x in inspect.getmro(plugin)] :
|
|
||||||
raise ValueError('unkown type for plugin')
|
|
||||||
# Find plugins by name using a default path
|
|
||||||
if type(plugin) == str :
|
|
||||||
available_plugins = [y for x,y in search_plugins().items() if x == plugin and plugin_class in [z.__name__ for z in inspect.getmro(y)]]
|
|
||||||
if len(available_plugins) == 0 :
|
|
||||||
raise FileNotFoundError(plugin + '.py not found')
|
|
||||||
plugin = available_plugins[0]
|
|
||||||
if plugin_class in [x.__name__ for x in inspect.getmro(plugin)] :
|
|
||||||
add_plugin(plugin,reload)
|
|
||||||
continue
|
|
||||||
|
|
||||||
def get_plugins() :
|
|
||||||
"""Returns a map of plugins configured and loaded."""
|
|
||||||
return _plugin_map
|
|
||||||
|
|
||||||
def search_plugins(directory=None) :
|
|
||||||
"""Searches a given directory for compatible plugins and returns a map of available plugin names and classes."""
|
|
||||||
if not directory :
|
|
||||||
directory = '/'.join(os.path.realpath(__file__).split('/')[:-1]) + '/' + 'plugins'
|
|
||||||
directory = os.path.normpath(os.path.expanduser(os.path.expandvars(directory)))
|
|
||||||
name_map = {}
|
|
||||||
candidates = {x.split('.')[0]:x for x in os.listdir(directory) if x.endswith('.py')}
|
|
||||||
for name,filename in candidates.items() :
|
|
||||||
try :
|
|
||||||
spec = importlib.util.spec_from_file_location(name, directory + '/' + filename)
|
|
||||||
mod = importlib.util.module_from_spec(spec)
|
|
||||||
spec.loader.exec_module(mod)
|
|
||||||
instance = getattr(mod,plugin_class)
|
|
||||||
name_map.update({filename.split('.')[0]:instance})
|
|
||||||
except Exception as e :
|
|
||||||
# Handle plugin loading issues if desired
|
|
||||||
print("Unable to load plugin from " + filename + ": " + str(e))
|
|
||||||
return name_map
|
|
||||||
|
|
||||||
class LoggingFormatter(logging.Formatter):
|
class LoggingFormatter(logging.Formatter):
|
||||||
def format(self, record):
|
def format(self, record):
|
||||||
@ -117,8 +56,15 @@ if __name__ == '__main__':
|
|||||||
logging.getLogger().handlers[0].setFormatter(LoggingFormatter())
|
logging.getLogger().handlers[0].setFormatter(LoggingFormatter())
|
||||||
logging.propagate=True
|
logging.propagate=True
|
||||||
|
|
||||||
# Load plugins
|
# Create persona instance
|
||||||
available_plugins = search_plugins(directory='/'.join(os.path.realpath(__file__).split('/')[:-2]) + '/bluebubbles_bot/' + 'plugins')
|
test = persona.Persona()
|
||||||
use_plugins([x for x in available_plugins.values()])
|
|
||||||
# Main functions
|
# Test Message
|
||||||
print('Hello World!')
|
message = persona.Message(text="Hello",sender_identifier="Daniel",chat_identifier=None,attachments=[],timestamp=datetime.datetime.now(), recipients=[], identifier=None)
|
||||||
|
responses = test.receive_message(message)
|
||||||
|
for response in responses :
|
||||||
|
print('Bot responded with:')
|
||||||
|
print(response.text)
|
||||||
|
|
||||||
|
# Create socket connection
|
||||||
|
sio = socketio.AsyncClient()
|
||||||
|
@ -1 +0,0 @@
|
|||||||
from bluebubbles_bot.bluebubbles_bot import *
|
|
@ -1 +0,0 @@
|
|||||||
# Write your modular code (classes, functions, etc) here. They'll be automatically imported in bin/bluebubbles_bot
|
|
65
persona/__init__.py
Normal file
65
persona/__init__.py
Normal file
@ -0,0 +1,65 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
from persona.persona import *
|
||||||
|
import abc
|
||||||
|
import os
|
||||||
|
import inspect
|
||||||
|
import importlib
|
||||||
|
from typing import List
|
||||||
|
from dataclasses import dataclass
|
||||||
|
import datetime
|
||||||
|
class PersonaException(Exception) :
|
||||||
|
"""The base Persona exception class."""
|
||||||
|
|
||||||
|
class PersonaStartupException(PersonaException) :
|
||||||
|
"""The Persona exception class to raise during startup failures."""
|
||||||
|
|
||||||
|
class PersonaIntentException(PersonaException) :
|
||||||
|
"""The Persona exception class to raise it cannot be determined if the skill should be matched."""
|
||||||
|
|
||||||
|
class PersonaResponseException(PersonaException) :
|
||||||
|
"""The Persona exception class to raise when a response cannot be generated."""
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Attachment :
|
||||||
|
"""Basic information about attachments in the messages the persona will receive."""
|
||||||
|
mime_type: str
|
||||||
|
data: any
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Message :
|
||||||
|
"""Basic information about the messages the persona will receive. Types are simple
|
||||||
|
for simplicity and flexibility."""
|
||||||
|
sender_identifier: str
|
||||||
|
chat_identifier: str
|
||||||
|
text: str
|
||||||
|
attachments: List[Attachment]
|
||||||
|
timestamp: datetime.datetime
|
||||||
|
recipients: List[String]
|
||||||
|
identifier: str
|
||||||
|
|
||||||
|
|
||||||
|
class PersonaBaseSkill() :
|
||||||
|
"""The base class for a Persona skill."""
|
||||||
|
|
||||||
|
def __init__(self) :
|
||||||
|
self.context = {}
|
||||||
|
|
||||||
|
def startup(self) :
|
||||||
|
"""Perform any initialization actions, such as searching the environment for config or logging into services.
|
||||||
|
This action should raise PersonaStartupException on failure."""
|
||||||
|
pass
|
||||||
|
|
||||||
|
def shutdown(self) :
|
||||||
|
"""Perform any shutdown and cleanup actions, such as searching logging out of online services or removing
|
||||||
|
temporary secrets. Exceptions raised here are ignored."""
|
||||||
|
pass
|
||||||
|
|
||||||
|
def match_intent(self,message: Message) -> Bool :
|
||||||
|
"""Receive text and determine if the skill should be used to respond to the message."""
|
||||||
|
raise PersonaIntentException('This skill does not know if it should respond.')
|
||||||
|
|
||||||
|
def respond(self, message: Message) -> Message :
|
||||||
|
"""Respond to a message by generating another message.
|
||||||
|
This is called after the intent has been matched and the skill should produce a response.
|
||||||
|
The Persona may modify the message, including its recipients, timestamp, or content."""
|
||||||
|
raise PersonaResponseException('This skill is not implemented to respond.')
|
101
persona/persona.py
Normal file
101
persona/persona.py
Normal file
@ -0,0 +1,101 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
import os
|
||||||
|
import inspect
|
||||||
|
import importlib
|
||||||
|
from typing import List
|
||||||
|
from dataclasses import dataclass
|
||||||
|
import datetime
|
||||||
|
|
||||||
|
class Persona :
|
||||||
|
"""A class that represents state data for the chatbot. This is the personality for the bot.
|
||||||
|
Personas load skills that allow them to execute different commands. The personas can receive
|
||||||
|
state data from the skills to affect their current context."""
|
||||||
|
|
||||||
|
_skill_map = {}
|
||||||
|
_delegate_map = {}
|
||||||
|
_initialized_skills = []
|
||||||
|
skill_class = 'Persona'.strip('-').capitalize() + 'Skill'
|
||||||
|
skill_class = 'PersonaSkill'
|
||||||
|
ready_skills = {}
|
||||||
|
def __init__(self) :
|
||||||
|
# Load skills
|
||||||
|
available_skills = self.search_skills(directory='/'.join(os.path.realpath(__file__).split('/')[:-2]) + '/persona' + '/skills')
|
||||||
|
self.use_skills([x for x in available_skills.values()])
|
||||||
|
self.startup()
|
||||||
|
|
||||||
|
# loading code
|
||||||
|
def add_skill(self,skill,reload) :
|
||||||
|
"""Adds a given skill and instance, reinitializing one if it already exists and such is specified."""
|
||||||
|
skill_name = skill.__module__.split('.')[-1]
|
||||||
|
if not reload and skill_name in self._skill_map.keys():
|
||||||
|
pass
|
||||||
|
else :
|
||||||
|
# We can't startup the skill here because it hasn't been configured. We'll handle that at runtime.
|
||||||
|
try:
|
||||||
|
# Remove any intialized objects of the same name, forcing a reinitialization
|
||||||
|
_initialized_skills.remove(self._skill_map[skill_name])
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
self._skill_map.update({skill_name:skill})
|
||||||
|
|
||||||
|
def use_skills(self,skills,reload=False) :
|
||||||
|
"""Defines skills that should be used in a lookup, optionally forcing them to reload."""
|
||||||
|
# Verify data
|
||||||
|
if type(skills) != list :
|
||||||
|
raise ValueError('argument \'skills\' should be of type list')
|
||||||
|
for skill in skills :
|
||||||
|
# Check if the skill is a string or a descendent of a persona-skill class
|
||||||
|
if type(skill) != str and self.skill_class not in [x.__name__ for x in inspect.getmro(skill)] :
|
||||||
|
raise ValueError('unkown type for skill')
|
||||||
|
# Find skills by name using a default path
|
||||||
|
if type(skill) == str :
|
||||||
|
available_skills = [y for x,y in search_skills().items() if x == skill and self.skill_class in [z.__name__ for z in inspect.getmro(y)]]
|
||||||
|
if len(available_skills) == 0 :
|
||||||
|
raise FileNotFoundError(skill + '.py not found')
|
||||||
|
skill = available_skills[0]
|
||||||
|
if self.skill_class in [x.__name__ for x in inspect.getmro(skill)] :
|
||||||
|
skill_name = skill.__module__.split('.')[-1]
|
||||||
|
self.add_skill(skill,reload)
|
||||||
|
continue
|
||||||
|
|
||||||
|
def get_skills(self) :
|
||||||
|
"""Returns a map of skills configured and loaded."""
|
||||||
|
return self._skill_map
|
||||||
|
|
||||||
|
def search_skills(self,directory=None) :
|
||||||
|
"""Searches a given directory for compatible skills and returns a map of available skill names and classes."""
|
||||||
|
if not directory :
|
||||||
|
directory = '/'.join(os.path.realpath(__file__).split('/')[:-1]) + '/' + 'skills'
|
||||||
|
directory = os.path.normpath(os.path.expanduser(os.path.expandvars(directory)))
|
||||||
|
name_map = {}
|
||||||
|
candidates = {x.split('.')[0]:x for x in os.listdir(directory) if x.endswith('.py')}
|
||||||
|
for name,filename in candidates.items() :
|
||||||
|
try :
|
||||||
|
spec = importlib.util.spec_from_file_location(name, directory + '/' + filename)
|
||||||
|
mod = importlib.util.module_from_spec(spec)
|
||||||
|
spec.loader.exec_module(mod)
|
||||||
|
instance = getattr(mod,self.skill_class)
|
||||||
|
name_map.update({filename.split('.')[0]:instance})
|
||||||
|
except Exception as e :
|
||||||
|
# Handle skill loading issues if desired
|
||||||
|
print("Unable to load skill from " + filename + ": " + str(e))
|
||||||
|
return name_map
|
||||||
|
|
||||||
|
def startup(self) :
|
||||||
|
"""Startup all message skill processors"""
|
||||||
|
for skill in self._skill_map.keys() :
|
||||||
|
ClassInstance = self._skill_map[skill]
|
||||||
|
skillinstance = ClassInstance()
|
||||||
|
skillinstance.startup()
|
||||||
|
self.ready_skills.update({skill:skillinstance})
|
||||||
|
|
||||||
|
|
||||||
|
def receive_message(self,message: Message) :
|
||||||
|
"""Process the receipt of a message."""
|
||||||
|
generated_messages = []
|
||||||
|
for skill in self.ready_skills.values() :
|
||||||
|
should_respond = skill.match_intent(message)
|
||||||
|
if should_respond :
|
||||||
|
response = skill.respond(message=message)
|
||||||
|
generated_messages.append(response)
|
||||||
|
return generated_messages
|
23
persona/skills/greeter.py
Normal file
23
persona/skills/greeter.py
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
from typing import List
|
||||||
|
import persona
|
||||||
|
from persona import PersonaBaseSkill
|
||||||
|
from dataclasses import dataclass
|
||||||
|
import datetime
|
||||||
|
import random
|
||||||
|
import re
|
||||||
|
|
||||||
|
class PersonaSkill(PersonaBaseSkill) :
|
||||||
|
"""A simple test skill that responds to the message 'Hello' with 'Hello!'"""
|
||||||
|
def match_intent(self,message: Message) -> Bool :
|
||||||
|
print(message.text)
|
||||||
|
matches = re.search('^Hello', message.text)
|
||||||
|
if matches :
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
def respond(self, message: Message) -> Message :
|
||||||
|
"""Respond to a message by generating another message."""
|
||||||
|
response_options = ['Hello!','Howdy!','Hello there!','What\'s up!','Hi there!']
|
||||||
|
response_text = random.choice(response_options)
|
||||||
|
return persona.Message(text=response_text,sender_identifier=message.sender_identifier,chat_identifier=message.chat_identifier,attachments=[],timestamp=datetime.datetime.now(), recipients=[message.sender_identifier], identifier=None)
|
5
setup.py
5
setup.py
@ -53,10 +53,9 @@ setup(name='bluebubbles_bot',
|
|||||||
author_email='github@cronocide.com',
|
author_email='github@cronocide.com',
|
||||||
description='A chatbot for a local BlueBlubbles server.',
|
description='A chatbot for a local BlueBlubbles server.',
|
||||||
packages=find_packages(exclude=['tests']),
|
packages=find_packages(exclude=['tests']),
|
||||||
package_data={"": ['plugins/*.py']},
|
package_data={"": ['skills/*.py']},
|
||||||
install_requires=['pyyaml',],
|
install_requires=['pyyaml','datetime','python-socketio[asyncio_client]'],
|
||||||
scripts=['bin/bluebubbles_bot'],
|
scripts=['bin/bluebubbles_bot'],
|
||||||
long_description=open('README.md').read(),
|
long_description=open('README.md').read(),
|
||||||
|
|
||||||
zip_safe=True
|
zip_safe=True
|
||||||
)
|
)
|
||||||
|
Loading…
Reference in New Issue
Block a user