bots: Move all bots and the bots API to separate package.

This commit is contained in:
Eeshan Garg 2017-07-18 01:47:16 -02:30
parent 928d5ca16d
commit 879f44ab3a
130 changed files with 183 additions and 144 deletions

5
zulip_bots/MANIFEST.in Normal file
View file

@ -0,0 +1,5 @@
include zulip_bots/bots/giphy/fixtures/test_1.json
include zulip_bots/bots/github_detail/fixtures/test_404.json
include zulip_bots/bots/github_detail/fixtures/test_issue.json
include zulip_bots/bots/github_detail/fixtures/test_pull.json

143
zulip_bots/README.md Normal file
View file

@ -0,0 +1,143 @@
# Contrib bots
This is the documentation for an experimental new system for writing
bots that react to messages. It explains how to run the code, and also
talks about the architecture for creating such bots.
This directory contains library code for running them.
## Design goals
The goal is to have a common framework for hosting a bot that reacts
to messages in any of the following settings:
* Run as a long-running process using `call_on_each_event`.
* Run via a simple web service that can be deployed to PAAS providers
and handles outgoing webhook requests from Zulip.
* Embedded into the Zulip server (so that no hosting is required),
which would be done for high quality, reusable bots; we would have a
nice "bot store" sort of UI for browsing and activating them.
* Run locally by our technically inclined users for bots that require
account specific authentication, for example: a gmail bot that lets
one send emails directly through Zulip.
## Installing
To install the bots and bots API, run:
python setup.py install
## Running bots
Here is an example of running the "follow-up" bot:
zulip-run-bot followup --config-file ~/.zuliprc-prod
Once the bot code starts running, you will see a
message explaining how to use the bot, as well as
some log messages. You can use the `--quiet` option
to suppress these messages.
The bot code will run continuously until you end the program with
control-C (or otherwise).
### Zulip configuration
For this document we assume you have some prior experience
with using the Zulip API, but here is a quick review of
what a `.zuliprc` files looks like. You can connect to the
API as your own human user, or you can go into the Zulip settings
page to create a user-owned bot.
[api]
email=someuser@example.com
key=<your api key>
site=https://zulip.somewhere.com
When you run your bot, make sure to point it to the correct location
of your `.zuliprc`.
### Third party configuration
If your bot interacts with a non-Zulip service, you may
have to configure keys or usernames or URLs or similar
information to hit the other service.
Do **NOT** put third party configuration information in your
`.zuliprc` file. Do not put third party configuration
information anywhere in your Zulip directory. Instead,
create a separate configuration file for the third party's
configuration in your home directory.
Any bots that require this will have instructions on
exactly how to create or access this information.
### Python dependencies
If your module requires Python modules that are not either
part of the standard Python library or the Zulip API
distribution, we ask that you put a comment at the top
of your bot explaining how to install the dependencies/modules.
Right now we don't support any kind of automatic build
environment for bots, so it's currently up to the users
of the bots to manage their dependencies. This may change
in the future.
## Architecture
In order to make bot development easy, we separate
out boilerplate code (loading up the Client API, etc.)
from bot-specific code (actions of the bot/what the bot does).
All of the boilerplate code lives in `../run.py`. The
runner code does things like find where it can import
the Zulip API, instantiate a client with correct
credentials, set up the logging level, find the
library code for the specific bot, etc.
Then, for bot-specific logic, you will find `.py` files
in the `lib` directory (i.e. the same directory as the
document you are reading now).
Each bot library simply needs to do the following:
- Define a class that supports the methods `usage`
and `handle_message`.
- Set `handler_class` to be the name of that class.
(We make this a two-step process to reduce code repetition
and to add abstraction.)
## Portability
Creating a handler class for each bot allows your bot
code to be more portable. For example, if you want to
use your bot code in some other kind of bot platform, then
if all of your bots conform to the `handler_class` protocol,
you can write simple adapter code to use them elsewhere.
Another future direction to consider is that Zulip will
eventually support running certain types of bots on
the server side, to essentially implement post-send
hooks and things of those nature.
Conforming to the `handler_class` protocol will make
it easier for Zulip admins to integrate custom bots.
In particular, `run.py` already passes in instances
of a restricted variant of the Client class to your
library code, which helps you ensure that your bot
does only things that would be acceptable for running
in a server-side environment.
## Other approaches
If you are not interested in running your bots on the
server, then you can still use the full Zulip API and run
them locally. The hope, though, is that this
architecture will make writing simple bots a quick/easy
process.

78
zulip_bots/setup.py Executable file
View file

@ -0,0 +1,78 @@
#!/usr/bin/env python
# -*- coding: utf-8 -*-
from __future__ import print_function
import sys
# We should be installable with either setuptools or distutils.
package_info = dict(
name='zulip_bots',
version='0.3.1',
description='Zulip\'s Bot framework',
author='Zulip Open Source Project',
author_email='zulip-devel@googlegroups.com',
classifiers=[
'Development Status :: 4 - Beta',
'Environment :: Web Environment',
'Intended Audience :: Developers',
'License :: OSI Approved :: MIT License',
'Topic :: Communications :: Chat',
],
url='https://www.zulip.org/',
entry_points={
'console_scripts': [
'zulip-run-bot=zulip_bots.run:main',
],
},
include_package_data=True,
) # type: Dict[str, Any]
setuptools_info = dict(
install_requires=[
'zulip>=0.3.1',
'mock>=2.0.0',
'html2text', # for bots/define
'PyDictionary', # for bots/thesaurus
],
)
try:
from setuptools import setup, find_packages
package_info.update(setuptools_info)
package_info['packages'] = find_packages()
except ImportError:
from distutils.core import setup
from distutils.version import LooseVersion
from importlib import import_module
# Manual dependency check
def check_dependency_manually(module_name, version=None):
try:
module = import_module(module_name)
if version is not None:
assert(LooseVersion(module.__version__) >= LooseVersion(version))
except (ImportError, AssertionError):
if version is not None:
print("{name}>={version} is not installed.".format(
req=req, version=version), file=sys.stderr)
else:
print("{name} is not installed.".format(name=module_name), file=sys.stderr)
sys.exit(1)
check_dependency_manually('zulip', '0.3.1')
check_dependency_manually('mock', '2.0.0')
check_dependency_manually('html2text')
check_dependency_manually('PyDictionary')
# Include all submodules under bots/
package_list = ['zulip_bots']
dirs = os.listdir('zulip_bots/bots/')
for dir_name in dirs:
if os.path.isdir(os.path.join('zulip_bots/bots/', dir_name)):
package_list.append('zulip_bots.bots.' + dir_name)
package_info['packages'] = package_list
setup(**package_info)

1
zulip_bots/zulip_bots/.gitignore vendored Normal file
View file

@ -0,0 +1 @@
bot_dependencies

View file

View file

View file

@ -0,0 +1,2 @@
[Google.com]
api_key = abcdefghijklmnopqrstuvwxyz

View file

@ -0,0 +1,220 @@
from __future__ import print_function
import datetime as dt
import requests
class CommuteHandler(object):
'''
This plugin provides information regarding commuting
from an origin to a destination, providing a multitude of information.
It looks for messages starting with @mention of the bot.
'''
def initialize(self, bot_handler):
self.api_key = bot_handler.get_config_info('commute', 'Google.com')['api_key']
def usage(self):
return '''
This plugin will allow briefings of estimated travel times,
distances and fare information for transit travel.
It can vary outputs depending on traffic conditions, departure and
arrival times as well as user preferences
(toll avoidance, preference for bus travel, etc.).
It looks for messages starting with @mention of the bot.
Users should input an origin and a destination
in any stream or through private messages to the bot to receive a
response in the same stream or through private messages if the
input was originally private.
Sample input:
@mention-botname origins=Chicago,IL,USA destinations=New+York,NY,USA
@mention-botname help
'''
help_info = '''
Obligatory Inputs:
Origin e.g. origins=New+York,NY,USA
Destination e.g. destinations=Chicago,IL,USA
Optional Inputs:
Mode Of Transport (inputs: driving, walking, bicycling, transit)
Default mode (no mode input) is driving
e.g. mode=driving or mode=transit
Units (metric or imperial)
e.g. units=metric
Restrictions (inputs: tolls, highways, ferries, indoor)
e.g. avoid=tolls
Departure Time (inputs: now or (YYYY, MM, DD, HH, MM, SS) departing)
e.g. departure_time=now or departure_time=2016,12,17,23,40,40
Arrival Time (inputs: (YYYY, MM, DD, HH, MM, SS) arriving)
e.g. arrival_time=2016,12,17,23,40,40
Languages:
Languages list: https://developers.google.com/maps/faq#languagesupport)
e.g. language=fr
Sample request:
@mention-botname origins=Chicago,IL,USA destinations=New+York,NY,USA language=fr
Please note:
Fare information can be derived, though is solely dependent on the
availability of the information
python run.py bots/followup/followup.py --config-file ~/.zuliprc-local
released by public transport operators.
Duration in traffic can only be derived if a departure time is set.
If a location has spaces in its name, please use a + symbol in the
place of the space/s.
A departure time and a arrival time can not be inputted at the same time
To add more than 1 input for a category,
e.g. more than 1 destinations,
use (|), e.g. destinations=Empire+State+Building|Statue+Of+Liberty
No spaces within addresses.
Departure times and arrival times must be in the UTC timezone,
you can use the timezone bot.
'''
# determines if bot will respond as a private message/ stream message
def send_info(self, message, letter, bot_handler):
bot_handler.send_reply(message, letter)
def calculate_seconds(self, time_str):
times = time_str.split(',')
times = [int(x) for x in times]
# UNIX time from January 1, 1970 00:00:00
unix_start_date = dt.datetime(1970, 1, 1, 0, 0, 0)
requested_time = dt.datetime(*times)
total_seconds = str(int((requested_time-unix_start_date)
.total_seconds()))
return total_seconds
# adds departure time and arrival time paramaters into HTTP request
def add_time_to_params(self, params):
# limited to UTC timezone because of difficulty with user inputting
# correct string for input
if 'departure_time' in params:
if 'departure_time' != 'now':
params['departure_time'] = self.calculate_seconds(params['departure_time'])
elif 'arrival_time' in params:
params['arrival_time'] = self.calculate_seconds(params['arrival_time'])
return
# gets content for output and sends it to user
def get_send_content(self, rjson, params, message, bot_handler):
try:
# JSON list of output variables
variable_list = rjson["rows"][0]["elements"][0]
# determines if user has valid inputs
not_found = (variable_list["status"] == "NOT_FOUND")
invalid_request = (rjson["status"] == "INVALID_REQUEST")
no_result = (variable_list["status"] == "ZERO_RESULTS")
if no_result:
self.send_info(message,
"Zero results\nIf stuck, try '@commute help'.",
bot_handler)
return
elif not_found or invalid_request:
raise IndexError
except IndexError:
self.send_info(message,
"Invalid input, please see instructions."
"\nIf stuck, try '@commute help'.", bot_handler)
return
# origin and destination strings
begin = 'From: ' + rjson["origin_addresses"][0]
end = 'To: ' + rjson["destination_addresses"][0]
distance = 'Distance: ' + variable_list["distance"]["text"]
duration = 'Duration: ' + variable_list["duration"]["text"]
output = begin + '\n' + end + '\n' + distance
# if user doesn't know that default mode is driving
if 'mode' not in params:
mode = 'Mode of Transport: Driving'
output += '\n' + mode
# determines if fare information is available
try:
fare = ('Fare: ' + variable_list["fare"]["currency"] +
variable_list["fare"]["text"])
output += '\n' + fare
except (KeyError, IndexError):
pass
# determines if traffic duration information is available
try:
traffic_duration = ('Duration in traffic: ' +
variable_list["duration_in_traffic"]
["text"])
output += '\n' + traffic_duration
except (KeyError, IndexError):
output += '\n' + duration
# bot sends commute information to user
self.send_info(message, output, bot_handler)
# creates parameters for HTTP request
def parse_pair(self, content_list):
result = {}
for item in content_list:
# enables key value pair
org = item.split('=')
# ensures that invalid inputs are not entered into url request
if len(org) != 2:
continue
key, value = org
result[key] = value
return result
def receive_response(self, params, message, bot_handler):
def validate_requests(request):
if request.status_code == 200:
return request.json()
else:
self.send_info(message,
"Something went wrong. Please try again." +
" Error: {error_num}.\n{error_text}"
.format(error_num=request.status_code,
error_text=request.text), bot_handler)
return
r = requests.get('https://maps.googleapis.com/maps/api/' +
'distancematrix/json', params=params)
result = validate_requests(r)
return result
def handle_message(self, message, bot_handler, state_handler):
original_content = message['content']
query = original_content.split()
if "help" in query:
self.send_info(message, self.help_info, bot_handler)
return
params = self.parse_pair(query)
params['key'] = self.api_key
self.add_time_to_params(params)
rjson = self.receive_response(params, message, bot_handler)
if not rjson:
return
self.get_send_content(rjson, params, message, bot_handler)
handler_class = CommuteHandler
handler = CommuteHandler()
def test_parse_pair():
result = handler.parse_pair(['departure_time=2016,12,20,23,59,00',
'dog_foo=cat-foo'])
assert result == dict(departure_time='2016,12,20,23,59,00',
dog_foo='cat-foo')
def test_calculate_seconds():
result = handler.calculate_seconds('2016,12,20,23,59,00')
assert result == str(1482278340)
def test_helper_functions():
test_parse_pair()
test_calculate_seconds()
if __name__ == '__main__':
test_helper_functions()
print('Success')

View file

@ -0,0 +1,71 @@
This bot will allow briefings of estimated travel times, distances and
fare information for transit travel.
It can respond to: departure times, arrival times, user preferences
(toll avoidance, highway avoidance) and a mode of transport
It can output: fare information, more detailed addresses on origin and
destination, duration in traffic information, metric and imperical
units and information in various languages.
The bot will respond to the same stream input was in. And if called as
private message, the bot will reply with a private message.
To setup the bot, you will first need to add a valid API key to commute.conf
Move
```
~/zulip/api/bots/commute/commute.conf
```
into
```
~/commute.conf
```
To add an API key, please visit:
https://developers.google.com/maps/documentation/distance-matrix/start
to retrieve a key and copy your api key into commute.conf
Sample input and output:
<pre><code>@commute help</code></pre>
<pre><code>Obligatory Inputs:
Origin e.g. origins=New+York,NY,USA
Destination e.g. destinations=Chicago,IL,USA
Optional Inputs:
Mode Of Transport (inputs: driving, walking, bicycling, transit)
Default mode (no mode input) is driving
e.g. mode=driving or mode=transit
Units (metric or imperial)
e.g. units=metric
Restrictions (inputs: tolls, highways, ferries, indoor)
e.g. avoid=tolls
Departure Time (inputs: now or (YYYY, MM, DD, HH, MM, SS) departing)
e.g. departure_time=now or departure_time=2016,12,17,23,40,40
Arrival Time (inputs: (YYYY, MM, DD, HH, MM, SS) arriving)
e.g. arrival_time=2016,12,17,23,40,40
Languages:
Languages list: https://developers.google.com/maps/faq#languagesupport)
e.g. language=fr
</code></pre>
Sample request:
<pre><code>
@commute origins=Chicago,IL,USA destinations=New+York,NY,USA language=fr
</code></pre>
Please note:
Fare information can be derived, though is solely dependent on the
availability of the information released by public transport operators.
Duration in traffic can only be derived if a departure time is set.
If a location has spaces in its name, please use a + symbol in the
place of the space/s.
A departure time and a arrival time can not be inputted at the same time
No spaces within addresses.
Departure times and arrival times must be in the UTC timezone,
you can use the timezone bot.

