#!/usr/bin/env python
# -*- coding: utf-8 -*-
# Licensed to the Apache Software Foundation (ASF) under one or more
# contributor license agreements. See the NOTICE file distributed with
# this work for additional information regarding copyright ownership.
# The ASF licenses this file to You under the Apache License, Version 2.0
# (the "License"); you may not use this file except in compliance with
# the License. You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
import logging
from multiprocessing import Process, Queue
from six import string_types
import sys
import time
import yaml
from .i18n import _
from .speaker import Vibes
[docs]class ShellBot(object):
"""
Manages interactions with one space, one store, one state machine
A bot consists of multiple components devoted to one chat channel:
- a space
- a store
- a state machine
- ... other optional components that may prove useful
It is designated by a unique id, that is also the unique id of the channel
itself.
A bot relies on an underlying engine instance for actual access
to the infrastructure, including configuration settings.
The life cycle of a bot can be described as follows::
1. A bot is commonly created from the engine, or directly::
bot = ShellBot(engine, channel_id='123')
2. The space is connected to some back-end API::
space.connect()
3. Multiple channels can be handled by a single space::
channel = space.create(title)
channel = space.get_by_title(title)
channel = space.get_by_id(id)
channel.title = 'A new title'
space.update(channel)
space.delete(id)
Channels feature common attributes, yet can be extended to
convey specificities of some platforms.
4. Messages can be posted::
space.post_message(id, 'Hello, World!')
5. The interface allows for the addition or removal of channel
participants::
space.add_participants(id, persons)
space.add_participant(id, person, is_moderator)
space.remove_participants(id, persons)
space.remove_participant(id, person)
"""
def __init__(self,
engine,
channel_id=None,
space=None,
store=None,
fan=None,
machine=None):
"""
Manages interactions with one space, one store, one state machine
:param engine: Engine instance for acces of the infrastructure
:type engine: Engine
:param channel_id: Unique id of the related chat space
:type channel_id: str
:param space: Chat space related to this bot
:type space: Space
:param store: Data store related to this bot
:type store: Store
:param fan: For asynchronous handling of user input
:type fan: Queue
:param machine: State machine related to this bot
:type machine: Machine
"""
self.engine = engine
if space:
self.space = space
else:
self.space = engine.space
if channel_id:
self.channel = self.space.get_by_id(channel_id)
else:
self.channel = None
if store:
self.store = store
else:
self.store = self.engine.build_store(channel_id)
self.fan = fan if fan else Queue()
self.machine = machine
self.on_init()
[docs] def on_init(self):
"""
Adds to bot initialization
It can be overlaid in subclass, where needed
"""
pass
@property
def is_ready(self):
"""
Checks if this bot is ready for interactions
:return: True or False
"""
if self.id is None:
return False
return True
@property
def id(self):
"""
Gets unique id of the related chat channel
:return: the id of the underlying channel, or None
"""
if self.channel:
return self.channel.id
return None
@property
def title(self):
"""
Gets title of the related chat channel
:return: the title of the underlying channel, or None
"""
if self.channel:
return self.channel.title
return None
[docs] def reset(self):
"""
Resets a space
After a call to this function, ``bond()`` has to be invoked to
return to normal mode of operation.
"""
if self.channel:
self.channel = None
self.on_reset()
[docs] def on_reset(self):
"""
Adds processing to space reset
This function should be expanded in sub-class, where necessary.
Example::
def on_reset(self):
self._last_message_id = 0
"""
pass
[docs] def bond(self):
"""
Bonds to a channel
This function is called either after the creation of a new channel,
or when the bot has been invited to an existing channel. In such
situations the banner should be displayed as well.
There are also situations where the engine has been completely
restarted. The bot bonds to a channel where it has been before.
In that case the banner should be avoided.
"""
assert self.is_ready
self.store.bond(id=self.id)
self.subscriber = self.engine.bus.subscribe('topic')
self.publisher = self.engine.publisher
self.publisher.put('topic', 'bot.bond()')
self.on_bond()
self.engine.dispatch('bond', bot=self)
if self.machine:
self.machine.restart(defer=2.0)
[docs] def on_bond(self):
"""
Adds processing to channel bonding
This function should be changed in sub-class, where necessary.
Example::
def on_bond(self):
do_something_important_on_bond()
"""
pass
[docs] def on_enter(self):
"""
Enters a channel
"""
self.say_banner()
if self.machine:
self.machine.restart(defer=2.0) # no effect if machine is running
[docs] def dispose(self, **kwargs):
"""
Disposes all resources
This function deletes the underlying channel in the cloud and resets
this instance. It is useful to restart a clean environment.
>>>bot.bond(title="Working Space")
...
>>>bot.dispose()
After a call to this function, ``bond()`` has to be invoked to
return to normal mode of operation.
"""
if self.id:
self.on_exit()
self.engine.dispatch('dispose', bot=self)
time.sleep(2)
self.space.delete(id=self.id, **kwargs)
self.reset()
[docs] def on_exit(self):
"""
Exits a channel
"""
text = self.engine.get('bot.on_exit', _(u"Bot is leaving this channel"))
self.say(text)
[docs] def add_participants(self, persons=[]):
"""
Adds multiple participants
:param persons: e-mail addresses of persons to add
:type persons: list of str
"""
if self.id:
self.space.add_participants(id=self.id, persons=persons)
[docs] def add_participant(self, person, is_moderator=False):
"""
Adds one participant
:param person: e-mail addresses of person to add
:type person: str
The underlying platform may, or not, take the optional
parameter ``is_moderator`` into account. The default bahaviour is to
discard it, as if the parameter had the value ``False``.
"""
assert person
assert is_moderator in (True, False)
if self.id:
self.space.add_participant(id=self.id,
person=person,
is_moderator=is_moderator)
[docs] def remove_participants(self, persons=[]):
"""
Removes multiple participants
:param persons: e-mail addresses of persons to remove
:type persons: list of str
"""
if self.id:
self.space.remove_participants(id=self.id, persons=persons)
[docs] def remove_participant(self, person):
"""
Removes one participant
:param person: e-mail addresses of person to add
:type person: str
"""
assert person
if self.id:
self.space.remove_participant(id=self.id, person=person)
[docs] def say(self, text=None, content=None, file=None, person=None):
"""
Sends a message to the chat space
:param text: Plain text message
:type text: str or None
:param content: Rich content such as Markdown or HTML
:type content: str or None
:param file: path or URL to a file to attach
:type file: str or None
:param person: for direct message to someone
:type person: str
"""
if text:
line = text[:50] + (text[50:] and '..')
elif content:
line = content[:50] + (content[50:] and '..')
else:
return
logging.info(u"Bot says: {}".format(line))
vibes = Vibes(text=text,
content=content,
file=file,
channel_id=None if person else self.id,
person=person)
if not self.is_ready:
logging.debug(u"- not ready to speak")
elif self.engine.mouth:
logging.debug(u"- pushing message to mouth queue")
self.engine.mouth.put(vibes)
else:
logging.debug(u"- calling speaker directly")
self.engine.speaker.process(vibes)
[docs] def say_banner(self):
"""
Sends banner to the channel
This function uses following settings from the context:
- ``bot.banner.text`` or ``bot.on_enter`` - a textual message
- ``bot.banner.content`` - some rich content, e.g., Markdown or HTML
- ``bot.banner.file`` - a document to be uploaded
The quickest setup is to change ``bot.on_enter`` in settings, or the
environment variable ``$BOT_ON_ENTER``.
Example::
os.environ['BOT_ON_ENTER'] = 'You can now chat with Batman'
engine.configure()
Then there are situtations where you want a lot more flexibility, and
rely on a smart banner. For example you could do the following::
settings = {
'bot': {
'banner': {
'text': u"Type '@{} help' for more information",
'content': u"Type ``@{} help`` for more information",
'file': "http://on.line.doc/guide.pdf"
}
}
}
engine.configure(settings)
When bonding to a channel, the bot will send an update similar to the
following one, with a nice looking message and image::
Type '@Shelly help' for more information
Default settings for the banner rely on the environment, so it is
easy to inject strings from the outside. Use following variables:
- ``$BOT_BANNER_TEXT`` or ``$BOT.ON_ENTER`` - the textual message
- ``$BOT_BANNER_CONTENT`` - some rich content, e.g., Markdown or HTML
- ``$BOT_BANNER_FILE`` - a document to be uploaded
"""
text = self.engine.get('bot.banner.text')
if not text:
text = self.engine.get('bot.on_enter')
if text:
text = text.format(self.engine.name)
content = self.engine.get('bot.banner.content')
if content:
content = content.format(self.engine.name)
file = self.engine.get('bot.banner.file')
self.say(text=text, content=content, file=file)
[docs] def remember(self, key, value):
"""
Remembers a value
:param key: name of the value
:type key: str
:param value: new value
:type value: any serializable type is accepted
This functions stores or updates a value in the back-end storage
system.
Example::
bot.remember('variable_123', 'George')
"""
self.store.remember(key, value)
[docs] def recall(self, key, default=None):
"""
Recalls a value
:param key: name of the value
:type key: str
:param default: default value
:type default: any serializable type is accepted
:return: the actual value, or the default value, or None
Example::
value = bot.recall('variable_123')
"""
return self.store.recall(key, default)
[docs] def forget(self, key=None):
"""
Forgets a value or all values
:param key: name of the value to forget, or None
:type key: str
To clear only one value, provides the name of it.
For example::
bot.forget('variable_123')
To clear all values in the store, just call the function
without a value.
For example::
bot.forget()
"""
self.store.forget(key)
[docs] def append(self, key, item):
"""
Appends an item to a list
:param key: name of the list
:type key: str
:param item: a new item to append
:type item: any serializable type is accepted
Example::
>>>bot.append('names', 'Alice')
>>>bot.append('names', 'Bob')
>>>bot.recall('names')
['Alice', 'Bob']
"""
self.store.append(key, item)
[docs] def update(self, key, label, item):
"""
Updates a dict
:param key: name of the dict
:type key: str
:param label: named entry in the dict
:type label: str
:param item: new value of this entry
:type item: any serializable type is accepted
Example::
>>>bot.update('input', 'PO Number', '1234A')
>>>bot.update('input', 'description', 'some description')
>>>bot.recall('input')
{'PO Number': '1234A',
'description': 'some description'}
"""
self.store.update(key, label, item)