├── .github └── PULL_REQUEST_TEMPLATE.md ├── .gitignore ├── .travis.yml ├── Dockerfile ├── LICENSE ├── Procfile ├── README.md ├── docs ├── config.md ├── heroku.md └── quickstart.md ├── hamper.acl.dist ├── hamper.conf.dist ├── hamper.env.dist ├── hamper ├── __init__.py ├── acl.py ├── cli.py ├── commander.py ├── config.py ├── interfaces.py ├── log.py ├── plugins │ ├── __init__.py │ ├── bitly.py │ ├── channel_utils.py │ ├── commands.py │ ├── dictionary.py │ ├── factoids.py │ ├── flip.py │ ├── foods.py │ ├── friendly.py │ ├── goodbye.py │ ├── help.py │ ├── karma.py │ ├── karma_adv.py │ ├── maniacal.py │ ├── platitudes.py │ ├── plugin_utils.py │ ├── questions.py │ ├── quotes.py │ ├── roulette.py │ ├── seen.py │ ├── suggest.py │ ├── timez.py │ ├── tinyurl.py │ └── whatwasthat.py ├── tests │ ├── __init__.py │ ├── test_command.py │ ├── test_interfaces.py │ └── test_permissions.py └── utils.py ├── requirements.txt ├── setup.py └── troubleshooting.md /.github/PULL_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | Thank you for improving Hamper! 2 | 3 | Check one: 4 | 5 | - [ ] I remembered to bump the version 6 | - [ ] This is a typo fix and no version bump is necessary 7 | 8 | Does this fix an issue? 9 | 10 | - [ ] Fixes #____ 11 | 12 | Do you want this change to show up in the LUG hamper? 13 | 14 | - [ ] I PR'd a version bump to [LUG hamper](https://github.com/hamperbot/hamper-lug/blob/master/requirements.txt) 15 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.pyc 2 | .*.swp 3 | .*.swo 4 | hamper.conf 5 | hamper.db 6 | /bin/ 7 | /include/ 8 | /lib/ 9 | dropin.cache 10 | build/ 11 | /_trial_temp*/ 12 | hamper.acl 13 | /man/ 14 | /venv/ 15 | 16 | dist/ 17 | hamper.egg-info/ 18 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: python 2 | python: '2.7' 3 | sudo: false 4 | 5 | before_script: 6 | - pyflakes hamper 7 | script: 8 | - trial hamper 9 | 10 | notifications: 11 | email: false 12 | irc: 13 | channels: 14 | - chat.freenode.org#hamper 15 | on_success: always 16 | on_failure: always 17 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM python:2.7.9 2 | 3 | RUN mkdir -p /usr/src/hamper 4 | WORKDIR /usr/src/hamper 5 | 6 | ENV HAMPER_DB_DIR /var/lib/hamper 7 | VOLUME $HAMPER_DB_DIR 8 | ENV DATABASE_URL sqlite:///$HAMPER_DB_DIR/hamper.db 9 | 10 | # helps with caching 11 | COPY requirements.txt /usr/src/hamper/requirements.txt 12 | RUN pip install -r requirements.txt 13 | 14 | COPY . /usr/src/hamper 15 | RUN pip install -e /usr/src/hamper 16 | 17 | CMD ["hamper"] 18 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2013 Michael Cooper, et. al. 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of 6 | this software and associated documentation files (the "Software"), to deal in 7 | the Software without restriction, including without limitation the rights to 8 | use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of 9 | the Software, and to permit persons to whom the Software is furnished to do so, 10 | subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS 17 | FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR 18 | COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER 19 | IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN 20 | CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 21 | -------------------------------------------------------------------------------- /Procfile: -------------------------------------------------------------------------------- 1 | irc: hamper 2 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | Hamper is an IRC bot to amuse us. 2 | 3 | 4 | Quick Start 5 | ----------- 6 | 7 | ```shell 8 | $ git clone https://github.com/hamperbot/hamper 9 | $ cd hamper 10 | $ virtualenv venv 11 | $ source venv/bin/activate 12 | $ python setup.py develop 13 | $ cp hamper.conf.dist hamper.conf 14 | $ vim hamper.conf 15 | $ hamper 16 | ``` 17 | 18 | 19 | Configuration 20 | ============= 21 | Make a file named `hamper.conf`. This should be a YAML file containing these 22 | fields: 23 | 24 | - `nickname` 25 | - `channels` 26 | - `server` 27 | - `port` 28 | - `db` - A database URL as described [here][dburl] 29 | 30 | For an example check out `hamper.conf.dist`. 31 | 32 | 33 | Plugin Development 34 | ================== 35 | Read `hamper/plugins/friendly.py`. 36 | To declare a plugin so that it can be used you need to edit *your* plugin's 37 | `setup.py` and add something like the following lines: 38 | ```python 39 | setup( 40 | name='YOUR_PLUGIN', 41 | # ...More lines here... 42 | entry_points = { 43 | 'hamperbot.plugins': [ 44 | 'plugin_name = module.import.path.to.plugin:PluginClass', 45 | ], 46 | }, 47 | # ...Possibly more lines here too... 48 | ``` 49 | For the new plugin system you no longer need to create an instance of each one 50 | at the bottom. 51 | Once you have declared your class as a plugin you need to install it with 52 | `setup.py`: 53 | ```sh 54 | $ python setup.py develop 55 | 56 | ``` 57 | This is so that setuptools can advertise your plugins to Hamper. Hamper uses 58 | setuptools to determine what plugins are available. 59 | Note that if you change your `setup.py`, you'll have to repeat those last two 60 | steps. However, you probably won't have to rebuild the package every time you 61 | change your plugin. 62 | 63 | Testing 64 | ======= 65 | 66 | Hamper uses Twisted's [Trial][] unit testing system, which is an extension of 67 | Python's unittest module. To run the tests, execute `trial hamper' in the top 68 | level directory of hamper. 69 | 70 | Using Docker 71 | ------------ 72 | 73 | **requires Docker > 1.3** 74 | 75 | This already assumes you've got Docker configured and installed on your system. 76 | 77 | To begin you need to build the Docker image for Hamper: `docker build -t hamper .` 78 | 79 | Now we can start the container using that image, but first start by copying the 80 | `hamper.env.dist` into `hamper.env` and adjusting settings as necessary. 81 | 82 | Now all we need to do is start the container by telling where to read our 83 | settings. 84 | 85 | ```shell 86 | docker run --env-file ./hamper.env --name hamper hamper 87 | ``` 88 | 89 | This *creates and starts* the container. If you want to re-use the same 90 | database then you should use `docker start hamper` to just *start* an existing 91 | container. If you want to create a new container with a new config, but the 92 | old database use `docker run --env-file ./hamper.env --volumes-from hamper 93 | --name hamper-new hamper` to create a container with a new name, but import the 94 | volume containing the database from the old container. 95 | 96 | 97 | This is great and all, but perhaps you want to hack on Hamper and use Docker 98 | simply to run Hamper with your current directory. Here's how to do that: 99 | 100 | ```shell 101 | docker run -it --env-file ./hamper.env -v $(pwd):/hamper hamper bash 102 | ``` 103 | 104 | This will mount the directory located at `$(pwd)` on the host running Docker 105 | in place of the Hamper project in your container. When you make changes to the 106 | code, they'll be seen in the container. The reason we run bash is so you can 107 | easily stop and restart the bot with the `hamper` command, however you can 108 | leave out the `bash` command at the end and just stop and start the container. 109 | 110 | Then to stop the container type `docker stop hamper`. To start it back up type 111 | `docker start hamper` To see logs of the running container use `docker logs hamper`. 112 | Refer to the [docker docs][docker] for more usage details. 113 | 114 | [docker]: http://docs.docker.io/en/latest/ 115 | [dburl]: http://www.sqlalchemy.org/docs/core/engines.html#sqlalchemy.create_engine 116 | [trial]: http://twistedmatrix.com/trac/wiki/TwistedTrial 117 | -------------------------------------------------------------------------------- /docs/config.md: -------------------------------------------------------------------------------- 1 | # Configuration 2 | 3 | ## Core configuration 4 | 5 | Configuration for hamper is stored in `hamper.conf`, which is parsed as 6 | [YAML][]. It can also be read from environment variables, as detailed below. 7 | 8 | [YAML]: http://www.yaml.com 9 | 10 | The core of hamper only uses a few configuration options. 11 | 12 | * `nickname` - The nick to use when connecting to IRC. (Required) 13 | * `server` - The hostname of the server to connect to. Try something like 14 | `irc.freenode.net`. (Required) 15 | * `port` - The port to connect to the server on. Try `6667`. Make sure to use 16 | an SSL enabled port if `ssl` is enabled. (Required) 17 | * `db` - This is a database URL as described [in the SQLAlechmy docs][dburl]. 18 | This is not required. If you leave it unspecified, Hamper will use a 19 | in-memory database instead. The url `sqlite:///hamper.db` will make a sqlite 20 | database in a file named `hamper.db` in the current directory. 21 | * `channels` - A list of channels to join, including the leading `#`. 22 | * `plugins` - A list of plugins to load. 23 | * `ssl` - If true, Hamper will use SSL to connect to the server. 24 | * `password` - Iff this is supplied, Hamper will attempt to authenticate with 25 | NickServ using `nickname` and this password. If this happens, it will delay 26 | loading channels until after NickServ has responded. 27 | 28 | [dburl]: http://www.sqlalchemy.org/docs/core/engines.html#sqlalchemy.create_engine 29 | 30 | ## Plugin Configuration 31 | 32 | Some plugins require extra configuration. These settings go in the same config 33 | file as the core settings above. For each of these plugins, the settings go 34 | under a key of the plugin's name. For example, the configuration for `tinyurl` 35 | might look like this in `hamper.conf`: 36 | 37 | ```yaml 38 | tinyurl: 39 | excluded-urls: 40 | - imgur.com 41 | - gist.github.com 42 | ``` 43 | 44 | > External plugins may require other configuration not listed here. 45 | 46 | ### [Tinyurl][] 47 | 48 | This plugin will automatically shorten long urls pasted into the channel. 49 | 50 | * `min-length` - The length of the shortest url that the plugin will attempt to 51 | shorten. (Default: 40) 52 | * `excluded-urls` - A list of urls to ignore. If any of these a substring of 53 | the seen urls, they won't be shortened. (Default: 54 | `['imgur.com', 'gist.github.com', 'pastebin.com']`). 55 | 56 | [Tinyurl]: plugins/tinyurl.md 57 | 58 | ### [Timez][] 59 | 60 | The timez plugin allows you to look up local time for different timezones. 61 | 62 | * `api-key` - An API key for the service. Get it from [here][timezapi]. The 63 | free api should work fine. 64 | 65 | [Timez]: plugins/timez.md 66 | [timezapi]: http://developer.worldweatheronline.com 67 | 68 | 69 | ## Environment Variables 70 | 71 | Hamper can also pull in settings from environment variables, which is useful 72 | for environments like [Heroku][] or [Docker][]. Environment variables are 73 | mapped directly to top level configuration variables. For example, to change 74 | the IRC port to 8001, you could set the environment variables `port=8001`. 75 | 76 | If an environment variable is valid YAML, it will be parsed as such. For 77 | example, you could configure Timez's api-key like 78 | `timez={"api-key": "LOLAPIKEY"}`. 79 | 80 | If you aren't sure if you need to do this, you likely don't have to. It's 81 | pretty specialized. Be aware of any escaping you may have to do to set these 82 | environment variables. 83 | 84 | [Heroku]: heroku.md 85 | [Docker]: docker.md 86 | -------------------------------------------------------------------------------- /docs/heroku.md: -------------------------------------------------------------------------------- 1 | # Hamper on Heroku 2 | 3 | Hamper was, literally, designed to run on [Heroku][], and tries to follow the 4 | [12 factors][12]. 5 | 6 | [Heroku]: http://heroku.com 7 | [12]: http://12factor.net/ 8 | 9 | ## Heroku in a Nutshell 10 | 11 | Heroku is Process-as-a-Service provider, or PaaS. Given an application as a git 12 | push, it builds and runs the service. It has a free tier which Hamper handily 13 | fits in. There is no need to use the paid-tier of Heroku for this. 14 | 15 | ## Setup 16 | 17 | Most of these steps could also be done in the web interface, but that is harder 18 | to write docs about. Run these commands from inside the hamper repository. 19 | 20 | 1. [Get a Heroku account](https://id.heroku.com/signup/www-home-top). 21 | 22 | 2. [Install the Heroku toolbelt](https://toolbelt.heroku.com/standalone). 23 | 24 | 3. Log in to the toolbelt. 25 | 26 | ```bash 27 | $ heroku auth:login 28 | ``` 29 | 30 | 4. Create an application. This also includes the database that will be needed. 31 | 32 | ```bash 33 | $ heroku apps:create hamper-mythmon-test --addons heroku-postgresql 34 | ``` 35 | 36 | 5. Set some configuration options. You can set multiple at a time, or one at a 37 | time. Since the app isn't running, it doesn't matter. In the future, you'll 38 | want to make all desired changes at once, as this triggers an application 39 | restart. 40 | 41 | ```bash 42 | $ heroku config:set nickname=my_hamper 43 | $ heroku config:set plugins='[karma,help,sed]' 44 | $ heroku config:set server=irc.freenode.net port=6697 ssl=true 45 | $ heroku config:set channels='["#hamper-testing"]' 46 | $ heroku config:set PYTHON_EXTRA_REQS=psycopg2==2.5.1 47 | ``` 48 | 49 | Remember that environment variables are parsed as YAML if they are valid as 50 | such, which is how to specify things like lists of plugins and channels. 51 | Be careful with `#` in channel names: YAML takes that as a comment, and you 52 | also have to deal with shell quoting. 53 | 54 | 6. Push the code to Heroku. Up until this point, Heroku has had no idea what 55 | code we have been talking about. Note that when you created the app, 56 | Heroku created a git remote named `heroku` for you to push to. 57 | 58 | ```bash 59 | git push heroku master 60 | ``` 61 | 62 | Make sure to commit any changes you may have made to the code, or they won't 63 | get deployed to Heroku. 64 | 65 | 6. Start Hamper. This instructs Heroku to run 1 `irc` *dyno* (one Heroku 66 | process). It is free to run a single dyno, and that is all that 67 | Hamper needs. Heroku knows what an `irc` process is because it is defined in 68 | the file named `Procfile`. 69 | 70 | ```bash 71 | $ heroku ps:scale irc=1 72 | ``` 73 | 74 | 7. Check the logs. This is not strictly necessary, but it is good to know 75 | in case anything goes wrong. 76 | 77 | ```bash 78 | $ heroku logs -t 79 | ``` 80 | 81 | (Press ctrl+c to exit) 82 | 83 | Heroku is a much more powerful tool than how it is used here, capable of 84 | hosting complex applications. This, however, is all we need for Hamper. 85 | 86 | > #### Note: Extra dependencies 87 | > This is technically a violation of 12 Factor, but it makes hacking on 88 | > hamper much easier. All of the core dependencies for Hamper are listed 89 | > in `requirements.txt`, but to use a Postgresql database, like we did 90 | > above, you also need the Postgresql python drive, psycopg2. 91 | > 92 | > Hamper uses a modified Python buildpack that will install extra Python 93 | > requirements listed in the `$PYTHON_EXTRA_REQS` argument. This gets 94 | > appended directly to `requirements.txt` during the build phase, so 95 | > newlines are needed for any more requirements. This is the mechanism 96 | > by which you could run [external plugins][extplug] on Heroku. 97 | 98 | [extplug]: [externalplugins.md] 99 | 100 | 101 | ## Theory 102 | 103 | Heroku is based on a theory called "The 12-Factor App". It lists 12 best 104 | practices for scalable maintainable apps. Hamper tries to follow these. 105 | 106 | 107 | ### I. Codebase 108 | > One codebase tracked in revision control, many deploys 109 | 110 | The master branch of Hamper is always eligible to push to Heroku. No 111 | extra files need to be checked in or edited. 112 | 113 | ### II. Dependencies 114 | > Explicitly declare and isolate dependencies 115 | 116 | All of Hamper's core dependencies are listed in `requirements.txt`, which 117 | Heroku reads. Sometimes extra requirements are required, which are dealt 118 | with as explained above. 119 | 120 | ### III. Config 121 | > Store config in the environment 122 | 123 | Hamper will pull configuration from enviroment variables, as detailed at 124 | the end of [the config docs][config]. 125 | 126 | [config]: config.md 127 | 128 | ### IV. Backing Services 129 | > Treat backing services as attached resources 130 | 131 | Hamper uses a database, and it can read the config for that database from 132 | environment variables, such as the one Heroku provides. 133 | 134 | ### V. Build, release, run 135 | > Strictly separate build and run stages 136 | 137 | Heroku forces this pattern, so Hamper works with it. 138 | 139 | ### VI. Processes 140 | > Execute the app as one or more stateless processes 141 | 142 | Hamper most follows this pattern, as it tries to store most state in the 143 | database. Restarting Hamper in place loses very little state, and is 144 | generally safe to do. Some plugins may try and store state in memory, 145 | such as the sed plugin. 146 | 147 | ### VII. Port binding 148 | > Export services via port binding 149 | 150 | Hamper doesn't bind any ports. 151 | 152 | ### VIII. Concurrency 153 | > Scale out via the process model 154 | 155 | Hamper doesn't scale, hasn't shown any need to, nor am I sure how to 156 | scale out an IRC bot. 157 | 158 | ### IX. Disposability 159 | > Maximize robustness with fast startup and graceful shutdown 160 | 161 | Since Hamper is a single process that is intended to run all the time, 162 | this doesn't apply much. However, startup is quick and shutdown is safe. 163 | 164 | ### X. Dev/prod parity 165 | > Keep development, staging, and production as similar as possible 166 | 167 | Hamper deploys roughly the same in production as the recommended set up process. 168 | 169 | ### XI. Logs 170 | > Treat logs as event streams 171 | 172 | Hamper could log better, but when it does log it does so in a Heroku-friendly 173 | way. 174 | 175 | ### XII. Admin processes 176 | > Run admin/management tasks as one-off processes 177 | 178 | Hamper doesn't have an admin tasks yet. 179 | 180 | -------------------------------------------------------------------------------- /docs/quickstart.md: -------------------------------------------------------------------------------- 1 | # Quickstart 2 | 3 | ## Installation 4 | 5 | You'll need Python 2.7, and the tools [Virtualenv][venv] and [Pip][pip] are 6 | highly recommended. 7 | 8 | [venv]: http://www.virtualenv.org/en/latest/ 9 | [pip]: https://pypi.python.org/pypi/pip 10 | 11 | Once you've got that set up, you should create a virtualenv to work in, 12 | and activate it. This keeps Hamper's libraries isolated from the rest of 13 | your system, which helps avoid version conflicts. Run these commands in 14 | the git repo you cloned. 15 | 16 | ```bash 17 | $ virtualenv venv 18 | $ source venv/bin/activate 19 | ``` 20 | 21 | > Note that on systems like Arch Linux, where Python 3 is the default, you will 22 | > have to specificy that you want to use Python 2. In particular, you should 23 | > use the command `virtualenv2` instead of `virtualenv`. This will create a 24 | > Python 2 virtualenv, so you would have to worry about it for the rest of this 25 | > guide. 26 | 27 | To install the requirements, run 28 | 29 | ```bash 30 | $ pip install -r requirements.txt 31 | ``` 32 | 33 | ## Tests 34 | 35 | A good way to make sure you have all the requirements is by running the tests. The tests don't rely on any configuration, so you can run them now. To run the tests run the command 36 | 37 | ```bashs 38 | $ trial hamper 39 | ``` 40 | 41 | If you get "command not found" you may have to give a better path to trial. Try 42 | 43 | ```bash 44 | $ venv/bin/trial hamper 45 | ``` 46 | 47 | ## Configuration 48 | 49 | You'll need to configure hamper so that your instance of Hamper is a 50 | unique snow flake (like all the rest). Configuration is stored in a file 51 | named `hamper.conf`. You'll notice it doesn't exist yet, but there is a 52 | template, `hamper.conf.dist`. Copy that file, name the copy 53 | `hamper.conf`, and edit it to reflect your desired set up. 54 | 55 | In particular, you problably want to change the `nickname`, `server`, 56 | `port`, `channels`, and `plugins`. These should be pretty straight 57 | forward, but if you want more details, read 58 | [the configuration docs][config]. 59 | 60 | [config]: config.md 61 | 62 | ## Running 63 | 64 | The main entry point for Hamper is the command `hamper` which starts the bot. 65 | 66 | ```bash 67 | $ hamper 68 | ``` 69 | 70 | If all you configured the bot correctly, it should start up, load any 71 | listed plugins, connect to the IRC server, and join the specified 72 | channels, where you can start interacting with it. Yay! 73 | 74 | For more information about where to go from here, see the docs about 75 | [adminstration][admin] or [plugin development][plugindev], or if you 76 | want to deploy Hamper in a little more permanent way, check out the 77 | deployment docs for [Heroku][heroku] or [Docker][docker] 78 | 79 | [admin]: admin.md 80 | [plugindev]: plugindev.md 81 | [heroku]: heroku.md 82 | [docker]: docker.md 83 | -------------------------------------------------------------------------------- /hamper.acl.dist: -------------------------------------------------------------------------------- 1 | { 2 | "permissions": { 3 | "#channel": [ 4 | "*" 5 | ], 6 | "#channel2": [ 7 | "*", 8 | "-factoid" 9 | ] 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /hamper.conf.dist: -------------------------------------------------------------------------------- 1 | # Sample configuration file for Hamper 2 | 3 | # IRC Settings 4 | nickname: cool_bot 5 | # password: your_password 6 | server: irc.freenode.net 7 | port: 6667 8 | # ssl: true 9 | 10 | db: "sqlite:///hamper.db" 11 | 12 | channels: 13 | - "#hamper-testing" 14 | # - #another 15 | # - #possibly_a_third 16 | 17 | # Available plugins with a few enabled. 18 | # See below for plugin specific configurations 19 | # WARNINGs: 20 | # Settings may or may not be mandatory for any given plugin. 21 | # Everything enabled enables two url shortners (bitly, tinurl) 22 | 23 | plugins: 24 | # - bitly 25 | # - botsnack 26 | # - choices 27 | # - dice 28 | # - factoids 29 | # - flip 30 | - friendly 31 | # - goodbye 32 | # - help 33 | # - karma 34 | # - lmgtfy 35 | # - lookup 36 | - ponies 37 | # - quit 38 | # - quotes 39 | # - rot13 40 | # - roulette 41 | - sed 42 | # - seen 43 | # - suggest 44 | # - timez 45 | # - tinyurl 46 | # - yesno 47 | 48 | # bitly: 49 | # login: user_name 50 | # api_key: username_s_api_key 51 | 52 | # karma: 53 | # timezone: UTC 54 | 55 | # tinyurl: 56 | # excluded-urls: 57 | # - imgur.com 58 | # - gist.github.com 59 | # - pastebin.com 60 | # min-length: 40 61 | -------------------------------------------------------------------------------- /hamper.env.dist: -------------------------------------------------------------------------------- 1 | nickname=cool_boot 2 | server=irc.freenode.net 3 | port=6667 4 | channels=["#hamper-testing"] 5 | plugins=["sed", "friendly", "ponies"] 6 | tinyurl={"excluded-urls": ["imgur.com", "gist.github.com", "pastebin.com"]} 7 | 8 | -------------------------------------------------------------------------------- /hamper/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hamperbot/hamper/d9533a3ff94ff1009bf5c4c779b2ca3a5e93c892/hamper/__init__.py -------------------------------------------------------------------------------- /hamper/acl.py: -------------------------------------------------------------------------------- 1 | import json 2 | import re 3 | 4 | 5 | class AllowAllACL(object): 6 | def has_permission(self, comm, thing): 7 | return True 8 | 9 | 10 | class ACL(object): 11 | 12 | ALLOW = 1 13 | DENY = 2 14 | 15 | def __init__(self, acl): 16 | self.acls = json.loads(acl) 17 | 18 | def has_permission(self, comm, thing): 19 | allow, deny = False, False 20 | self.add_groups(comm) 21 | 22 | for selector, permissions in self.acls.get('permissions', {}).items(): 23 | if self.match_selector(selector, comm): 24 | for p in permissions: 25 | policy = self.glob_permission_match(thing, p) 26 | if policy == self.ALLOW: 27 | allow = True 28 | elif policy == self.DENY: 29 | deny = True 30 | 31 | return allow and not deny 32 | 33 | def match_selector(self, selector, comm): 34 | parsed = self.parse_selector(selector) 35 | for key, val in parsed.items(): 36 | if key == 'group': 37 | if val not in comm['groups']: 38 | return False 39 | else: 40 | if comm[key] != val: 41 | return False 42 | 43 | return True 44 | 45 | def parse_selector(self, selector): 46 | if selector == '*': 47 | return {} 48 | 49 | user = re.search(r'^([^@#]+)', selector) 50 | group = re.search(r'(@[^@#]+)', selector) 51 | channel = re.search(r'(#[^@#]+)', selector) 52 | 53 | parsed = {} 54 | if user: 55 | parsed['user'] = user.groups()[0] 56 | if group: 57 | parsed['group'] = group.groups()[0] 58 | if channel: 59 | parsed['channel'] = channel.groups()[0] 60 | 61 | return parsed 62 | 63 | def glob_permission_match(self, permission, pattern): 64 | if pattern[0] == '-': 65 | ret = self.DENY 66 | pattern = pattern[1:] 67 | else: 68 | ret = self.ALLOW 69 | 70 | permissions = permission.split('.') 71 | patterns = pattern.split('.') 72 | 73 | if len(permissions) != len(patterns) and patterns[-1] != '*': 74 | return False 75 | 76 | for perm, pat in zip(permissions, patterns): 77 | if perm != pat and pat != '*': 78 | return None 79 | return ret 80 | 81 | def add_groups(self, comm): 82 | user = comm.get('user') 83 | comm['groups'] = [] 84 | for name, members in self.acls.get('groups', {}).items(): 85 | if user in members: 86 | comm['groups'].append(name) 87 | # Yes it is modified, but returning it is nice too. 88 | return comm 89 | -------------------------------------------------------------------------------- /hamper/cli.py: -------------------------------------------------------------------------------- 1 | from twisted.internet import reactor 2 | from twisted.internet.stdio import StandardIO 3 | from twisted.protocols.basic import LineReceiver 4 | 5 | import sqlalchemy 6 | from sqlalchemy import orm 7 | 8 | from hamper.commander import DB, PluginLoader 9 | import hamper.config 10 | import hamper.log 11 | 12 | 13 | class CLIProtocol(LineReceiver): 14 | """ 15 | A bare-bones protocol meant for imitating a single-user session with 16 | Hamper over stdio. 17 | """ 18 | 19 | delimiter = "\n" 20 | 21 | def __init__(self, config): 22 | self.loader = PluginLoader(config) 23 | 24 | self.history = {} 25 | 26 | if 'db' in config: 27 | print('Loading db from config: ' + config['db']) 28 | db_engine = sqlalchemy.create_engine(config['db']) 29 | else: 30 | print('Using in-memory db') 31 | db_engine = sqlalchemy.create_engine('sqlite:///:memory:') 32 | DBSession = orm.sessionmaker(db_engine) 33 | session = DBSession() 34 | 35 | self.loader.db = DB(db_engine, session) 36 | 37 | # Load all plugins mentioned in the configuration. Allow globbing. 38 | print "Loading plugins", config["plugins"] 39 | self.loader.loadAll() 40 | 41 | def connectionLost(self, reason): 42 | if reactor.running: 43 | reactor.stop() 44 | 45 | def lineReceived(self, line): 46 | comm = { 47 | 'raw_message': line, 48 | 'message': line, 49 | 'raw_user': "user", 50 | 'user': "user", 51 | 'mask': "", 52 | 'target': "hamper", 53 | 'channel': "hamper", 54 | 'directed': True, 55 | 'pm': True, 56 | } 57 | 58 | self.loader.runPlugins("chat", "message", self, comm) 59 | 60 | def _sendLine(self, user, message): 61 | if user != "hamper": 62 | message = "[%s] %s" % (user, message) 63 | self.sendLine(message) 64 | 65 | # Stub out some IRCClient methods 66 | 67 | def quit(self): 68 | self.stopProducing() 69 | 70 | def msg(self, user, message, length=None): 71 | for line in message.splitlines(): 72 | self._sendLine(user, line) 73 | 74 | def notice(self, user, message): 75 | self._sendLine(user, '** ' + message) 76 | 77 | # CommanderProtocol methods 78 | 79 | def reply(self, comm, message, encode=True, tag=None, vars=[], kwvars={}): 80 | kwvars = kwvars.copy() 81 | kwvars.update(comm) 82 | message = message.format(*vars, **kwvars) 83 | if encode: 84 | message = message.encode('utf-8') 85 | self.msg(comm['channel'], message) 86 | 87 | # The help plugin expects bot.factory.loader to exist. 88 | @property 89 | def factory(self): 90 | return self 91 | 92 | 93 | def main(): 94 | hamper.log.setup_logging() 95 | config = hamper.config.load() 96 | StandardIO(CLIProtocol(config)) 97 | reactor.run() 98 | 99 | 100 | if __name__ == "__main__": 101 | main() 102 | -------------------------------------------------------------------------------- /hamper/commander.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import re 3 | import traceback 4 | from collections import deque, namedtuple 5 | 6 | from twisted.words.protocols import irc 7 | from twisted.internet import protocol, reactor, ssl 8 | from pkg_resources import iter_entry_points 9 | 10 | import sqlalchemy 11 | from sqlalchemy import orm 12 | 13 | import hamper.config 14 | import hamper.log 15 | import hamper.plugins 16 | from hamper.acl import ACL, AllowAllACL 17 | 18 | log = logging.getLogger('hamper') 19 | 20 | 21 | def main(): 22 | config = hamper.config.load() 23 | hamper.log.setup_logging() 24 | 25 | if config.get('ssl', False): 26 | reactor.connectSSL( 27 | config['server'], config['port'], CommanderFactory(config), 28 | ssl.ClientContextFactory()) 29 | else: 30 | reactor.connectTCP( 31 | config['server'], config['port'], CommanderFactory(config)) 32 | reactor.run() 33 | 34 | 35 | class CommanderProtocol(irc.IRCClient): 36 | """Interacts with a single server, and delegates to the plugins.""" 37 | 38 | # #### Properties ##### 39 | @property 40 | def nickname(self): 41 | return self.factory.nickname 42 | 43 | @property 44 | def password(self): 45 | return self.factory.password 46 | 47 | @property 48 | def db(self): 49 | return self.factory.loader.db 50 | 51 | @property 52 | def acl(self): 53 | return self.factory.acl 54 | 55 | # #### Twisted events ##### 56 | 57 | def signedOn(self): 58 | """Called after successfully signing on to the server.""" 59 | log.info("Signed on as %s.", self.nickname) 60 | if not self.password: 61 | # We aren't wating for auth, join all the channels 62 | self.joinChannels() 63 | else: 64 | self.msg("NickServ", "IDENTIFY %s" % self.password) 65 | 66 | def joinChannels(self): 67 | self.dispatch('presence', 'signedOn') 68 | for c in self.factory.channels: 69 | self.join(*c) 70 | 71 | def joined(self, channel): 72 | """Called after successfully joining a channel.""" 73 | log.info("Joined %s.", channel) 74 | # ask for the current list of users in the channel 75 | self.dispatch('presence', 'joined', channel) 76 | 77 | def left(self, channel): 78 | """Called after leaving a channel.""" 79 | log.info("Left %s.", channel) 80 | self.dispatch('presence', 'left', channel) 81 | 82 | def action(self, raw_user, channel, raw_message): 83 | return self.process_action(raw_user, channel, raw_message) 84 | 85 | def privmsg(self, raw_user, channel, raw_message): 86 | return self.process_action(raw_user, channel, raw_message) 87 | 88 | def process_action(self, raw_user, channel, raw_message): 89 | """Called when a message is received from a channel or user.""" 90 | log.info("%s %s %s", channel, raw_user, raw_message) 91 | 92 | if not raw_user: 93 | # ignore server messages 94 | return 95 | 96 | # This monster of a regex extracts msg and target from a message, where 97 | # the target may not be there, and the target is a valid irc name. 98 | # Valid ways to target someone are ": ..." and ", ..." 99 | target, message = re.match( 100 | r'^(?:([a-z_\-\[\]\\^{}|`]' # First letter can't be a number 101 | '[a-z0-9_\-\[\]\\^{}|`]*)' # The rest can be many things 102 | '[:,] )? *(.*)$', # The actual message 103 | raw_message, re.I).groups() 104 | 105 | pm = channel == self.nickname 106 | if pm: 107 | directed = True 108 | if target: 109 | if target.lower() == self.nickname.lower(): 110 | directed = True 111 | else: 112 | directed = False 113 | message = '{0}: {1}'.format(target, message) 114 | else: 115 | directed = False 116 | if message.startswith('!'): 117 | message = message[1:] 118 | directed = True 119 | 120 | if directed: 121 | message = message.rstrip() 122 | 123 | try: 124 | user, mask = raw_user.split('!', 1) 125 | except ValueError: 126 | user = raw_user 127 | mask = '' 128 | 129 | comm = { 130 | 'raw_message': raw_message, 131 | 'message': message, 132 | 'raw_user': raw_user, 133 | 'user': user, 134 | 'mask': mask, 135 | 'target': target, 136 | 'channel': channel, 137 | 'directed': directed, 138 | 'pm': pm, 139 | } 140 | 141 | self.dispatch('chat', 'message', comm) 142 | 143 | self.factory.history.setdefault( 144 | channel, deque(maxlen=100)).append(comm) 145 | 146 | def connectionLost(self, reason): 147 | """Called when the connection is lost to the server.""" 148 | self.factory.loader.db.session.commit() 149 | if reactor.running: 150 | reactor.stop() 151 | 152 | def userJoined(self, user, channel): 153 | """Called when I see another user joining a channel.""" 154 | self.dispatch('population', 'userJoined', user, channel) 155 | 156 | def userLeft(self, user, channel): 157 | """Called when I see another user leaving a channel.""" 158 | self.dispatch('population', 'userLeft', user, channel) 159 | 160 | def userQuit(self, user, quitmessage): 161 | """Called when I see another user quitting.""" 162 | self.dispatch('population', 'userQuit', user, quitmessage) 163 | 164 | def userKicked(self, kickee, channel, kicker, message): 165 | """Called when I see another user get kicked.""" 166 | self.dispatch('population', 'userKicked', kickee, channel, kicker, 167 | message) 168 | 169 | def irc_RPL_NAMREPLY(self, prefix, params): 170 | """Called when the server responds to my names request""" 171 | self.dispatch('population', 'namesReply', prefix, params) 172 | 173 | def irc_RPL_ENDOFNAMES(self, prefix, params): 174 | """Called after the names request is finished""" 175 | self.dispatch('population', 'namesEnd', prefix, params) 176 | 177 | def noticed(self, user, channel, message): 178 | log.info("NOTICE %s %s %s" % (user, channel, message)) 179 | # mozilla's nickserv responds as NickServ!services@mozilla.org 180 | if (self.password and channel == self.nickname and 181 | user.startswith('NickServ')): 182 | if ("Password accepted" in message or 183 | "You are now identified" in message): 184 | self.joinChannels() 185 | elif "Password incorrect" in message: 186 | log.info("NickServ AUTH FAILED!!!!!!!") 187 | reactor.stop() 188 | 189 | # #### Hamper specific functions. ##### 190 | 191 | def dispatch(self, category, func, *args): 192 | """Dispatch an event to all listening plugins.""" 193 | self.factory.loader.runPlugins(category, func, self, *args) 194 | 195 | def _hamper_send(self, func, comm, message, encode, tag, vars, kwvars): 196 | if type(message) == str: 197 | log.warning('Warning, passing message as ascii instead of unicode ' 198 | 'will cause problems. The message is: {0}' 199 | .format(message)) 200 | 201 | format_kwargs = {} 202 | format_kwargs.update(kwvars) 203 | format_kwargs.update(comm) 204 | try: 205 | message = message.format(*vars, **format_kwargs) 206 | except (ValueError, KeyError, IndexError) as e: 207 | log.error('Could not format message: {e}'.format(e=e)) 208 | 209 | if encode: 210 | message = message.encode('utf8') 211 | 212 | if comm['pm']: 213 | func(comm['user'], message) 214 | else: 215 | func(comm['channel'], message) 216 | 217 | (self.factory.sent_messages 218 | .setdefault(comm['channel'], deque(maxlen=100)) 219 | .append({ 220 | 'comm': comm, 221 | 'message': message, 222 | 'tag': tag, 223 | })) 224 | 225 | def reply(self, comm, message, encode=True, tag=None, vars=[], kwvars={}): 226 | self._hamper_send(self.msg, comm, message, encode, tag, vars, kwvars) 227 | 228 | def me(self, comm, message, encode=True, tag=None, vars=[], kwvars={}): 229 | self._hamper_send( 230 | self.describe, comm, message, encode, tag, vars, kwvars) 231 | 232 | 233 | class CommanderFactory(protocol.ClientFactory): 234 | protocol = CommanderProtocol 235 | 236 | def __init__(self, config): 237 | self.channels = [c.split(' ', 1) for c in config['channels']] 238 | self.nickname = config['nickname'] 239 | self.password = config.get('password', None) 240 | self.history = {} 241 | self.sent_messages = {} 242 | acl_fname = config.get('acl', None) 243 | 244 | if acl_fname: 245 | # Bubble up an IOError if they passed a bad file 246 | with open(acl_fname, 'r') as acl_fd: 247 | self.acl = ACL(acl_fd.read()) 248 | log.info('Loaded ACLs from %s', acl_fname) 249 | else: 250 | self.acl = AllowAllACL() 251 | log.info('Using no-op ACLs.') 252 | 253 | self.loader = PluginLoader(config) 254 | 255 | if 'db' in config: 256 | log.info('Loading db from config: %s', config['db']) 257 | db_engine = sqlalchemy.create_engine(config['db']) 258 | else: 259 | log.info('Using in-memory db') 260 | db_engine = sqlalchemy.create_engine('sqlite:///:memory:') 261 | 262 | DBSession = orm.sessionmaker(db_engine) 263 | session = DBSession() 264 | self.loader.db = DB(db_engine, session) 265 | 266 | self.loader.loadAll() 267 | 268 | def clientConnectionLost(self, connector, reason): 269 | log.info('Lost connection (%s).', (reason)) 270 | # Reconnect 271 | connector.connect() 272 | 273 | def clientConnectionFailed(self, connector, reason): 274 | log.info('Could not connect: %s', (reason,)) 275 | 276 | 277 | class DB(namedtuple("DB", "engine, session")): 278 | """ 279 | A small data structure that stores database information. 280 | """ 281 | 282 | 283 | class PluginLoader(object): 284 | """ 285 | I am a repository for plugins. 286 | 287 | I understand how to load plugins and how to enumerate the plugins I've 288 | loaded. Additionally, I can store configuration data for plugins. 289 | 290 | Think of me as the piece of code that isolates plugin state from the 291 | details of the network. 292 | """ 293 | 294 | def __init__(self, config): 295 | self.config = config 296 | self.plugins = [] 297 | 298 | def loadAll(self): 299 | plugins_to_load = set() 300 | 301 | # Gather plugins 302 | for plugin in iter_entry_points(group='hamperbot.plugins', name=None): 303 | if plugin.name in self.config['plugins']: 304 | plugins_to_load.add(plugin.load()) 305 | 306 | # Sort by priority, highest first 307 | plugins_to_load = sorted(plugins_to_load, key=lambda p: -p.priority) 308 | 309 | # Check dependencies and load plugins. 310 | for plugin_class in plugins_to_load: 311 | plugin_obj = plugin_class() 312 | if not self.dependencies_satisfied(plugin_obj): 313 | log.warning('Dependency not satisfied for {0}. Not loading.' 314 | .format(plugin_class.__name__)) 315 | continue 316 | log.info('Loading plugin {0}.'.format(plugin_class.__name__)) 317 | plugin_obj.setup(self) 318 | self.plugins.append(plugin_obj) 319 | 320 | # Check for missing plugins 321 | plugin_names = {x.name for x in self.plugins} 322 | # Don't allow karma and karma_adv to be loaded at once 323 | if ('karma' in self.config['plugins'] and 324 | 'karma_adv' in self.config['plugins']): 325 | quit( 326 | "Unable to load both karma and karma_adv at the same time") 327 | 328 | for pattern in self.config['plugins']: 329 | if pattern not in plugin_names: 330 | log.warning('Sorry, I couldn\'t find a plugin named "%s"', 331 | pattern) 332 | 333 | def dependencies_satisfied(self, plugin): 334 | """ 335 | Checks whether a plugin's dependencies are satisfied. 336 | 337 | Logs an error if there is an unsatisfied dependencies 338 | Returns: Bool 339 | """ 340 | for depends in plugin.dependencies: 341 | if depends not in self.config['plugins']: 342 | log.error("{0} depends on {1}, but {1} wasn't in the " 343 | "config file. To use {0}, install {1} and add " 344 | "it to the config.".format(plugin.name, depends)) 345 | return False 346 | return True 347 | 348 | def runPlugins(self, category, func, protocol, *args): 349 | """ 350 | Run the specified set of plugins against a given protocol. 351 | """ 352 | # Plugins are already sorted by priority 353 | for plugin in self.plugins: 354 | # If a plugin throws an exception, we should catch it gracefully. 355 | try: 356 | event_listener = getattr(plugin, func) 357 | except AttributeError: 358 | # If the plugin doesn't implement the event, do nothing 359 | pass 360 | else: 361 | try: 362 | stop = event_listener(protocol, *args) 363 | if stop: 364 | break 365 | except Exception: 366 | # A plugin should not be able to crash the bot. 367 | # Catch and log all errors. 368 | traceback.print_exc() 369 | -------------------------------------------------------------------------------- /hamper/config.py: -------------------------------------------------------------------------------- 1 | import os 2 | import sys 3 | from copy import deepcopy 4 | 5 | import yaml 6 | 7 | 8 | def load(): 9 | try: 10 | with open('hamper.conf') as config_file: 11 | config = yaml.load(config_file) 12 | except IOError: 13 | config = {} 14 | 15 | config = replace_env_vars(config) 16 | 17 | # Fill in data from the env: 18 | for k, v in os.environ.items(): 19 | try: 20 | config[k] = yaml.load(v) 21 | except yaml.error.YAMLError: 22 | config[k] = v 23 | 24 | # Special case: database 25 | if 'DATABASE_URL' in os.environ: 26 | config['db'] = os.environ['DATABASE_URL'] 27 | 28 | for key in ['server', 'port', 'nickname', 'channels']: 29 | if (key not in config) or (not config[key]): 30 | print('You need to define {0} in the config file.'.format(key)) 31 | sys.exit() 32 | 33 | return config 34 | 35 | 36 | def replace_env_vars(conf): 37 | """Fill `conf` with environment variables, where appropriate. 38 | 39 | Any value of the from $VAR will be replaced with the environment variable 40 | VAR. If there are sub dictionaries, this function will recurse. 41 | 42 | This will preserve the original dictionary, and return a copy. 43 | """ 44 | d = deepcopy(conf) 45 | for key, value in d.items(): 46 | if type(value) == dict: 47 | d[key] = replace_env_vars(value) 48 | elif type(value) == str: 49 | if value[0] == '$': 50 | var_name = value[1:] 51 | d[key] = os.environ[var_name] 52 | 53 | return d 54 | -------------------------------------------------------------------------------- /hamper/interfaces.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import re 3 | 4 | from zope.interface import implements, Interface, Attribute 5 | from zope.interface.exceptions import DoesNotImplement 6 | from zope.interface.declarations import implementedBy 7 | 8 | from twisted.plugin import IPlugin 9 | 10 | log = logging.getLogger('hamper.interfaces') 11 | 12 | 13 | class BaseInterface(Interface): 14 | """Interface for a plugin..""" 15 | 16 | name = Attribute('Human readable name for the plugin.') 17 | 18 | def setup(factory): 19 | """Called when the factory loads the plugin.""" 20 | 21 | 22 | class Plugin(object): 23 | name = "genericplugin" 24 | dependencies = [] 25 | implements(IPlugin) 26 | 27 | def __init__(self): 28 | self.commands = [] 29 | 30 | def setup(self, factory): 31 | pass 32 | 33 | 34 | class IChatPlugin(BaseInterface): 35 | """Interface for a chat plugin.""" 36 | 37 | priority = Attribute('Priority of plugins. High numbers are called first') 38 | 39 | def message(bot, comm): 40 | """ 41 | Called when a message comes in to the bot. 42 | 43 | Return `True` if execution of plugins should stop after this. A return 44 | value of `False` or no return value (implicit `None`) will cause the 45 | next plugin to be called. 46 | """ 47 | 48 | 49 | class ChatPlugin(Plugin): 50 | """Base class for a chat plugin.""" 51 | implements(IChatPlugin) 52 | 53 | priority = 0 54 | 55 | def message(self, bot, comm): 56 | pass 57 | 58 | 59 | class ChatCommandPlugin(ChatPlugin): 60 | """ 61 | Helper class for a ChatCommand plugin 62 | 63 | If any of a classes children are Command classes, automatically call out to 64 | them. 65 | """ 66 | 67 | def setup(self, *args, **kwargs): 68 | super(ChatCommandPlugin, self).setup(*args, **kwargs) 69 | 70 | for name in dir(self): 71 | cls = getattr(self, name) 72 | try: 73 | if ICommand in implementedBy(cls): 74 | log.info("Loading command {0}".format(cls.__name__)) 75 | self.commands.append(cls(self)) 76 | except (DoesNotImplement, TypeError, AttributeError): 77 | pass 78 | 79 | def message(self, bot, comm): 80 | super(ChatCommandPlugin, self).message(bot, comm) 81 | for cmd in self.commands: 82 | stop = cmd.message(bot, comm) 83 | if stop: 84 | return stop 85 | 86 | 87 | class ICommand(BaseInterface): 88 | """Interface for a command.""" 89 | 90 | name = Attribute('The name of the command, for code purposes.') 91 | regex = Attribute('The regex to trigger this command for.') 92 | caseSensitive = Attribute("The case sensitivity of the trigger regex.") 93 | onlyDirected = Attribute("Only respond to command directed at the bot.") 94 | 95 | def message(bot, comm): 96 | """Chooses whether or not to trigger the command.""" 97 | 98 | def command(bot, comm, groups): 99 | """This function gets called when the command is triggered.""" 100 | 101 | 102 | class Command(object): 103 | """ 104 | A convenience wrapper to implement a single command. 105 | 106 | To use it, define a clas that inherits from Command inside a Plugin. 107 | """ 108 | implements(IPlugin, ICommand) 109 | 110 | caseSensitive = False 111 | onlyDirected = True 112 | 113 | def __init__(self, plugin): 114 | self.plugin = plugin 115 | if type(self.regex) == str: 116 | opts = 0 if self.caseSensitive else re.I 117 | self.regex = re.compile(self.regex, opts) 118 | 119 | def message(self, bot, comm): 120 | if self.onlyDirected and not comm['directed']: 121 | return 122 | match = self.regex.match(comm['message']) 123 | if match: 124 | self.command(bot, comm, match.groups()) 125 | return True 126 | 127 | 128 | class IPresencePlugin(BaseInterface): 129 | """A plugin that gets events about the bot joining and leaving channels.""" 130 | 131 | def joined(bot, channel): 132 | """ 133 | Called when I finish joining a channel. 134 | 135 | Channel has the starting character (# or &) intact. 136 | """ 137 | 138 | def left(bot, channel): 139 | """ 140 | Called when I have left a channel. 141 | 142 | Channel has the starting character (# or &) intact. 143 | """ 144 | 145 | def signedOn(bot): 146 | """Called after successfully signing on to the server.""" 147 | 148 | 149 | class PresencePlugin(Plugin): 150 | implements(IPresencePlugin) 151 | 152 | def joined(self, bot, channel): 153 | pass 154 | 155 | def left(self, bot, channel): 156 | pass 157 | 158 | def signedOn(self, bot): 159 | pass 160 | 161 | 162 | class IPopulationPlugin(BaseInterface): 163 | """A plugin that recieves events about the population of channels.""" 164 | 165 | def userJoined(bot, user, channel): 166 | """Called when I see another user joinging a channel.""" 167 | 168 | def userLeft(bot, user, channe): 169 | """Called when I see another user leaving a channel.""" 170 | 171 | def userQuit(bot, user, quitMessage): 172 | """Called when I see another user disconnect from the network.""" 173 | 174 | def userKicked(bot, kickee, channel, kicker, message): 175 | """Called when I see someone else being kicked from a channel.""" 176 | 177 | def namesReply(bot, prefix, params): 178 | """Called when the server responds to a names request""" 179 | 180 | def namesEnd(bot, prefix, params): 181 | """Called when the server finishes responding to a names request""" 182 | 183 | 184 | class PopulationPlugin(Plugin): 185 | implements(IPopulationPlugin) 186 | 187 | def userJoined(self, bot, user, channel): 188 | pass 189 | 190 | def userLeft(self, bot, user, channe): 191 | pass 192 | 193 | def userQuit(self, bot, user, quitMessage): 194 | pass 195 | 196 | def userKicked(self, bot, kickee, channel, kicker, message): 197 | pass 198 | 199 | def namesReply(self, bot, prefix, params): 200 | pass 201 | 202 | def namesEnd(self, bot, prefix, params): 203 | pass 204 | -------------------------------------------------------------------------------- /hamper/log.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | 4 | class ColorizingStreamHandler(logging.StreamHandler): 5 | """StreamHandler that colorizes log out based on level. 6 | 7 | Copyright (C) 2010-2012 Vinay Sajip. All rights reserved. Licensed under 8 | the new BSD license. From https://gist.github.com/758430. 9 | 10 | """ 11 | 12 | # color names to indices 13 | color_map = { 14 | 'black': 0, 15 | 'red': 1, 16 | 'green': 2, 17 | 'yellow': 3, 18 | 'blue': 4, 19 | 'magenta': 5, 20 | 'cyan': 6, 21 | 'white': 7, 22 | } 23 | 24 | level = logging.DEBUG 25 | 26 | # levels to (background, foreground) 27 | level_map = { 28 | logging.DEBUG: (None, 'blue'), 29 | logging.INFO: (None, None), 30 | logging.WARNING: (None, 'yellow'), 31 | logging.ERROR: (None, 'red'), 32 | logging.CRITICAL: ('red', 'white'), 33 | } 34 | csi = '\x1b[' 35 | reset = '\x1b[0m' 36 | 37 | @property 38 | def is_tty(self): 39 | isatty = getattr(self.stream, 'isatty', None) 40 | return isatty and isatty() 41 | 42 | def emit(self, record): 43 | try: 44 | message = self.format(record) 45 | stream = self.stream 46 | if not self.is_tty: 47 | stream.write(message) 48 | else: 49 | self.output_colorized(message) 50 | stream.write(getattr(self, 'terminator', '\n')) 51 | self.flush() 52 | except (KeyboardInterrupt, SystemExit): 53 | raise 54 | except: 55 | self.handleError(record) 56 | 57 | def output_colorized(self, message): 58 | self.stream.write(message) 59 | 60 | def colorize(self, message, record): 61 | if record.levelno in self.level_map: 62 | bg, fg = self.level_map[record.levelno] 63 | params = [] 64 | if bg in self.color_map: 65 | params.append(str(self.color_map[bg] + 40)) 66 | if fg in self.color_map: 67 | params.append(str(self.color_map[fg] + 30)) 68 | if params: 69 | message = ''.join((self.csi, ';'.join(params), 70 | 'm', message, self.reset)) 71 | return message 72 | 73 | def format(self, record): 74 | message = logging.StreamHandler.format(self, record) 75 | if self.is_tty: 76 | # Don't colorize any traceback 77 | parts = message.split('\n', 1) 78 | parts[0] = self.colorize(parts[0], record) 79 | message = '\n'.join(parts) 80 | return message 81 | 82 | 83 | config = { 84 | 'version': 1, 85 | 'formatters': { 86 | 'default': { 87 | 'format': '%(levelname)s %(module)s %(message)s', 88 | } 89 | }, 90 | 'filters': {}, 91 | 'handlers': { 92 | 'color': { 93 | 'level': 'DEBUG', 94 | 'class': 'hamper.log.ColorizingStreamHandler', 95 | 'formatter': 'default', 96 | }, 97 | 'console': { 98 | 'level': 'DEBUG', 99 | 'class': 'logging.StreamHandler', 100 | 'formatter': 'default', 101 | }, 102 | }, 103 | 'loggers': { 104 | 'hamper': { 105 | 'handlers': ['color'], 106 | 'propogate': True, 107 | 'level': 'DEBUG', 108 | }, 109 | 'bravo': { 110 | 'handlers': ['color'], 111 | 'propogate': True, 112 | 'level': 'DEBUG', 113 | } 114 | } 115 | } 116 | 117 | 118 | def setup_logging(): 119 | h = logging.getLogger('hamper') 120 | h.setLevel(logging.DEBUG) 121 | h.addHandler(ColorizingStreamHandler()) 122 | -------------------------------------------------------------------------------- /hamper/plugins/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hamperbot/hamper/d9533a3ff94ff1009bf5c4c779b2ca3a5e93c892/hamper/plugins/__init__.py -------------------------------------------------------------------------------- /hamper/plugins/bitly.py: -------------------------------------------------------------------------------- 1 | import json 2 | import StringIO as SIO 3 | import re 4 | import urllib 5 | import urllib2 6 | 7 | from hamper.interfaces import ChatPlugin 8 | 9 | 10 | class Bitly(ChatPlugin): 11 | name = 'bitly' 12 | priority = 2 13 | 14 | # Regex is taken from: 15 | # http://daringfireball.net/2010/07/improved_regex_for_matching_urls 16 | regex = ur""" 17 | ( # Capture 1: entire matched URL 18 | (?: 19 | (?Phttps?://) # http or https protocol 20 | | # or 21 | www\d{0,3}[.] # "www.", "www1.", "www2." ... "www999." 22 | | # or 23 | [a-z0-9.\-]+[.][a-z]{2,4}/ # looks like domain name 24 | # followed by a slash 25 | ) 26 | (?: # One or more: 27 | [^\s()<>]+ # Run of non-space, non-()<> 28 | | # or 29 | \(([^\s()<>]+|(\([^\s()<>]+\)))*\) # balanced parens, up to 2 levels 30 | )+ 31 | (?: # End with: 32 | \(([^\s()<>]+|(\([^\s()<>]+\)))*\) # balanced parens, up to 2 levels 33 | | # or 34 | [^\s`!()\[\]{};:'".,<>?] # not a space or one of 35 | # these punct chars 36 | ) 37 | ) 38 | """ 39 | title_regex = ur"(.*?)" 40 | 41 | def setup(self, loader): 42 | self.regex = re.compile(self.regex, re.VERBOSE | re.IGNORECASE | re.U) 43 | self.title_regex = re.compile(self.title_regex, re.I | re.U) 44 | self.api_url = 'https://api-ssl.bitly.com/v3/shorten' 45 | # If an exclude value is found in the url 46 | # it will not be shortened 47 | self.excludes = ['imgur.com', 'gist.github.com', 'pastebin.com'] 48 | # Make sure they've configured the bitly config values. 49 | try: 50 | self.username = loader.config['bitly']['login'] 51 | self.api_key = loader.config['bitly']['api_key'] 52 | except KeyError: 53 | print ('\nTo use the bitly plugin you need to set your bitly login' 54 | '\nand api_key in your config file.\n' 55 | 'Example:\n' 56 | 'bitly:\n' 57 | " login: '123456789000'\n" 58 | " api_key: '1234678901234567890123467890123456'\n") 59 | quit() 60 | 61 | def message(self, bot, comm): 62 | match = self.regex.search(comm['message']) 63 | # Found a url 64 | if match: 65 | # base url isn't % encoded, python 2.7 doesn't do this well, and I 66 | # couldn't figure it out. 67 | long_url = match.group(0) 68 | 69 | # Only shorten urls which are longer than a bitly url (12 chars) 70 | if len(long_url) <= 21: 71 | return False 72 | 73 | # Don't shorten url's which are in the exclude list 74 | for item in self.excludes: 75 | if item in long_url.lower(): 76 | return False 77 | 78 | # Bitly requires a valid URI 79 | if not match.group('prot'): 80 | long_url = 'http://' + long_url 81 | 82 | # Bitly requires valid % encoded urls 83 | params = urllib.urlencode({'login': self.username, 84 | 'apiKey': self.api_key, 85 | 'longUrl': long_url}) 86 | req = urllib2.Request(self.api_url, data=params) 87 | response = urllib2.urlopen(req) 88 | data = json.load(response) 89 | 90 | if data['status_txt'] == 'OK': 91 | # Grab the title of said link 92 | page_req = urllib2.Request(long_url) 93 | page_resp = urllib2.urlopen(page_req) 94 | contents = SIO.StringIO() 95 | contents.write(page_resp.read()) 96 | title = self.title_regex.search(contents.getvalue()).group(1) 97 | 98 | bot.reply(comm, "{0[user]}'s shortened url is {1[url]}" 99 | .format(comm, data['data'])) 100 | bot.reply(comm, "Title: {0}".format(title)) 101 | 102 | # Always let the other plugins run 103 | return False 104 | -------------------------------------------------------------------------------- /hamper/plugins/channel_utils.py: -------------------------------------------------------------------------------- 1 | from hamper.interfaces import Command, ChatCommandPlugin 2 | 3 | 4 | class ChannelUtils(ChatCommandPlugin): 5 | 6 | name = 'channelutils' 7 | priority = 0 8 | 9 | class JoinCommand(Command): 10 | name = 'join' 11 | regex = r'^join (.*)$' 12 | 13 | short_desc = 'join #channel - Ask the bot to join a channel.' 14 | long_desc = None 15 | 16 | def command(self, bot, comm, groups): 17 | """Join a channel, and say you did.""" 18 | if not bot.acl.has_permission(comm, 'channel_utils.join'): 19 | bot.reply(comm, "You don't have permission to do that.") 20 | return 21 | 22 | chan = groups[0] 23 | if not chan.startswith('#'): 24 | chan = '#' + chan 25 | bot.join(chan) 26 | bot.reply(comm, 'OK, {0}.'.format(comm['user'])) 27 | 28 | class LeaveCommand(Command): 29 | name = 'leave' 30 | regex = r'^leave(?: (#?[-_a-zA-Z0-9]+))?$' 31 | 32 | short_desc = 'leave [#channel] - Ask the bot to leave.' 33 | long_desc = 'If channel is ommited, leave the current channel.' 34 | 35 | def command(self, bot, comm, groups): 36 | """Leave a channel.""" 37 | if not bot.acl.has_permission(comm, 'channel_utils.leave'): 38 | bot.reply(comm, "You don't have permission to do that.") 39 | return 40 | 41 | chan = groups[0] 42 | if chan is None: 43 | chan = comm['channel'] 44 | if not chan.startswith('#'): 45 | chan = '#' + chan 46 | bot.reply(comm, 'Bye!') 47 | bot.leave(chan) 48 | -------------------------------------------------------------------------------- /hamper/plugins/commands.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import re 3 | import random 4 | from sre_constants import error as RegexError 5 | 6 | from hamper.interfaces import Command, ChatCommandPlugin 7 | 8 | 9 | log = logging.getLogger('hamper.plugins') 10 | 11 | 12 | class Quit(ChatCommandPlugin): 13 | """Know when hamper isn't wanted.""" 14 | name = 'quit' 15 | 16 | class QuitCommand(Command): 17 | regex = 'quit' 18 | 19 | def command(self, bot, comm, groups): 20 | if comm['pm']: 21 | bot.msg(comm['channel'], "You can't do that from PM.") 22 | return False 23 | 24 | bot.reply(comm, 'Bye!') 25 | bot.quit() 26 | return True 27 | 28 | 29 | class Sed(ChatCommandPlugin): 30 | """To be honest, I feel strange in channels that don't have this.""" 31 | name = 'sed' 32 | priority = 3 33 | 34 | def setup(self, loader): 35 | self.onlyDirected = loader.config.get('sed', {}).get('only-directed') 36 | super(Sed, self).setup(loader) 37 | 38 | class SedCommand(Command): 39 | name = 'sed' 40 | regex = r's/(.*?)/(.*?)/([mig]*)(?:\s+s/(.*?)/(.*?)/([mig]*))*' 41 | onlyDirected = False 42 | 43 | short_desc = 's/find/replace/ - Perform sed style find and replace.' 44 | long_desc = ('Use like "s/foo/bar/" to search for "foo" and replace ' 45 | 'it with "bar". \n' 46 | 'Flags: Add these flags to the end of the command:\n' 47 | 'm - Restrict the search to only your messages\n' 48 | 'i - Case insensitive searching.\n' 49 | 'g - Match an unlimited number of times.') 50 | 51 | def command(self, bot, comm, groups): 52 | if self.plugin.onlyDirected and not comm['directed']: 53 | return False 54 | 55 | key = comm['channel'] 56 | if key not in bot.factory.history: 57 | bot.reply(comm, 'Who are you?! How did you get in my house?!') 58 | return False 59 | 60 | groups_len = len(groups) 61 | user_regexs = [] 62 | try: 63 | for idx in range(0, groups_len, 3): 64 | user_regexs.append(re.compile(groups[idx])) 65 | except RegexError: 66 | bot.reply(comm, 'Do you even lift?') 67 | return False 68 | user_replaces = [groups[idx] for idx in range(1, groups_len, 3)] 69 | regex_opts = [groups[idx] for idx in range(2, groups_len, 3)] 70 | 71 | # m is a global flag, meaning if any regex wants to match user 72 | # messages only, all regexs will only match user messages 73 | m_found = False 74 | for opts in regex_opts: 75 | if 'm' in opts: 76 | m_found = True 77 | break 78 | if m_found: 79 | for opts in regex_opts: 80 | if 'm' not in opts: 81 | opts += 'm' 82 | 83 | for hist in reversed(bot.factory.history[key]): 84 | # Remember that 'm' is global 85 | if 'm' in regex_opts[0] and hist['user'] != comm['user']: 86 | # Only look at the user's messages 87 | continue 88 | 89 | # Don't look at other sed commands 90 | if 's/' in hist['raw_message']: 91 | continue 92 | 93 | tmp = hist['raw_message'] 94 | for idx in range(0, len(user_regexs)): 95 | if user_regexs[idx].search(tmp): 96 | try: 97 | tmp = user_regexs[idx].sub( 98 | user_replaces[idx], tmp, 'g' in regex_opts[idx] 99 | ) 100 | except RegexError: 101 | bot.reply(comm, 'Do you even lift?') 102 | return False 103 | bot.reply(comm, '{0} actually meant: {1}'.format( 104 | hist['user'], tmp)) 105 | break 106 | else: 107 | bot.reply(comm, "Sorry, I couldn't match '{0}'." 108 | .format(", ".join(r.pattern for r in user_regexs))) 109 | 110 | 111 | class LetMeGoogleThatForYou(ChatCommandPlugin): 112 | """Link to the sarcastic letmegooglethatforyou.com.""" 113 | name = 'lmgtfy' 114 | 115 | class LMGTFYCommand(Command): 116 | name = 'lmgtfy' 117 | regex = '^lmgtfy\s+(.*)' 118 | onlyDirected = False 119 | 120 | short_desc = 'lmgtfy - Show someone where to find something.' 121 | long_desc = ('This command will be triggered at the beginning of a ' 122 | 'message to anyone, so you can use it like "Bob: lmgtfy ' 123 | 'rtfm" to show Bob how to search for "rtfm".') 124 | 125 | def command(self, bot, comm, groups): 126 | target = '' 127 | if comm['target']: 128 | target = comm['target'] + ': ' 129 | args = groups[0].replace(' ', '+') 130 | bot.reply(comm, target + 'http://lmgtfy.com/?q=' + args) 131 | 132 | 133 | class Rot13(ChatCommandPlugin): 134 | """Encode secret messages.""" 135 | name = 'rot13' 136 | 137 | class Rot13Command(Command): 138 | name = 'rot13' 139 | regex = '^rot13\s+(.*)' 140 | onlyDirected = False 141 | 142 | short_desc = 'rot13 - Encodes string using rot13 cipher.' 143 | long_desc = ('The rot13 cipher rotates every letter to the ' 144 | 'other side of the alphabet. Applying it twice ' 145 | 'returns the original string.\n' 146 | 'Example: !rot13 science yields fpvrapr' 147 | ' and !rot13 fpvrapr yields science') 148 | 149 | def command(self, bot, comm, groups): 150 | target = '' 151 | if comm['target']: 152 | target = comm['target'] + ': ' 153 | try: 154 | args = groups[0].encode('rot13') 155 | bot.reply(comm, target + args) 156 | except UnicodeDecodeError: 157 | bot.reply( 158 | comm, 159 | "It doesn't make sense to rot13 unicode characters" 160 | ) 161 | 162 | 163 | class Dice(ChatCommandPlugin): 164 | """Random dice rolls!""" 165 | name = 'dice' 166 | priority = 5 167 | 168 | def setup(self, *args, **kwargs): 169 | super(Dice, self).setup(*args, **kwargs) 170 | log.info('dice setup') 171 | 172 | @classmethod 173 | def roll(cls, num, sides, add): 174 | """Rolls a die of sides sides, num times, sums them, and adds add""" 175 | rolls = [] 176 | for i in range(num): 177 | rolls.append(random.randint(1, sides)) 178 | rolls.append(add) 179 | return rolls 180 | 181 | class DiceCommand(Command): 182 | name = 'dice' 183 | regex = '^(\d*)d(?:ice)?(\d*)\+?(\d*)$' 184 | onlyDirected = True 185 | 186 | short_desc = 'Dice - Roll dice by saying !XdY+Z.' 187 | long_desc = ('Use like XdY+Z to roll X Y sided dice and add Z. Any ' 188 | 'number may be left off.\n' 189 | 'Example: "!1d20+5" to roll a single twenty sided die ' 190 | 'and add 5 to the result. You don\'t have to direct ' 191 | 'this to the bot.') 192 | 193 | def command(self, bot, com, groups): 194 | num, sides, add = groups 195 | 196 | if not num: 197 | num = 1 198 | else: 199 | num = int(num) 200 | 201 | if not sides: 202 | sides = 6 203 | else: 204 | sides = int(sides) 205 | 206 | if not add: 207 | add = 0 208 | else: 209 | add = int(add) 210 | 211 | result = Dice.roll(num, sides, add) 212 | output = '%s: You rolled %sd%s+%s and got ' % (com['user'], num, 213 | sides, add) 214 | if len(result) < 11: 215 | # the last one is the constant to add 216 | for die in result[:-1]: 217 | output += "%s, " % die 218 | else: 219 | output += "a lot of dice " 220 | 221 | output += "for a total of %s" % sum(result) 222 | 223 | bot.say(com['channel'], output) 224 | -------------------------------------------------------------------------------- /hamper/plugins/dictionary.py: -------------------------------------------------------------------------------- 1 | from hamper.interfaces import ChatCommandPlugin, Command 2 | 3 | try: 4 | # Python 2 5 | import HTMLParser 6 | html = HTMLParser.HTMLParser() 7 | except ImportError: 8 | # Python 3 9 | import html.parser 10 | html = html.parser.HTMLParser() 11 | 12 | import re 13 | import requests 14 | import json 15 | 16 | 17 | class Lookup(ChatCommandPlugin): 18 | name = 'lookup' 19 | priority = 2 20 | 21 | short_desc = 'lookup - look something up' 22 | long_desc = ('lookup and cite - look something up and cite a ' 23 | 'source\n') 24 | 25 | # Inspired by http://googlesystem.blogspot.com/2009/12/on-googles-unofficial-dictionary-api.html # noqa 26 | search_url = "http://www.google.com/dictionary/json?callback=dict_api.callbacks.id100&q={query}&sl=en&tl=en&restrict=pr%2Cde&client=te" # noqa 27 | 28 | def setup(self, loader): 29 | super(Lookup, self).setup(loader) 30 | 31 | class Lookup(Command): 32 | name = 'lookup' 33 | regex = '^(lookup\s+and\s+cite|lookup)\s*(\d+)?\s+(.*)' 34 | 35 | def command(self, bot, comm, groups): 36 | lookup_type = groups[0] 37 | def_num = int(groups[1]) if groups[1] else 1 38 | query = groups[2] 39 | 40 | resp = requests.get(self.plugin.search_url.format(query=query)) 41 | if resp.status_code != 200: 42 | raise Exception( 43 | "Lookup Error: A non 200 status code was returned" 44 | ) 45 | 46 | # We have actually asked for this cruft to be tacked onto our JSON 47 | # response. When I tried to remove the callback parameter from the 48 | # URL the api broke, so I'm going to leave it. Put it down, and 49 | # walk away... 50 | # Strip off the JS callback 51 | gr = resp.content.strip('dict_api.callbacks.id100(') 52 | gr = gr.strip(',200,null)') 53 | 54 | gr = gr.replace('\\x', "\u00") # Google uses javascript JSON crap 55 | gr = json.loads(gr) 56 | 57 | if 'primaries' in gr: 58 | entries = gr['primaries'][0]['entries'] 59 | elif 'webDefinitions' in gr: 60 | entries = gr['webDefinitions'][0]['entries'] 61 | else: 62 | bot.reply(comm, "No definition found") 63 | return False 64 | 65 | seen = 0 66 | definition = None 67 | url = None 68 | for entry in entries: 69 | if not entry['type'] == 'meaning': 70 | continue 71 | 72 | for term in entry['terms']: 73 | if term['type'] == 'url': 74 | url = re.sub('<[^<]+?>', '', term['text']) 75 | else: 76 | seen += 1 77 | if not definition and seen == def_num: 78 | definition = term['text'] 79 | 80 | if not definition or def_num > seen: 81 | bot.reply( 82 | comm, 83 | "Looks like there might not be %s definitions" % def_num 84 | ) 85 | else: 86 | bot.reply( 87 | comm, "%s (%s/%s)" % ( 88 | html.unescape(definition), def_num, seen 89 | ) 90 | ) 91 | if 'cite' in lookup_type: 92 | if url: 93 | bot.reply(comm, html.unescape(url)) 94 | else: 95 | bot.reply(comm, '[No citation]') 96 | 97 | # Always let the other plugins run 98 | return False 99 | -------------------------------------------------------------------------------- /hamper/plugins/factoids.py: -------------------------------------------------------------------------------- 1 | import re 2 | import random 3 | 4 | from sqlalchemy import Column, Integer, String 5 | from sqlalchemy.ext.declarative import declarative_base 6 | 7 | from hamper.interfaces import ChatPlugin 8 | from hamper.utils import ude 9 | 10 | 11 | SQLAlchemyBase = declarative_base() 12 | 13 | 14 | class Factoids(ChatPlugin): 15 | """Learn and repeat Factoids.""" 16 | name = 'factoids' 17 | priority = 2 18 | 19 | def setup(self, loader): 20 | super(Factoids, self).setup(loader) 21 | self.db = loader.db 22 | SQLAlchemyBase.metadata.create_all(self.db.engine) 23 | 24 | self.factoids = {} 25 | 26 | def message(self, bot, comm): 27 | subs = [self.try_add_factoid, 28 | self.try_forget_factoid, 29 | self.try_forget_factoid_mass, 30 | self.try_respond_to_factoid] 31 | 32 | ret = False 33 | for sub in subs: 34 | ret = sub(bot, comm) 35 | if ret: 36 | break 37 | return ret 38 | 39 | def try_add_factoid(self, bot, comm): 40 | if not comm['directed']: 41 | return 42 | 43 | msg = comm['message'].strip() 44 | match = re.match(r'learn(?: that)? (.+)\s+(\w+)\s+<(\w+)>\s+(.*)', msg) 45 | 46 | if not match: 47 | return 48 | 49 | if not bot.acl.has_permission(comm, 'factoid'): 50 | bot.reply(comm, "I cannot learn new things") 51 | return 52 | 53 | trigger, type_, action, response = match.groups() 54 | trigger = trigger.strip() 55 | 56 | if action not in ['say', 'reply', 'me']: 57 | bot.reply(comm, "I don't know the action {0}.".format(action)) 58 | return 59 | if type_ not in ['is', 'triggers']: 60 | bot.reply(comm, "I don't the type {0}.".format(type_)) 61 | return 62 | 63 | self.db.session.add( 64 | Factoid(ude(trigger), type_, action, ude(response)) 65 | ) 66 | self.db.session.commit() 67 | 68 | bot.reply(comm, 'OK, {user}'.format(**comm)) 69 | 70 | return True 71 | 72 | def try_forget_factoid(self, bot, comm): 73 | if not comm['directed']: 74 | return 75 | 76 | msg = ude(comm['message'].strip()) 77 | match = re.match(r'forget that\s+(.+)\s+is\s+(.*)', msg) 78 | 79 | if not match: 80 | return 81 | 82 | if not bot.acl.has_permission(comm, 'factoid'): 83 | bot.reply(comm, "Never Forget!") 84 | return 85 | 86 | trigger, response = match.groups() 87 | 88 | factoids = (self.db.session.query(Factoid) 89 | .filter(Factoid.trigger == ude(trigger), 90 | Factoid.response == ude(response)) 91 | .all()) 92 | if len(factoids) == 0: 93 | bot.reply(comm, "I don't have anything like that.") 94 | return 95 | for factoid in factoids: 96 | self.db.session.delete(factoid) 97 | self.db.session.commit() 98 | bot.reply(comm, 'Done, {user}'.format(**comm)) 99 | 100 | return True 101 | 102 | def try_forget_factoid_mass(self, bot, comm): 103 | if not comm['directed']: 104 | return 105 | 106 | msg = ude(comm['message'].strip()) 107 | match = re.match(r'forget all about (.+)', msg) 108 | 109 | if not match: 110 | return 111 | 112 | if not bot.acl.has_permission(comm, 'factoid'): 113 | bot.reply(comm, "Never Forget!") 114 | return 115 | 116 | trigger = match.groups()[0] 117 | factoids = (self.db.session.query(Factoid) 118 | .filter(Factoid.trigger == ude(trigger)) 119 | .all()) 120 | 121 | if len(factoids) == 0: 122 | bot.reply(comm, "I don't have anything like that.") 123 | return 124 | 125 | for factoid in factoids: 126 | self.db.session.delete(factoid) 127 | self.db.session.commit() 128 | 129 | bot.reply(comm, 'Done, {user}'.format(**comm)) 130 | 131 | return True 132 | 133 | def try_respond_to_factoid(self, bot, comm): 134 | msg = ude(comm['message'].strip()) 135 | 136 | if comm['directed']: 137 | msg = '!' + msg 138 | 139 | factoids = (self.db.session.query(Factoid) 140 | .filter(Factoid.trigger == msg) 141 | .all()) 142 | if len(factoids) == 0: 143 | factoids = (self.db.session.query(Factoid) 144 | .filter(Factoid.type == 'triggers') 145 | .all()) 146 | factoids = filter(lambda f: f.trigger in msg, factoids) 147 | 148 | if len(factoids) == 0: 149 | return 150 | 151 | factoid = random.choice(factoids) 152 | if factoid.action == 'say': 153 | bot.reply(comm, factoid.response) 154 | return True 155 | elif factoid.action == 'reply': 156 | bot.reply(comm, '{}: {}'.format(comm['user'], factoid.response)) 157 | return True 158 | elif factoid.action == 'me': 159 | bot.me(comm, factoid.response) 160 | return True 161 | else: 162 | bot.reply(comm, 'Um, what is the verb {}?'.format(factoid.action)) 163 | 164 | 165 | class Factoid(SQLAlchemyBase): 166 | '''The object that will get persisted by the database.''' 167 | 168 | __tablename__ = 'factoids' 169 | 170 | id = Column(Integer, primary_key=True) 171 | type = Column(String) 172 | trigger = Column(String) 173 | action = Column(String) 174 | response = Column(String) 175 | 176 | def __init__(self, trigger, type, action, response): 177 | self.type = type 178 | self.trigger = trigger 179 | self.action = action 180 | self.response = response 181 | -------------------------------------------------------------------------------- /hamper/plugins/flip.py: -------------------------------------------------------------------------------- 1 | # -*- coding: latin-1 -*- 2 | 3 | from hamper.interfaces import Command, ChatCommandPlugin 4 | import upsidedown 5 | 6 | 7 | class Flip(ChatCommandPlugin): 8 | ''' 9 | FLOSS Flip Utility 10 | You need to install the upsidedown package from pip to use this plugin 11 | ''' 12 | 13 | name = 'flip' 14 | priority = 0 15 | 16 | class Flip(Command): 17 | """Deliver a quote.""" 18 | regex = r'^flip\s(.*)$' 19 | 20 | name = 'flip' 21 | short_desc = 'flip - flip it' 22 | long_desc = '' 23 | 24 | def command(self, bot, comm, groups): 25 | msg = groups[0].decode('utf-8') 26 | angry = '(╯°□°)╯︵ ' 27 | try: 28 | flip = upsidedown.transform(msg).encode('utf-8') 29 | ret = angry + flip 30 | except: 31 | ret = u'ಠ_ಠ'.encode('utf-8') 32 | bot.reply(comm, ret, encode=False) 33 | return True 34 | -------------------------------------------------------------------------------- /hamper/plugins/foods.py: -------------------------------------------------------------------------------- 1 | import random 2 | 3 | from hamper.interfaces import ChatPlugin 4 | from hamper.utils import ude 5 | 6 | foodverbs = [ 7 | "bake", 8 | "order", 9 | "fry", 10 | "steam", 11 | "boil", 12 | "simmer", 13 | "blend", 14 | "eat", 15 | "nosh", 16 | "nibble", 17 | "snack on", 18 | "nom", 19 | "chomp", 20 | "consume", 21 | ] 22 | 23 | foodtools = [ 24 | "blender", 25 | "frying pan", 26 | "colander", 27 | "kettle", 28 | "pot", 29 | "plate", 30 | "knife", 31 | "fork", 32 | "spoon", 33 | "sous vide", 34 | "crockpot", 35 | "whipping siphon", 36 | "spatula", 37 | "tray", 38 | ] 39 | 40 | foodunits = [ 41 | "bag", 42 | "box", 43 | "carton", 44 | "can", 45 | "jar", 46 | "package", 47 | "piece", 48 | "bottle", 49 | "bar", 50 | ] 51 | 52 | foodqualities = [ 53 | "acidic", 54 | "bland", 55 | "creamy", 56 | "fatty", 57 | "fruity", 58 | "healthy", 59 | "nutty", 60 | "oily", 61 | "raw", 62 | "salty", 63 | "sharp", 64 | "sour", 65 | "spicy", 66 | "sweet", 67 | "tender", 68 | "tough", 69 | "huge", 70 | "tiny", 71 | "spoiled", 72 | ] 73 | 74 | ingredients = [ 75 | "dairy", 76 | "fish", 77 | "fruit", 78 | "anchovy", 79 | "apple", 80 | "apricot", 81 | "artichoke", 82 | "asparagus", 83 | "bacon", 84 | "banana", 85 | "beans", 86 | "beef", 87 | "blackberry", 88 | "blackcurrant", 89 | "blueberry", 90 | "bread", 91 | "broccoli", 92 | "brownies", 93 | "buffalo", 94 | "butter", 95 | "carrot", 96 | "cauliflower", 97 | "celery", 98 | "cereal", 99 | "cheese", 100 | "cherry", 101 | "chicken", 102 | "chocolate", 103 | "coconut", 104 | "cod", 105 | "coffee", 106 | "cooked meat", 107 | "cream", 108 | "creams", 109 | "cucumber", 110 | "duck", 111 | "egg", 112 | "eggplant", 113 | "fig", 114 | "garlic", 115 | "ginger", 116 | "gooseberry", 117 | "grape", 118 | "grapefruit", 119 | "grapes", 120 | "haddock", 121 | "ham", 122 | "herring", 123 | "kidney", 124 | "kiwi", 125 | "kiwi fruit", 126 | "lamb", 127 | "leek", 128 | "lemon", 129 | "lettuce", 130 | "lime", 131 | "liver", 132 | "mackerel", 133 | "mango", 134 | "melon", 135 | "milk", 136 | "mince", 137 | "mushroom", 138 | "olive", 139 | "onion", 140 | "orange", 141 | "peach", 142 | "peanut", 143 | "pear", 144 | "peas", 145 | "pepper", 146 | "pineapple", 147 | "plum", 148 | "pomegranate", 149 | "pork", 150 | "potato", 151 | "pumpkin", 152 | "quark", 153 | "radish", 154 | "raspberry", 155 | "redcurrant", 156 | "rhubarb", 157 | "rye", 158 | "salami", 159 | "salmon", 160 | "sausages", 161 | "strawberry", 162 | "sweet potato", 163 | "trout", 164 | "tuna", 165 | "turkey", 166 | "veal", 167 | "vegetable", 168 | "walnut", 169 | "water", 170 | "wheat", 171 | "yoghurt", 172 | ] 173 | 174 | foodpreparations = [ 175 | "casserole", 176 | "burrito", 177 | "lasagne", 178 | "porridge", 179 | "pie", 180 | "cake", 181 | "wrap", 182 | "salad", 183 | "ice cream", 184 | "tea", 185 | "wine", 186 | "beer", 187 | "juice", 188 | "soda", 189 | "roll", 190 | "sandwich", 191 | "stir-fry", 192 | "soup", 193 | "pasta", 194 | "pizza", 195 | ] 196 | 197 | meals = [ 198 | "elvensies", 199 | "second breakfast", 200 | "lunch", 201 | "breakfast", 202 | "afternoon tea", 203 | "dinner", 204 | "supper", 205 | "a snack", 206 | "a midnight snack", 207 | "your cheat meal", 208 | ] 209 | 210 | additives = [ 211 | "basil", 212 | "chives", 213 | "coriander", 214 | "dill", 215 | "parsley", 216 | "rosemary", 217 | "sage", 218 | "thyme", 219 | "spices", 220 | "chilli powder", 221 | "cinnamon", 222 | "cumin", 223 | "curry powder", 224 | "nutmeg", 225 | "paprika", 226 | "saffron", 227 | "ketchup", 228 | "mayonnaise", 229 | "mustard", 230 | "pepper", 231 | "salad dressing", 232 | "salt", 233 | "vinaigrette", 234 | "vinegar", 235 | ] 236 | 237 | suggestions = [ 238 | "how about you", 239 | "I recommend you", 240 | "perhaps you should", 241 | "maybe you could", 242 | "just", 243 | "better not", 244 | "why not just", 245 | ] 246 | 247 | discussors = [ 248 | " eat ", 249 | "food", 250 | "hungry", 251 | "lunch", 252 | "dinner", 253 | "breakfast", 254 | "snack", 255 | ] 256 | 257 | class FoodsPlugin(ChatPlugin): 258 | """Even robots can get peckish""" 259 | 260 | name = 'foods' 261 | priority = 0 262 | 263 | def setup(self, *args): 264 | pass 265 | 266 | def articleize(self, noun): 267 | if random.random() < .3: 268 | noun = random.choice(foodunits) + " of " + noun 269 | if noun[0] in ['a', 'e', 'i', 'o', 'u', 'y']: 270 | return "an " + noun 271 | return "a " + noun 272 | 273 | def discusses_food(self, msg): 274 | for d in discussors: 275 | if d in msg: 276 | return d.strip() + "? " 277 | return False 278 | 279 | def describe_ingredient(self): 280 | """ apple. tart apple with vinegar. """ 281 | resp = random.choice(ingredients) 282 | if random.random() < .2: 283 | resp = random.choice(foodqualities) + " " + resp 284 | if random.random() < .2: 285 | resp += " with " + self.describe_additive() 286 | return resp 287 | 288 | def describe_additive(self): 289 | """ vinegar. spicy vinegar. a spicy vinegar. """ 290 | resp = random.choice(additives) 291 | if random.random() < .2: 292 | resp = random.choice(foodqualities) + ' ' + resp 293 | if random.random() < .01: 294 | resp = self.articleize(resp) 295 | return resp 296 | 297 | def describe_dish(self): 298 | """a burrito. a lettuce burrito with ketchup and raspberry.""" 299 | resp = random.choice(foodpreparations) 300 | if random.random() < .85: 301 | resp = self.describe_ingredient() + ' ' + resp 302 | if random.random() < .2: 303 | resp = self.describe_ingredient() + ' and ' + resp 304 | if random.random() < .2: 305 | resp = self.describe_ingredient() + ', ' + resp 306 | if random.random() < .5: 307 | resp += " with " + self.describe_additive() 308 | elif random.random() < .5: 309 | resp += " with " + self.describe_ingredient() 310 | return self.articleize(resp) 311 | 312 | def describe_meal(self): 313 | resp = self.describe_dish() 314 | if random.random() < .1: 315 | resp += ", and " + self.describe_meal() 316 | return resp 317 | 318 | def suggest(self): 319 | resp = self.describe_meal() 320 | if random.random() < .7: 321 | resp = random.choice(foodverbs) + ' ' + resp 322 | if random.random() < .5: 323 | resp = random.choice(suggestions) + ' ' + resp 324 | if random.random() < .3: 325 | resp += random.choice([' made with ', ' on ', ' using ']) 326 | resp += self.articleize(random.choice(foodtools)) 327 | return resp 328 | 329 | def foodyreply(self, bot, comm, prefix = ""): 330 | resp = prefix + self.suggest() 331 | bot.reply(comm, '{0}: {1}'.format(comm['user'], resp)) 332 | 333 | def message(self, bot, comm): 334 | msg = ude(comm['message'].strip()) 335 | prefix = self.discusses_food(msg) 336 | if prefix: 337 | if comm['directed']: 338 | # always reply on question or comment to self about food 339 | self.foodyreply(bot, comm) 340 | elif random.random() < .7: 341 | # often interject anyways 342 | self.foodyreply(bot, comm, prefix) 343 | return True 344 | return False 345 | 346 | -------------------------------------------------------------------------------- /hamper/plugins/friendly.py: -------------------------------------------------------------------------------- 1 | import random 2 | import re 3 | from datetime import datetime 4 | 5 | from hamper.interfaces import ChatPlugin 6 | 7 | 8 | class Friendly(ChatPlugin): 9 | """Be polite. When people say hello, respond.""" 10 | 11 | name = 'friendly' 12 | priority = 0 13 | 14 | def setup(self, factory): 15 | self.greetings = ['hi', 16 | 'hey look a squirrel', 17 | 'meh', 18 | 'wtf', 19 | 'wb', 20 | 'laundrybot', 21 | 'y tho', 22 | 'wat', 23 | '...', 24 | 'nope'] 25 | 26 | def message(self, bot, comm): 27 | if not comm['directed']: 28 | return 29 | 30 | if comm['message'].strip() in self.greetings: 31 | bot.reply(comm, '{0} {1[user]}' 32 | .format(random.choice(self.greetings), comm)) 33 | return True 34 | 35 | 36 | class OmgPonies(ChatPlugin): 37 | """The classics never die.""" 38 | 39 | name = 'ponies' 40 | priority = 3 41 | 42 | cooldown = 30 # Seconds 43 | 44 | def setup(self, factory): 45 | self.last_pony_time = datetime.now() 46 | 47 | def message(self, bot, comm): 48 | if re.match(r'.*pon(y|ies).*', comm['message'], re.I): 49 | now = datetime.now() 50 | since_last = now - self.last_pony_time 51 | since_last = since_last.seconds + 24 * 60 * 60 * since_last.days 52 | 53 | if since_last >= self.cooldown: 54 | bot.reply(comm, 'OMG!!! PONIES!!!') 55 | self.last_pony_time = now 56 | else: 57 | print('too many ponies') 58 | 59 | # Always let the other plugins run 60 | return False 61 | 62 | 63 | class BotSnack(ChatPlugin): 64 | """Reward a good bot.""" 65 | 66 | name = 'botsnack' 67 | priority = 1 68 | 69 | def setup(self, factory): 70 | self.rewards = { 71 | 'botsnack': ['yummy', 'my favorite!', 'thanks i guess', '!humansnack'], 72 | 'goodhamper': ['^_^', ':D', '>.>', '<.<'], 73 | } 74 | 75 | def message(self, bot, comm): 76 | if not comm['directed']: 77 | return 78 | slug = comm['message'].lower().replace(' ', '') 79 | for k, v in self.rewards.items(): 80 | if k in slug: 81 | bot.reply(comm, random.choice(v)) 82 | return True 83 | 84 | return False 85 | -------------------------------------------------------------------------------- /hamper/plugins/goodbye.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 2 | import random 3 | from hamper.interfaces import ChatPlugin 4 | 5 | goodbyes = [ 6 | "Goodbye", 7 | "Bye-bye", 8 | "Bye", 9 | "Godspeed", 10 | "Buhbyes", 11 | "Talk to you later", 12 | "Ta ta for now", 13 | "Ciao", 14 | "Sayanora", 15 | "Kamsahamnidah", 16 | "Unhello", 17 | "See ya", 18 | "McGoodbye", 19 | "May the forces of evil get lost on the way to you front doorstep", 20 | "Live long and prosper", 21 | "Cheerio!", 22 | "Out", 23 | "Y'all come back now", 24 | "May your teeth never be replaced by freshly ironed wool socks.", 25 | "So long", 26 | "Farewell", 27 | "Bon voyage", 28 | ("May you never awaken one morning and find yourself with Iranian death " 29 | "camps for hands and Gorbachev's eyelids attached to your feet."), 30 | "Aloha", 31 | "L'hitraot", 32 | "Kol Tuv", 33 | "Shalom", 34 | "Peace out", 35 | ("May your spleen never transform into a solution to the European Union's " 36 | "impending energy crisis and become a battlefield for an upcoming war " 37 | "to end all wars."), 38 | "Be well, fellow citizen", 39 | "I must take leave of you now", 40 | "Adios", 41 | "Arrivaderci", 42 | "Do svidanja", 43 | "Au revoir", 44 | "Hasta la vista", 45 | "Fare thee well", 46 | "May your mouth never be conquered by a band of marauding Vikings.", 47 | "Auf Wiederhören", 48 | "Auf Wiedersehen", 49 | "Servus", 50 | "Tschüss", 51 | "Tschüs", 52 | "Tschö", 53 | "Tschau", 54 | "Bis dann", 55 | "Bis bald", 56 | "Bis später", 57 | ("May you never have your soul absorbed into the Netherworld by a " 58 | "power-hungry televangelist."), 59 | "Bis morgen", 60 | "Bis Freitagabend", 61 | "Bis nächste Woche", 62 | "God be with ye", 63 | "Bis zur Konferenz", 64 | "Bis irgendwann", 65 | "Kkkkk...out.", 66 | "Stay cool, home-brotha", 67 | "Tot Ziens", 68 | "Later days", 69 | "Tschuess", 70 | "Avrio", 71 | "adeus", 72 | "That is all.", 73 | ("daa daa dit daa daa daa daa daa daa daa dit dit daa dit dit dit daa dit " 74 | "daa daa dit"), 75 | ("(*wave flags down and down-right) (*wave flags left and up-left) " 76 | "(*wave flags left and up-left) (*wave flags up and down) (*wave " 77 | "flags left and down) (*wave flags up-left and right) (*wave flags " 78 | "down and up-right)"), 79 | "No more of you.", 80 | "SHOO! SHOO!", 81 | "And...I'm out.", 82 | "So long, and thanks for all the fish.", 83 | "In a while, crocodile!", 84 | "Uh, I can hear the voices calling me...see ya", 85 | "Ta-ra", 86 | "Cheers", 87 | "Fine, then go!", 88 | "I'm gone!", 89 | "Get lost", 90 | "Na-na-naa-na, hey hey hey, Goodbye!", 91 | "Pasta-and-bagels", 92 | "All-feet-are-the-same", 93 | "Olive-oil", 94 | "Aavjo", 95 | "Shalom, y'all!", 96 | "Hallo", 97 | "Later, gator!", 98 | "If I don't return, avenge my death!", 99 | "This is me. And this is the back of me.", 100 | "Tutloo", 101 | "Chevio", 102 | "Pip Pip", 103 | "See ya later, alligator", 104 | "Not now, brown cow", 105 | "'Til then, penguin", 106 | "Hasta mañana, iguana", 107 | "Tallyhoo!", 108 | "Timeout for now Houston", 109 | "Keep it between the lines...", 110 | "Keep it between the lines...and dirty side down", 111 | "Dasvedania", 112 | "Paka", 113 | "Keep it real", 114 | ("May your mother's cousin never be assaulted by Attila the Hun at the " 115 | "supermarket"), 116 | "Hasta luego", 117 | "Leb wohl!", 118 | "Mach's gut!", 119 | "À voir!", 120 | "À bientôt!", 121 | "Äddi!", 122 | "Avoir!", 123 | "Nos veremos.", 124 | "Vaarwel.", 125 | "Zàijiàn", 126 | "Zdravo", 127 | "Tchau", 128 | "Tootles", 129 | "Até logo", 130 | "Hasta la pasta", 131 | "Valle!", 132 | "Móin, móin!", 133 | "Xau", 134 | "Beijinhos", 135 | "Vá", 136 | "Abraço", 137 | "Fica bem", 138 | "Até amanhã", 139 | "May bad luck always be at your heels, but never catch you", 140 | "Xi chien", 141 | "TTYL", 142 | "C U L8R", 143 | "chao", 144 | "adichao", 145 | "Inabit, inabizzle.", 146 | "May your feet never fall off and grow back as cactuses", 147 | "Moi" 148 | ] 149 | 150 | 151 | class GoodBye(ChatPlugin): 152 | """Be nice when someone says goodbye.""" 153 | 154 | name = 'goodbye' 155 | priority = 0 156 | onlyDirected = False 157 | 158 | def setup(self, factory): 159 | # Be careful with these words, if they're something said in normal 160 | # conversation, it'll trigger. 161 | self.triggers = ['cya', 'bye', 'goodbye', 'good bye', 'farewell'] 162 | 163 | def message(self, bot, comm): 164 | if (any(trigger in comm['message'] for trigger in self.triggers) 165 | and comm['target']): 166 | 167 | # TODO: Make it check Seen.users to make sure the user being 168 | # mentioned exists 169 | if comm['target']: 170 | response = random.choice(goodbyes) 171 | bot.reply(comm, '{0[target]}: {1}'.format(comm, response)) 172 | return True 173 | 174 | return False 175 | -------------------------------------------------------------------------------- /hamper/plugins/help.py: -------------------------------------------------------------------------------- 1 | import logging as _logging 2 | 3 | from hamper.interfaces import Command, ChatCommandPlugin 4 | 5 | 6 | log = _logging.getLogger('hamper.commands.help') 7 | 8 | 9 | class Help(ChatCommandPlugin): 10 | """Provide an interface to get help on commands.""" 11 | 12 | name = 'help' 13 | priority = 0 14 | _command_cache = [] 15 | 16 | def __init__(self, *args, **kwargs): 17 | super(Help, self).__init__(*args, **kwargs) 18 | self._command_cache = [] 19 | 20 | @classmethod 21 | def helpful_commands(cls, bot): 22 | commands = set() 23 | for plugin in bot.factory.loader.plugins: 24 | commands.add(plugin) 25 | commands.update(plugin.commands) 26 | 27 | for cmd in commands: 28 | if getattr(cmd, 'short_desc', None): 29 | yield cmd 30 | 31 | class HelpMainMenu(Command): 32 | name = 'help' 33 | regex = 'help$' 34 | 35 | short_desc = 'help [command] - Show help for commands.' 36 | long_desc = ('For detailed help on a command, say "!help ". ' 37 | 'Some commands may not be listed. If you think they ' 38 | 'should, poke the plugin author.') 39 | 40 | def command(self, bot, comm, groups): 41 | commands = Help.helpful_commands(bot) 42 | response = ['Available commands'] 43 | for command in commands: 44 | response.append('{0.short_desc}'.format(command)) 45 | response = '\n'.join(response) 46 | 47 | bot.msg(comm['user'], response) 48 | 49 | class HelpCommand(Command): 50 | name = 'help.individual' 51 | regex = 'help (.*)$' 52 | 53 | def command(self, bot, comm, groups): 54 | search = groups[0] 55 | 56 | commands = Help.helpful_commands(bot) 57 | try: 58 | command = [c for c in commands if c.name == search][0] 59 | except IndexError: 60 | bot.reply(comm, 'Unknown command') 61 | return 62 | 63 | if command.short_desc: 64 | bot.reply(comm, '{0.short_desc}'.format(command)) 65 | if command.long_desc: 66 | bot.reply(comm, '{0.long_desc}'.format(command)) 67 | -------------------------------------------------------------------------------- /hamper/plugins/karma.py: -------------------------------------------------------------------------------- 1 | import re 2 | import random 3 | from collections import defaultdict 4 | 5 | from hamper.interfaces import ChatCommandPlugin, Command 6 | from hamper.utils import ude, uen 7 | 8 | from sqlalchemy import Column, Integer, String 9 | from sqlalchemy.ext.declarative import declarative_base 10 | 11 | SQLAlchemyBase = declarative_base() 12 | 13 | positives = [ 14 | "Heck yeah, ", 15 | "You go, ", 16 | "I support ", 17 | "I like ", 18 | "You do you, ", 19 | ] 20 | 21 | negatives = [ 22 | "Eww, why ", 23 | "Who needs ", 24 | "What good is ", 25 | ] 26 | class Karma(ChatCommandPlugin): 27 | '''Give, take, and scoreboard Internet Points''' 28 | 29 | """ 30 | Hamper will look for lines that end in ++ or -- and modify that user's 31 | karma value accordingly 32 | 33 | !karma --top: shows (at most) the top 5 34 | !karma --bottom: shows (at most) the bottom 5 35 | !karma : displays the karma for a given user 36 | 37 | NOTE: The user is just a string, this really could be anything...like 38 | potatoes or the infamous cookie clicker.... 39 | """ 40 | 41 | name = 'karma' 42 | 43 | priority = -2 44 | 45 | short_desc = 'karma/score - Give or take karma from someone' 46 | long_desc = ('username++ - Give karma\n' 47 | 'username-- - Take karma\n' 48 | '!karma --top - Show the top 5 karma earners\n' 49 | '!karma --bottom - Show the bottom 5 karma earners\n' 50 | '!karma username - Show the user\'s karma count\n') 51 | 52 | gotta_catch_em_all = r"""# 3 or statement 53 | ( 54 | 55 | # Starting with a (, look for anything within 56 | # parens that end with 2 or more + or - 57 | (?=\()[^\)]+\)(\+\++|--+) | 58 | 59 | # Looking from the start of the line until 2 or 60 | # more - or + are found. No whitespace in this 61 | # grouping 62 | ^[^\s]+(\+\++|--+) | 63 | 64 | # Finally group any non-whitespace groupings 65 | # that end with 2 or more + or - 66 | [^\s]+?(\+\++|--+)((?=\s)|(?=$)) 67 | ) 68 | """ 69 | 70 | regstr = re.compile(gotta_catch_em_all, re.X) 71 | 72 | def setup(self, loader): 73 | super(Karma, self).setup(loader) 74 | self.db = loader.db 75 | SQLAlchemyBase.metadata.create_all(self.db.engine) 76 | 77 | def message(self, bot, comm): 78 | """ 79 | Check for strings ending with 2 or more '-' or '+' 80 | """ 81 | super(Karma, self).message(bot, comm) 82 | 83 | # No directed karma giving or taking 84 | if not comm['directed'] and not comm['pm']: 85 | msg = comm['message'].strip().lower() 86 | # use the magic above 87 | words = self.regstr.findall(msg) 88 | # Do things to people 89 | karmas = self.modify_karma(words) 90 | # Notify the users they can't modify their own karma 91 | if comm['user'] in karmas.keys(): 92 | bot.reply(comm, "Nice try, no modifying your own karma") 93 | # Maybe have an opinion 94 | self.opine(bot, comm, karmas) 95 | # Commit karma changes to the db 96 | self.update_db(karmas, comm['user']) 97 | 98 | def opine(self, bot, comm, karmas): 99 | if len(karmas) == 0: 100 | return False 101 | resp = ' and '.join(karmas) 102 | # Let's have an opinion! 103 | if random.random() < .7: 104 | resp = random.choice(positives) + resp + "!" 105 | else: 106 | resp = random.choice(negatives) + resp + "?" 107 | if random.random() < .3: 108 | bot.reply(comm, resp) 109 | 110 | def modify_karma(self, words): 111 | """ 112 | Given a regex object, look through the groups and modify karma 113 | as necessary 114 | """ 115 | 116 | # 'user': karma 117 | k = defaultdict(int) 118 | 119 | if words: 120 | # For loop through all of the group members 121 | for word_tuple in words: 122 | word = word_tuple[0] 123 | ending = word[-1] 124 | # This will either end with a - or +, if it's a - subract 1 125 | # kara, if it ends with a +, add 1 karma 126 | change = -1 if ending == '-' else 1 127 | # Now strip the ++ or -- from the end 128 | if '-' in ending: 129 | word = word.rstrip('-') 130 | elif '+' in ending: 131 | word = word.rstrip('+') 132 | # Check if surrounded by parens, if so, remove them 133 | if word.startswith('(') and word.endswith(')'): 134 | word = word[1:-1] 135 | # Finally strip whitespace 136 | word = word.strip() 137 | # Add the user to the dict 138 | if word: 139 | k[word] += change 140 | return k 141 | 142 | def update_db(self, userkarma, username): 143 | """ 144 | Change the users karma by the karma amount (either 1 or -1) 145 | """ 146 | 147 | kt = self.db.session.query(KarmaTable) 148 | for user in userkarma: 149 | if user != username: 150 | # Modify the db accourdingly 151 | urow = kt.filter(KarmaTable.user == ude(user)).first() 152 | # If the user doesn't exist, create it 153 | if not urow: 154 | urow = KarmaTable(ude(user)) 155 | urow.kcount += userkarma[user] 156 | self.db.session.add(urow) 157 | self.db.session.commit() 158 | 159 | class KarmaList(Command): 160 | """ 161 | Return the top or bottom 5 162 | """ 163 | 164 | regex = r'^(?:score|karma) --(top|bottom)$' 165 | 166 | LIST_MAX = 5 167 | 168 | def command(self, bot, comm, groups): 169 | users = bot.factory.loader.db.session.query(KarmaTable) 170 | user_count = users.count() 171 | top = self.LIST_MAX if user_count >= self.LIST_MAX else user_count 172 | 173 | if top: 174 | show = (KarmaTable.kcount.desc() if groups[0] == 'top' 175 | else KarmaTable.kcount) 176 | for user in users.order_by(show)[0:top]: 177 | bot.reply( 178 | comm, str('%s\x0f: %d' % (user.user, user.kcount)) 179 | ) 180 | else: 181 | bot.reply(comm, r'No one has any karma yet :-(') 182 | 183 | class UserKarma(Command): 184 | """ 185 | Retrieve karma for a given user 186 | """ 187 | 188 | # !karma 189 | regex = r'^(?:score|karma)\s+([^-].*)$' 190 | 191 | def command(self, bot, comm, groups): 192 | # Play nice when the user isn't in the db 193 | kt = bot.factory.loader.db.session.query(KarmaTable) 194 | thing = ude(groups[0].strip().lower()) 195 | user = kt.filter(KarmaTable.user == thing).first() 196 | 197 | if user: 198 | bot.reply( 199 | comm, '%s has %d points' % (uen(user.user), user.kcount), 200 | encode=False 201 | ) 202 | else: 203 | bot.reply( 204 | comm, 'No karma for %s ' % uen(thing), encode=False 205 | ) 206 | 207 | 208 | class KarmaTable(SQLAlchemyBase): 209 | """ 210 | Keep track of users karma in a persistant manner 211 | """ 212 | 213 | __tablename__ = 'karma' 214 | 215 | # Calling the primary key user, though, really, this can be any string 216 | user = Column(String, primary_key=True) 217 | kcount = Column(Integer) 218 | 219 | def __init__(self, user, kcount=0): 220 | self.user = user 221 | self.kcount = kcount 222 | 223 | 224 | karma = Karma() 225 | -------------------------------------------------------------------------------- /hamper/plugins/karma_adv.py: -------------------------------------------------------------------------------- 1 | import re 2 | from collections import Counter, defaultdict 3 | from datetime import datetime 4 | 5 | from hamper.interfaces import ChatCommandPlugin, Command 6 | from hamper.utils import ude, uen 7 | 8 | import pytz 9 | from pytz import timezone 10 | from pytz.exceptions import UnknownTimeZoneError 11 | 12 | from sqlalchemy import Column, DateTime, Integer, String, func 13 | from sqlalchemy.ext.declarative import declarative_base 14 | 15 | SQLAlchemyBase = declarative_base() 16 | 17 | 18 | class KarmaAdv(ChatCommandPlugin): 19 | '''Give, take, and scoreboard Internet Points''' 20 | 21 | """ 22 | Hamper will look for lines that end in ++ or -- and modify that user's 23 | karma value accordingly as well as track a few other stats about users 24 | 25 | NOTE: The user is just a string, this really could be anything...like 26 | potatoes or the infamous cookie clicker.... 27 | """ 28 | 29 | name = 'karma_adv' 30 | 31 | priority = -2 32 | 33 | short_desc = ("karma - Give positive or negative karma. Where you see" 34 | " !karma, !score will work as well") 35 | long_desc = ("username++ - Give karma\n" 36 | "username-- - Take karma\n" 37 | "!karma --top - Show the top 5 karma earners\n" 38 | "!karma --bottom - Show the bottom 5 karma earners\n" 39 | "!karma --giver or --taker - Show who's given the most" 40 | " positive or negative karma\n" 41 | "!karma --when-positive or --when-negative " 42 | " - Show when people are the most positive or negative\n" 43 | "!karma username - Show the user's karma count\n") 44 | 45 | gotta_catch_em_all = r"""# 3 or statement 46 | ( 47 | 48 | # Starting with a (, look for anything within 49 | # parens that end with 2 or more + or - 50 | (?=\()[^\)]+\)(\+\++|--+) | 51 | 52 | # Looking from the start of the line until 2 or 53 | # more - or + are found. No whitespace in this 54 | # grouping 55 | ^[^\s]+(\+\++|--+) | 56 | 57 | # Finally group any non-whitespace groupings 58 | # that end with 2 or more + or - 59 | [^\s]+?(\+\++|--+)((?=\s)|(?=$)) 60 | ) 61 | """ 62 | 63 | regstr = re.compile(gotta_catch_em_all, re.X) 64 | 65 | def setup(self, loader): 66 | super(KarmaAdv, self).setup(loader) 67 | self.db = loader.db 68 | SQLAlchemyBase.metadata.create_all(self.db.engine) 69 | 70 | # Config 71 | config = loader.config.get("karma_adv", {}) 72 | self.timezone = config.get('timezone', 'UTC') 73 | try: 74 | self.tzinfo = timezone(self.timezone) 75 | except UnknownTimeZoneError: 76 | self.tzinfo = timezone('UTC') 77 | self.timezone = 'UTC' 78 | 79 | def message(self, bot, comm): 80 | """ 81 | Check for strings ending with 2 or more '-' or '+' 82 | """ 83 | super(KarmaAdv, self).message(bot, comm) 84 | 85 | # No directed karma giving or taking 86 | if not comm['directed'] and not comm['pm']: 87 | msg = comm['message'].strip().lower() 88 | # use the magic above 89 | words = self.regstr.findall(msg) 90 | # Do things to people 91 | karmas = self.modify_karma(words) 92 | # Notify the users they can't modify their own karma 93 | if comm['user'] in karmas.keys(): 94 | if karmas[comm['user']] <= 0: 95 | bot.reply(comm, "Don't be so hard on yourself.") 96 | else: 97 | bot.reply(comm, "Tisk, tisk, no up'ing your own karma.") 98 | # Commit karma changes to the db 99 | self.update_db(comm["user"], karmas) 100 | 101 | def modify_karma(self, words): 102 | """ 103 | Given a regex object, look through the groups and modify karma 104 | as necessary 105 | """ 106 | 107 | # 'user': karma 108 | k = defaultdict(int) 109 | 110 | if words: 111 | # For loop through all of the group members 112 | for word_tuple in words: 113 | word = word_tuple[0] 114 | ending = word[-1] 115 | # This will either end with a - or +, if it's a - subract 1 116 | # kara, if it ends with a +, add 1 karma 117 | change = -1 if ending == '-' else 1 118 | # Now strip the ++ or -- from the end 119 | if '-' in ending: 120 | word = word.rstrip('-') 121 | elif '+' in ending: 122 | word = word.rstrip('+') 123 | # Check if surrounded by parens, if so, remove them 124 | if word.startswith('(') and word.endswith(')'): 125 | word = word[1:-1] 126 | # Finally strip whitespace 127 | word = word.strip() 128 | # Add the user to the dict 129 | if word: 130 | k[word] += change 131 | return k 132 | 133 | def update_db(self, giver, receiverkarma): 134 | """ 135 | Record a the giver of karma, the receiver of karma, and the karma 136 | amount. Typically the count will be 1, but it can be any positive or 137 | negative integer. 138 | """ 139 | 140 | for receiver in receiverkarma: 141 | if receiver != giver: 142 | urow = KarmaStatsTable( 143 | ude(giver), ude(receiver), receiverkarma[receiver]) 144 | self.db.session.add(urow) 145 | self.db.session.commit() 146 | 147 | class KarmaList(Command): 148 | """ 149 | Return the highest or lowest 5 receivers of karma 150 | """ 151 | 152 | regex = r'^(?:score|karma) --(top|bottom)$' 153 | 154 | LIST_MAX = 5 155 | 156 | def command(self, bot, comm, groups): 157 | # Let the database restrict the amount of rows we get back. 158 | # We can then just deal with a few rows later on 159 | session = bot.factory.loader.db.session 160 | kcount = func.sum(KarmaStatsTable.kcount).label('kcount') 161 | kts = session.query(KarmaStatsTable.receiver, kcount) \ 162 | .group_by(KarmaStatsTable.receiver) 163 | 164 | # For legacy support 165 | classic = session.query(KarmaStatsTable) 166 | 167 | # Counter for sorting and updating data 168 | counter = Counter() 169 | 170 | if kts.count() or classic.count(): 171 | # We should limit the list of users to at most self.LIST_MAX 172 | if groups[0] == 'top': 173 | classic_q = classic.order_by( 174 | KarmaStatsTable.kcount.desc()).limit( 175 | self.LIST_MAX).all() 176 | query = kts.order_by(kcount.desc())\ 177 | .limit(self.LIST_MAX).all() 178 | 179 | counter.update(dict(classic_q)) 180 | counter.update(dict(query)) 181 | snippet = counter.most_common(self.LIST_MAX) 182 | elif groups[0] == 'bottom': 183 | classic_q = classic.order_by(KarmaStatsTable.kcount)\ 184 | .limit(self.LIST_MAX).all() 185 | query = kts.order_by(kcount)\ 186 | .limit(self.LIST_MAX).all() 187 | counter.update(dict(classic_q)) 188 | counter.update(dict(query)) 189 | snippet = reversed(counter.most_common(self.LIST_MAX)) 190 | else: 191 | bot.reply( 192 | comm, r'Something went wrong with karma\'s regex' 193 | ) 194 | return 195 | 196 | for rec in snippet: 197 | bot.reply( 198 | comm, '%s\x0f: %d' % (uen(rec[0]), rec[1]), 199 | encode=False 200 | ) 201 | else: 202 | bot.reply(comm, 'No one has any karma yet :-(') 203 | 204 | class UserKarma(Command): 205 | """ 206 | Retrieve karma for a given user 207 | """ 208 | 209 | # !karma 210 | regex = r'^(?:score|karma)(?:\s+([^-].*))?$' 211 | 212 | def command(self, bot, comm, groups): 213 | # The receiver (or in old terms, user) of the karma being tallied 214 | receiver = groups[0] 215 | if receiver is None: 216 | reciever = comm['user'] 217 | receiver = ude(reciever.strip().lower()) 218 | 219 | # Manage both tables 220 | sesh = bot.factory.loader.db.session 221 | 222 | # Old Table 223 | kt = sesh.query(KarmaStatsTable) 224 | user = kt.filter(KarmaStatsTable.user == receiver).first() 225 | 226 | # New Table 227 | kst = sesh.query(KarmaStatsTable) 228 | kst_list = kst.filter(KarmaStatsTable.receiver == receiver).all() 229 | 230 | # The total amount of karma from both tables 231 | total = 0 232 | 233 | # Add karma from the old table 234 | if user: 235 | total += user.kcount 236 | 237 | # Add karma from the new table 238 | if kst_list: 239 | for row in kst_list: 240 | total += row.kcount 241 | 242 | # Pluralization 243 | points = "points" 244 | if total == 1 or total == -1: 245 | points = "point" 246 | 247 | # Send the message 248 | bot.reply( 249 | comm, '%s has %d %s' % (uen(receiver), total, points), 250 | encode=False 251 | ) 252 | 253 | class KarmaGiver(Command): 254 | """ 255 | Identifies the person who gives the most karma 256 | """ 257 | 258 | regex = r'^(?:score|karma) --(giver|taker)$' 259 | 260 | def command(self, bot, comm, groups): 261 | kt = bot.factory.loader.db.session.query(KarmaStatsTable) 262 | counter = Counter() 263 | 264 | if groups[0] == 'giver': 265 | positive_karma = kt.filter(KarmaStatsTable.kcount > 0) 266 | for row in positive_karma: 267 | counter[row.giver] += row.kcount 268 | 269 | m = counter.most_common(1) 270 | most = m[0] if m else None 271 | if most: 272 | bot.reply( 273 | comm, 274 | '%s has given the most karma (%d)' % 275 | (uen(most[0]), most[1]) 276 | ) 277 | else: 278 | bot.reply( 279 | comm, 280 | 'No positive karma has been given yet :-(' 281 | ) 282 | elif groups[0] == 'taker': 283 | negative_karma = kt.filter(KarmaStatsTable.kcount < 0) 284 | for row in negative_karma: 285 | counter[row.giver] += row.kcount 286 | 287 | m = counter.most_common() 288 | most = m[-1] if m else None 289 | if most: 290 | bot.reply( 291 | comm, 292 | '%s has given the most negative karma (%d)' % 293 | (uen(most[0]), most[1]) 294 | ) 295 | else: 296 | bot.reply( 297 | comm, 298 | 'No negative karma has been given yet' 299 | ) 300 | 301 | class MostActive(Command): 302 | """ 303 | Least/Most active hours of karma giving/taking 304 | 305 | This will now look in the config for a timezone to use when displaying 306 | the hour. 307 | 308 | Example 309 | Karma: 310 | timezone: America/Los_Angeles 311 | 312 | If no timezone is given, or it's invalid, time will be reported in UTC 313 | """ 314 | 315 | regex = r'^(?:score|karma)\s+--when-(positive|negative)' 316 | 317 | def command(self, bot, comm, groups): 318 | kt = bot.factory.loader.db.session.query(KarmaStatsTable) 319 | counter = Counter() 320 | 321 | if groups[0] == "positive": 322 | karma = kt.filter(KarmaStatsTable.kcount > 0) 323 | elif groups[0] == "negative": 324 | karma = kt.filter(KarmaStatsTable.kcount < 0) 325 | 326 | for row in karma: 327 | hour = row.datetime.hour 328 | counter[hour] += row.kcount 329 | 330 | common_hour = (counter.most_common(1)[0][0] 331 | if counter.most_common(1) else None) 332 | 333 | # Title case for when 334 | title_case = groups[0][0].upper() + groups[0][1:] 335 | 336 | if common_hour: 337 | # Create a datetime object 338 | current_time = datetime.now(pytz.utc) 339 | # Give it the common_hour 340 | current_time = current_time.replace(hour=int(common_hour)) 341 | # Get the localized common hour 342 | hour = self.plugin.tzinfo.normalize( 343 | current_time.astimezone(self.plugin.tzinfo)).hour 344 | # Report to the channel 345 | bot.reply( 346 | comm, 347 | '%s karma is usually given during the %d:00 hour (%s)' % 348 | (title_case, hour, self.plugin.timezone) 349 | ) 350 | else: 351 | # Inform that no karma of that type has been awarded yet 352 | bot.reply( 353 | comm, 354 | '%s karma has been given yet' % title_case 355 | ) 356 | 357 | 358 | class KarmaTable(SQLAlchemyBase): 359 | """ 360 | Bringing back the classic table so data doesn't need to be dumped 361 | """ 362 | 363 | __tablename__ = 'karma' 364 | 365 | # Karma Classic Table 366 | user = Column(String, primary_key=True) 367 | kcount = Column(Integer) 368 | 369 | def __init__(self, user, kcount): 370 | self.user = user 371 | self.kcount = kcount 372 | 373 | 374 | class KarmaStatsTable(SQLAlchemyBase): 375 | """ 376 | Keep track of users karma in a persistant manner 377 | """ 378 | 379 | __tablename__ = 'karmastats' 380 | 381 | # Calling the primary key user, though, really, this can be any string 382 | id = Column(Integer, primary_key=True) 383 | giver = Column(String) 384 | receiver = Column(String) 385 | kcount = Column(Integer) 386 | datetime = Column(DateTime, default=datetime.utcnow()) 387 | 388 | def __init__(self, giver, receiver, kcount): 389 | self.giver = giver 390 | self.receiver = receiver 391 | self.kcount = kcount 392 | -------------------------------------------------------------------------------- /hamper/plugins/maniacal.py: -------------------------------------------------------------------------------- 1 | import random 2 | 3 | from hamper.interfaces import ChatPlugin 4 | from hamper.utils import ude 5 | 6 | vowels = ['a','e','o','i','u', 'y'] 7 | 8 | unfunnies = [ 9 | 'chuckle', 10 | 'funny', 11 | 'hilarious', 12 | 'giggle', 13 | 'grin', 14 | 'humor', 15 | 'humour', 16 | 'jest', 17 | 'joke', 18 | 'joking', 19 | 'jolly', 20 | 'laugh', 21 | 'smile', 22 | 'make fun', 23 | 'making fun', 24 | ] 25 | 26 | goods = [ 27 | 'great', 28 | 'good', 29 | 'grand', 30 | 'jolly', 31 | 'jovial', 32 | 'magnificent', 33 | 'marvelous', 34 | 'splendid', 35 | 'superb', 36 | ] 37 | 38 | ends = [ 39 | '!', 40 | '.', 41 | '...', 42 | '?', 43 | '!!', 44 | '!!!', 45 | '', 46 | ] 47 | 48 | chuckles = [ 49 | ' heh ', 50 | ' hah ', 51 | ' ha ', 52 | 'teehee', 53 | 'tee-hee-hee', 54 | ' lol ', 55 | '*snicker*', 56 | ' haha', 57 | 'lmao', 58 | 'rofl', 59 | 'lmfao', 60 | 'lollerskates', 61 | 'lollercoasteer', 62 | 'loltastic', 63 | 'roflcopter', 64 | ' lul ', 65 | ' lel ', 66 | ' kek ', 67 | ':)', 68 | ':3', 69 | ] 70 | 71 | class ManiacalPlugin(ChatPlugin): 72 | """Apparently robots 'laugh manically out of the blue' sometimes?""" 73 | # TODO: gendered laughter. Apparently girls say tee hee snicker snicker? 74 | name = 'maniacal' 75 | priority = 2 76 | 77 | def setup(self, *args): 78 | pass 79 | 80 | def startLaugh(self): 81 | s = random.random() 82 | if s < .2: 83 | return "MWA" 84 | if s < .4: 85 | return "BWA" 86 | if s < .45: 87 | return " ..." 88 | return "" 89 | 90 | def midLaugh(self): 91 | syl = "h" 92 | if random.random() > .7: 93 | syl = "-h" 94 | if random.random() < .2: 95 | syl = " h" 96 | for v in vowels: 97 | if random.random() < .7: 98 | syl += v + v * int(random.random() * 4) 99 | break 100 | if random.random() < .5: 101 | syl = syl.upper() 102 | return syl 103 | 104 | def makelaugh(self): 105 | resp = self.startLaugh() + self.midLaugh() 106 | for i in range(10): 107 | if random.random() < .5: 108 | resp += self.midLaugh() 109 | resp += random.choice(ends) 110 | return resp 111 | 112 | def demurelaugh(self): 113 | return random.choice(chuckles) + random.choice(ends) 114 | 115 | def laughfor(self, bot, comm): 116 | if random.random() < .01: 117 | resp = self.makelaugh() 118 | else: 119 | resp = self.demurelaugh() 120 | resp += " " + random.choice(goods) + " " + random.choice(unfunnies) 121 | resp += random.choice(ends) 122 | if random.random() < .5: 123 | resp += ", " + comm['user'] 124 | bot.reply(comm, '{0}: {1}'.format(comm['user'], resp)) 125 | 126 | def laughalong(self, bot, comm): 127 | bot.reply(comm, random.choice(chuckles).strip() + random.choice(ends)) 128 | 129 | def message(self, bot, comm): 130 | msg = ude(comm['message']).lower() 131 | for f in unfunnies: 132 | if f in msg: 133 | if comm['directed'] or random.random() > .6: 134 | self.laughfor(bot, comm) 135 | for c in chuckles: 136 | if c in msg: 137 | if len(msg.strip()) < 6 or random.random() < .8: 138 | self.laughalong() 139 | return False 140 | 141 | -------------------------------------------------------------------------------- /hamper/plugins/platitudes.py: -------------------------------------------------------------------------------- 1 | import random 2 | 3 | from hamper.interfaces import ChatPlugin 4 | from hamper.utils import ude 5 | 6 | platitudes = [ 7 | "Analysis without numbers is only an opinion.", 8 | "This is why it's a good idea to design them to operate when some things are wrong .", 9 | "This is true at any point in time.", 10 | "Learn to live with the disappointment.", 11 | "Three points determine a curve.", 12 | "Everything is linear if plotted log-log with a fat magic marker.", 13 | "At the start of any design effort, the person who most wants to be team leader is least likely to be capable of it.", 14 | "Distrust assertions that the optimum is at an extreme point.", 15 | "Not having all the information you need is never a satisfactory excuse for not starting the analysis.", 16 | "But be sure to go back and clean up the mess when the real numbers come along.", 17 | "Sometimes, the fastest way to get to the end is to throw everything out and start over.", 18 | "There are always multiple wrong ones, though.", 19 | "There's no justification for designing something one bit better than the requirements dictate.", 20 | "Better is the enemy of good.", 21 | "This is also the prime location for screwing it up.", 22 | "There is especially no reason to present their analysis as yours.", 23 | "The fact that an analysis appears in print has no relationship to the likelihood of its being correct.", 24 | "Too much reality can doom an otherwise worthwhile design, though.", 25 | "If your analysis says your terminal velocity is twice the speed of light, you may have invented warp drive, but the chances are a lot better that you've screwed up.", 26 | "A good design with a bad presentation is doomed immediately.", 27 | "Education is figuring out which half is which.", 28 | "Documentation requirements will reach a maximum shortly after the termination of a program.", 29 | "The schedule you develop will seem like a complete work of fiction up until the time your customer fires you for not meeting it.", 30 | "It's called a Work Breakdown Structure because the Work remaining will grow until you have a Breakdown, unless you enforce some Structure on it.", 31 | "Following a testing failure, it's always possible to refine the analysis to show that you really had negative margins all along.", 32 | "Don't do nuthin' dumb.", 33 | "Schedules only move in one direction.", 34 | "There ain't no such thing as a free launch.", 35 | "To get an accurate estimate of final program requirements, multiply the initial time estimates by pi, and slide the decimal point on the cost estimates one place to the right.", 36 | "Engineers always wind up designing the vehicle to look like the initial artist's concept.", 37 | "You can't get to the moon by climbing successively taller trees.", 38 | "When the hardware is working perfectly, the really important visitors don't show up.", 39 | "A good plan violently executed now is better than a perfect plan next week.", 40 | "Do what you can, where you are, with what you have.", 41 | "A designer knows that he has achieved perfection not when there is nothing left to add, but when there is nothing left to take away.", 42 | "A great engineer designs them to be effective.", 43 | "One key to success in a mission is establishing clear lines of blame.", 44 | "Capabilities drive requirements, regardless of what the systems engineering textbooks say.", 45 | "Any exploration program which just happens to include a new launch vehicle is, de facto, a launch vehicle program.", 46 | "You can't make it better until you make it work.", 47 | "There's never enough time to do it right, but somehow, there's always enough time to do it over.", 48 | "If you screw up the engineering, somebody dies (and there's no partial credit because most of the analysis was right...)", 49 | "A waste of time", 50 | "All for one, and one for all", 51 | "All is fair in love and war", 52 | "All’s well that ends well", 53 | "And they all lived happily ever after", 54 | "At least you're not homeless.", 55 | "At the speed of light", 56 | "Better out than in", 57 | "Brevity is the soul of wit.", 58 | "Cat got your tongue?", 59 | "Do as I say,not as I do.", 60 | "Don't dwell on it.", 61 | "Don't let it bother you.", 62 | "Don't let it eat at you.", 63 | "Don't let it get you down.", 64 | "Don't let life get you down.", 65 | "Don't linger on the past.", 66 | "Don’t cry over spilt milk", 67 | "Don’t get your knickers in a twist", 68 | "Every cloud has a silver lining", 69 | "Everything happens for a reason.", 70 | "Everything's going to be OK.", 71 | "Frightened to death", 72 | "Get on with your life.", 73 | "Good things come to people who wait.", 74 | "Good things happen to those who wait.", 75 | "Gut-wrenching pain", 76 | "Happiness is a choice.", 77 | "Haste makes waste", 78 | "He has his tail between his legs", 79 | "Head over heels in love", 80 | "Heart-stopping fear", 81 | "I feel your pain.", 82 | "I share your pain.", 83 | "I understand this is difficult for you.", 84 | "If at first you don't succeed, try, try again.", 85 | "If life gives you lemons, make lemonade.", 86 | "If you can't say something nice, don't say anything at all.", 87 | "If you knew what I knew, you'd think differently.", 88 | "In a jiffy ", 89 | "In the nick of time", 90 | "It all comes out in the wash.", 91 | "It can't be that bad.", 92 | "It could be worse.", 93 | "It is what it is.", 94 | "It was just their time to go.", 95 | "It was meant to be.", 96 | "It wasn't meant to be.", 97 | "It will all be worth it in the end.", 98 | "It's God's will.", 99 | "It's OK.", 100 | "It's for your own good.", 101 | "It's in the past.", 102 | "It's not rocket science.", 103 | "Just a matter of time", 104 | "Just be yourself.", 105 | "Just don't think about it.", 106 | "Just follow your heart.", 107 | "Just give it time.", 108 | "Just think positive.", 109 | "Just try harder next time.", 110 | "Karma's a hunter2.", 111 | "Keep a stiff upper lip.", 112 | "Kiss and make up", 113 | "Lasted an eternity", 114 | "Laughter is the best medicine", 115 | "Let it slide off your back.", 116 | "Life doesn't give you things you can't handle.", 117 | "Live and let live.", 118 | "Look on the bright side.", 119 | "Lost track of time", 120 | "Love you more than life itself", 121 | "Maybe your heart just isn't in to it.", 122 | "No good deed goes unpunished.", 123 | "No matter what you do, it's always something.", 124 | "Not all that glitters is gold", 125 | "Nothing is impossible.", 126 | "One day you'll see things differently.", 127 | "Only time will tell", 128 | "Opposites attract", 129 | "Other people go through this every day.", 130 | "Our hearts go out to them", 131 | "Our thoughts and prayers go out to them", 132 | "Own it and move on.", 133 | "Patience is a virtue.", 134 | "Perception is reality.", 135 | "Read between the lines", 136 | "Rushed for time", 137 | "Scared out of my wits", 138 | "Someone woke up on the wrong side of the bed", 139 | "Something will turn up.", 140 | "Such is life.", 141 | "Take it one day at a time.", 142 | "The best revenge is to have a fulfilling life.", 143 | "The calm before the storm", 144 | "The only thing to fear is fear itself.", 145 | "The time of my life", 146 | "The writing's on the wall", 147 | "There is someone worse off than you.", 148 | "There's plenty of fish in the sea.", 149 | "There's somebody out there for everyone.", 150 | "They don't deserve you.", 151 | "They weren't right for you anyway.", 152 | "They're in a better place.", 153 | "Things will get easier.", 154 | "Things will work out.", 155 | "Things would be better if you were more positive.", 156 | "This is just a bump in the road.", 157 | "This is life.", 158 | "This too shall pass.", 159 | "Time heals all wounds", 160 | "Time heals all wounds.", 161 | "Time will tell.", 162 | "Tomorrow will be better.", 163 | "Tomorrow's another day.", 164 | "We all have problems.", 165 | "We all have to do things we don't want to do.", 166 | "We're not laughing at you we’re laughing with you", 167 | "What doesn't kill you makes you stronger.", 168 | "What goes around comes around", 169 | "What's done is done.", 170 | "When life gives you lemons, make lemonade", 171 | "You can be anything you want to be.", 172 | "You can do so much better.", 173 | "You did the best you could.", 174 | "You did this to yourself.", 175 | "You don't need people like that in your life.", 176 | "You gotta do what you gotta do.", 177 | "You have to know your limitations.", 178 | "You have to move on.", 179 | "You have your whole life ahead of you.", 180 | "You just have to try a little harder.", 181 | "You just haven't met the right person yet.", 182 | "You just need some sleep.", 183 | "You just need to believe in yourself.", 184 | "You just need to get over it.", 185 | "You look OK.", 186 | "You still have your health.", 187 | "You'll be OK.", 188 | "You'll be back in the game before you know it.", 189 | "You'll be better off for this.", 190 | "You'll be fine.", 191 | "You'll get better at it.", 192 | "You'll get over it.", 193 | "You'll get through it.", 194 | "You'll get used to it.", 195 | "You'll thank me for this one day.", 196 | "You're better off without them.", 197 | "You're better than this.", 198 | "You're lucky to be alive.", 199 | "You're making a mountain out of a molehill.", 200 | "You're paddling against the current.", 201 | "Your negativity is your only hurdle.", 202 | "Your time will come.", 203 | "Between a rock and a hard place", 204 | "Absence makes the heart grow fonder", 205 | "The way to someone's heart is between the third and fourth ribs", 206 | "Darned if you do, darned if you don't.", 207 | ] 208 | 209 | class PlatitudesPlugin(ChatPlugin): 210 | """If you can't say something nice, don't say anything at all.""" 211 | 212 | name = 'platitudes' 213 | priority = -3 214 | 215 | def setup(self, *args): 216 | pass 217 | 218 | def contemplate(self, bot, comm): 219 | resp = random.choice(platitudes) 220 | bot.reply(comm, resp) 221 | return True 222 | 223 | def inform(self, bot, comm): 224 | resp = random.choice(platitudes) 225 | bot.reply(comm, '{0}: {1}'.format(comm['user'], resp)) 226 | return True 227 | 228 | def message(self, bot, comm): 229 | msg = ude(comm['message'].strip()) 230 | if comm['directed']: 231 | if not '?' in msg: 232 | if random.random() < .3: 233 | self.inform(bot, comm) 234 | else: 235 | self.contemplate(bot, comm) 236 | return True 237 | elif random.random() < .0001: 238 | # Occasionally pipe up 239 | if random.random() < .2: 240 | self.inform(bot, comm) 241 | else: 242 | self.contemplate(bot, comm) 243 | return True 244 | return False 245 | 246 | 247 | -------------------------------------------------------------------------------- /hamper/plugins/plugin_utils.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | from hamper.interfaces import IPlugin, Command, ChatCommandPlugin 4 | 5 | import twisted 6 | 7 | 8 | log = logging.getLogger('hamper.plugins.plugin_utils') 9 | 10 | 11 | class PluginUtils(ChatCommandPlugin): 12 | 13 | name = 'plugins' 14 | priority = 0 15 | 16 | @classmethod 17 | def get_plugins(cls, bot): 18 | all_plugins = set() 19 | for kind, plugins in bot.factory.loader.plugins.items(): 20 | all_plugins.update(plugins) 21 | return all_plugins 22 | 23 | class ListPlugins(Command): 24 | regex = r'^plugins?(?: list)?$' 25 | 26 | name = 'plugins' 27 | short_desc = 'plugins subcommand - See extended help for more details.' 28 | long_desc = ('Manipulate plugins\n' 29 | 'list - List all loaded plugins\n' 30 | 'reload name - Reload a plugin by name.\n' 31 | 'unload name - Unload a plugin by name.\n' 32 | 'load name - Load a plugin by name.\n') 33 | 34 | def command(self, bot, comm, groups): 35 | """Reply with a list of all currently loaded plugins.""" 36 | plugins = PluginUtils.get_plugins(bot) 37 | names = ', '.join(p.name for p in plugins) 38 | bot.reply(comm, 'Loaded Plugins: {0}.'.format(names)) 39 | return True 40 | 41 | class LoadPlugin(Command): 42 | regex = r'^plugins? load (.*)$' 43 | 44 | def command(self, bot, comm, groups): 45 | """Load a named plugin.""" 46 | name = groups[0] 47 | plugins = PluginUtils.get_plugins(bot) 48 | matched_plugins = [p for p in plugins if p.name == name] 49 | if len(matched_plugins) != 0: 50 | bot.reply(comm, "%s is already loaded." % name) 51 | return False 52 | 53 | # Fun fact: the fresh thing is just a dummy. It just can't be None 54 | new_plugin = twisted.plugin.retrieve_named_plugins( 55 | IPlugin, [name], 'hamper.plugins', {'fresh': True})[0] 56 | 57 | bot.factory.loader.registerPlugin(new_plugin) 58 | bot.reply(comm, 'Loading {0}.'.format(new_plugin)) 59 | return True 60 | 61 | class UnloadPlugin(Command): 62 | regex = r'^plugins? unload (.*)$' 63 | 64 | def command(self, bot, comm, groups): 65 | """Unload a named plugin.""" 66 | name = groups[0] 67 | plugins = PluginUtils.get_plugins(bot) 68 | matched_plugins = [p for p in plugins if p.name == name] 69 | if len(matched_plugins) == 0: 70 | bot.reply(comm, "I can't find a plugin named {0}!" 71 | .format(name)) 72 | return False 73 | 74 | target_plugin = matched_plugins[0] 75 | 76 | bot.factory.loader.removePlugin(target_plugin) 77 | bot.reply(comm, 'Unloading {0}.'.format(target_plugin)) 78 | return True 79 | -------------------------------------------------------------------------------- /hamper/plugins/questions.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | import random 4 | import re 5 | 6 | from hamper.interfaces import ChatCommandPlugin, ChatPlugin, Command 7 | from hamper.utils import ude 8 | 9 | betwords = [ 10 | "bet", 11 | "odds", 12 | "chances", 13 | "wager", 14 | "gamble", 15 | "probability", 16 | "likelihood", 17 | ] 18 | 19 | idcall = [ 20 | "I'd call the odds ", 21 | "The odds are ", 22 | "Chances might be ", 23 | ] 24 | 25 | foragainst = [ 26 | "of ", 27 | "for ", 28 | "against ", 29 | ] 30 | 31 | bettings = [ 32 | "Across the board", 33 | "Act of God", 34 | "As good luck would have it", 35 | "Back the field", 36 | "Bet your bottom dollar", 37 | "Bread always falls buttered side down", 38 | "Copper-bottomed", 39 | "Dead ringer", 40 | "Don't count your chickens before they are hatched", 41 | "Draw a blank", 42 | "Eeny, meeny, miny, mo", 43 | "Hedge your bets", 44 | "Second-guess", 45 | "Take potluck", 46 | "Third time lucky", 47 | "Turn the tables", 48 | ] 49 | 50 | adjs = ["able", 51 | "acid", 52 | "angry", 53 | "automatic", 54 | "awake", 55 | "bad", 56 | "beautiful", 57 | "bent", 58 | "bitter", 59 | "black", 60 | "blue", 61 | "boiling", 62 | "bright", 63 | "broken", 64 | "brown", 65 | "certain", 66 | "cheap", 67 | "chemical", 68 | "chief", 69 | "clean", 70 | "clear", 71 | "cold", 72 | "common", 73 | "complete", 74 | "complex", 75 | "conscious", 76 | "cruel", 77 | "cut", 78 | "dark", 79 | "dead", 80 | "dear", 81 | "deep", 82 | "delicate", 83 | "dependent", 84 | "different", 85 | "dirty", 86 | "dry", 87 | "early", 88 | "elastic", 89 | "electric", 90 | "equal", 91 | "false", 92 | "fat", 93 | "feeble", 94 | "female", 95 | "fertile", 96 | "first", 97 | "fixed", 98 | "flat", 99 | "free", 100 | "frequent", 101 | "full", 102 | "future", 103 | "general", 104 | "good", 105 | "gray", 106 | "great", 107 | "green", 108 | "hanging", 109 | "happy", 110 | "hard", 111 | "healthy", 112 | "high", 113 | "hollow", 114 | "ill", 115 | "important", 116 | "kind", 117 | "last", 118 | "late", 119 | "left", 120 | "like", 121 | "living", 122 | "long", 123 | "loose", 124 | "loud", 125 | "low", 126 | "male", 127 | "married", 128 | "material", 129 | "medical", 130 | "military", 131 | "mixed", 132 | "narrow", 133 | "natural", 134 | "necessary", 135 | "new", 136 | "normal", 137 | "old", 138 | "open", 139 | "opposite", 140 | "parallel", 141 | "past", 142 | "physical", 143 | "political", 144 | "poor", 145 | "possible", 146 | "present", 147 | "private", 148 | "public", 149 | "quick", 150 | "quiet", 151 | "ready", 152 | "red", 153 | "regular", 154 | "responsible", 155 | "right", 156 | "rough", 157 | "round", 158 | "sad", 159 | "safe", 160 | "same", 161 | "second", 162 | "secret", 163 | "separate", 164 | "serious", 165 | "sharp", 166 | "short", 167 | "shut", 168 | "simple", 169 | "slow", 170 | "small", 171 | "smooth", 172 | "soft", 173 | "solid", 174 | "special", 175 | "sticky", 176 | "stiff", 177 | "straight", 178 | "strange", 179 | "strong", 180 | "sudden", 181 | "sweet", 182 | "tall", 183 | "thick", 184 | "thin", 185 | "tight", 186 | "tired", 187 | "true", 188 | "violent", 189 | "waiting", 190 | "warm", 191 | "wet", 192 | "white", 193 | "wide", 194 | "wise", 195 | "wrong", 196 | "yellow", 197 | "young", 198 | ] 199 | 200 | obliques = [ 201 | "Turn it over.", 202 | "Switch the axes.", 203 | "Think about color.", 204 | "Make it black and white.", 205 | "Use the tangents.", 206 | "Move across the room.", 207 | "Restart.", 208 | "Make it ridiculous.", 209 | "Stop making sense.", 210 | "Emphasize the side effects.", 211 | "Turn it into a game.", 212 | "More semicolons.", 213 | "Apply the Sieve of Eratosthenes.", 214 | "Try faking it.", 215 | "State the problem in words as clearly as possible.", 216 | "Only one element of each kind.", 217 | "What would your closest friend do?", 218 | "What to increase? What to reduce?", 219 | "Are there sections? Consider transitions.", 220 | "Don't think. Do.", 221 | "But, does it float?", 222 | "Remove half.", 223 | "Abandon normal instruments.", 224 | "Accept advice.", 225 | "A line has two sides.", 226 | "Balance the consistency principle with the inconsistency principle.", 227 | "Breathe more deeply.", 228 | "Bridges: -build, -burn.", 229 | "Consider different fading systems.", 230 | "Courage!", 231 | "Cut a vital connection.", 232 | "Decorate, decorate.", 233 | "Define an area as 'safe' and use it as an anchor.", 234 | "Destroy the most important thing.", 235 | "Discard an axiom.", 236 | "Disconnect from desire.", 237 | "Discover the recipes you are using and abandon them.", 238 | "Distorting time.", 239 | "Don't be afraid of things because they're easy to do.", 240 | "Don't be frightened of cliches.", 241 | "Don't be frightened to display your talents.", 242 | "Don't stress one thing more than another.", 243 | "Do something boring.", 244 | "Do the washing up.", 245 | "Do the words need changing?", 246 | "Do we need holes?", 247 | "Emphasize differences.", 248 | "Emphasize repetitions.", 249 | "Emphasize the flaws.", 250 | "Go slowly all the way round the outside.", 251 | "Honor thy error as a hidden intention.", 252 | "How would you have done it?", 253 | "Humanize something free of error.", 254 | "Infinitesimal gradations.", 255 | "Into the impossible.", 256 | "Is it finished?", 257 | "Is there something missing?", 258 | "Just carry on.", 259 | "Left channel, right channel, centre channel.", 260 | "Look at a very small object, look at its centre.", 261 | "Look at the order in which you do things.", 262 | "Look closely at the most embarrassing details and amplify them.", 263 | "Make a blank valuable by putting it in an exquisite frame.", 264 | ("Make an exhaustive list of everything you might do and do the last " 265 | "thing on the list."), 266 | "Make a sudden, destructive, unpredictable action; incorporate.", 267 | "Only one element of each kind.", 268 | "Remember those quiet evenings.", 269 | "Remove ambiguities and convert to specifics.", 270 | "Remove specifics and convert to ambiguities.", 271 | "Repetition is a form of change.", 272 | "Reverse.", 273 | "Simple subtraction.", 274 | "Spectrum analysis.", 275 | "Take a break.", 276 | "Take away the elements in order of apparent non-importance.", 277 | "Tidy up.", 278 | "Turn it upside down.", 279 | "Twist the spine.", 280 | "Use an old idea.", 281 | "Use an unacceptable color.", 282 | "Water.", 283 | "What are you really thinking about just now? Incorporate.", 284 | "What is the reality of the situation?", 285 | "What mistakes did you make last time?", 286 | "What wouldn't you do?", 287 | "Work at a different speed.", 288 | "Take a walk.", 289 | "Take a shower.", 290 | "Look to Nature.", 291 | "Talk it through with a friend.", 292 | "Who's your audience?", 293 | "Forget the money, make it cool." 294 | ] 295 | 296 | 297 | nouns = ["angle", 298 | "ant", 299 | "apple", 300 | "arch", 301 | "arm", 302 | "army", 303 | "baby", 304 | "bag", 305 | "ball", 306 | "band", 307 | "basin", 308 | "basket", 309 | "bath", 310 | "bed", 311 | "bee", 312 | "bell", 313 | "berry", 314 | "bird", 315 | "blade", 316 | "board", 317 | "boat", 318 | "bone", 319 | "book", 320 | "boot", 321 | "bottle", 322 | "box", 323 | "boy", 324 | "brain", 325 | "brake", 326 | "branch", 327 | "brick", 328 | "bridge", 329 | "brush", 330 | "bucket", 331 | "bulb", 332 | "button", 333 | "cake", 334 | "camera", 335 | "card", 336 | "carriage", 337 | "cart", 338 | "cat", 339 | "chain", 340 | "cheese", 341 | "chess", 342 | "chin", 343 | "church", 344 | "circle", 345 | "clock", 346 | "cloud", 347 | "coat", 348 | "collar", 349 | "comb", 350 | "cord", 351 | "cow", 352 | "cup", 353 | "curtain", 354 | "cushion", 355 | "dog", 356 | "door", 357 | "drain", 358 | "drawer", 359 | "dress", 360 | "drop", 361 | "ear", 362 | "egg", 363 | "engine", 364 | "eye", 365 | "face", 366 | "farm", 367 | "feather", 368 | "finger", 369 | "fish", 370 | "flag", 371 | "floor", 372 | "fly", 373 | "foot", 374 | "fork", 375 | "fowl", 376 | "frame", 377 | "garden", 378 | "girl", 379 | "glove", 380 | "goat", 381 | "gun", 382 | "hair", 383 | "hammer", 384 | "hand", 385 | "hat", 386 | "head", 387 | "heart", 388 | "hook", 389 | "horn", 390 | "horse", 391 | "hospital", 392 | "house", 393 | "island", 394 | "jewel", 395 | "kettle", 396 | "key", 397 | "knee", 398 | "knife", 399 | "knot", 400 | "leaf", 401 | "leg", 402 | "library", 403 | "line", 404 | "lip", 405 | "lock", 406 | "map", 407 | "match", 408 | "monkey", 409 | "moon", 410 | "mouth", 411 | "muscle", 412 | "nail", 413 | "neck", 414 | "needle", 415 | "nerve", 416 | "net", 417 | "nose", 418 | "nut", 419 | "office", 420 | "orange", 421 | "oven", 422 | "parcel", 423 | "pen", 424 | "pencil", 425 | "picture", 426 | "pig", 427 | "pin", 428 | "pipe", 429 | "plane", 430 | "plate", 431 | "plough", 432 | "pocket", 433 | "pot", 434 | "potato", 435 | "prison", 436 | "pump", 437 | "rail", 438 | "rat", 439 | "receipt", 440 | "ring", 441 | "rod", 442 | "roof", 443 | "root", 444 | "sail", 445 | "school", 446 | "scissors", 447 | "screw", 448 | "seed", 449 | "sheep", 450 | "shelf", 451 | "ship", 452 | "shirt", 453 | "shoe", 454 | "skin", 455 | "skirt", 456 | "snake", 457 | "sock", 458 | "spade", 459 | "sponge", 460 | "spoon", 461 | "spring", 462 | "square", 463 | "stamp", 464 | "star", 465 | "station", 466 | "stem", 467 | "stick", 468 | "stocking", 469 | "stomach", 470 | "store", 471 | "street", 472 | "sun", 473 | "table", 474 | "tail", 475 | "thread", 476 | "throat", 477 | "thumb", 478 | "ticket", 479 | "toe", 480 | "tongue", 481 | "tooth", 482 | "town", 483 | "train", 484 | "tray", 485 | "tree", 486 | "trousers", 487 | "umbrella", 488 | "wall", 489 | "watch", 490 | "wheel", 491 | "whip", 492 | "whistle", 493 | "window", 494 | "wing", 495 | "wire", 496 | "worm", 497 | ] 498 | 499 | canstarts = [ 500 | "Yes, you'll need ", 501 | "Yes, but watch out for ", 502 | "Maybe if you had ", 503 | "Nope, there's too much of ", 504 | "Never! Insufficient ", 505 | "Perhaps try with ", 506 | ] 507 | 508 | affirmatives = [ 509 | ' do ', 510 | 'Heck yes!', 511 | 'I think... Yes.', 512 | 'It could be.', 513 | 'Maybe.', 514 | 'Possibly.', 515 | 'Without a doubt.', 516 | 'Yes.', 517 | 'does the pope... er, is this the right channel?' 518 | 'mhmm', 519 | 'right', 520 | 'sure', 521 | 'yeah', 522 | 'yes', 523 | 'you said it', 524 | 'yup', 525 | 'yus', 526 | ] 527 | 528 | negatories = [ 529 | "don't", 530 | 'no', 531 | 'nope', 532 | 'nerp', 533 | 'never', 534 | 'nah', 535 | 'NEVAR', 536 | 'Nope.', 537 | 'No.', 538 | 'Eww.', 539 | 'No. No, no way', 540 | 'That would be negatory.', 541 | 'Why would anyone?', 542 | ] 543 | 544 | class YesNoPlugin(ChatPlugin): 545 | 546 | name = 'yesno' 547 | priority = -1 548 | 549 | def setup(self, *args): 550 | """ 551 | Set up the list of responses, with weights. If the weight of a response 552 | is 'eq', it will be assigned a value that splits what is left after 553 | everything that has a number is assigned. If it's weight is some 554 | fraction of 'eq' (ie: 'eq/2' or 'eq/3'), then it will be assigned 555 | 1/2, 1/3, etc of the 'eq' weight. All probabilities will add up to 556 | 1.0 (plus or minus any rounding errors). 557 | """ 558 | 559 | responses = [ 560 | ('Yes.', 'eq'), 561 | ('How should I know?', 'eq'), 562 | ('Try asking a human', 'eq/10'), 563 | ('Eww.', 'eq'), 564 | ('You\'d just do the opposite of whatever I tell you', 'eq/50'), 565 | ('No.', 'eq'), 566 | ('Nope.', 'eq'), 567 | ('Maybe.', 'eq'), 568 | ('Possibly.', 'eq'), 569 | ('It could be.', 'eq'), 570 | ("No. No, I don't think so.", 'eq/2'), 571 | ('Without a doubt.', 'eq/2'), 572 | ('I think... Yes.', 'eq/2'), 573 | ('Heck yes!', 'eq/2'), 574 | ('Maybe. Possibly. It could be.', 'eq/2'), 575 | ('Ask again later.', 'eq/3'), 576 | ("I don't know.", 'eq/3'), 577 | ("I'm sorry, I was thinking of bananas", 'eq/100'), 578 | ] 579 | responses += [(i.strip(), 'eq') for i in affirmatives] 580 | responses += [(i.strip(), 'eq') for i in negatories] 581 | self.advices = [(x, 1) for x in obliques] 582 | total_prob = 0 583 | real_resp = [] 584 | evens = [] 585 | for resp, prob in responses: 586 | if isinstance(prob, str): 587 | if prob.startswith('eq'): 588 | sp = prob.split('/') 589 | if len(sp) == 1: 590 | evens.append((resp, 1)) 591 | else: 592 | div = int(sp[1]) 593 | evens.append((resp, 1.0 / div)) 594 | 595 | else: 596 | real_resp.append((resp, prob)) 597 | total_prob += prob 598 | 599 | # Share is the probability of a "eq" probability. Share/2 would be the 600 | # probability of a "eq/2" probability. 601 | share = (1 - total_prob) / sum(div for _, div in evens) 602 | for resp, divisor in evens: 603 | real_resp.append((resp, share * divisor)) 604 | 605 | self.responses = real_resp 606 | self.is_question = re.compile('.*\?(\?|!)*$') 607 | 608 | def shouldq(self, bot, comm): 609 | resp = random.choice(obliques) 610 | bot.reply(comm, '{0}: {1}'.format(comm['user'], resp)) 611 | return True 612 | 613 | def articleize(self, noun): 614 | if random.random() < .3: 615 | noun = random.choice(adjs) + ' ' + noun 616 | if noun[0] in ['a', 'e', 'i', 'o', 'u', 'y']: 617 | return "an " + noun 618 | return "a " + noun 619 | 620 | def canq(self, bot, comm): 621 | resp = random.choice(canstarts) 622 | resp += self.articleize(random.choice(nouns)) 623 | if random.random() < .5: 624 | resp += " and " + self.articleize(random.choice(nouns)) 625 | if random.random() < .1: 626 | resp += " and " + self.articleize(random.choice(nouns)) 627 | resp += random.choice(['...', '.', '?']) 628 | bot.reply(comm, '{0}: {1}'.format(comm['user'], resp)) 629 | return True 630 | 631 | def xtoy(self): 632 | x = str(int(random.random()*10)) 633 | y = str(int(random.random()*10)) 634 | return x + " to " + y 635 | 636 | def manything(self, msg): 637 | parts = msg.split() 638 | quantifiers = ['many', 'much'] 639 | for q in quantifiers: 640 | if q in msg: 641 | idx = parts.index(q) 642 | for i in range(idx, len(parts)): 643 | if len(parts[i]) > 4: 644 | # Let's pretend to plural. 645 | return parts[i].rstrip('s') + 's' 646 | return None 647 | 648 | def howmany(self, bot, comm, msg): 649 | thing = self.manything(msg) 650 | resp = random.randint(-5, 100) 651 | if resp > 80: 652 | resp = random.randint(80, 1000) 653 | resp = str(resp) 654 | if resp == '0': 655 | if thing: 656 | resp = "No " + thing + " at all." 657 | else: 658 | resp = "None at all." 659 | if thing: 660 | if resp == '1': 661 | resp = "Just a single " + thing.rstrip('s') 662 | else: 663 | resp += " " + thing 664 | if random.random() < .05: 665 | if thing: 666 | resp = "All the " + thing + "!" 667 | else: 668 | resp = "All of them!" 669 | bot.reply(comm, resp) 670 | return True 671 | 672 | def betting(self, bot, comm): 673 | resp = random.choice(bettings) 674 | if random.random() < .7: 675 | resp = random.choice(idcall) 676 | resp += random.choice(foragainst) 677 | resp += random.choice(['it ','that ','such nonsense ', 'such a thing ']) 678 | resp += self.xtoy() 679 | bot.reply(comm, resp) 680 | return True 681 | 682 | def hamperesque(self, bot, comm, msg): 683 | whatsay = "" 684 | if "n't" in msg: 685 | whatsay = random.choice(negatories) 686 | for n in negatories: 687 | if n in msg: 688 | whatsay = random.choice(negatories) 689 | for a in affirmatives: 690 | if a in msg: 691 | whatsay = random.choice(affirmatives) 692 | if "n't" in msg: 693 | whatsay = random.choice(negatories) 694 | if whatsay != "": 695 | bot.reply(comm, '{0}: {1}'.format(comm['user'], whatsay)) 696 | else: 697 | r = random.random() 698 | replies = self.responses 699 | for resp, prob in replies: 700 | r -= prob 701 | if r < 0: 702 | bot.reply(comm, '{0}: {1}'.format(comm['user'], resp)) 703 | return True 704 | 705 | def sortq(self, bot, comm, msg): 706 | if "should " in msg: 707 | return self.shouldq(bot, comm) 708 | for b in betwords: 709 | if b in msg: 710 | return self.betting(bot, comm) 711 | if "can " in msg or "could" in msg: 712 | return self.canq(bot, comm) 713 | if "many" in msg or "much" in msg: 714 | # TODO handle "much" with units 715 | return self.howmany(bot, comm, msg) 716 | return self.hamperesque(bot, comm, msg) 717 | 718 | def message(self, bot, comm): 719 | msg = ude(comm['message'].strip()).lower() 720 | if self.is_question.search(msg): 721 | if comm['directed']: 722 | self.sortq(bot, comm, msg) 723 | elif random.random() < .1: 724 | self.sortq(bot, comm, msg) 725 | return False 726 | 727 | 728 | class ChoicesPlugin(ChatCommandPlugin): 729 | """ 730 | Answers questions like "apples or bananas?" "this, that or the other 731 | things", and "should I do homework or play videogames?" 732 | """ 733 | 734 | name = 'choices' 735 | priority = 0 736 | 737 | class ChoicesCommand(Command): 738 | regex = r'^.* or .*\?$' 739 | 740 | name = 'choices' 741 | short_desc = None 742 | long_desc = None 743 | 744 | def command(self, bot, comm, groups): 745 | choices = self.parse(comm['message']) 746 | print choices 747 | chance_of_snark = 0.05 748 | snarks = [ 749 | "I don't know, I'm just a bot", 750 | ['Neither', 'None of them.'], 751 | ['Why not both?', 'Why not all of them?'], 752 | [u'¿Por qué no los dos?', u'¿Por qué no los todos?'], 753 | ] 754 | snarks += obliques 755 | 756 | if random.random() < chance_of_snark: 757 | # snark. ignore choices and choose something funny 758 | snark = random.choice(snarks) 759 | if isinstance(snarks, list): 760 | conjugation = 0 if len(choices) == 2 else 1 761 | choice = snark[conjugation] 762 | else: 763 | choice = snark 764 | else: 765 | # no snark, give one of the original choices 766 | choice = random.choice(choices) + '.' 767 | print choice 768 | bot.reply(comm, u'{0}: {1}'.format(comm['user'], choice)) 769 | return True 770 | 771 | @staticmethod 772 | def parse(question): 773 | """ 774 | Parses out choices in a 'or' based, possible comma-ed list. 775 | 776 | >>> parse = ChoicesPlugin.ChoicesCommand.parse 777 | >>> parse('x or y?') 778 | ['x', 'y'] 779 | >>> parse('x, y or z?') 780 | ['x', 'y', 'z'] 781 | >>> parse('this thing, that thing or the other thing?') 782 | ['this thing', 'that thing', 'the other thing'] 783 | >>> parse('door or window?') 784 | ['door', 'window'] 785 | >>> parse('should i do homework or play video games?') 786 | ['do homework', 'play video games'] 787 | """ 788 | # Handle things like "should ___ X or Y" 789 | if question.lower().startswith('should'): 790 | question = ' '.join(question.split()[2:]) 791 | 792 | question = question.strip('?') 793 | # split on both ',' and ' or ' 794 | choices = question.split(',') 795 | choices = sum((c.split(' or ') for c in choices), []) 796 | # Get rid of empty strings 797 | choices = filter(bool, (c.strip() for c in choices)) 798 | return choices 799 | 800 | 801 | if __name__ == '__main__': 802 | import doctest 803 | doctest.testmod() 804 | -------------------------------------------------------------------------------- /hamper/plugins/quotes.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime 2 | import random 3 | 4 | from sqlalchemy import Integer, String, Date, Column 5 | from sqlalchemy.ext.declarative import declarative_base 6 | 7 | from hamper.interfaces import Command, ChatCommandPlugin 8 | from hamper.utils import uen 9 | 10 | 11 | SQLAlchemyBase = declarative_base() 12 | 13 | 14 | class Quotes(ChatCommandPlugin): 15 | '''Remember quotes, and recall on demand.''' 16 | 17 | name = 'quotes' 18 | priority = 0 19 | 20 | def setup(self, loader): 21 | super(Quotes, self).setup(loader) 22 | SQLAlchemyBase.metadata.create_all(loader.db.engine) 23 | 24 | class DeliverQuote(Command): 25 | """Deliver a quote.""" 26 | regex = r'^quotes?$' 27 | 28 | name = 'quote' 29 | short_desc = 'quote - Show, add, or count quotes.' 30 | long_desc = ('quote - Show a quote\n' 31 | 'quote --add QUOTE - Add a quote\n' 32 | 'quote --count - Count quotes\n') 33 | 34 | def command(self, bot, comm, groups): 35 | index = random.randrange(0, bot.db.session.query(Quote).count()) 36 | quote = bot.factory.loader.db.session.query(Quote)[index] 37 | bot.reply(comm, uen(quote.text)) 38 | return True 39 | 40 | class AddQuote(Command): 41 | """Add a quote.""" 42 | regex = r'^quotes? --add (.*)$' 43 | 44 | def command(self, bot, comm, groups): 45 | text = groups[0] 46 | quote = Quote(text, comm['user']) 47 | bot.factory.loader.db.session.add(quote) 48 | bot.reply(comm, 'Successfully added quote.') 49 | 50 | class CountQuotes(Command): 51 | """Count how many quotes the bot knows.""" 52 | regex = r'^quotes? --count$' 53 | 54 | def command(self, bot, comm, groups): 55 | count = bot.factory.loader.db.session.query(Quote).count() 56 | bot.reply(comm, 'I know {0} quotes.'.format(count)) 57 | 58 | 59 | class Quote(SQLAlchemyBase): 60 | '''The object that will get persisted by the database.''' 61 | 62 | __tablename__ = 'quotes' 63 | 64 | id = Column(Integer, primary_key=True) 65 | text = Column(String) 66 | adder = Column(String) 67 | added = Column(Date) 68 | 69 | def __init__(self, text, adder, added=None): 70 | if not added: 71 | added = datetime.now() 72 | 73 | self.text = text 74 | self.adder = adder 75 | self.added = added 76 | -------------------------------------------------------------------------------- /hamper/plugins/roulette.py: -------------------------------------------------------------------------------- 1 | import random 2 | 3 | from hamper.interfaces import ChatCommandPlugin, Command 4 | 5 | 6 | class Roulette(ChatCommandPlugin): 7 | """Feeling lucky? !roulette to see how lucky""" 8 | 9 | name = 'roulette' 10 | priority = 0 11 | 12 | class Roulette(Command): 13 | '''Try not to die''' 14 | 15 | regex = r'^roulette$' 16 | 17 | name = 'roulette' 18 | short_desc = "roulette - feeling lucky? [hint: !roulette]" 19 | long_desc = "See how lucky you are, just don't bleed everywhere" 20 | 21 | def command(self, bot, comm, groups): 22 | if comm['pm']: 23 | return False 24 | 25 | dice = random.randint(1, 6) 26 | if dice == 6: 27 | bot.kick(comm["channel"], comm["user"], "You shot yourself!") 28 | else: 29 | bot.reply(comm, "*click*") 30 | 31 | return True 32 | -------------------------------------------------------------------------------- /hamper/plugins/seen.py: -------------------------------------------------------------------------------- 1 | from hamper.interfaces import ( 2 | ChatCommandPlugin, Command, PopulationPlugin, PresencePlugin 3 | ) 4 | from hamper.utils import ude 5 | 6 | from sqlalchemy import Column, Integer, DateTime, String 7 | from sqlalchemy.ext.declarative import declarative_base 8 | 9 | from datetime import datetime 10 | 11 | SQLAlchemyBase = declarative_base() 12 | 13 | 14 | class Seen(ChatCommandPlugin, PopulationPlugin, PresencePlugin): 15 | """Keep track of when a user has last done something.""" 16 | 17 | name = 'seen' 18 | priority = 10 19 | 20 | def setup(self, loader): 21 | super(Seen, self).setup(loader) 22 | self.db = loader.db 23 | SQLAlchemyBase.metadata.create_all(self.db.engine) 24 | 25 | def queryUser(self, channel, user): 26 | return (self.db.session.query(SeenTable) 27 | .filter(SeenTable.channel == channel) 28 | .filter(SeenTable.user == ude(user))) 29 | 30 | def record(self, channel, user, doing): 31 | logs = self.queryUser(channel, user) 32 | 33 | if logs.count(): # Because exists() doesn't exist? 34 | log = logs.first() 35 | log.seen = datetime.now() 36 | log.doing = ude(doing) 37 | else: 38 | self.db.session.add( 39 | SeenTable(channel, user, datetime.now(), ude(doing)) 40 | ) 41 | self.db.session.commit() 42 | 43 | def userJoined(self, bot, user, channel): 44 | self.record(channel, user, '(Joining)') 45 | return super(Seen, self).userJoined(bot, user, channel) 46 | 47 | def userLeft(self, bot, user, channel): 48 | self.record(channel, user, '(Leaving)') 49 | return super(Seen, self).userLeft(bot, user, channel) 50 | 51 | def message(self, bot, comm): 52 | self.record(comm['channel'], comm['user'], comm['raw_message']) 53 | return super(Seen, self).message(bot, comm) 54 | 55 | def userQuit(self, bot, user, quitMessage): 56 | # Go through every log we have for this user and set their most recent 57 | # doing to (Quiting with message 'quitMessage') 58 | logs = self.db.session.query(SeenTable).filter( 59 | SeenTable.user == ude(user) 60 | ) 61 | logs.update({ 62 | 'doing': '(Quiting) with message "%s"' % ude(quitMessage), 63 | 'seen': datetime.now() 64 | }) 65 | return super(Seen, self).userQuit(bot, user, quitMessage) 66 | 67 | class SeenCommand(Command): 68 | """Say the last thing you've seen of a user""" 69 | regex = r'^seen (.*)$' 70 | 71 | name = 'seen' 72 | 73 | short_desc = 'seen - When was the user last seen?' 74 | long_desc = '' 75 | 76 | def command(self, bot, comm, groups): 77 | if groups[0].isspace(): 78 | return 79 | 80 | name = groups[0].strip() 81 | if name.lower() == bot.nickname.lower(): 82 | bot.reply(comm, 'I am always here!') 83 | return True 84 | 85 | logs = self.plugin.queryUser(comm['channel'], name) 86 | 87 | if not logs.count(): 88 | bot.reply(comm, 'I have not seen %s' % ude(name), encode=True) 89 | else: 90 | log = logs.first() 91 | time_format = 'at %I:%M %p on %b-%d' 92 | seen = log.seen.strftime(time_format) 93 | bot.reply( 94 | comm, 'I observed %s %s -- %s' % (name, seen, log.doing) 95 | ) 96 | 97 | 98 | class SeenTable(SQLAlchemyBase): 99 | """One log per channel per user""" 100 | 101 | __tablename__ = 'seen' 102 | 103 | id = Column(Integer, primary_key=True) 104 | user = Column(String) 105 | channel = Column(String) 106 | seen = Column(DateTime) 107 | doing = Column(String) 108 | 109 | def __init__(self, channel, user, seen, doing): 110 | self.channel = channel 111 | self.user = user 112 | self.seen = seen 113 | self.doing = doing 114 | 115 | def __str__(self): 116 | return "%s %s %s %s" % (self.channel, self.user, self.seen, self.doing) 117 | 118 | def __repr__(self): 119 | return "" % self 120 | -------------------------------------------------------------------------------- /hamper/plugins/suggest.py: -------------------------------------------------------------------------------- 1 | from hamper.interfaces import ChatCommandPlugin, Command 2 | 3 | import requests 4 | import json 5 | 6 | 7 | class Suggest(ChatCommandPlugin): 8 | name = 'suggest' 9 | priority = 2 10 | 11 | short_desc = 'suggest - suggest a phrase given something' 12 | long_desc = ( 13 | 'suggest - suggest the th phrase given ' 14 | 'something\n' 15 | 'This pluggin is similar to the suggestion engine found on a smart ' 16 | 'phone.\n' 17 | 'You can use it to correct the spelling of difficult words.\n' 18 | ) 19 | 20 | search_url = "http://suggestqueries.google.com/complete/search?client=firefox&q={query}" # noqa 21 | 22 | def setup(self, loader): 23 | super(Suggest, self).setup(loader) 24 | 25 | class Suggest(Command): 26 | name = 'suggest' 27 | regex = '^suggest\s*(\d+)?\s+(.*)' 28 | 29 | def command(self, bot, comm, groups): 30 | query = groups[1] 31 | s_num = int(groups[0]) if groups[0] else 1 32 | 33 | resp = requests.get(self.plugin.search_url.format(query=query)) 34 | if resp.status_code != 200: 35 | raise Exception( 36 | "Suggest Error: A non 200 status code was returned" 37 | ) 38 | 39 | gr = json.loads(resp.content) 40 | o_query, suggestions = gr 41 | if not suggestions: 42 | bot.reply(comm, "No suggestions found.") 43 | else: 44 | try: 45 | bot.reply( 46 | comm, "%s (%s/%s)" % ( 47 | suggestions[s_num - 1], s_num, len(suggestions) 48 | ) 49 | ) 50 | except IndexError: 51 | bot.reply( 52 | comm, "Suggestion number #%s does not exist" % s_num 53 | ) 54 | 55 | # Always let the other plugins run 56 | return False 57 | -------------------------------------------------------------------------------- /hamper/plugins/timez.py: -------------------------------------------------------------------------------- 1 | import requests 2 | import json 3 | 4 | from hamper.interfaces import ChatCommandPlugin, Command 5 | 6 | 7 | class Timez(ChatCommandPlugin): 8 | name = 'timez' 9 | priority = 2 10 | 11 | def setup(self, loader): 12 | try: 13 | self.api_key = loader.config['timez']['api-key'] 14 | except (KeyError, TypeError): 15 | self.api_key = None 16 | 17 | api_url = "http://api.worldweatheronline.com/free/v1/tz.ashx" 18 | self.api_url = "%s?key=%s&q=%%s&format=json" % (api_url, self.api_key) 19 | super(Timez, self).setup(loader) 20 | 21 | class Timez(Command): 22 | ''' ''' 23 | name = 'timez' 24 | regex = '^timez (.*)' 25 | 26 | long_desc = short_desc = ( 27 | "timez - Look up time for [ZIP code | " 28 | "City, State (US Only) | City Name, State, Country | City Name, " 29 | "Country | Airport Code | IP " 30 | ) 31 | 32 | def command(self, bot, comm, groups): 33 | if not self.plugin.api_key: 34 | bot.reply( 35 | comm, "This plugin is missconfigured. Its missing an API " 36 | "key. Go register one at " 37 | "http://developer.worldweatheronline.com/apps/register" 38 | ) 39 | return 40 | 41 | query = comm['message'].strip('timez ') 42 | resp = requests.get(self.plugin.api_url % query) 43 | if resp.status_code != 200: 44 | bot.reply(comm, "Error: A non 200 status code was returned") 45 | 46 | jresp = json.loads(resp.text) 47 | 48 | try: 49 | tz = jresp['data']['time_zone'][0] 50 | bot.reply( 51 | comm, 52 | "For %s, local time is %s at UTC offset %s" % ( 53 | query, tz['localtime'], tz['utcOffset'] 54 | ) 55 | ) 56 | except KeyError: 57 | bot.reply( 58 | comm, "Sorry, the internet didn't understand your request." 59 | ) 60 | 61 | # Always let the other plugins run 62 | return False 63 | -------------------------------------------------------------------------------- /hamper/plugins/tinyurl.py: -------------------------------------------------------------------------------- 1 | import re 2 | import requests 3 | 4 | from hamper.interfaces import ChatPlugin 5 | 6 | 7 | class Tinyurl(ChatPlugin): 8 | name = 'tinyurl' 9 | priority = 0 10 | 11 | # Regex is taken from: 12 | # http://daringfireball.net/2010/07/improved_regex_for_matching_urls 13 | regex = ur""" 14 | ( # Capture 1: entire matched URL 15 | (?: 16 | (?Phttps?://) # http or https protocol 17 | | # or 18 | www\d{0,3}[.] # "www.", "www1.", "www2." ... "www999." 19 | | # or 20 | [a-z0-9.\-]+[.][a-z]{2,4}/ # looks like domain name 21 | # followed by a slash 22 | ) 23 | (?: # One or more: 24 | [^\s()<>]+ # Run of non-space, non-()<> 25 | | # or 26 | \(([^\s()<>]+|(\([^\s()<>]+\)))*\) # balanced parens, up to 2 levels 27 | )+ 28 | (?: # End with: 29 | \(([^\s()<>]+|(\([^\s()<>]+\)))*\) # balanced parens, up to 2 levels 30 | | # or 31 | [^\s`!()\[\]{};:'".,<>?] # not a space or one of 32 | # these punct chars 33 | ) 34 | ) 35 | """ 36 | 37 | def setup(self, loader): 38 | self.regex = re.compile(self.regex, re.VERBOSE | re.IGNORECASE | re.U) 39 | self.api_url = 'http://tinyurl.com/api-create.php?url={0}' 40 | self.config = loader.config.get('tinyurl', {}) 41 | 42 | defaults = { 43 | 'excluded-urls': ['imgur.com', 'gist.github.com', 'pastebin.com'], 44 | 'min-length': 40, 45 | } 46 | for key, val in defaults.items(): 47 | self.config.setdefault(key, val) 48 | 49 | def message(self, bot, comm): 50 | match = self.regex.search(comm['message']) 51 | # Found a url 52 | if match: 53 | long_url = match.group(0) 54 | 55 | # Only shorten urls which are longer than a tinyurl url 56 | if len(long_url) < self.config['min-length']: 57 | return False 58 | 59 | # Don't shorten url's which are in the exclude list 60 | for item in self.config['excluded-urls']: 61 | if item in long_url.lower(): 62 | return False 63 | 64 | # tinyurl requires a valid URI 65 | if not match.group('prot'): 66 | long_url = 'http://' + long_url 67 | 68 | resp = requests.get(self.api_url.format(long_url)) 69 | data = resp.content 70 | 71 | if resp.status_code == 200: 72 | bot.reply( 73 | comm, 74 | "{0}'s shortened url is {1}" .format(comm['user'], data) 75 | ) 76 | else: 77 | bot.reply( 78 | comm, "Error while shortening URL: saw status code %s" % 79 | resp.status_code 80 | ) 81 | 82 | # Always let the other plugins run 83 | return False 84 | -------------------------------------------------------------------------------- /hamper/plugins/whatwasthat.py: -------------------------------------------------------------------------------- 1 | from hamper.interfaces import Command, ChatCommandPlugin 2 | 3 | 4 | class WhatWasThat(ChatCommandPlugin): 5 | ''' 6 | What Was That? 7 | 8 | Give details about the last thing that was said. 9 | ''' 10 | 11 | name = 'whatwasthat' 12 | priority = 3 13 | 14 | class WhatWasThat(Command): 15 | regex = r'^what\s*was\s*that\??$' 16 | 17 | name = 'whatwasthat' 18 | short_desc = 'whatwasthat - Say what hamper did last' 19 | long_desc = '' 20 | 21 | def command(self, bot, comm, groups): 22 | sent_messages = bot.factory.sent_messages.get(comm['channel'], []) 23 | if sent_messages: 24 | last = sent_messages[-1] 25 | if last['tag']: 26 | bot.reply(comm, '{user}: That was {0}' 27 | .format(last['tag'], **comm)) 28 | else: 29 | bot.reply(comm, "{user}: I'm not sure why I said that." 30 | .format(**comm)) 31 | else: 32 | bot.reply(comm, "I didn't do it!") 33 | return True 34 | -------------------------------------------------------------------------------- /hamper/tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hamperbot/hamper/d9533a3ff94ff1009bf5c4c779b2ca3a5e93c892/hamper/tests/__init__.py -------------------------------------------------------------------------------- /hamper/tests/test_command.py: -------------------------------------------------------------------------------- 1 | from unittest import TestCase 2 | 3 | from hamper.interfaces import Command 4 | 5 | 6 | class CommandSubclass(Command): 7 | regex = "^!test(.*)$" 8 | 9 | def command(self, unused, d, matches): 10 | self.matches = matches 11 | 12 | 13 | class TestCommand(TestCase): 14 | """ 15 | The Command class provides some basic structure for regex-powered 16 | commands. 17 | """ 18 | 19 | def test_only_directed(self): 20 | c = CommandSubclass(None) 21 | 22 | d = {"directed": False} 23 | 24 | self.assertFalse(c.message(None, d)) 25 | 26 | def test_no_match(self): 27 | c = CommandSubclass(None) 28 | c.onlyDirected = False 29 | 30 | d = {"directed": False, "message": "!example"} 31 | 32 | self.assertFalse(c.message(None, d)) 33 | 34 | def test_simple_match(self): 35 | c = CommandSubclass(None) 36 | c.onlyDirected = False 37 | 38 | d = {"directed": False, "message": "!test"} 39 | 40 | self.assertTrue(c.message(None, d)) 41 | self.assertEqual(c.matches, ("",)) 42 | 43 | def test_match_groups(self): 44 | c = CommandSubclass(None) 45 | c.onlyDirected = False 46 | 47 | d = {"directed": False, "message": "!testing"} 48 | 49 | self.assertTrue(c.message(None, d)) 50 | self.assertEqual(c.matches, ("ing",)) 51 | -------------------------------------------------------------------------------- /hamper/tests/test_interfaces.py: -------------------------------------------------------------------------------- 1 | from bisect import insort 2 | from unittest import TestCase 3 | 4 | from hamper.interfaces import ChatPlugin 5 | 6 | 7 | class TestPlugin1(ChatPlugin): 8 | priority = 1 9 | 10 | 11 | class TestPlugin2(ChatPlugin): 12 | priority = 2 13 | 14 | 15 | class TestPlugin3(ChatPlugin): 16 | priority = 3 17 | 18 | 19 | class TestPluginSorting(TestCase): 20 | 21 | def test_plugin_sorting(self): 22 | p1 = TestPlugin1() 23 | p2 = TestPlugin2() 24 | p3 = TestPlugin3() 25 | 26 | self.assertTrue(p1 < p3) 27 | self.assertTrue(p1 < p2) 28 | self.assertTrue(p2 < p3) 29 | self.assertTrue(p2 > p1) 30 | self.assertTrue(p3 > p1) 31 | self.assertTrue(p3 > p2) 32 | 33 | def test_plugin_insort(self): 34 | p1 = TestPlugin1() 35 | p2 = TestPlugin2() 36 | p3 = TestPlugin3() 37 | 38 | plugins = [] 39 | insort(plugins, p1) 40 | insort(plugins, p3) 41 | insort(plugins, p2) 42 | self.assertEquals(plugins, [p1, p2, p3]) 43 | 44 | plugins = [] 45 | insort(plugins, p3) 46 | insort(plugins, p1) 47 | insort(plugins, p2) 48 | self.assertEquals(plugins, [p1, p2, p3]) 49 | -------------------------------------------------------------------------------- /hamper/tests/test_permissions.py: -------------------------------------------------------------------------------- 1 | import json 2 | from unittest import TestCase 3 | 4 | from hamper.acl import ACL 5 | 6 | TEST_ACL = json.dumps({ 7 | 'groups': { 8 | '@ops': ['uberj', 'mythmon'], 9 | '@spammers': ['spambot'], 10 | '@7letters': ['mythmon', 'spambot'], 11 | }, 12 | 'permissions': { 13 | '#channel1': ['figlet', 'quote', '-quote.add'], 14 | '#channel2': ['*', '-figlet'], 15 | '@ops': ['*'], 16 | 'mythmon': ['quote.*'], 17 | } 18 | }) 19 | 20 | 21 | class TestACL(TestCase): 22 | def setUp(self): 23 | self.acl = ACL(TEST_ACL) 24 | 25 | def test_channel_star_perms(self): 26 | comm = { 27 | 'user': 'uberj', 28 | 'channel': '#channel2', 29 | } 30 | self.assertTrue(self.acl.has_permission(comm, 'foo')) 31 | 32 | def test_channel_perms(self): 33 | comm = { 34 | 'user': 'foobar', 35 | 'channel': '#channel1', 36 | } 37 | self.assertTrue(self.acl.has_permission(comm, 'figlet')) 38 | self.assertTrue(self.acl.has_permission(comm, 'quote')) 39 | self.assertFalse(self.acl.has_permission(comm, 'quote.add')) 40 | self.assertFalse(self.acl.has_permission(comm, 'channel.leave')) 41 | 42 | def test_channel_everything_but_figlet(self): 43 | comm = { 44 | 'user': 'foobar', 45 | 'channel': '#channel2', 46 | } 47 | self.assertTrue(self.acl.has_permission(comm, 'cmd')) 48 | self.assertTrue(self.acl.has_permission(comm, 'anothercmd')) 49 | self.assertFalse(self.acl.has_permission(comm, 'figlet')) 50 | 51 | def test_group_permissions(self): 52 | comm = { 53 | 'user': 'uberj', 54 | 'channel': '#channel3', 55 | # group will be filled in as '@op' 56 | } 57 | self.assertTrue(self.acl.has_permission(comm, 'cmd')) 58 | 59 | comm['channel'] = '#channel2' 60 | # Not even ops can use figlet here. 61 | self.assertFalse(self.acl.has_permission(comm, 'figlet')) 62 | 63 | def test_user_permission(self): 64 | comm = { 65 | 'user': 'mythmon', 66 | 'channel': '#channel3', 67 | } 68 | self.assertTrue(self.acl.has_permission(comm, 'quote')) 69 | self.assertTrue(self.acl.has_permission(comm, 'quote.add')) 70 | self.assertTrue(self.acl.has_permission(comm, 'quote.delete')) 71 | 72 | def test_empty_acls(self): 73 | # This should not error. 74 | acl = ACL('{}') 75 | acl.has_permission({}, 'foo') 76 | 77 | 78 | class TestACLParser(TestCase): 79 | def setUp(self): 80 | self.acl = ACL(TEST_ACL) 81 | 82 | def _check(self, selector, expected): 83 | acl = ACL(TEST_ACL) 84 | self.assertEqual(acl.parse_selector(selector), expected) 85 | 86 | # 0 items 87 | 88 | def test_star(self): 89 | self._check('*', {}) 90 | 91 | # 1 items 92 | 93 | def test_user(self): 94 | self._check('foo', {'user': 'foo'}) 95 | self._check('bar', {'user': 'bar'}) 96 | 97 | def test_group(self): 98 | self._check('@foo', {'group': '@foo'}) 99 | self._check('@bar', {'group': '@bar'}) 100 | 101 | def test_channel(self): 102 | self._check('#foo', {'channel': '#foo'}) 103 | self._check('#bar', {'channel': '#bar'}) 104 | 105 | # 2 items 106 | 107 | def test_user_group(self): 108 | self._check('foo@bar', {'user': 'foo', 'group': '@bar'}) 109 | self._check('baz@qux', {'user': 'baz', 'group': '@qux'}) 110 | 111 | def test_user_channel(self): 112 | self._check('foo#bar', {'user': 'foo', 'channel': '#bar'}) 113 | self._check('baz#qux', {'user': 'baz', 'channel': '#qux'}) 114 | 115 | def test_group_channel(self): 116 | self._check('@foo#bar', {'group': '@foo', 'channel': '#bar'}) 117 | self._check('#baz@qux', {'channel': '#baz', 'group': '@qux'}) 118 | 119 | # 3 items 120 | 121 | def test_user_channel_group(self): 122 | self._check('foo@bar#baz', { 123 | 'user': 'foo', 124 | 'group': '@bar', 125 | 'channel': '#baz', 126 | }) 127 | self._check('foo#baz@bar', { 128 | 'user': 'foo', 129 | 'group': '@bar', 130 | 'channel': '#baz', 131 | }) 132 | 133 | 134 | class TestPermissionGlobbing(TestCase): 135 | 136 | def setUp(self): 137 | self.acl = ACL(TEST_ACL) 138 | 139 | def _check(self, perm, pattern, match=ACL.ALLOW): 140 | if match: 141 | self.assertTrue(self.acl.glob_permission_match(perm, pattern)) 142 | else: 143 | self.assertFalse(self.acl.glob_permission_match(perm, pattern)) 144 | 145 | def test_single(self): 146 | self._check('foo', 'foo') 147 | self._check('foo', 'bar', None) 148 | 149 | def test_dotted(self): 150 | self._check('foo.bar', 'foo.bar') 151 | self._check('foo.bar', 'foo.baz', None) 152 | 153 | def test_partial(self): 154 | self._check('foo', 'foo.bar', None) 155 | self._check('foo.bar', 'foo', None) 156 | 157 | def test_end_star(self): 158 | self._check('foo.bar', 'foo.*') 159 | self._check('foo.bar', 'bar.*', None) 160 | 161 | def test_middle_star(self): 162 | self._check('foo.bar.baz', 'foo.*.baz') 163 | self._check('foo.bar.baz', 'foo.*.qux', None) 164 | 165 | def test_partial_star(self): 166 | self._check('foo.bar.baz', 'foo.*') 167 | self._check('foo.bar.baz', 'bar.*', None) 168 | 169 | def test_negation(self): 170 | self._check('foo', '-foo', ACL.DENY) 171 | self._check('foo.bar', '-foo.bar', ACL.DENY) 172 | self._check('foo', '-foo.bar', None) 173 | 174 | 175 | class TestAddGroups(TestCase): 176 | 177 | def setUp(self): 178 | self.acl = ACL(TEST_ACL) 179 | 180 | def test_single(self): 181 | comm = {'user': 'uberj'} 182 | self.assertEqual(self.acl.add_groups(comm)['groups'], ['@ops']) 183 | 184 | def test_double(self): 185 | comm = {'user': 'mythmon'} 186 | groups = set(self.acl.add_groups(comm)['groups']) 187 | expected = set(['@ops', '@7letters']) 188 | self.assertEqual(groups, expected) 189 | 190 | def test_none(self): 191 | comm = {'user': 'edunham'} 192 | self.assertEqual(self.acl.add_groups(comm)['groups'], []) 193 | -------------------------------------------------------------------------------- /hamper/utils.py: -------------------------------------------------------------------------------- 1 | # These functions help you when things in irc interact with SQA 2 | # When to use uen (unicode encode): 3 | # - Whenever you are pulling something *FROM THE DB* that will end up on the 4 | # wire to IRC 5 | 6 | # When to use ude (unicode decode): 7 | # - Whenever you are putting something *INTO THE DB* that was user input from 8 | # IRC. Note: this applies to values used in any SQA statement, including 9 | # WHERE cluases 10 | 11 | 12 | def uen(s): 13 | return s.encode('utf-8') 14 | 15 | 16 | def ude(s): 17 | return s.decode('utf-8') 18 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | cffi==1.10.0 2 | cryptography==1.8.1 3 | flake8==3.3.0 4 | pyOpenSSL==17.0.0 5 | pytz==2017.2 6 | PyYAML==3.12 7 | requests==2.14.2 8 | SQLAlchemy==1.1.10 9 | Twisted==17.1.0 10 | upsidedown==0.3 11 | zope.interface==4.4.1 12 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python2 2 | 3 | from setuptools import setup, find_packages 4 | 5 | requires = open('requirements.txt').read().split('\n') 6 | 7 | setup( 8 | name='hamper', 9 | version='1.12.0', 10 | description='Yet another IRC bot', 11 | install_requires=requires, 12 | author='Mike Cooper', 13 | author_email='mythmon@gmail.com', 14 | url='https://www.github.com/hamperbot/hamper', 15 | packages=find_packages(), 16 | entry_points={ 17 | 'console_scripts': [ 18 | 'hamper = hamper.commander:main', 19 | ], 20 | 'hamperbot.plugins': [ 21 | 'bitly = hamper.plugins.bitly:Bitly', 22 | 'botsnack = hamper.plugins.friendly:BotSnack', 23 | 'channel_utils = hamper.plugins.channel_utils:ChannelUtils', 24 | 'choices = hamper.plugins.questions:ChoicesPlugin', 25 | 'dice = hamper.plugins.commands:Dice', 26 | 'selfaware = hamper.plugins.selfaware:SelfAwarePlugin', 27 | 'factoids = hamper.plugins.factoids:Factoids', 28 | 'flip = hamper.plugins.flip:Flip', 29 | 'foods = hamper.plugins.foods:FoodsPlugin', 30 | 'friendly = hamper.plugins.friendly:Friendly', 31 | 'goodbye = hamper.plugins.goodbye:GoodBye', 32 | 'help = hamper.plugins.help:Help', 33 | 'karma = hamper.plugins.karma:Karma', 34 | 'karma_adv = hamper.plugins.karma_adv:KarmAdv', 35 | 'lmgtfy = hamper.plugins.commands:LetMeGoogleThatForYou', 36 | 'lookup = hamper.plugins.dictionary:Lookup', 37 | 'maniacal = hamper.plugins.maniacal:ManiacalPlugin', 38 | 'platitudes = hamper.plugins.platitudes:PlatitudesPlugin', 39 | 'ponies = hamper.plugins.friendly:OmgPonies', 40 | 'quit = hamper.plugins.commands:Quit', 41 | 'quotes = hamper.plugins.quotes:Quotes', 42 | 'remindme = hamper.plugins.remindme:Reminder', 43 | 'rot13 = hamper.plugins.commands:Rot13', 44 | 'roulette = hamper.plugins.roulette:Roulette', 45 | 'sed = hamper.plugins.commands:Sed', 46 | 'seen = hamper.plugins.seen:Seen', 47 | 'suggest = hamper.plugins.suggest:Suggest', 48 | 'timez = hamper.plugins.timez:Timez', 49 | 'tinyurl = hamper.plugins.tinyurl:Tinyurl', 50 | 'whatwasthat = hamper.plugins.whatwasthat:WhatWasThat', 51 | 'yesno = hamper.plugins.questions:YesNoPlugin', 52 | ], 53 | }, 54 | ) 55 | -------------------------------------------------------------------------------- /troubleshooting.md: -------------------------------------------------------------------------------- 1 | Trouble Installing 2 | ================== 3 | 4 | ### Problem: pyopenssl has issues compiling in Ubuntu 5 | 6 | If `pip install -r requirements.txt` in your virtualenv gives an error like: 7 | 8 | 9 | ``` 10 | OpenSSL/crypto/x509.h:17:25: fatal error: openssl/ssl.h: No such file or directory 11 | #include 12 | ^ 13 | compilation terminated. 14 | 15 | ``` 16 | 17 | resulting in the final error 18 | 19 | ``` 20 | error: command 'x86_64-linux-gnu-gcc' failed with exit status 1 21 | ```` 22 | 23 | ### Solution: Install libssl-dev 24 | 25 | Make sure you have libssl-dev installed from your system's package manager: 26 | 27 | ``` 28 | $ sudo apt-get install libssl-dev 29 | ``` 30 | --------------------------------------------------------------------------------