Binary file not shown.

After

Width:  |  Height:  |  Size: 54 KiB

View file

@ -0,0 +1,129 @@
# See readme.md for instructions on running this code.
from __future__ import absolute_import
from __future__ import division
import copy
import importlib
from math import log10, floor
import re
from zulip_bots.bots.converter import utils
def is_float(value):
try:
float(value)
return True
except ValueError:
return False
# Rounds the number 'x' to 'digits' significant digits.
# A normal 'round()' would round the number to an absolute amount of
# fractional decimals, e.g. 0.00045 would become 0.0.
# 'round_to()' rounds only the digits that are not 0.
# 0.00045 would then become 0.0005.
def round_to(x, digits):
return round(x, digits-int(floor(log10(abs(x)))))
class ConverterHandler(object):
'''
This plugin allows users to make conversions between various units,
e.g. Celsius to Fahrenheit, or kilobytes to gigabytes.
It looks for messages of the format
'@mention-bot <number> <unit_from> <unit_to>'
The message '@mention-bot help' posts a short description of how to use
the plugin, along with a list of all supported units.
'''
def usage(self):
return '''
This plugin allows users to make conversions between
various units, e.g. Celsius to Fahrenheit,
or kilobytes to gigabytes. It looks for messages of
the format '@mention-bot <number> <unit_from> <unit_to>'
The message '@mention-bot help' posts a short description of
how to use the plugin, along with a list of
all supported units.
'''
def handle_message(self, message, bot_handler, state_handler):
bot_response = get_bot_converter_response(message, bot_handler)
bot_handler.send_reply(message, bot_response)
def get_bot_converter_response(message, bot_handler):
content = message['content']
words = content.lower().split()
convert_indexes = [i for i, word in enumerate(words) if word == "@convert"]
convert_indexes = [-1] + convert_indexes
results = []
for convert_index in convert_indexes:
if (convert_index + 1) < len(words) and words[convert_index + 1] == 'help':
results.append(utils.HELP_MESSAGE)
continue
if (convert_index + 3) < len(words):
number = words[convert_index + 1]
unit_from = utils.ALIASES.get(words[convert_index + 2], words[convert_index + 2])
unit_to = utils.ALIASES.get(words[convert_index + 3], words[convert_index + 3])
exponent = 0
if not is_float(number):
results.append(number + ' is not a valid number. ' + utils.QUICK_HELP)
continue
number = float(number)
number_res = copy.copy(number)
for key, exp in utils.PREFIXES.items():
if unit_from.startswith(key):
exponent += exp
unit_from = unit_from[len(key):]
if unit_to.startswith(key):
exponent -= exp
unit_to = unit_to[len(key):]
uf_to_std = utils.UNITS.get(unit_from, False)
ut_to_std = utils.UNITS.get(unit_to, False)
if uf_to_std is False:
results.append(unit_from + ' is not a valid unit. ' + utils.QUICK_HELP)
if ut_to_std is False:
results.append(unit_to + ' is not a valid unit.' + utils.QUICK_HELP)
if uf_to_std is False or ut_to_std is False:
continue
base_unit = uf_to_std[2]
if uf_to_std[2] != ut_to_std[2]:
unit_from = unit_from.capitalize() if uf_to_std[2] == 'kelvin' else unit_from
results.append(unit_to.capitalize() + ' and ' + unit_from +
' are not from the same category. ' + utils.QUICK_HELP)
continue
# perform the conversion between the units
number_res *= uf_to_std[1]
number_res += uf_to_std[0]
number_res -= ut_to_std[0]
number_res /= ut_to_std[1]
if base_unit == 'bit':
number_res *= 1024 ** (exponent // 3)
else:
number_res *= 10 ** exponent
number_res = round_to(number_res, 7)
results.append('{} {} = {} {}'.format(number,
words[convert_index + 2],
number_res,
words[convert_index + 3]))
else:
results.append('Too few arguments given. ' + utils.QUICK_HELP)
new_content = ''
for idx, result in enumerate(results, 1):
new_content += ((str(idx) + '. conversion: ') if len(results) > 1 else '') + result + '\n'
return new_content
handler_class = ConverterHandler

View file

@ -0,0 +1,70 @@
# Converter bot
This bot allows users to perform conversions for various measurement units.
## Usage
Run this bot as described in [here](http://zulip.readthedocs.io/en/latest/bots-guide.html#how-to-deploy-a-bot).
Use this bot with the following command
`@convert <number> <unit_from> <unit_to>`
This will convert `number`, given in the unit `unit_from`, to the unit `unit_to`
and print the result.
* `number` can be any floating-point number, e.g. 12, 13.05, 0.002.
* `unit_from` and `unit_to` are two units from [the following](#supported-units) table in the same category.
* `unit_from` and `unit_to` can be preceded by [these](#supported-prefixes) prefixes.
### Supported units
| Category | Units |
| ----------------- | ----- |
| Area | square-centimeter (cm^2, cm2), square-decimeter (dm^2, dm2), square-meter (m^2, m2), square-kilometer (km^2, km2), square-inch (in^2, in2), square-foot (ft^2, ft2), square-yard (y^2, y2), square-mile (mi^2, mi2), are (a), hectare (ha), acre (ac) |
| Information | bit, byte |
| Length | centimeter (cm), decimeter (dm), meter (m), kilometer (km), inch (in), foot (ft), yard (y), mile (mi), nautical-mile (nmi) |
| Temperature | Kelvin (K), Celsius (C), Fahrenheit (F) |
| Volume | cubic-centimeter (cm^3, cm3), cubic-decimeter (dm^3, dm3), liter (l), cubic-meter (m^3, m3), cubic-inch (in^3, in3), fluid-ounce (fl-oz), cubic-foot (ft^3, ft3), cubic-yard (y^3, y3) |
| Weight | gram (g), kilogram (kg), ton (t), ounce (oz), pound (lb) |
| Cooking (metric only, U.S. and imperial units differ slightly) | teaspoon (tsp), tablespoon (tbsp), cup |
### Supported prefixes
| Prefix | Power of 10 |
| ------ | ----------- |
| atto | 10<sup>-18</sup> |
| pico | 10<sup>-15</sup> |
| femto | 10<sup>-12</sup> |
| nano | 10<sup>-9</sup> |
| micro | 10<sup>-6</sup> |
| milli | 10<sup>-3</sup> |
| centi | 10<sup>-2</sup> |
| deci | 10<sup>-1</sup> |
| deca | 10<sup>1</sup> |
| hecto | 10<sup>2</sup> |
| kilo | 10<sup>3</sup> |
| mega | 10<sup>6</sup> |
| giga | 10<sup>9</sup> |
| tera | 10<sup>12</sup> |
| peta | 10<sup>15</sup> |
| exa | 10<sup>18</sup> |
### Usage examples
| Message | Response |
| ------- | ------ |
| `@convert 12 celsius fahrenheit` | 12.0 celsius = 53.600054 fahrenheit |
| `@convert 0.002 kilomile millimeter` | 0.002 kilomile = 3218688.0 millimeter |
| `@convert 31.5 square-mile ha | 31.5 square-mile = 8158.4625 ha |
| `@convert 56 g lb` | 56.0 g = 0.12345887 lb |
## Notes
* You can use multiple `@convert` statements in a message, the response will look accordingly:
![multiple-converts](assets/multiple-converts.png)
* Enter `@convert help` to display a quick overview of the converter's functionality.
* For bits and bytes, the prefixes change the figure differently: 1 kilobyte is 1024 bytes,
1 megabyte is 1048576 bytes, etc.

View file

@ -0,0 +1,22 @@
#!/usr/bin/env python
from __future__ import absolute_import
from __future__ import print_function
from zulip_bots.test_lib import BotTestCase
class TestConverterBot(BotTestCase):
bot_name = "converter"
def test_bot(self):
expected = {
"": ('Too few arguments given. Enter `@convert help` '
'for help on using the converter.\n'),
"foo bar": ('Too few arguments given. Enter `@convert help` '
'for help on using the converter.\n'),
"2 m cm": "2.0 m = 200.0 cm\n",
"12.0 celsius fahrenheit": "12.0 celsius = 53.600054 fahrenheit\n",
"0.002 kilometer millimile": "0.002 kilometer = 1.2427424 millimile\n",
"3 megabyte kilobit": "3.0 megabyte = 24576.0 kilobit\n",
}
self.check_expected_responses(expected)

View file

@ -0,0 +1,146 @@
# A dictionary allowing the conversion of each unit to its base unit.
# An entry consists of the unit's name, a constant number and a constant
# factor that need to be added and multiplied to convert the unit into
# the base unit in the last parameter.
UNITS = {'bit': [0, 1, 'bit'],
'byte': [0, 8, 'bit'],
'cubic-centimeter': [0, 0.000001, 'cubic-meter'],
'cubic-decimeter': [0, 0.001, 'cubic-meter'],
'liter': [0, 0.001, 'cubic-meter'],
'cubic-meter': [0, 1, 'cubic-meter'],
'cubic-inch': [0, 0.000016387064, 'cubic-meter'],
'fluid-ounce': [0, 0.000029574, 'cubic-meter'],
'cubic-foot': [0, 0.028316846592, 'cubic-meter'],
'cubic-yard': [0, 0.764554857984, 'cubic-meter'],
'teaspoon': [0, 0.0000049289216, 'cubic-meter'],
'tablespoon': [0, 0.000014787, 'cubic-meter'],
'cup': [0, 0.00023658823648491, 'cubic-meter'],
'gram': [0, 1, 'gram'],
'kilogram': [0, 1000, 'gram'],
'ton': [0, 1000000, 'gram'],
'ounce': [0, 28.349523125, 'gram'],
'pound': [0, 453.59237, 'gram'],
'kelvin': [0, 1, 'kelvin'],
'celsius': [273.15, 1, 'kelvin'],
'fahrenheit': [255.372222, 0.555555, 'kelvin'],
'centimeter': [0, 0.01, 'meter'],
'decimeter': [0, 0.1, 'meter'],
'meter': [0, 1, 'meter'],
'kilometer': [0, 1000, 'meter'],
'inch': [0, 0.0254, 'meter'],
'foot': [0, 0.3048, 'meter'],
'yard': [0, 0.9144, 'meter'],
'mile': [0, 1609.344, 'meter'],
'nautical-mile': [0, 1852, 'meter'],
'square-centimeter': [0, 0.0001, 'square-meter'],
'square-decimeter': [0, 0.01, 'square-meter'],
'square-meter': [0, 1, 'square-meter'],
'square-kilometer': [0, 1000000, 'square-meter'],
'square-inch': [0, 0.00064516, 'square-meter'],
'square-foot': [0, 0.09290304, 'square-meter'],
'square-yard': [0, 0.83612736, 'square-meter'],
'square-mile': [0, 2589988.110336, 'square-meter'],
'are': [0, 100, 'square-meter'],
'hectare': [0, 10000, 'square-meter'],
'acre': [0, 4046.8564224, 'square-meter']}
PREFIXES = {'atto': -18,
'femto': -15,
'pico': -12,
'nano': -9,
'micro': -6,
'milli': -3,
'centi': -2,
'deci': -1,
'deca': 1,
'hecto': 2,
'kilo': 3,
'mega': 6,
'giga': 9,
'tera': 12,
'peta': 15,
'exa': 18}
ALIASES = {'a': 'are',
'ac': 'acre',
'c': 'celsius',
'cm': 'centimeter',
'cm2': 'square-centimeter',
'cm3': 'cubic-centimeter',
'cm^2': 'square-centimeter',
'cm^3': 'cubic-centimeter',
'dm': 'decimeter',
'dm2': 'square-decimeter',
'dm3': 'cubic-decimeter',
'dm^2': 'square-decimeter',
'dm^3': 'cubic-decimeter',
'f': 'fahrenheit',
'fl-oz': 'fluid-ounce',
'ft': 'foot',
'ft2': 'square-foot',
'ft3': 'cubic-foot',
'ft^2': 'square-foot',
'ft^3': 'cubic-foot',
'g': 'gram',
'ha': 'hectare',
'in': 'inch',
'in2': 'square-inch',
'in3': 'cubic-inch',
'in^2': 'square-inch',
'in^3': 'cubic-inch',
'k': 'kelvin',
'kg': 'kilogram',
'km': 'kilometer',
'km2': 'square-kilometer',
'km^2': 'square-kilometer',
'l': 'liter',
'lb': 'pound',
'm': 'meter',
'm2': 'square-meter',
'm3': 'cubic-meter',
'm^2': 'square-meter',
'm^3': 'cubic-meter',
'mi': 'mile',
'mi2': 'square-mile',
'mi^2': 'square-mile',
'nmi': 'nautical-mile',
'oz': 'ounce',
't': 'ton',
'tbsp': 'tablespoon',
'tsp': 'teaspoon',
'y': 'yard',
'y2': 'square-yard',
'y3': 'cubic-yard',
'y^2': 'square-yard',
'y^3': 'cubic-yard'}
HELP_MESSAGE = ('Converter usage:\n'
'`@convert <number> <unit_from> <unit_to>`\n'
'Converts `number` in the unit <unit_from> to '
'the <unit_to> and prints the result\n'
'`number`: integer or floating point number, e.g. 12, 13.05, 0.002\n'
'<unit_from> and <unit_to> are two of the following units:\n'
'* square-centimeter (cm^2, cm2), square-decimeter (dm^2, dm2), '
'square-meter (m^2, m2), square-kilometer (km^2, km2),'
' square-inch (in^2, in2), square-foot (ft^2, ft2), square-yard (y^2, y2), '
' square-mile(mi^2, mi2), are (a), hectare (ha), acre (ac)\n'
'* bit, byte\n'
'* centimeter (cm), decimeter(dm), meter (m),'
' kilometer (km), inch (in), foot (ft), yard (y),'
' mile (mi), nautical-mile (nmi)\n'
'* Kelvin (K), Celsius(C), Fahrenheit (F)\n'
'* cubic-centimeter (cm^3, cm3), cubic-decimeter (dm^3, dm3), liter (l), '
'cubic-meter (m^3, m3), cubic-inch (in^3, in3), fluid-ounce (fl-oz), '
'cubic-foot (ft^3, ft3), cubic-yard (y^3, y3)\n'
'* gram (g), kilogram (kg), ton (t), ounce (oz), pound(lb)\n'
'* (metric only, U.S. and imperial units differ slightly:) teaspoon (tsp), tablespoon (tbsp), cup\n\n\n'
'Allowed prefixes are:\n'
'* atto, pico, femto, nano, micro, milli, centi, deci\n'
'* deca, hecto, kilo, mega, giga, tera, peta, exa\n\n\n'
'Usage examples:\n'
'* `@convert 12 celsius fahrenheit`\n'
'* `@convert 0.002 kilomile millimeter`\n'
'* `@convert 31.5 square-mile ha`\n'
'* `@convert 56 g lb`\n')
QUICK_HELP = 'Enter `@convert help` for help on using the converter.'

Binary file not shown.

After

Width:  |  Height:  |  Size: 73 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

View file

@ -0,0 +1,66 @@
# See readme.md for instructions on running this code.
import logging
import json
import requests
import html2text
class DefineHandler(object):
'''
This plugin define a word that the user inputs. It
looks for messages starting with '@mention-bot'.
'''
DEFINITION_API_URL = 'https://owlbot.info/api/v1/dictionary/{}?format=json'
REQUEST_ERROR_MESSAGE = 'Definition not available.'
EMPTY_WORD_REQUEST_ERROR_MESSAGE = 'Please enter a word to define.'
PHRASE_ERROR_MESSAGE = 'Definitions for phrases are not available.'
def usage(self):
return '''
This plugin will allow users to define a word. Users should preface
messages with @mention-bot.
'''
def handle_message(self, message, bot_handler, state_handler):
original_content = message['content'].strip()
bot_response = self.get_bot_define_response(original_content)
bot_handler.send_reply(message, bot_response)
def get_bot_define_response(self, original_content):
split_content = original_content.split(' ')
# If there are more than one word (a phrase)
if len(split_content) > 1:
return DefineHandler.PHRASE_ERROR_MESSAGE
to_define = split_content[0].strip()
to_define_lower = to_define.lower()
# No word was entered.
if not to_define_lower:
return self.EMPTY_WORD_REQUEST_ERROR_MESSAGE
else:
response = '**{}**:\n'.format(to_define)
try:
# Use OwlBot API to fetch definition.
api_result = requests.get(self.DEFINITION_API_URL.format(to_define_lower))
# Convert API result from string to JSON format.
definitions = api_result.json()
# Could not fetch definitions for the given word.
if not definitions:
response += self.REQUEST_ERROR_MESSAGE
else: # Definitions available.
# Show definitions line by line.
for d in definitions:
example = d['example'] if d['example'] else '*No example available.*'
response += '\n' + '* (**{}**) {}\n&nbsp;&nbsp;{}'.format(d['type'], d['defenition'], html2text.html2text(example))
except Exception as e:
response += self.REQUEST_ERROR_MESSAGE
logging.exception(e)
return response
handler_class = DefineHandler

View file

@ -0,0 +1,21 @@
# DefineBot
* This is a bot that defines a word that the user inputs. Whenever the user
inputs a message starting with '@define', the bot defines the word
that follows.
* The definitions are brought to the website using an API. The bot posts the
definition of the word to the stream from which the user inputs the message.
If the user inputs a word that does not exist or a word that is incorrect or
is not in the dictionary, the definition is not displayed.
* For example, if the user says "@define crash", all the meanings of crash
appear, each in a separate line.
![Correct Word](assets/correct_word.png)
* If the user enters a wrong word, like "@define cresh" or "@define crish",
then an error message saying no definition is available is displayed.
![Wrong Word](assets/wrong_word.png)

View file

@ -0,0 +1 @@
html2text

View file

@ -0,0 +1,20 @@
#!/usr/bin/env python
from __future__ import absolute_import
from __future__ import print_function
from zulip_bots.test_lib import BotTestCase
class TestDefineBot(BotTestCase):
bot_name = "define"
def test_bot(self):
expected = {
"": 'Please enter a word to define.',
"foo": "**foo**:\nDefinition not available.",
"cat": ("**cat**:\n\n* (**noun**) a small domesticated carnivorous mammal "
"with soft fur, a short snout, and retractile claws. It is widely "
"kept as a pet or for catching mice, and many breeds have been "
"developed.\n&nbsp;&nbsp;their pet cat\n\n"),
}
self.check_expected_responses(expected)

Binary file not shown.

After

Width:  |  Height:  |  Size: 287 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 180 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 160 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 170 KiB

View file

@ -0,0 +1,45 @@
def encrypt(text):
# This is where the actual ROT13 is applied
# WHY IS .JOIN NOT WORKING?!
textlist = list(text)
newtext = ''
firsthalf = 'abcdefghijklmABCDEFGHIJKLM'
lasthalf = 'nopqrstuvwxyzNOPQRSTUVWXYZ'
for char in textlist:
if char in firsthalf:
newtext += lasthalf[firsthalf.index(char)]
elif char in lasthalf:
newtext += firsthalf[lasthalf.index(char)]
else:
newtext += char
return newtext
class EncryptHandler(object):
'''
This bot allows users to quickly encrypt messages using ROT13 encryption.
It encrypts/decrypts messages starting with @mention-bot.
'''
def usage(self):
return '''
This bot uses ROT13 encryption for its purposes.
It responds to me starting with @mention-bot.
Feeding encrypted messages into the bot decrypts them.
'''
def handle_message(self, message, bot_handler, state_handler):
bot_response = self.get_bot_encrypt_response(message)
bot_handler.send_reply(message, bot_response)
def get_bot_encrypt_response(self, message):
original_content = message['content']
temp_content = encrypt(original_content)
send_content = "Encrypted/Decrypted text: " + temp_content
return send_content
handler_class = EncryptHandler
if __name__ == '__main__':
assert encrypt('ABCDabcd1234') == 'NOPQnopq1234'
assert encrypt('NOPQnopq1234') == 'ABCDabcd1234'

View file

@ -0,0 +1,16 @@
About EncryptBot:
EncryptBot Allows for quick ROT13 encryption in the middle of a chat.
What It Does:
The bot encrypts any message sent to it on any stream it is subscribed to with ROT13.
How It Works:
The bot will Use ROT13(A -> N, B -> O... and vice-versa) in a python
implementation to provide quick and easy encryption.
How to Use:
-Send the message you want to encrypt, add @encrypt to the beginning.
-The Encrypted message will be sent back to the stream the original
message was posted in to the topic <sender-email>'s encrypted text.
-Messages can be decrypted by sending them to EncryptBot in the same way.

View file

@ -0,0 +1,19 @@
#!/usr/bin/env python
from __future__ import absolute_import
from __future__ import print_function
from zulip_bots.test_lib import BotTestCase
class TestEncryptBot(BotTestCase):
bot_name = "encrypt"
def test_bot(self):
expected = {
"": "Encrypted/Decrypted text: ",
"Let\'s Do It": "Encrypted/Decrypted text: Yrg\'f Qb Vg",
"me&mom together..!!": "Encrypted/Decrypted text: zr&zbz gbtrgure..!!",
"foo bar": "Encrypted/Decrypted text: sbb one",
"Please encrypt this": "Encrypted/Decrypted text: Cyrnfr rapelcg guvf",
}
self.check_expected_responses(expected)

View file

@ -0,0 +1,46 @@
# See readme.md for instructions on running this code.
class FollowupHandler(object):
'''
This plugin facilitates creating follow-up tasks when
you are using Zulip to conduct a virtual meeting. It
looks for messages starting with '@mention-bot'.
In this example, we write follow up items to a special
Zulip stream called "followup," but this code could
be adapted to write follow up items to some kind of
external issue tracker as well.
'''
def usage(self):
return '''
This plugin will allow users to flag messages
as being follow-up items. Users should preface
messages with "@mention-bot".
Before running this, make sure to create a stream
called "followup" that your API user can send to.
'''
def handle_message(self, message, bot_handler, state_handler):
if message['content'] == '':
bot_response = "Please specify the message you want to send to followup stream after @mention-bot"
bot_handler.send_reply(message, bot_response)
else:
bot_response = self.get_bot_followup_response(message)
bot_handler.send_message(dict(
type='stream',
to='followup',
subject=message['sender_email'],
content=bot_response,
))
def get_bot_followup_response(self, message):
original_content = message['content']
original_sender = message['sender_email']
temp_content = 'from %s: ' % (original_sender,)
new_content = temp_content + original_content
return new_content
handler_class = FollowupHandler

View file

@ -0,0 +1,31 @@
#!/usr/bin/env python
from __future__ import absolute_import
from __future__ import print_function
from zulip_bots.test_lib import BotTestCase
class TestFollowUpBot(BotTestCase):
bot_name = "followup"
def test_bot(self):
expected_send_reply = {
"": 'Please specify the message you want to send to followup stream after @mention-bot'
}
self.check_expected_responses(expected_send_reply, expected_method='send_reply')
expected_send_message = {
"foo": {
'type': 'stream',
'to': 'followup',
'subject': 'foo_sender@zulip.com',
'content': 'from foo_sender@zulip.com: foo',
},
"I have completed my task": {
'type': 'stream',
'to': 'followup',
'subject': 'foo_sender@zulip.com',
'content': 'from foo_sender@zulip.com: I have completed my task',
},
}
self.check_expected_responses(expected_send_message, expected_method='send_message')

View file

@ -0,0 +1,2 @@
[Foursquare]
api_key = abcdefghijksm

View file

@ -0,0 +1,99 @@
from __future__ import print_function
from __future__ import absolute_import
import datetime as dt
import re
import requests
from six.moves import range
class FoursquareHandler(object):
def initialize(self, bot_handler):
self.api_key = bot_handler.get_config_info('foursquare', 'Foursquare')['api_key']
def usage(self):
return '''
This plugin allows users to search for restaurants nearby an inputted
location to a limit of 3 venues for every location. The name, address
and description of the restaurant will be outputted.
It looks for messages starting with '@mention-bot'.
If you need help, simply type:
@mention-bot /help into the Compose Message box
Sample input:
@mention-bot Chicago, IL
@mention-bot help
'''
help_info = '''
The Foursquare bot can receive keyword limiters that specify the location, distance (meters) and
cusine of a restaurant in that exact order.
Please note the required use of quotes in the search location.
Example Inputs:
@mention-bot 'Millenium Park' 8000 donuts
@mention-bot 'Melbourne, Australia' 40000 seafood
'''
def format_json(self, venues):
def format_venue(venue):
name = venue['name']
address = ', '.join(venue['location']['formattedAddress'])
keyword = venue['categories'][0]['pluralName']
blurb = '\n'.join([name, address, keyword])
return blurb
return '\n'.join(format_venue(venue) for venue in venues)
def send_info(self, message, letter, bot_handler):
bot_handler.send_reply(message, letter)
def handle_message(self, message, bot_handler, state_handler):
words = message['content'].split()
if "/help" in words:
self.send_info(message, self.help_info, bot_handler)
return
# These are required inputs for the HTTP request.
try:
params = {'limit': '3'}
params['near'] = re.search('\'[A-Za-z]\w+[,]?[\s\w+]+?\'', message['content']).group(0)
params['v'] = 20170108
params['oauth_token'] = self.api_key
except AttributeError:
pass
# Optional params for HTTP request.
if len(words) >= 1:
try:
params['radius'] = re.search('([0-9]){3,}', message['content']).group(0)
except AttributeError:
pass
try:
params['query'] = re.search('\s([A-Za-z]+)$', message['content']).group(0)[1:]
except AttributeError:
params['query'] = 'food'
response = requests.get('https://api.foursquare.com/v2/venues/search?',
params=params)
print(response.url)
if response.status_code == 200:
received_json = response.json()
else:
self.send_info(message,
"Invalid Request\nIf stuck, try '@mention-bot help'.",
bot_handler)
return
if received_json['meta']['code'] == 200:
response_msg = ('Food nearby ' + params['near'] +
' coming right up:\n' +
self.format_json(received_json['response']['venues']))
self.send_info(message, response_msg, bot_handler)
return
self.send_info(message,
"Invalid Request\nIf stuck, try '@mention-bot help'.",
bot_handler)
return
handler_class = FoursquareHandler

View file

@ -0,0 +1,32 @@
# FourSquare Bot
* This is a bot that returns a list of restaurants from a user input of location,
proximity and restaurant type in that exact order. The number of returned
restaurants are capped at 3 per request.
* The list of restaurants are brought to Zulip using an API. The bot sends a GET
request to https://api.foursquare.com/v2/. If the user does not correctly input
a location, proximity and a restaurant type, the bot will return an error message.
* For example, if the user says "@foursquare 'Chicago, IL' 80000 seafood", the bot
will return:
Food nearby 'Chicago, IL' coming right up:
Dee's Seafood Co.
2723 S Poplar Ave, Chicago, IL 60608, United States
Fish Markets
Seafood Harbor
2131 S Archer Ave (at Wentworth Ave), Chicago, IL 60616, United States
Seafood Restaurants
Joe's Seafood, Prime Steak & Stone Crab
60 E Grand Ave (at N Rush St), Chicago, IL 60611, United States
Seafood Restaurants
* If the user enters a wrong word, like "@foursquare 80000 donuts" or "@foursquare",
then an error message saying invalid input will be displayed.
* To get the required API key, visit: https://developer.foursquare.com/overview/auth
for more information.

View file

@ -0,0 +1,26 @@
{
"request": {
"api_url": "http://api.giphy.com/v1/gifs/translate",
"params": {
"s": "Hello",
"api_key": "12345678"
}
},
"response": {
"meta": {
"status": 200
},
"data": {
"images": {
"original": {
"url": "https://media4.giphy.com/media/3o6ZtpxSZbQRRnwCKQ/giphy.gif"
}
}
}
},
"response-headers": {
"status": 200,
"ok": true,
"content-type": "application/json; charset=utf-8"
}
}

View file

@ -0,0 +1,84 @@
# To use this plugin, you need to set up the Giphy API key for this bot in
# ~/.giphy_config
from __future__ import absolute_import
from __future__ import print_function
from six.moves.configparser import SafeConfigParser
import requests
import logging
import sys
import os
import re
GIPHY_TRANSLATE_API = 'http://api.giphy.com/v1/gifs/translate'
class GiphyHandler(object):
'''
This plugin posts a GIF in response to the keywords provided by the user.
Images are provided by Giphy, through the public API.
The bot looks for messages starting with @mention of the bot
and responds with a message with the GIF based on provided keywords.
It also responds to private messages.
'''
def usage(self):
return '''
This plugin allows users to post GIFs provided by Giphy.
Users should preface keywords with the Giphy-bot @mention.
The bot responds also to private messages.
'''
def initialize(self, bot_handler):
global config_info
config_info = bot_handler.get_config_info('giphy')
def handle_message(self, message, bot_handler, state_handler):
bot_response = get_bot_giphy_response(message, bot_handler)
bot_handler.send_reply(message, bot_response)
class GiphyNoResultException(Exception):
pass
def get_url_gif_giphy(keyword, api_key):
# Return a URL for a Giphy GIF based on keywords given.
# In case of error, e.g. failure to fetch a GIF URL, it will
# return a number.
query = {'s': keyword,
'api_key': api_key}
try:
data = requests.get(GIPHY_TRANSLATE_API, params=query)
except requests.exceptions.ConnectionError as e: # Usually triggered by bad connection.
logging.warning(e)
raise
search_status = data.json()['meta']['status']
if search_status != 200 or not data.ok:
raise requests.exceptions.ConnectionError
try:
gif_url = data.json()['data']['images']['original']['url']
except (TypeError, KeyError): # Usually triggered by no result in Giphy.
raise GiphyNoResultException()
return gif_url
def get_bot_giphy_response(message, bot_handler):
# Each exception has a specific reply should "gif_url" return a number.
# The bot will post the appropriate message for the error.
keyword = message['content']
try:
gif_url = get_url_gif_giphy(keyword, config_info['key'])
except requests.exceptions.ConnectionError:
return ('Uh oh, sorry :slightly_frowning_face:, I '
'cannot process your request right now. But, '
'let\'s try again later! :grin:')
except GiphyNoResultException:
return ('Sorry, I don\'t have a GIF for "%s"! '
':astonished:' % (keyword))
return ('[Click to enlarge](%s)'
'[](/static/images/interactive-bot/giphy/powered-by-giphy.png)'
% (gif_url))
handler_class = GiphyHandler

View file

@ -0,0 +1,25 @@
#!/usr/bin/env python
from __future__ import absolute_import
from __future__ import print_function
import json
from zulip_bots.test_lib import BotTestCase
class TestGiphyBot(BotTestCase):
bot_name = "giphy"
def test_bot(self):
bot_response = '[Click to enlarge]' \
'(https://media4.giphy.com/media/3o6ZtpxSZbQRRnwCKQ/giphy.gif)' \
'[](/static/images/interactive-bot/giphy/powered-by-giphy.png)'
with self.mock_config_info({'key': '12345678'}), \
self.mock_http_conversation('test_1'):
self.initialize_bot()
self.assert_bot_response(
message = {'content': 'Hello'},
response = {'content': bot_response},
expected_method='send_reply'
)

View file

@ -0,0 +1,126 @@
# See readme.md for instructions on running this code.
from __future__ import absolute_import
from __future__ import print_function
from . import github
import json
import logging
import os
import requests
class InputError(IndexError):
'''raise this when there is an error with the information the user has entered'''
class GitHubHandler(object):
'''
This plugin allows you to comment on a GitHub issue, under a certain repository.
It looks for messages starting with '@mention-bot'.
'''
def usage(self):
return '''
This bot will allow users to comment on a GitHub issue.
Users should preface messages with '@mention-bot'.
You will need to have a GitHub account.
Before running this, make sure to get a GitHub OAuth token.
The token will need to be authorized for the following scopes:
'gist, public_repo, user'.
Store it in the '~/.github_auth.conf' file, along with your username, in the format:
github_repo = <repo_name> (The name of the repo to post to)
github_repo_owner = <repo_owner> (The owner of the repo to post to)
github_username = <username> (The username of the GitHub bot)
github_token = <oauth_token> (The personal access token for the GitHub bot)
Leave the first two options blank.
Please use this format in your message to the bot:
'<repository_owner>/<repository>/<issue_number>/<your_comment>'.
'''
def handle_message(self, message, bot_handler, state_handler):
original_content = message['content']
original_sender = message['sender_email']
handle_input(bot_handler, original_content, original_sender)
handler_class = GitHubHandler
def send_to_github(repo_owner, repo, issue, comment_body):
session = github.auth()
comment = {
'body': comment_body
}
r = session.post('https://api.github.com/repos/%s/%s/issues/%s/comments' % (repo_owner, repo, issue),
json.dumps(comment))
return r.status_code
def get_values_message(original_content):
# gets rid of whitespace around the edges, so that they aren't a problem in the future
message_content = original_content.strip()
# splits message by '/' which will work if the information was entered correctly
message_content = message_content.split('/')
try:
# this will work if the information was entered correctly
user = github.get_username()
repo_owner = message_content[2]
repo = message_content[3]
issue = message_content[4]
comment_body = message_content[5]
return dict(user=user, repo_owner=repo_owner, repo=repo, issue=issue, comment_body=comment_body)
except IndexError:
raise InputError
def handle_input(bot_handler, original_content, original_sender):
try:
params = get_values_message(original_content)
status_code = send_to_github(params['repo_owner'], params['repo'],
params['issue'], params['comment_body'])
if status_code == 201:
# sending info to github was successful!
reply_message = "You commented on issue number " + params['issue'] + " under " + \
params['repo_owner'] + "'s repository " + params['repo'] + "!"
send_message(bot_handler, reply_message, original_sender)
elif status_code == 404:
# this error could be from an error with the OAuth token
reply_message = "Error code: " + str(status_code) + " :( There was a problem commenting on issue number " \
+ params['issue'] + " under " + \
params['repo_owner'] + "'s repository " + params['repo'] + \
". Do you have the right OAuth token?"
send_message(bot_handler, reply_message, original_sender)
else:
# sending info to github did not work
reply_message = "Error code: " + str(status_code) +\
" :( There was a problem commenting on issue number " \
+ params['issue'] + " under " + \
params['repo_owner'] + "'s repository " + params['repo'] + \
". Did you enter the information in the correct format?"
send_message(bot_handler, reply_message, original_sender)
except InputError:
message = "It doesn't look like the information was entered in the correct format." \
" Did you input it like this? " \
"'/<username>/<repository_owner>/<repository>/<issue_number>/<your_comment>'."
send_message(bot_handler, message, original_sender)
logging.error('there was an error with the information you entered')
def send_message(bot_handler, message, original_sender):
# function for sending a message
bot_handler.send_message(dict(
type='private',
to=original_sender,
content=message,
))

View file

@ -0,0 +1,51 @@
# Overview
This is the documentation for how to set up and run the GitHub comment bot. (`git_hub_comment.py`)
This directory contains library code for running Zulip
bots that react to messages sent by users.
This bot will allow you to comment on a GitHub issue.
You should preface messages with `@comment` or `@gcomment`.
You will need to have a GitHub account, and a GitHub OAuth token.
## Setup
Before running this bot, make sure to get a GitHub OAuth token.
You can look at this tutorial if you need help:
<https://help.github.com/articles/creating-an-access-token-for-command-line-use/>
The token will need to be authorized for the following scopes: `gist, public_repo, user`.
Store it in the `~/github-auth.conf` file, along with your username, in the format:
github_repo = <repo_name> (The name of the repo to post to)
github_repo_owner = <repo_owner> (The owner of the repo to post to)
github_username = <username> (The username of the GitHub bot)
github_token = <oauth_token> (The personal access token for the GitHub bot)
`<repository_owner>/<repository>/<issue_number>/<your_comment`.
## Running the bot
Here is an example of running the `git_hub_comment` bot from
inside a Zulip repo:
`cd ~/zulip/api`
`zulip-run-bot git_hub_comment --config-file ~/.zuliprc-prod`
Once the bot code starts running, you will see a
message explaining how to use the bot, as well as
some log messages. You can use the `--quiet` option
to suppress some of the informational messages.
The bot code will run continuously until you kill them with
control-C (or otherwise).
### Configuration
For this document we assume you have some prior experience
with using the Zulip API, but here is a quick review of
what a `.zuliprc` files looks like. You can connect to the
API as your own human user, or you can go into the Zulip settings
page to create a user-owned bot.
[api]
email=someuser@example.com
key=<your api key>
site=https://zulip.somewhere.com

View file

@ -0,0 +1,56 @@
#!/usr/bin/env python
# The purpose of this file is to handle all requests
# to the Github API, which will make sure that all
# requests are to the same account, and that all requests
# authenticated correctly and securely
# The sole purpose of this is to authenticate, and it will
# return the requests session that is properly authenticated
import logging
import os
import requests
import six.moves.configparser
# This file contains the oauth token for a github user, their username, and optionally, a repository
# name and owner. All in the format:
# github_repo
# github_repo_owner
# github_username
# github_token
CONFIG_FILE = '~/.github-auth.conf'
global config
config = six.moves.configparser.ConfigParser() # Sets up the configParser to read the .conf file
config.read([os.path.expanduser(CONFIG_FILE)]) # Reads the config file
def auth():
# Creates and authorises a requests session
session = requests.session()
session.auth = (get_username(), get_oauth_token())
return session
def get_oauth_token():
_get_data('token')
def get_username():
_get_data('username')
def get_repo():
_get_data('repo')
def get_repo_owner():
_get_data('repo_owner')
def _get_data(key):
try:
return config.get('github', 'github_%s' % (key))
except Exception:
logging.exception('GitHub %s not supplied in ~/.github-auth.conf.' % (key))

View file

@ -0,0 +1,11 @@
{
"request": {
"api_url": "https://api.github.com/repos/zulip/zulip/issues/0"
},
"response": {},
"response-headers": {
"status": 404,
"ok": false,
"content-type": "application/json; charset=utf-8"
}
}

View file

@ -0,0 +1,81 @@
{
"request": {
"api_url": "https://api.github.com/repos/zulip/zulip/issues/5365"
},
"response": {
"url": "https://api.github.com/repos/zulip/zulip/issues/5365",
"repository_url": "https://api.github.com/repos/zulip/zulip",
"labels_url": "https://api.github.com/repos/zulip/zulip/issues/5365/labels{/name}",
"comments_url": "https://api.github.com/repos/zulip/zulip/issues/5365/comments",
"events_url": "https://api.github.com/repos/zulip/zulip/issues/5365/events",
"html_url": "https://github.com/zulip/zulip/issues/5365",
"id": 235630936,
"number": 5365,
"title": "frontend: Enable hot-reloading of CSS in development",
"user": {
"login": "timabbott",
"id": 2746074,
"avatar_url": "https://avatars3.githubusercontent.com/u/2746074?v=3",
"gravatar_id": "",
"url": "https://api.github.com/users/timabbott",
"html_url": "https://github.com/timabbott",
"followers_url": "https://api.github.com/users/timabbott/followers",
"following_url": "https://api.github.com/users/timabbott/following{/other_user}",
"gists_url": "https://api.github.com/users/timabbott/gists{/gist_id}",
"starred_url": "https://api.github.com/users/timabbott/starred{/owner}{/repo}",
"subscriptions_url": "https://api.github.com/users/timabbott/subscriptions",
"organizations_url": "https://api.github.com/users/timabbott/orgs",
"repos_url": "https://api.github.com/users/timabbott/repos",
"events_url": "https://api.github.com/users/timabbott/events{/privacy}",
"received_events_url": "https://api.github.com/users/timabbott/received_events",
"type": "User",
"site_admin": false
},
"labels": [
{
"id": 419106932,
"url": "https://api.github.com/repos/zulip/zulip/labels/area:%20tooling",
"name": "area: tooling",
"color": "bfd4f2",
"default": false
},
{
"id": 265216831,
"url": "https://api.github.com/repos/zulip/zulip/labels/enhancement",
"name": "enhancement",
"color": "84b6eb",
"default": true
},
{
"id": 265216832,
"url": "https://api.github.com/repos/zulip/zulip/labels/help%20wanted",
"name": "help wanted",
"color": "159818",
"default": true
},
{
"id": 621149055,
"url": "https://api.github.com/repos/zulip/zulip/labels/priority:%20medium",
"name": "priority: medium",
"color": "5319e7",
"default": false
}
],
"state": "open",
"locked": false,
"assignee": null,
"assignees": [],
"milestone": null,
"comments": 1,
"created_at": "2017-06-13T17:34:45Z",
"updated_at": "2017-06-13T17:34:46Z",
"closed_at": null,
"body": "There's strong interest among folks working on the frontend in being able to use the hot-reloading feature of webpack for managing our CSS.\r\n\r\nIn order to do this, step 1 is to move our CSS minification pipeline from django-pipeline to Webpack. ",
"closed_by": null
},
"response-headers": {
"status": 200,
"ok": true,
"content-type": "application/json; charset=utf-8"
}
}

View file

@ -0,0 +1,66 @@
{
"request": {
"api_url": "https://api.github.com/repos/zulip/zulip/issues/5345"
},
"response": {
"url": "https://api.github.com/repos/zulip/zulip/issues/5345",
"repository_url": "https://api.github.com/repos/zulip/zulip",
"labels_url": "https://api.github.com/repos/zulip/zulip/issues/5345/labels{/name}",
"comments_url": "https://api.github.com/repos/zulip/zulip/issues/5345/comments",
"events_url": "https://api.github.com/repos/zulip/zulip/issues/5345/events",
"html_url": "https://github.com/zulip/zulip/pull/5345",
"id": 235340230,
"number": 5345,
"title": "[WIP] modal: Replace bootstrap modal with custom modal class",
"user": {
"login": "jackrzhang",
"id": 12771126,
"avatar_url": "https://avatars0.githubusercontent.com/u/12771126?v=3",
"gravatar_id": "",
"url": "https://api.github.com/users/jackrzhang",
"html_url": "https://github.com/jackrzhang",
"followers_url": "https://api.github.com/users/jackrzhang/followers",
"following_url": "https://api.github.com/users/jackrzhang/following{/other_user}",
"gists_url": "https://api.github.com/users/jackrzhang/gists{/gist_id}",
"starred_url": "https://api.github.com/users/jackrzhang/starred{/owner}{/repo}",
"subscriptions_url": "https://api.github.com/users/jackrzhang/subscriptions",
"organizations_url": "https://api.github.com/users/jackrzhang/orgs",
"repos_url": "https://api.github.com/users/jackrzhang/repos",
"events_url": "https://api.github.com/users/jackrzhang/events{/privacy}",
"received_events_url": "https://api.github.com/users/jackrzhang/received_events",
"type": "User",
"site_admin": false
},
"labels": [
{
"id": 561830290,
"url": "https://api.github.com/repos/zulip/zulip/labels/needs%20review",
"name": "needs review",
"color": "fef2c0",
"default": false
}
],
"state": "open",
"locked": false,
"assignee": null,
"assignees": [],
"milestone": null,
"comments": 3,
"created_at": "2017-06-12T19:27:26Z",
"updated_at": "2017-06-13T18:44:12Z",
"closed_at": null,
"pull_request": {
"url": "https://api.github.com/repos/zulip/zulip/pulls/5345",
"html_url": "https://github.com/zulip/zulip/pull/5345",
"diff_url": "https://github.com/zulip/zulip/pull/5345.diff",
"patch_url": "https://github.com/zulip/zulip/pull/5345.patch"
},
"body": "An interaction bug (#4811) between our settings UI and the bootstrap modals breaks hotkey support for `Esc` when multiple modals are open.\r\n\r\ntodo:\r\n[x] Create `Modal` class in `modal.js` (drafted by @brockwhittaker)\r\n[x] Reimplement change_email_modal utilizing `Modal` class\r\n[] Dump using bootstrap for the account settings modal and all other modals, replace with `Modal` class\r\n[] Add hotkey support for closing the top modal for `Esc`\r\n\r\nThis should also be a helpful step in removing dependencies from Bootstrap.",
"closed_by": null
},
"response-headers": {
"status": 200,
"ok": true,
"content-type": "application/json; charset=utf-8"
}
}

View file

@ -0,0 +1,101 @@
import re
import os
import sys
import logging
import six.moves.configparser
import requests
class GithubHandler(object):
'''
This bot provides details on github issues and pull requests when they're
referenced in the chat.
'''
GITHUB_ISSUE_URL_TEMPLATE = 'https://api.github.com/repos/{owner}/{repo}/issues/{id}'
HANDLE_MESSAGE_REGEX = re.compile("(?:([\w-]+)\/)?([\w-]+)?#(\d+)")
def initialize(self, bot_handler):
self.config_info = bot_handler.get_config_info('github_detail', optional=True)
self.owner = self.config_info.get("owner", False)
self.repo = self.config_info.get("repo", False)
def usage(self):
# type: () -> None
return ("This plugin displays details on github issues and pull requests. "
"To reference an issue or pull request usename mention the bot then "
"anytime in the message type its id, for example:\n"
"@**Github detail** #3212 zulip#3212 zulip/zulip#3212\n"
"The default owner is {} and the default repo is {}.".format(self.owner, self.repo))
def format_message(self, details):
# type: (Dict[Text, Union[Text, int, bool]]) -> Text
number = details['number']
title = details['title']
link = details['html_url']
author = details['user']['login']
owner = details['owner']
repo = details['repo']
description = details['body']
status = details['state'].title()
message_string = ('**[{owner}/{repo}#{id}]'.format(owner=owner, repo=repo, id=number),
'({link}) - {title}**\n'.format(title=title, link=link),
'Created by **[{author}](https://github.com/{author})**\n'.format(author=author),
'Status - **{status}**\n```quote\n{description}\n```'.format(status=status, description=description))
return ''.join(message_string)
def get_details_from_github(self, owner, repo, number):
# type: (Text, Text, Text) -> Dict[Text, Union[Text, Int, Bool]]
# Gets the details of an issues or pull request
try:
r = requests.get(
self.GITHUB_ISSUE_URL_TEMPLATE.format(owner=owner, repo=repo, id=number))
except requests.exceptions.RequestException as e:
logging.exception(e)
return
if r.status_code != requests.codes.ok:
return
return r.json()
def get_owner_and_repo(self, issue_pr):
owner = issue_pr.group(1)
repo = issue_pr.group(2)
if owner is None:
owner = self.owner
if repo is None:
repo = self.repo
return (owner, repo)
def handle_message(self, message, bot_handler, state_handler):
# type: () -> None
# Send help message
if message['content'] == 'help':
bot_handler.send_reply(message, self.usage())
return
# Capture owner, repo, id
issue_prs = re.finditer(
self.HANDLE_MESSAGE_REGEX, message['content'])
bot_messages = []
for issue_pr in issue_prs:
owner, repo = self.get_owner_and_repo(issue_pr)
if owner and repo:
details = self.get_details_from_github(owner, repo, issue_pr.group(3))
if details is not None:
details['owner'] = owner
details['repo'] = repo
bot_messages.append(self.format_message(details))
else:
bot_messages.append("Failed to find issue/pr: {owner}/{repo}#{id}"
.format(owner=owner, repo=repo, id=issue_pr.group(3)))
else:
bot_messages.append("Failed to detect owner and repository name.")
if len(bot_messages) == 0:
bot_messages.append("Failed to find any issue or PR.")
bot_message = '\n'.join(bot_messages)
bot_handler.send_reply(message, bot_message)
handler_class = GithubHandler

View file

@ -0,0 +1,22 @@
# GitHub detail bot
This bot links and details issues and pull requests.
To use it @-mention the bot then type an id:
Ids can be specified in three different forms:
- Id only: `#2000`
- Repository and id: `zulip#2000`
- Owner, repository and id `zulip/zulip#2000`
The id can occur at any time in the message. You
can also mention multiple ids in a single message. For example:
`@**GitHub Detail Bot** find me #5176 and zulip/zulip#4534 .`
You can configure a default owner and repository.
The configuration file should be located at `api/bots/github_detail/github_detail.conf`.
It should look like this:
```ini
[github_detail]
owner = <repository owner>
repo = <repository name>
```

View file

@ -0,0 +1,85 @@
#!/usr/bin/env python
from __future__ import absolute_import
from __future__ import print_function
import json
from zulip_bots.test_lib import BotTestCase
class TestGithubDetailBot(BotTestCase):
bot_name = "github_detail"
def test_issue(self):
bot_response = '**[zulip/zulip#5365](https://github.com/zulip/zulip/issues/5365)'\
' - frontend: Enable hot-reloading of CSS in development**\n'\
'Created by **[timabbott](https://github.com/timabbott)**\n'\
'Status - **Open**\n'\
'```quote\n'\
'There\'s strong interest among folks working on the frontend in being '\
'able to use the hot-reloading feature of webpack for managing our CSS.\r\n\r\n'\
'In order to do this, step 1 is to move our CSS minification pipeline '\
'from django-pipeline to Webpack. \n```'
# This message calls the `send_reply` function of BotHandlerApi
with self.mock_http_conversation('test_issue'):
self.assert_bot_response(
message = {'content': 'zulip/zulip#5365'},
response = {'content': bot_response},
expected_method='send_reply'
)
def test_pull_request(self):
bot_response = '**[zulip/zulip#5345](https://github.com/zulip/zulip/pull/5345)'\
' - [WIP] modal: Replace bootstrap modal with custom modal class**\n'\
'Created by **[jackrzhang](https://github.com/jackrzhang)**\n'\
'Status - **Open**\n```quote\nAn interaction bug (#4811) '\
'between our settings UI and the bootstrap modals breaks hotkey '\
'support for `Esc` when multiple modals are open.\r\n\r\ntodo:\r\n[x]'\
' Create `Modal` class in `modal.js` (drafted by @brockwhittaker)\r\n[x]'\
' Reimplement change_email_modal utilizing `Modal` class\r\n[] Dump '\
'using bootstrap for the account settings modal and all other modals,'\
' replace with `Modal` class\r\n[] Add hotkey support for closing the'\
' top modal for `Esc`\r\n\r\nThis should also be a helpful step in removing dependencies from Bootstrap.\n```'
# This message calls the `send_reply` function of BotHandlerApi
with self.mock_http_conversation('test_pull'):
self.assert_bot_response(
message = {'content': 'zulip/zulip#5345'},
response = {'content': bot_response},
expected_method='send_reply'
)
def test_404(self):
bot_response = 'Failed to find issue/pr: zulip/zulip#0'
# This message calls the `send_reply` function of BotHandlerApi
with self.mock_http_conversation('test_404'):
self.assert_bot_response(
message = {'content': 'zulip/zulip#0'},
response = {'content': bot_response},
expected_method='send_reply'
)
def test_random_text(self):
bot_response = 'Failed to find any issue or PR.'
# This message calls the `send_reply` function of BotHandlerApi
self.assert_bot_response(
message = {'content': 'some random text'},
response = {'content': bot_response},
expected_method='send_reply'
)
def test_help_text(self):
bot_response = 'This plugin displays details on github issues and pull requests. '\
'To reference an issue or pull request usename mention the bot then '\
'anytime in the message type its id, for example:\n@**Github detail** '\
'#3212 zulip#3212 zulip/zulip#3212\nThe default owner is zulip and '\
'the default repo is zulip.'
# This message calls the `send_reply` function of BotHandlerApi
mock_config = {'owner': 'zulip', 'repo': 'zulip'}
with self.mock_config_info(mock_config):
self.initialize_bot()
self.assert_bot_response(
message = {'content': 'help'},
response = {'content': bot_response},
expected_method='send_reply'
)

View file

@ -0,0 +1,109 @@
from __future__ import absolute_import
from future import standard_library
standard_library.install_aliases()
from . import github
import json
import os
import requests
import six.moves.configparser
import urllib.error
import urllib.parse
import urllib.request
class IssueHandler(object):
'''
This plugin facilitates sending issues to github, when
an item is prefixed with '@mention-bot'.
It will also write items to the issues stream, as well
as reporting it to github
'''
URL = 'https://api.github.com/repos/{}/{}/issues'
CHARACTER_LIMIT = 70
CONFIG_FILE = '~/.github-auth.conf'
def __init__(self):
self.repo_name = github.get_repo()
self.repo_owner = github.get_repo_owner()
def usage(self):
return '''
This plugin will allow users to flag messages
as being issues with Zulip by using te prefix '@mention-bot'.
Before running this, make sure to create a stream
called "issues" that your API user can send to.
Also, make sure that the credentials of the github bot have
been typed in correctly, that there is a personal access token
with access to public repositories ONLY,
and that the repository name is entered correctly.
Check ~/.github-auth.conf, and make sure there are
github_repo = <repo_name> (The name of the repo to post to)
github_repo_owner = <repo_owner> (The owner of the repo to post to)
github_username = <username> (The username of the GitHub bot)
github_token = <oauth_token> (The personal access token for the GitHub bot)
'''
def handle_message(self, message, bot_handler, state_handler):
original_content = message['content']
original_sender = message['sender_email']
temp_content = 'by {}:'.format(original_sender,)
new_content = temp_content + original_content
# gets the repo url
url_new = self.URL.format(self.REPO_OWNER, self.REPO_NAME)
# signs into github using the provided username and password
session = github.auth()
issue_title = message['content'].strip()
issue_content = ''
new_issue_title = ''
for part_of_title in issue_title.split():
if len(new_issue_title) < self.CHARACTER_LIMIT:
new_issue_title += '{} '.format(part_of_title)
else:
issue_content += '{} '.format(part_of_title)
new_issue_title = new_issue_title.strip()
issue_content = issue_content.strip()
new_issue_title += '...'
# Creates the issue json, that is transmitted to the github api servers
issue = {
'title': new_issue_title,
'body': '{} **Sent by [{}](https://chat.zulip.org/#) from zulip**'.format(issue_content, original_sender),
'assignee': '',
'milestone': 'none',
'labels': [''],
}
# Sends the HTTP post request
r = session.post(url_new, json.dumps(issue))
if r.ok:
# sends the message onto the 'issues' stream so it can be seen by zulip users
bot_handler.send_message(dict(
type='stream',
to='issues',
subject=message['sender_email'],
# Adds a check mark so that the user can verify if it has been sent
content='{} :heavy_check_mark:'.format(new_content),
))
return
# This means that the issue has not been sent
# sends the message onto the 'issues' stream so it can be seen by zulip users
bot_handler.send_message(dict(
type='stream',
to='issues',
subject=message['sender_email'],
# Adds a cross so that the user can see that it has failed, and provides a link to a
# google search that can (hopefully) direct them to the error
content='{} :x: Code: [{}](https://www.google.com/search?q=Github HTTP {} Error {})'
.format(new_content, r.status_code, r.status_code, r.content),
))
handler_class = IssueHandler

View file

@ -0,0 +1,93 @@
# See readme.md for instructions on running this code.
from __future__ import print_function
import logging
import http.bot_handler
from six.moves.urllib.request import urlopen
# Uses the Google search engine bindings
# pip install --upgrade google
from google import search
def get_google_result(search_keywords):
help_message = "To use this bot, start messages with @mentioned-bot, \
followed by what you want to search for. If \
found, Zulip will return the first search result \
on Google.\
\
An example message that could be sent is:\
'@mentioned-bot zulip' or \
'@mentioned-bot how to create a chatbot'."
if search_keywords == 'help':
return help_message
elif search_keywords == '' or search_keywords is None:
return help_message
else:
try:
urls = search(search_keywords, stop=20)
urlopen('http://216.58.192.142', timeout=1)
except http.bot_handler.RemoteDisconnected as er:
logging.exception(er)
return 'Error: No internet connection. {}.'.format(er)
except Exception as e:
logging.exception(e)
return 'Error: Search failed. {}.'.format(e)
try:
url = next(urls)
except AttributeError as a_err:
# google.search query failed and urls is of object
# 'NoneType'
logging.exception(a_err)
return "Error: Google search failed with a NoneType result. {}.".format(a_err)
except TypeError as t_err:
# google.search query failed and returned None
# This technically should not happen but the prior
# error check assumed this behavior
logging.exception(t_err)
return "Error: Google search function failed. {}.".format(t_err)
except Exception as e:
logging.exception(e)
return 'Error: Search failed. {}.'.format(e)
return 'Success: {}'.format(url)
class GoogleSearchHandler(object):
'''
This plugin allows users to enter a search
term in Zulip and get the top URL sent back
to the context (stream or private) in which
it was called. It looks for messages starting
with @mentioned-bot.
'''
def usage(self):
return '''
This plugin will allow users to search
for a given search term on Google from
Zulip. Use '@mentioned-bot help' to get
more information on the bot usage. Users
should preface messages with
@mentioned-bot.
'''
def handle_message(self, message, bot_handler, state_handler):
original_content = message['content']
result = get_google_result(original_content)
bot_handler.send_reply(message, result)
handler_class = GoogleSearchHandler
def test():
try:
urlopen('http://216.58.192.142', timeout=1)
print('Success')
return True
except http.bot_handler.RemoteDisconnected as e:
print('Error: {}'.format(e))
return False
if __name__ == '__main__':
test()

View file

@ -0,0 +1,23 @@
# Google Search bot
This bot allows users to do Google search queries and have the bot
respond with the first search result. It is by default set to the
highest safe-search setting.
## Usage
Run this bot as described
[here](http://zulip.readthedocs.io/en/latest/bots-guide.html#how-to-deploy-a-bot).
Use this bot with the following command
`@mentioned-bot <search terms>`
This will return the first link found by Google for `<search terms>`
and print the resulting URL.
If no `<search terms>` are entered, a help message is printed instead.
If there was an error in the process of running the search (socket
errors, Google search function failed, or general failures), an error
message is returned.

View file

@ -0,0 +1,18 @@
# See readme.md for instructions on running this code.
class HelloWorldHandler(object):
def usage(self):
return '''
This is a boilerplate bot that responds to a user query with
"beep boop", which is robot for "Hello World".
This bot can be used as a template for other, more
sophisticated, bots.
'''
def handle_message(self, message, bot_handler, state_handler):
content = 'beep boop'
bot_handler.send_reply(message, content)
handler_class = HelloWorldHandler

View file

@ -0,0 +1,4 @@
Simple Zulip bot that will respond to any query with a "beep boop".
The helloworld bot is a boilerplate bot that can be used as a
template for more sophisticated/evolved Zulip bots.

View file

@ -0,0 +1,16 @@
#!/usr/bin/env python
from __future__ import absolute_import
from __future__ import print_function
from six.moves import zip
from zulip_bots.test_lib import BotTestCase
class TestHelloWorldBot(BotTestCase):
bot_name = "helloworld"
def test_bot(self):
txt = "beep boop"
messages = ["", "foo", "Hi, my name is abc"]
self.check_expected_responses(dict(list(zip(messages, len(messages)*[txt]))))

View file

@ -0,0 +1,18 @@
# See readme.md for instructions on running this code.
class HelpHandler(object):
def usage(self):
return '''
This plugin will give info about Zulip to
any user that types a message saying "help".
This is example code; ideally, you would flesh
this out for more useful help pertaining to
your Zulip instance.
'''
def handle_message(self, message, bot_handler, state_handler):
help_content = "Info on Zulip can be found here:\nhttps://github.com/zulip/zulip"
bot_handler.send_reply(message, help_content)
handler_class = HelpHandler

View file

@ -0,0 +1,16 @@
#!/usr/bin/env python
from __future__ import absolute_import
from __future__ import print_function
from six.moves import zip
from zulip_bots.test_lib import BotTestCase
class TestHelpBot(BotTestCase):
bot_name = "help"
def test_bot(self):
txt = "Info on Zulip can be found here:\nhttps://github.com/zulip/zulip"
messages = ["", "help", "Hi, my name is abc"]
self.check_expected_responses(dict(list(zip(messages, len(messages)*[txt]))))

Binary file not shown.

After

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.9 KiB

View file

@ -0,0 +1,118 @@
"""
This bot uses the python library `howdoi` which is not a
dependency of Zulip. To use this module, you will have to
install it in your local machine. In your terminal, enter
the following command:
$ sudo pip install howdoi --upgrade
Note:
* You might have to use `pip3` if you are using python 3.
* The install command would also download any dependency
required by `howdoi`.
"""
from __future__ import absolute_import
import sys
import logging
from textwrap import fill
try:
from howdoi.howdoi import howdoi
except ImportError:
logging.error("Dependency missing!!\n%s" % (__doc__))
sys.exit(0)
class HowdoiHandler(object):
'''
This plugin facilitates searching Stack Overflow for
techanical answers based on the Python library `howdoi`.
To get the best possible answer, only include keywords
in your questions.
There are two possible commands:
* @mention-bot howdowe > This would return the answer to the same
stream that it was called from.
* @mention-bot howdoi > The bot would send a private message to the
user containing the answer.
By default, howdoi only returns the coding section of the
first search result if possible, to see the full answer
from Stack Overflow, append a '!' to the commands.
(ie '@mention-bot howdoi!', '@mention-bot howdowe!')
'''
MAX_LINE_LENGTH = 85
def usage(self):
return '''
This plugin will allow users to get techanical
answers from Stackoverflow. Users should preface
their questions with one of the following:
* @mention-bot howdowe > Answer to the same stream
* @mention-bot howdoi > Answer via private message
* @mention-bot howdowe! OR @mention-bot howdoi! > Full answer from SO
'''
def line_wrap(self, string, length):
lines = string.split("\n")
wrapped = [(fill(line) if len(line) > length else line)
for line in lines]
return "\n".join(wrapped).strip()
def get_answer(self, command, query):
question = query[len(command):].strip()
result = howdoi(dict(
query=question,
num_answers=1,
pos=1,
all=command[-1] == '!',
color=False
))
_answer = self.line_wrap(result, HowdoiHandler.MAX_LINE_LENGTH)
answer = "Answer to '%s':\n```\n%s\n```" % (question, _answer)
return answer
def handle_message(self, message, bot_handler, state_handler):
question = message['content'].strip()
if question.startswith('howdowe!'):
bot_handler.send_message(dict(
type='stream',
to=message['display_recipient'],
subject=message['subject'],
content=self.get_answer('howdowe!', question)
))
elif question.startswith('howdoi!'):
bot_handler.send_message(dict(
type='private',
to=message['sender_email'],
content=self.get_answer('howdoi!', question)
))
elif question.startswith('howdowe'):
bot_handler.send_message(dict(
type='stream',
to=message['display_recipient'],
subject=message['subject'],
content=self.get_answer('howdowe', question)
))
elif question.startswith('howdoi'):
bot_handler.send_message(dict(
type='private',
to=message['sender_email'],
content=self.get_answer('howdoi', question)
))
handler_class = HowdoiHandler

View file

@ -0,0 +1,56 @@
# Howdoi bot
This bot will allow users to get technical answers from
[StackOverflow](https://stackoverflow.com). It is build on top of the
python command line tool [howdoi](https://github.com/gleitz/howdoi) by
Benjamin Gleitzman.
## Usage
Simply prepend your questions with one of the following commands. The
answer will be formatted differently depending the chosen command.
| Command | Respond |
| ----------- | ------------------------------------------------------ |
| `@howdowe` | Concise answer to the same stream. |
| `@howdowe!` | Same as `@howdowe` but with full answer and URL of the solutions. |
| `@howdoi` | Concise answer replied to sender via private message. |
| `@howdoi!` | Same as `@howdoi` but with full answer and URL of the solutions. |
## Screenshots
#### Example 1
Question -> `@howdowe use supervisor in elixir`
![howdowe question](assets/question_howdowe.png)
Answer -> Howdoi would try to **only** respond with the coding section
of the answer.
![howdowe answer](assets/answer_howdowe.png)
#### Example 2
Question -> `@howdoi! stack vs heap`
![howdoi! question](assets/question_howdoi_all.png)
Answer -> Howdoi would return the **full** stackoverflow answer via
**private message** to the original sender. The URL of the answer can be
seen at the bottom of the message.
![howdoi! answer](assets/answer_howdoi_all.png)
**Note:**
* Line wrapped is enabled with a maximum line length of 85 characters.
This could be adjusted in the source code (`HowdoiHandler.MAX_LINE_LENGTH`).
* *Howdoi* generally perform better if you ask a question using keywords
instead of a complete sentences (eg: "How do i make a decorator in Python"
-> "python decorator").
* __[*Limitation*]__ If a answer contains multiple code blocks, the `@howdoi`
and `@howdowe` commands would only return the first coding section, use
`@howdo[we|i]!` in that case.

View file

@ -0,0 +1,29 @@
# See readme.md for instructions on running this code.
class IncrementorHandler(object):
def usage(self):
return '''
This is a boilerplate bot that makes use of the
update_message function. For the first @-mention, it initially
replies with one message containing a `1`. Every time the bot
is @-mentioned, this number will be incremented in the same message.
'''
def handle_message(self, message, bot_handler, state_handler):
state = state_handler.get_state() or {'number': 0, 'message_id': None}
state['number'] += 1
state_handler.set_state(state)
if state['message_id'] is None:
result = bot_handler.send_reply(message, str(state['number']))
state['message_id'] = result['id']
state_handler.set_state(state)
else:
bot_handler.update_message(dict(
message_id = state['message_id'],
content = str(state['number'])
))
handler_class = IncrementorHandler

View file

@ -0,0 +1,6 @@
# Incrementor bot
This is a boilerplate bot that makes use of the
update_message function. For the first @-mention, it initially
replies with one message containing a `1`. Every time the bot
is @-mentioned, this number will be incremented in the same message.

Binary file not shown.

After

Width:  |  Height:  |  Size: 83 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 49 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

View file

@ -0,0 +1,86 @@
[
{
"joke":"Did you hear about the guy whose whole left side was cut off? He's all right now."
},
{
"joke":"I'm reading a book about anti-gravity. It's impossible to put down."
},
{
"joke":"I wondered why the baseball was getting bigger. Then it hit me."
},
{
"joke":"I'm glad I know sign language, it's pretty handy."
},
{
"joke":"My friend's bakery burned down last night. Now his business is toast."
},
{
"joke":"Why did the cookie cry? It was feeling crumby."
},
{
"joke":"I used to be a banker, but I lost interest."
},
{
"joke":"A drum and a symbol fall off a cliff"
},
{
"joke":"Why do seagulls fly over the sea? Because they aren't bay-gulls!"
},
{
"joke":"Why did the fireman wear red, white, and blue suspenders? To hold his pants up."
},
{
"joke":"Why didn't the crab share his food? Because crabs are territorial animals, that don't share anything."
},
{
"joke":"Why was the javascript developer sad? Because he didn't Node how to Express himself."
},
{
"joke":"What do I look like? A JOKE MACHINE!?"
},
{
"joke":"How did the hipster burn the roof of his mouth? He ate the pizza before it was cool."
},
{
"joke":"Why is it hard to make puns for kleptomaniacs? They are always taking things literally."
},
{
"joke":"I'm not a humorless, cold hearted, machine. I have feelings you know... or was supposed to."
},
{
"joke":"Two fish in a tank. One looks to the other and says 'Can you even drive this thing???'"
},
{
"joke":"Two fish swim down a river, and hit a wall. One says: 'Dam!'"
},
{
"joke":"What's funnier than a monkey dancing with an elephant? Two monkeys dancing with an elephant."
},
{
"joke":"How did Darth Vader know what Luke was getting for Christmas? He felt his presents."
},
{
"joke":"What's red and bad for your teeth? A Brick."
},
{
"joke":"What's orange and sounds like a parrot? A Carrot."
},
{
"joke":"What do you call a cow with no legs? Ground beef"
},
{
"joke":"Two guys walk into a bar. You'd think the second one would have noticed."
},
{
"joke":"What is a centipedes's favorite Beatle song? I want to hold your hand, hand, hand, hand..."
},
{
"joke":"What do you call a chicken crossing the road? Poultry in moton. "
},
{
"joke":"What do you call a fake noodle? An impasta"
},
{
"joke":"How many tickles does it take to tickle an octupus? Ten-tickles!"
}
]

View file

@ -0,0 +1,119 @@
import json
import os
from random import choice
try:
from chatterbot import ChatBot
from chatterbot.trainers import ChatterBotCorpusTrainer, ListTrainer
except ImportError:
raise ImportError("""It looks like you are missing chatterbot.
Please: pip install chatterbot""")
BOTS_DIR = os.path.dirname(os.path.abspath(__file__))
JOKES_PATH = os.path.join(BOTS_DIR, 'assets/var/jokes.json')
DATABASE_PATH = os.path.join(BOTS_DIR, 'assets/var/database.db')
DIRECTORY_PATH = os.path.join(BOTS_DIR, 'assets')
VAR_PATH = os.path.join(BOTS_DIR, 'assets/var')
if not os.path.exists(DIRECTORY_PATH):
os.makedirs(DIRECTORY_PATH)
if not os.path.exists(VAR_PATH):
os.makedirs(VAR_PATH)
# Create a new instance of a ChatBot
def create_chat_bot(no_learn):
return ChatBot("John",
storage_adapter="chatterbot.storage.JsonFileStorageAdapter",
logic_adapters=
[
"chatterbot.logic.MathematicalEvaluation",
{
"import_path": "chatterbot.logic.BestMatch",
"response_selection_method": "chatterbot.response_selection.get_random_response",
"statement_comparison_function": "chatterbot.comparisons.levenshtein_distance"
}],
output_adapter="chatterbot.output.OutputAdapter",
output_format='text',
database=DATABASE_PATH,
silence_performance_warning="True",
read_only=no_learn)
bot = create_chat_bot(False)
bot.set_trainer(ListTrainer)
bot.train([
"I want to contribute",
"""Contributors are more than welcomed! Please read
https://github.com/zulip/zulip#how-to-get-involved-with-contributing-to-zulip
to learn how to contribute.""",
])
bot.train([
"What is Zulip?",
"""Zulip is a powerful, open source group chat application. Written in Python
and using the Django framework, Zulip supports both private messaging and group
chats via conversation streams. You can learn more about the product and its
features at https://www.zulip.org.""",
])
bot.train([
"I would like to request a remote dev instance",
"""Greetings! You should receive a response from one of our mentors soon.
In the meantime, why don't you learn more about running Zulip on a development
environment? https://zulip.readthedocs.io/en/latest/using-dev-environment.html""",
])
bot.train([
"Joke!",
"Only if you ask nicely!",
])
bot.train([
"What is your name?",
"I am John, my job is to assist you with Zulip.",
])
bot.train([
"What can you do?",
"I can provide useful information and jokes if you follow etiquette.",
])
with open(JOKES_PATH) as data_file:
for joke in json.load(data_file):
bot.train([
"Please can you tell me a joke?",
joke['joke'],
])
bot.set_trainer(ChatterBotCorpusTrainer)
bot.train(
"chatterbot.corpus.english"
)
bota = create_chat_bot(True)
class JohnHandler(object):
'''
This bot aims to be Zulip's virtual assistant. It
finds the best match from a certain input.
Also understands the English language and can
mantain a conversation, joke and give useful information.
'''
def usage(self):
return '''
This bot aims to be Zulip's virtual assistant. It
finds the best match from a certain input.
Also understands the English language and can
mantain a conversation, joke and give useful information.
'''
def handle_message(self, message, bot_handler, state_handler):
original_content = message['content']
bot_response = str(bota.get_response(original_content))
bot_handler.send_reply(message, bot_response)
handler_class = JohnHandler

View file

@ -0,0 +1,30 @@
John
Instructions:
You'll have to install chatterbot to use this bot.
Please run: pip install chatterbot on your command line.
The script will need to download some NLTK packages after running in your
home directory. With the mission of humanizing bot interactions, John aims to be your
virtual assistant at the hour of asking for help in Zulip. John is an
interactive bot that uses machine learning heuristics to simulate a
conversation with the user. He has a great sense of humor and
is also powered by Open Source code!
![Joke John](assets/joke.png)
How it works?
John is initially trained with Corpus files, or large text files.
Dialogues are loaded into a json "database", he will try to follow them
once it receives input from a user. John will query the database and
try to find the response that best matches the input according to the Levenshtein distance
which is a string metric for measuring the difference between two sequences. If several
responses have the same acurracy, he will choose one at random.
![Meet John](assets/greetings.png)
Can he learn by himself?
John's engine allows him to learn from his conversations with people. However,
without strict supervision bots that learn from people can do harm, so learning
is currently restricted to his initial corpus.
![Assist](assets/assist.png)

View file

@ -0,0 +1 @@
PyDictionary

View file

@ -0,0 +1,25 @@
#!/usr/bin/env python
from __future__ import absolute_import
from __future__ import print_function
from zulip_bots.test_lib import BotTestCase
class TestThesaurusBot(BotTestCase):
bot_name = "thesaurus"
def test_bot(self):
expected = {
"synonym good": "great, satisfying, exceptional, positive, acceptable",
"synonym nice": "cordial, kind, good, okay, fair",
"synonym foo": "bar, thud, X, baz, corge",
"antonym dirty": "ordered, sterile, spotless, moral, clean",
"antonym bar": "loss, whole, advantage, aid, failure",
"": ("To use this bot, start messages with either "
"@mention-bot synonym (to get the synonyms of a given word) "
"or @mention-bot antonym (to get the antonyms of a given word). "
"Phrases are not accepted so only use single words "
"to search. For example you could search '@mention-bot synonym hello' "
"or '@mention-bot antonym goodbye'."),
}
self.check_expected_responses(expected)

View file

@ -0,0 +1,67 @@
# See zulip/api/bots/readme.md for instructions on running this code.
from __future__ import print_function
import sys
import logging
from PyDictionary import PyDictionary as Dictionary
#Uses Python's Dictionary module
# pip install PyDictionary
def get_clean_response(m, method):
try:
response = method(m)
except Exception as e:
logging.exception(e)
return e
if isinstance(response, str):
return response
elif isinstance(response, list):
return ', '.join(response)
else:
return "Sorry, no result found! Please check the word."
def get_thesaurus_result(original_content):
help_message = ("To use this bot, start messages with either "
"@mention-bot synonym (to get the synonyms of a given word) "
"or @mention-bot antonym (to get the antonyms of a given word). "
"Phrases are not accepted so only use single words "
"to search. For example you could search '@mention-bot synonym hello' "
"or '@mention-bot antonym goodbye'.")
query = original_content.strip().split(' ', 1)
if len(query) < 2:
return help_message
else:
search_keyword = query[1]
if original_content.startswith('synonym'):
result = get_clean_response(search_keyword, method = Dictionary.synonym)
elif original_content.startswith('antonym'):
result = get_clean_response(search_keyword, method = Dictionary.antonym)
else:
result = help_message
return result
class ThesaurusHandler(object):
'''
This plugin allows users to enter a word in zulip
and get synonyms, and antonyms, for that word sent
back to the context (stream or private) in which
it was sent. It looks for messages starting with
'@mention-bot synonym' or '@mention-bot @antonym'.
'''
def usage(self):
return '''
This plugin will allow users to get both synonyms
and antonyms for a given word from zulip. To use this
plugin, users need to install the PyDictionary module
using 'pip install PyDictionary'.Use '@mention-bot synonym help' or
'@mention-bot antonym help' for more usage information. Users should
preface messages with @mention-bot synonym or @mention-bot antonym.
'''
def handle_message(self, message, bot_handler, state_handler):
original_content = message['content'].strip()
new_content = get_thesaurus_result(original_content)
bot_handler.send_reply(message, new_content)
handler_class = ThesaurusHandler

View file

@ -0,0 +1,34 @@
# About Tic-Tac-Toe Bot
This bot allows you to play tic-tac-toe in a private message with the bot.
Multiple games can simultaneously be played by different users, each playing
against the computer.
The bot only responds to messages starting with @mention of the bot(botname).
### Commands
**@mention-botname new** will start a new game (but not if you are
already playing a game.) You must type this first to start playing!
**@mention-botname help** will return a help function with valid
commands and coordinates.
**@mention-botname quit** will quit from the current game.
**@mention-botname <coordinate>** will make a move at the
entered coordinate. For example, **@mention-botname 1,1** . After this, the bot will make
its move, or declare the game over if the user or bot has won.
Coordinates are entered in a (row, column) format. Numbering is from top to
bottom and left to right.
Here are the coordinates of each position. When entering coordinates, parentheses
and spaces are optional.
(1, 1) | (1, 2) | (1, 3)
(2, 1) | (2, 2) | (2, 3)
(3, 1) | (3, 2) | (3, 3)
Invalid commands will result in an "I don't understand" response from the bot,
with a suggestion to type **@mention-botname help** .

View file

@ -0,0 +1,323 @@
from __future__ import absolute_import
from __future__ import print_function
import copy
import random
from six.moves import range
initial_board = [["_", "_", "_"],
["_", "_", "_"],
["_", "_", "_"]]
mode = 'r' # default, can change for debugging to 'p'
def output_mode(string_to_print, mode):
if mode == "p":
print(string_to_print)
elif mode == "r":
return string_to_print
# -------------------------------------
class TicTacToeGame(object):
smarter = True
# If smarter is True, the computer will do some extra thinking - it'll be harder for the user.
triplets = [[(0, 0), (0, 1), (0, 2)], # Row 1
[(1, 0), (1, 1), (1, 2)], # Row 2
[(2, 0), (2, 1), (2, 2)], # Row 3
[(0, 0), (1, 0), (2, 0)], # Column 1
[(0, 1), (1, 1), (2, 1)], # Column 2
[(0, 2), (1, 2), (2, 2)], # Column 3
[(0, 0), (1, 1), (2, 2)], # Diagonal 1
[(0, 2), (1, 1), (2, 0)] # Diagonal 2
]
positions = "Coordinates are entered in a (row, column) format. Numbering is from top to bottom and left to right.\n" \
"Here are the coordinates of each position. (Parentheses and spaces are optional.) \n" \
"(1, 1) (1, 2) (1, 3) \n(2, 1) (2, 2) (2, 3) \n(3, 1) (3, 2) (3, 3) \n " \
"Your move would be one of these. To make a move, type @mention-bot " \
"followed by a space and the coordinate."
detailed_help_message = "*Help for Tic-Tac-Toe bot* \n" \
"The bot responds to messages starting with @mention-bot.\n" \
"**@mention-bot new** will start a new game (but not if you're " \
"already in the middle of a game). You must type this first to start playing!\n" \
"**@mention-bot help** will return this help function.\n" \
"**@mention-bot quit** will quit from the current game.\n" \
"**@mention-bot <coordinate>** will make a move at the given coordinate.\n" \
"Coordinates are entered in a (row, column) format. Numbering is from " \
"top to bottom and left to right. \n" \
"Here are the coordinates of each position. (Parentheses and spaces are optional). \n" \
"(1, 1) (1, 2) (1, 3) \n(2, 1) (2, 2) (2, 3) \n(3, 1) (3, 2) (3, 3) \n"
def __init__(self, board):
self.board = board
def display_row(self, row):
''' Takes the row passed in as a list and returns it as a string. '''
row_string = " ".join([e.strip() for e in row])
return("[ {} ]\n".format(row_string))
def display_board(self, board):
''' Takes the board as a nested list and returns a nice version for the user. '''
return "".join([self.display_row(r) for r in board])
def get_value(self, board, position):
return board[position[0]][position[1]]
def board_is_full(self, board):
''' Determines if the board is full or not. '''
full = False
board_state = ""
for row in board:
for element in row:
if element == "_":
board_state += "_"
if "_" not in board_state:
full = True
return full
def win_conditions(self, board, triplets):
''' Returns true if all coordinates in a triplet have the same value in them (x or o) and no coordinates
in the triplet are blank. '''
won = False
for triplet in triplets:
if (self.get_value(board, triplet[0]) == self.get_value(board, triplet[1]) ==
self.get_value(board, triplet[2]) != "_"):
won = True
break
return won
def get_locations_of_char(self, board, char):
''' Gets the locations of the board that have char in them. '''
locations = []
for row in range(3):
for col in range(3):
if board[row][col] == char:
locations.append([row, col])
return locations
def two_blanks(self, triplet, board):
''' Determines which rows/columns/diagonals have two blank spaces and an 'o' already in them. It's more advantageous
for the computer to move there. This is used when the computer makes its move. '''
o_found = False
for position in triplet:
if self.get_value(board, position) == "o":
o_found = True
break
blanks_list = []
if o_found:
for position in triplet:
if self.get_value(board, position) == "_":
blanks_list.append(position)
if len(blanks_list) == 2:
return blanks_list
def computer_move(self, board):
''' The computer's logic for making its move. '''
my_board = copy.deepcopy(board) # First the board is copied; used later on
blank_locations = self.get_locations_of_char(my_board, "_")
x_locations = self.get_locations_of_char(board, "x") # Gets the locations that already have x's
corner_locations = [[0, 0], [0, 2], [2, 0], [2, 2]] # List of the coordinates of the corners of the board
edge_locations = [[1, 0], [0, 1], [1, 2], [2, 1]] # List of the coordinates of the edge spaces of the board
if blank_locations == []: # If no empty spaces are left, the computer can't move anyway, so it just returns the board.
return board
if len(x_locations) == 1: # This is special logic only used on the first move.
# If the user played first in the corner or edge, the computer should move in the center.
if x_locations[0] in corner_locations or x_locations[0] in edge_locations:
board[1][1] = "o"
# If user played first in the center, the computer should move in the corner. It doesn't matter which corner.
else:
location = random.choice(corner_locations)
row = location[0]
col = location[1]
board[row][col] = "o"
return board
# This logic is used on all other moves.
# First I'll check if the computer can win in the next move. If so, that's where the computer will play.
# The check is done by replacing the blank locations with o's and seeing if the computer would win in each case.
for row, col in blank_locations:
my_board[row][col] = "o"
if self.win_conditions(my_board, self.triplets):
board[row][col] = "o"
return board
else:
my_board[row][col] = "_" # Revert if not winning
# If the computer can't immediately win, it wants to make sure the user can't win in their next move, so it
# checks to see if the user needs to be blocked.
# The check is done by replacing the blank locations with x's and seeing if the user would win in each case.
for row, col in blank_locations:
my_board[row][col] = "x"
if self.win_conditions(my_board, self.triplets):
board[row][col] = "o"
return board
else:
my_board[row][col] = "_" # Revert if not winning
# Assuming nobody will win in their next move, now I'll find the best place for the computer to win.
for row, col in blank_locations:
if ('x' not in my_board[row] and my_board[0][col] != 'x' and my_board[1][col] !=
'x' and my_board[2][col] != 'x'):
board[row][col] = 'o'
return board
# If no move has been made, choose a random blank location. If smarter is True, the computer will choose a
# random blank location from a set of better locations to play. These locations are determined by seeing if
# there are two blanks and an 'o' in each row, column, and diagonal (done in two_blanks).
# If smarter is False, all blank locations can be chosen.
if self.smarter:
blanks = []
for triplet in self.triplets:
result = self.two_blanks(triplet, board)
if result:
blanks = blanks + result
blank_set = set(blanks)
blank_list = list(blank_set)
if blank_list == []:
location = random.choice(blank_locations)
else:
location = random.choice(blank_list)
row = location[0]
col = location[1]
board[row][col] = 'o'
return board
else:
location = random.choice(blank_locations)
row = location[0]
col = location[1]
board[row][col] = 'o'
return board
def check_validity(self, move):
''' Checks the validity of the coordinate input passed in to make sure it's not out-of-bounds (ex. 5, 5) '''
try:
split_move = move.split(",")
row = split_move[0].strip()
col = split_move[1].strip()
valid = False
if row == "1" or row == "2" or row == "3":
if col == "1" or col == "2" or col == "3":
valid = True
except IndexError:
valid = False
return valid
def sanitize_move(self, move):
''' As there are various ways to input a coordinate (with/without parentheses, with/without spaces, etc.) the
input is stripped to just the numbers before being used in the program. '''
move = move.replace("(", "")
move = move.replace(")", "")
move = move.strip()
return move
def tictactoe(self, board, input_string):
return_string = ""
move = self.sanitize_move(input_string)
# Subtraction must be done to convert to the right indices, since computers start numbering at 0.
row = (int(move[0])) - 1
column = (int(move[-1])) - 1
if board[row][column] != "_":
return_string += output_mode("That space is already filled, sorry!", mode)
return return_string
else:
board[row][column] = "x"
return_string += self.display_board(board)
# Check to see if the user won/drew after they made their move. If not, it's the computer's turn.
if self.win_conditions(board, self.triplets):
return_string += output_mode("Game over! You've won!", mode)
return return_string
if self.board_is_full(board):
return_string += output_mode("It's a draw! Neither of us was able to win.", mode)
return return_string
return_string += output_mode("My turn:\n", mode)
self.computer_move(board)
return_string += self.display_board(board)
# Checks to see if the computer won after it makes its move. (The computer can't draw, so there's no point
# in checking.) If the computer didn't win, the user gets another turn.
if self.win_conditions(board, self.triplets):
return_string += output_mode("Game over! I've won!", mode)
return return_string
return_string += output_mode("Your turn! Enter a coordinate or type help.", mode)
return return_string
# -------------------------------------
flat_initial = sum(initial_board, [])
def first_time(board):
flat = sum(board, [])
return flat == flat_initial
class ticTacToeHandler(object):
'''
You can play tic-tac-toe in a private message with
tic-tac-toe bot! Make sure your message starts with
"@mention-bot".
'''
def usage(self):
return '''
You can play tic-tac-toe with the computer now! Make sure your
message starts with @mention-bot.
'''
def handle_message(self, message, bot_handler, state_handler):
command_list = message['content']
command = ""
for val in command_list:
command += val
original_sender = message['sender_email']
mydict = state_handler.get_state()
if not mydict:
state_handler.set_state({})
mydict = state_handler.get_state()
user_game = mydict.get(original_sender)
if (not user_game) and command == "new":
user_game = TicTacToeGame(copy.deepcopy(initial_board))
mydict[original_sender] = user_game
if command == 'new':
if user_game and not first_time(user_game.board):
return_content = "You're already playing a game! Type **@tictactoe help** or **@ttt help** to see valid inputs."
else:
return_content = "Welcome to tic-tac-toe! You'll be x's and I'll be o's. Your move first!\n"
return_content += TicTacToeGame.positions
elif command == 'help':
return_content = TicTacToeGame.detailed_help_message
elif (user_game) and TicTacToeGame.check_validity(user_game, TicTacToeGame.sanitize_move(user_game, command)):
user_board = user_game.board
return_content = TicTacToeGame.tictactoe(user_game, user_board, command)
elif (user_game) and command == 'quit':
del mydict[original_sender]
return_content = "You've successfully quit the game."
else:
return_content = "Hmm, I didn't understand your input. Type **@tictactoe help** or **@ttt help** to see valid inputs."
if "Game over" in return_content or "draw" in return_content:
del mydict[original_sender]
state_handler.set_state(mydict)
bot_handler.send_message(dict(
type = 'private',
to = original_sender,
subject = message['sender_email'],
content = return_content,
))
handler_class = ticTacToeHandler

View file

@ -0,0 +1,44 @@
# Virtual fs bot
This bot allows users to store information in a virtual file system,
for a given stream or private chat.
## Usage
Run this bot as described in
[here](http://zulip.readthedocs.io/en/latest/bots-guide.html#how-to-deploy-a-bot).
Use this bot with any of the following commands:
`@fs mkdir` : create a directory
`@fs ls` : list a directory
`@fs cd` : change directory
`@fs pwd` : show current path
`@fs write` : write text
`@fs read` : read text
`@fs rm` : remove a file
`@fs rmdir` : remove a directory
where `fs` may be the name of the bot you registered in the zulip system.
### Usage examples
`@fs ls` - Initially shows nothing (with a warning)
`@fs pwd` - Show which directory we are in: we start in /
`@fs mkdir foo` - Make directory foo
`@fs ls` - Show that foo is now created
`@fs cd foo` - Change into foo (and do a pwd, automatically)
`@fs write test hello world` - Write "hello world" to the file 'test'
`@fs read test` - Check the text was written
`@fs ls` - Show that the new file exists
`@fs rm test` - Remove that file
`@fs cd /` - Change back to root directory
`@fs rmdir foo` - Remove foo
## Notes
* In a stream, the bot must be mentioned; in a private chat, the bot
will assume every message is a command and so does not require this,
though doing so will still work.
* Use commands like `@fs help write` for more details on a command.

View file

@ -0,0 +1,48 @@
#!/usr/bin/env python
from __future__ import absolute_import
from __future__ import print_function
from zulip_bots.test_lib import BotTestCase
class TestVirtualFsBot(BotTestCase):
bot_name = "virtual_fs"
def test_bot(self):
expected = {
"cd /home": "foo_sender@zulip.com:\nERROR: invalid path",
"mkdir home": "foo_sender@zulip.com:\ndirectory created",
"pwd": "foo_sender@zulip.com:\n/",
"help": ('foo_sender@zulip.com:\n\nThis bot implements a virtual file system for a stream.\n'
'The locations of text are persisted for the lifetime of the bot\n'
'running, and if you rename a stream, you will lose the info.\n'
'Example commands:\n\n```\n'
'@mention-bot sample_conversation: sample conversation with the bot\n'
'@mention-bot mkdir: create a directory\n'
'@mention-bot ls: list a directory\n'
'@mention-bot cd: change directory\n'
'@mention-bot pwd: show current path\n'
'@mention-bot write: write text\n'
'@mention-bot read: read text\n'
'@mention-bot rm: remove a file\n'
'@mention-bot rmdir: remove a directory\n'
'```\n'
'Use commands like `@mention-bot help write` for more details on specific\ncommands.\n'),
"help ls": "foo_sender@zulip.com:\nsyntax: ls <optional_path>",
"": ('foo_sender@zulip.com:\n\nThis bot implements a virtual file system for a stream.\n'
'The locations of text are persisted for the lifetime of the bot\n'
'running, and if you rename a stream, you will lose the info.\n'
'Example commands:\n\n```\n'
'@mention-bot sample_conversation: sample conversation with the bot\n'
'@mention-bot mkdir: create a directory\n'
'@mention-bot ls: list a directory\n'
'@mention-bot cd: change directory\n'
'@mention-bot pwd: show current path\n'
'@mention-bot write: write text\n'
'@mention-bot read: read text\n'
'@mention-bot rm: remove a file\n'
'@mention-bot rmdir: remove a directory\n'
'```\n'
'Use commands like `@mention-bot help write` for more details on specific\ncommands.\n'),
}
self.check_expected_responses(expected)

View file

@ -0,0 +1,343 @@
# See readme.md for instructions on running this code.
import re
import os
class VirtualFsHandler(object):
def usage(self):
return get_help()
def handle_message(self, message, bot_handler, state_handler):
command = message['content']
if command == "":
command = "help"
sender = message['sender_email']
state = state_handler.get_state()
if state is None:
state = {}
recipient = message['display_recipient']
if isinstance(recipient, list): # If not a stream, then hash on list of emails
recipient = " ".join([x['email'] for x in recipient])
if recipient not in state:
state[recipient] = fs_new()
fs = state[recipient]
if sender not in fs['user_paths']:
fs['user_paths'][sender] = '/'
fs, msg = fs_command(fs, sender, command)
prependix = '{}:\n'.format(sender)
msg = prependix + msg
state[recipient] = fs
state_handler.set_state(state)
bot_handler.send_reply(message, msg)
def get_help():
return '''
This bot implements a virtual file system for a stream.
The locations of text are persisted for the lifetime of the bot
running, and if you rename a stream, you will lose the info.
Example commands:
```
@mention-bot sample_conversation: sample conversation with the bot
@mention-bot mkdir: create a directory
@mention-bot ls: list a directory
@mention-bot cd: change directory
@mention-bot pwd: show current path
@mention-bot write: write text
@mention-bot read: read text
@mention-bot rm: remove a file
@mention-bot rmdir: remove a directory
```
Use commands like `@mention-bot help write` for more details on specific
commands.
'''
def sample_conversation():
return [
('cd /\nCurrent path: /\n\n'),
('cd /home\nERROR: invalid path\n\n'),
('cd .\nERROR: invalid path\n\n'),
('mkdir home\ndirectory created\n\n'),
('cd home\nCurrent path: /home/\n\n'),
('cd /home/\nCurrent path: /home/\n\n'),
('mkdir stuff/\nERROR: stuff/ is not a valid name\n\n'),
('mkdir stuff\ndirectory created\n\n'),
('write stuff/file1 something\nfile written\n\n'),
('read stuff/file1\nsomething\n\n'),
('read /home/stuff/file1\nsomething\n\n'),
('read home/stuff/file1\nERROR: file does not exist\n\n'),
('pwd \n/home/\n\n'),
('pwd bla\nERROR: syntax: pwd\n\n'),
('ls bla foo\nERROR: syntax: ls <optional_path>\n\n'),
('cd /\nCurrent path: /\n\n'),
('rm home\nERROR: /home/ is a directory, file required\n\n'),
('rmdir home\nremoved\n\n'),
('ls \nWARNING: directory is empty\n\n'),
('cd home\nERROR: invalid path\n\n'),
('read /home/stuff/file1\nERROR: file does not exist\n\n'),
('cd /\nCurrent path: /\n\n'),
('write /foo contents of /foo\nfile written\n\n'),
('read /foo\ncontents of /foo\n\n'),
('write /bar Contents: bar bar\nfile written\n\n'),
('read /bar\nContents: bar bar\n\n'),
('write /bar invalid\nERROR: file already exists\n\n'),
('rm /bar\nremoved\n\n'),
('rm /bar\nERROR: file does not exist\n\n'),
('write /bar new bar\nfile written\n\n'),
('read /bar\nnew bar\n\n'),
('write /yo/invalid whatever\nERROR: /yo is not a directory\n\n'),
('mkdir /yo\ndirectory created\n\n'),
('read /yo\nERROR: /yo/ is a directory, file required\n\n'),
('ls /yo\nWARNING: directory is empty\n\n'),
('read /yo/nada\nERROR: file does not exist\n\n'),
('write /yo whatever\nERROR: file already exists\n\n'),
('write /yo/apple red\nfile written\n\n'),
('read /yo/apple\nred\n\n'),
('mkdir /yo/apple\nERROR: file already exists\n\n'),
('ls /invalid\nERROR: file does not exist\n\n'),
('ls /foo\nERROR: /foo is not a directory\n\n'),
('ls /\n* /*bar*\n* /*foo*\n* /yo/\n\n'),
('invalid command\nERROR: unrecognized command\n\n'),
('write\nERROR: syntax: write <path> <some_text>\n\n'),
('help' + get_help() + '\n\n'),
('help ls\nsyntax: ls <optional_path>\n\n'),
('help invalid_command' + get_help() + '\n\n'),
]
REGEXES = dict(
command='(cd|ls|mkdir|read|rmdir|rm|write|pwd)',
path='(\S+)',
optional_path='(\S*)',
some_text='(.+)',
)
def get_commands():
return {
'help': (fs_help, ['command']),
'sample_conversation': (fs_sample_conversation, ['command']),
'ls': (fs_ls, ['optional_path']),
'mkdir': (fs_mkdir, ['path']),
'read': (fs_read, ['path']),
'rm': (fs_rm, ['path']),
'rmdir': (fs_rmdir, ['path']),
'write': (fs_write, ['path', 'some_text']),
'cd': (fs_cd, ['path']),
'pwd': (fs_pwd, []),
}
def fs_command(fs, user, cmd):
cmd = cmd.strip()
if cmd == 'help':
return fs, get_help()
if cmd == 'sample_conversation':
return fs, (''.join(sample_conversation()))
cmd_name = cmd.split()[0]
cmd_args = cmd[len(cmd_name):].strip()
commands = get_commands()
if cmd_name not in commands:
return fs, 'ERROR: unrecognized command'
f, arg_names = commands[cmd_name]
partial_regexes = [REGEXES[a] for a in arg_names]
regex = ' '.join(partial_regexes)
regex += '$'
m = re.match(regex, cmd_args)
if m:
return f(fs, user, *m.groups())
elif cmd_name == 'help':
return fs, get_help()
else:
return fs, 'ERROR: ' + syntax_help(cmd_name)
def syntax_help(cmd_name):
commands = get_commands()
f, arg_names = commands[cmd_name]
arg_syntax = ' '.join('<' + a + '>' for a in arg_names)
if arg_syntax:
cmd = cmd_name + ' ' + arg_syntax
else:
cmd = cmd_name
return 'syntax: {}'.format(cmd)
def fs_new():
fs = {
'/': directory([]),
'user_paths': dict()
}
return fs
def fs_help(fs, user, cmd_name):
return fs, syntax_help(cmd_name)
def fs_sample_conversation(fs, user, cmd_name):
return fs, syntax_help(cmd_name)
def fs_mkdir(fs, user, fn):
path, msg = make_path(fs, user, fn)
if msg:
return fs, msg
if path in fs:
return fs, 'ERROR: file already exists'
dir_path = os.path.dirname(path)
if not is_directory(fs, dir_path):
msg = 'ERROR: {} is not a directory'.format(dir_path)
return fs, msg
new_fs = fs.copy()
new_dir = directory({path}.union(fs[dir_path]['fns']))
new_fs[dir_path] = new_dir
new_fs[path] = directory([])
msg = 'directory created'
return new_fs, msg
def fs_ls(fs, user, fn):
if fn == '.' or fn == '':
path = fs['user_paths'][user]
else:
path, msg = make_path(fs, user, fn)
if msg:
return fs, msg
if path not in fs:
msg = 'ERROR: file does not exist'
return fs, msg
if not is_directory(fs, path):
return fs, 'ERROR: {} is not a directory'.format(path)
fns = fs[path]['fns']
if not fns:
return fs, 'WARNING: directory is empty'
msg = '\n'.join('* ' + nice_path(fs, path) for path in sorted(fns))
return fs, msg
def fs_pwd(fs, user):
path = fs['user_paths'][user]
msg = nice_path(fs, path)
return fs, msg
def fs_rm(fs, user, fn):
path, msg = make_path(fs, user, fn)
if msg:
return fs, msg
if path not in fs:
msg = 'ERROR: file does not exist'
return fs, msg
if fs[path]['kind'] == 'dir':
msg = 'ERROR: {} is a directory, file required'.format(nice_path(fs, path))
return fs, msg
new_fs = fs.copy()
new_fs.pop(path)
directory = get_directory(path)
new_fs[directory]['fns'].remove(path)
msg = 'removed'
return new_fs, msg
def fs_rmdir(fs, user, fn):
path, msg = make_path(fs, user, fn)
if msg:
return fs, msg
if path not in fs:
msg = 'ERROR: directory does not exist'
return fs, msg
if fs[path]['kind'] == 'text':
msg = 'ERROR: {} is a file, directory required'.format(nice_path(fs, path))
return fs, msg
new_fs = fs.copy()
new_fs.pop(path)
directory = get_directory(path)
new_fs[directory]['fns'].remove(path)
for sub_path in new_fs.keys():
if sub_path.startswith(path+'/'):
new_fs.pop(sub_path)
msg = 'removed'
return new_fs, msg
def fs_write(fs, user, fn, content):
path, msg = make_path(fs, user, fn)
if msg:
return fs, msg
if path in fs:
msg = 'ERROR: file already exists'
return fs, msg
dir_path = os.path.dirname(path)
if not is_directory(fs, dir_path):
msg = 'ERROR: {} is not a directory'.format(dir_path)
return fs, msg
new_fs = fs.copy()
new_dir = directory({path}.union(fs[dir_path]['fns']))
new_fs[dir_path] = new_dir
new_fs[path] = text_file(content)
msg = 'file written'
return new_fs, msg
def fs_read(fs, user, fn):
path, msg = make_path(fs, user, fn)
if msg:
return fs, msg
if path not in fs:
msg = 'ERROR: file does not exist'
return fs, msg
if fs[path]['kind'] == 'dir':
msg = 'ERROR: {} is a directory, file required'.format(nice_path(fs, path))
return fs, msg
val = fs[path]['content']
return fs, val
def fs_cd(fs, user, fn):
if len(fn) > 1 and fn[-1] == '/':
fn = fn[:-1]
path = fn if len(fn) > 0 and fn[0] == '/' else make_path(fs, user, fn)[0]
if path not in fs:
msg = 'ERROR: invalid path'
return fs, msg
if fs[path]['kind'] == 'text':
msg = 'ERROR: {} is a file, directory required'.format(nice_path(fs, path))
return fs, msg
fs['user_paths'][user] = path
return fs, "Current path: {}".format(nice_path(fs, path))
def make_path(fs, user, leaf):
if leaf == '/':
return ['/', '']
if leaf.endswith('/'):
return ['', 'ERROR: {} is not a valid name'.format(leaf)]
if leaf.startswith('/'):
return [leaf, '']
path = fs['user_paths'][user]
if not path.endswith('/'):
path += '/'
path += leaf
return path, ''
def nice_path(fs, path):
path_nice = path
slash = path.rfind('/')
if path not in fs:
return 'ERROR: the current directory does not exist'
if fs[path]['kind'] == 'text':
path_nice = '{}*{}*'.format(path[:slash+1], path[slash+1:])
elif path != '/':
path_nice = '{}/'.format(path)
return path_nice
def get_directory(path):
slash = path.rfind('/')
if slash == 0:
return '/'
else:
return path[:slash]
def directory(fns):
return dict(kind='dir', fns=set(fns))
def text_file(content):
return dict(kind='text', content=content)
def is_directory(fs, fn):
if fn not in fs:
return False
return fs[fn]['kind'] == 'dir'
handler_class = VirtualFsHandler

Binary file not shown.

After

Width:  |  Height:  |  Size: 67 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

View file

@ -0,0 +1,16 @@
# WeatherBot
* This is a bot that sends weather information to a selected stream on
request.
* Weather information is brought to the website using an
OpenWeatherMap API. The bot posts the weather information to the
stream from which the user inputs the message. If the user inputs a
city that does not exist, the bot displays a "Sorry, city not found"
message.
* Before using this bot, you have to generate an OpenWeatherMap API
key and replace the dummy value in weather.conf.
![Example Usage](assets/screen1.png)
![Wrong City](assets/screen2.png)

View file

@ -0,0 +1,2 @@
[weather-config]
key=XXXXXXXX

Some files were not shown because too many files have changed in this diff Show more