Skip to content

Commit

Permalink
Merge pull request #64 from OrBin/dev
Browse files Browse the repository at this point in the history
Merging dev to master: version 2.0.0
  • Loading branch information
meirhalachmi committed Jun 5, 2019
2 parents ee86113 + 1458cb4 commit 5786c1e
Show file tree
Hide file tree
Showing 32 changed files with 782 additions and 113 deletions.
23 changes: 9 additions & 14 deletions .travis.yml
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ language: python
python: 3.7
install:
- pip install -r requirements.txt
- pip install pylint flake8 pytype pytest
- pip install pylint flake8 pytype pytest invoke twine

stages:
- lint
Expand All @@ -13,40 +13,35 @@ stages:
- name: build
if: (branch != master && tag IS blank) || type != push

- name: deploy docker image
- name: deploy
if: (branch = master || tag IS present) && type = push

jobs:
include:
- stage: lint
script:
- pylint ./*.py ./gramhopper
- pylint --rcfile=./tests/tests.pylintrc ./tests
- flake8 --exclude=venv
- pytype --config ./setup.cfg -V $TRAVIS_PYTHON_VERSION ./*.py ./gramhopper
- invoke lint

- stage: test
script:
- pytest
- cp -R tests/assets/.gramhopper ~/.gramhopper
- invoke test

- stage: build docs
script:
- cd ./docs && make html
- invoke build-docs

- stage: build
script:
- python setup.py sdist bdist_wheel
- docker build -t $DOCKER_IMAGE_NAME .
- invoke build

- stage: deploy docker image
- stage: deploy
script:
- |
if [ ! -z "$TRAVIS_TAG" ]; then
export IMAGE_TAG=$TRAVIS_TAG
else
export IMAGE_TAG='latest'
fi
- docker build -t $DOCKER_IMAGE_NAME:$IMAGE_TAG .
- invoke build --docker-tag=$IMAGE_TAG
- echo "$DOCKER_PASSWORD" | docker login -u "$DOCKER_USERNAME" --password-stdin
- docker push $DOCKER_IMAGE_NAME:$IMAGE_TAG
- invoke publish --docker-tag=$IMAGE_TAG --production-pypi
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,4 +1,8 @@
# gramhopper
[![image](https://img.shields.io/pypi/v/gramhopper.svg)](https://pypi.org/project/gramhopper/)
[![image](https://img.shields.io/pypi/l/gramhopper.svg)](https://pypi.org/project/gramhopper/)
[![image](https://img.shields.io/pypi/pyversions/gramhopper.svg)](https://pypi.org/project/gramhopper/)

A bot platform for automatic responses based on various triggers.

## Install
Expand Down
4 changes: 4 additions & 0 deletions docs/source/conf.py
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@
extensions = [
'sphinx.ext.autodoc',
'sphinx.ext.coverage',
'sphinx.ext.intersphinx',
]

# Add any paths that contain templates here, relative to this directory.
Expand Down Expand Up @@ -159,3 +160,6 @@


# -- Extension configuration -------------------------------------------------
intersphinx_mapping = {
'python-telegram-bot': ('https://python-telegram-bot.readthedocs.io/en/stable', None)
}
31 changes: 30 additions & 1 deletion docs/source/triggers/filter-triggers.rst
Original file line number Diff line number Diff line change
@@ -1,4 +1,33 @@
Filter Triggers
===============

Filter triggers will be documented soon, after a small refactor with them.
.. autoclass:: gramhopper.triggers.filter_triggers.FilterTriggers
:members:

filter.user
-------------------
.. autoclass:: gramhopper.triggers.filter_triggers._UserFilterBasedTrigger
:members:

.. automethod:: __init__

filter.chat
------------------
.. autoclass:: gramhopper.triggers.filter_triggers._ChatFilterBasedTrigger
:members:

.. automethod:: __init__

filter.language
-----------
.. autoclass:: gramhopper.triggers.filter_triggers._LanguageFilterBasedTrigger
:members:

.. automethod:: __init__

filter.message_type
-----------
.. autoclass:: gramhopper.triggers.filter_triggers._MessageTypeFilterBasedTrigger
:members:

.. automethod:: __init__
13 changes: 9 additions & 4 deletions gramhopper/bot.py
Original file line number Diff line number Diff line change
@@ -1,22 +1,26 @@
import logging
from telegram.ext import Updater
from .configuration import token_file_path, rules_file_path
from .logging_config import configure_logger
from .paths import token_file_path, default_rules_file_path
from .configuration.rules_parser import RulesParser
from .handlers.combined_handlers import CombinedConversationHandler
from .handlers.default_error_handler import handle_error


def start_bot():
logging.basicConfig(format='%(asctime)s - %(name)s - %(levelname)s - %(message)s',
level=logging.INFO)
configure_logger()

with open(token_file_path(), 'r') as token_file:
bot_token = token_file.read().strip()

rule_parser = RulesParser()
rule_handlers = rule_parser.parse_file(rules_file_path())
rules_file_path = default_rules_file_path()
logging.info('Reading and parsing rules file from %s', rules_file_path)
rule_handlers = rule_parser.parse_file(rules_file_path)
logging.info('Found %d rules', len(rule_handlers))
conversation_handler = CombinedConversationHandler(rule_handlers)

logging.info('Creating bot updater')
updater = Updater(bot_token)
updater.dispatcher.add_handler(conversation_handler)
updater.dispatcher.add_error_handler(handle_error)
Expand All @@ -25,4 +29,5 @@ def start_bot():
# Run the bot until you press Ctrl-C or the process receives SIGINT,
# SIGTERM or SIGABRT. This should be used most of the time, since
# start_polling() is non-blocking and will stop the bot gracefully.
logging.info('Moving the bot to idle mode to keep listening to updates')
updater.idle()
19 changes: 0 additions & 19 deletions gramhopper/configuration/__init__.py
Original file line number Diff line number Diff line change
@@ -1,19 +0,0 @@
from pathlib import Path


CONFIG_DIR = Path(Path.home(), '.gramhopper/')
TOKEN_FILE_NAME = 'token.txt'
RULES_FILE_NAME = 'rules.yml'
USERS_FILE_NAME = 'users.json'


def token_file_path():
return Path(CONFIG_DIR, TOKEN_FILE_NAME)


def rules_file_path():
return Path(CONFIG_DIR, RULES_FILE_NAME)


def users_file_path():
return Path(CONFIG_DIR, USERS_FILE_NAME)
7 changes: 6 additions & 1 deletion gramhopper/configuration/rules_parser.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import logging
from os import PathLike
from typing import Union
from .partial_ruamel_yaml import YAML
Expand All @@ -24,12 +25,16 @@ def parse_single_rule(self, rule: CommentedMap):
trigger = RulesParsingHelper.parse_rule_trigger_or_response(rule, self.trigger_params)
response = RulesParsingHelper.parse_rule_trigger_or_response(rule, self.response_params)
probability = rule['probability'] if 'probability' in rule else 1
return Handler(trigger, response, probability=probability)
handler = Handler(trigger, response, probability=probability)
logging.info('Parsed %s', handler.handler_repr)
return handler

def parse_file(self, file_path: Union[PathLike, str, bytes]):
with open(file_path, 'r', encoding='utf-8') as stream:
config = self.yaml.load(stream)

logging.info('Parsing globals from %s', file_path)
self.parse_globals(config)

logging.info('Parsing rules from %s', file_path)
return [self.parse_single_rule(rule) for rule in config['rules']]
8 changes: 6 additions & 2 deletions gramhopper/configuration/triggers_reponses_parsers.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,16 +16,20 @@ def mapping_class():
@classmethod
def parse_single(cls, config, global_elements): # pylint: disable=unused-argument
config_copy = dict(config)
name = None
if 'name' in config_copy:
config_copy.pop('name')
name = config_copy.pop('name')

mapping_class = cls.mapping_class()
element = mapping_class[config_copy.pop('type')]

# Some triggers (most of them) are classes and some are instances (mostly filter triggers).
# This allows both cases to be used.
if isclass(element):
return element(**config_copy)
trigger_or_response = element(**config_copy)
if name:
trigger_or_response.name = name
return trigger_or_response
return element

@classmethod
Expand Down
10 changes: 10 additions & 0 deletions gramhopper/handlers/handler.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import logging
import random
from telegram import Update, Bot
from ..responses.basic_responses import BaseResponse
Expand All @@ -12,14 +13,23 @@ def __init__(self,
self.trigger_checker = trigger_checker
self.responder = responder

self.handler_repr = f'{self.trigger_checker} -> {self.responder}'
self.handler_repr = f'{self.handler_repr:70}'

if probability > 1 or probability <= 0:
raise ValueError(f'Parameter \'probability\' should be in range (0, 1], '
f'but {probability} was given')

self.probability_to_respond = probability

def handle(self, bot: Bot, update: Update):
logging.debug('[%s] Received update %s', self.handler_repr, update.update_id)
trigger_result = self.trigger_checker.check_trigger(update)
if trigger_result.should_respond:
logging.info('[%s] Bot should respond to update %s with a probability of %f',
self.handler_repr,
update.update_id,
self.probability_to_respond)
if random.random() <= self.probability_to_respond:
logging.info('[%s] Responding to update %s', self.handler_repr, update.update_id)
self.responder.respond(bot, update, trigger_result.response_payload)
23 changes: 23 additions & 0 deletions gramhopper/logging_config.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import logging
from .paths import log_file_path


def configure_logger():
root_logger = logging.getLogger()
root_logger.setLevel(logging.INFO)

file_handler = logging.FileHandler(log_file_path())

# Create console handler with a higher log level
console_handler = logging.StreamHandler()
console_handler.setLevel(logging.WARNING)

# Create formatter and add it to the handlers
formatter = logging.Formatter('%(asctime)s %(module)s.%(funcName)s %(levelname)s - %(message)s',
datefmt='%Y-%m-%d %H:%M:%S')
file_handler.setFormatter(formatter)
console_handler.setFormatter(formatter)

# Add the handlers to the logger
root_logger.addHandler(file_handler)
root_logger.addHandler(console_handler)
24 changes: 24 additions & 0 deletions gramhopper/paths.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
from pathlib import Path


CONFIG_DIR = Path(Path.home(), '.gramhopper/')
TOKEN_FILE_NAME = 'token.txt'
DEFAULT_RULES_FILE_NAME = 'rules.yml'
USERS_FILE_NAME = 'users.json'
LOG_FILE_NAME = 'gramhopper.log'


def token_file_path():
return Path(CONFIG_DIR, TOKEN_FILE_NAME)


def default_rules_file_path():
return Path(CONFIG_DIR, DEFAULT_RULES_FILE_NAME)


def users_file_path():
return Path(CONFIG_DIR, USERS_FILE_NAME)


def log_file_path(file_name=LOG_FILE_NAME):
return Path(CONFIG_DIR, file_name)
18 changes: 18 additions & 0 deletions gramhopper/representable.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
class Representable:

def __init__(self):
self.__name = None

@property
def name(self):
return self.__name

@name.setter
def name(self, value):
self.__name = value

def __str__(self):
if self.__name:
return self.__name

return f'inline {self.__class__.__name__}'
7 changes: 5 additions & 2 deletions gramhopper/responses/basic_responses.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,11 @@
import abc
from telegram import Bot, Update
from telegram.message import Message
from ..representable import Representable


class BaseResponse(abc.ABC):
class BaseResponse(abc.ABC, Representable):

@abc.abstractmethod
def respond(self, bot: Bot, update: Update, response_payload: dict) -> None:
def respond(self, bot: Bot, update: Update, response_payload: dict) -> Message:
pass
12 changes: 7 additions & 5 deletions gramhopper/responses/match_responses.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import abc
from telegram import Bot, Update
from telegram.message import Message
from ..dict_enum import DictEnum
from .basic_responses import BaseResponse
from .response_helper import ResponseHelper
Expand All @@ -17,10 +18,11 @@ def __init__(self, template: str):
:param template: The template to use when building the response text
"""
super().__init__()
self.template = template

@abc.abstractmethod
def respond(self, bot: Bot, update: Update, response_payload: dict) -> None:
def respond(self, bot: Bot, update: Update, response_payload: dict) -> Message:
pass

def build_response_text(self, response_payload: dict):
Expand All @@ -30,15 +32,15 @@ def build_response_text(self, response_payload: dict):
class _MatchMessageResponse(_MatchTextResponse):
"""A regexp-based response in which the response method is a normal message"""

def respond(self, bot: Bot, update: Update, response_payload: dict) -> None:
ResponseHelper.message(bot, update, self.build_response_text(response_payload))
def respond(self, bot: Bot, update: Update, response_payload: dict) -> Message:
return ResponseHelper.message(bot, update, self.build_response_text(response_payload))


class _MatchReplyResponse(_MatchTextResponse):
"""A regexp-based response in which the response method is a reply to the triggering message"""

def respond(self, bot: Bot, update: Update, response_payload: dict) -> None:
ResponseHelper.reply(bot, update, self.build_response_text(response_payload))
def respond(self, bot: Bot, update: Update, response_payload: dict) -> Message:
return ResponseHelper.reply(bot, update, self.build_response_text(response_payload))


class MatchResponses(DictEnum):
Expand Down

0 comments on commit 5786c1e

Please sign in to comment.