From c8752db12f2a519621a81b223feb8ee33f2c1822 Mon Sep 17 00:00:00 2001 From: Rob Siemborski Date: Wed, 2 Oct 2024 13:51:18 -0400 Subject: [PATCH] Initial Public Commit for ABTech zdaemon Python Version, Slack & Zulip Support Co-authored-by: Perry Naseck --- .gitattributes | 2 + .github/workflows/docker.yml | 49 ++ .gitignore | 15 + Dockerfile | 53 ++ LICENSE | 29 + README.md | 68 ++ compose.yml.sample | 40 ++ deploy/crontab | 36 ++ deploy/cube-xfer.sh | 51 ++ deploy/zdaemon-docker-start.sh | 31 + other/rss-bot | 227 +++++++ other/sendfortune.sh | 30 + perl-convert/ppdbconvert | 139 +++++ slack-bot.yaml.sample | 56 ++ triggers.yaml.sample | 118 ++++ zdaemon.json.sample | 12 + zdaemon/__init__.py | 0 zdaemon/common.py | 419 +++++++++++++ zdaemon/config.py | 268 ++++++++ zdaemon/cube.py | 1056 ++++++++++++++++++++++++++++++++ zdaemon/html-cubes.py | 88 +++ zdaemon/notify-maintainer.py | 48 ++ zdaemon/plusplus.py | 414 +++++++++++++ zdaemon/requirements.txt | 18 + zdaemon/triggers.py | 266 ++++++++ zdaemon/zdaemon.py | 338 ++++++++++ zdaemon/zsendcube.py | 56 ++ zdaemon/zudaemon | 149 +++++ 28 files changed, 4076 insertions(+) create mode 100644 .gitattributes create mode 100644 .github/workflows/docker.yml create mode 100644 .gitignore create mode 100644 Dockerfile create mode 100644 LICENSE create mode 100644 README.md create mode 100644 compose.yml.sample create mode 100644 deploy/crontab create mode 100755 deploy/cube-xfer.sh create mode 100644 deploy/zdaemon-docker-start.sh create mode 100755 other/rss-bot create mode 100755 other/sendfortune.sh create mode 100644 perl-convert/ppdbconvert create mode 100644 slack-bot.yaml.sample create mode 100644 triggers.yaml.sample create mode 100644 zdaemon.json.sample create mode 100644 zdaemon/__init__.py create mode 100644 zdaemon/common.py create mode 100644 zdaemon/config.py create mode 100644 zdaemon/cube.py create mode 100644 zdaemon/html-cubes.py create mode 100644 zdaemon/notify-maintainer.py create mode 100644 zdaemon/plusplus.py create mode 100644 zdaemon/requirements.txt create mode 100644 zdaemon/triggers.py create mode 100644 zdaemon/zdaemon.py create mode 100644 zdaemon/zsendcube.py create mode 100755 zdaemon/zudaemon diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..e681d30 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,2 @@ +zdaemon ident +zdaemon.py ident diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml new file mode 100644 index 0000000..adce3b4 --- /dev/null +++ b/.github/workflows/docker.yml @@ -0,0 +1,49 @@ +name: Create and publish a Docker image + +# Configures this workflow to run every time a change is pushed to the branch called `release`. +on: + push: + branches: + - 'main' + - 'dev' + +# Defines two custom environment variables for the workflow. These are used for the Container registry domain, and a name for the Docker image that this workflow builds. +env: + REGISTRY: ghcr.io + IMAGE_NAME: ${{ github.repository }} + +# There is a single job in this workflow. It's configured to run on the latest available version of Ubuntu. +jobs: + build-and-push-image: + runs-on: ubuntu-latest + # Sets the permissions granted to the `GITHUB_TOKEN` for the actions in this job. + permissions: + contents: read + packages: write + # + steps: + - name: Checkout repository + uses: actions/checkout@v4 + # Uses the `docker/login-action` action to log in to the Container registry registry using the account and password that will publish the packages. Once published, the packages are scoped to the account defined here. + - name: Log in to the Container registry + uses: docker/login-action@v3 + with: + registry: ${{ env.REGISTRY }} + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + # This step uses [docker/metadata-action](https://github.com/docker/metadata-action#about) to extract tags and labels that will be applied to the specified image. The `id` "meta" allows the output of this step to be referenced in a subsequent step. The `images` value provides the base name for the tags and labels. + - name: Extract metadata (tags, labels) for Docker + id: meta + uses: docker/metadata-action@v5 + with: + images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} + # This step uses the `docker/build-push-action` action to build the image, based on your repository's `Dockerfile`. If the build succeeds, it pushes the image to GitHub Packages. + # It uses the `context` parameter to define the build's context as the set of files located in the specified path. For more information, see "[Usage](https://github.com/docker/build-push-action#usage)" in the README of the `docker/build-push-action` repository. + # It uses the `tags` and `labels` parameters to tag and label the image with the output from the "meta" step. + - name: Build and push Docker image + uses: docker/build-push-action@v5 + with: + context: . + push: true + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..1e05ab3 --- /dev/null +++ b/.gitignore @@ -0,0 +1,15 @@ +# Don't keep the local default config file name +# (rely on using .json.sample in the repo) +zdaemon.json + +# Python virtual environment names & other cruft +env/* +venv/* +__pycache__ + +# Some log files we don't need to keep +log + +# Don't keep any data files +data/* +www/* diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..0b48bd5 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,53 @@ +# Copyright (c) 2024, AB Tech +# All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions are met: +# +# * Redistributions of source code must retain the above copyright notice, this +# list of conditions and the following disclaimer. +# +# * Redistributions in binary form must reproduce the above copyright notice, +# this list of conditions and the following disclaimer in the documentation +# and/or other materials provided with the distribution. +# +# * Neither the name of the copyright holder nor the names of its +# contributors may be used to endorse or promote products derived from +# this software without specific prior written permission. +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +# DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE +# FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +# DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +# SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +# CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, +# OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +# Need uptime, grep, cron to run, so we use the alpine version. +FROM python:3.10-alpine + +RUN mkdir /home/zdaemon + +ADD zdaemon/requirements.txt /home/zdaemon +ADD zdaemon/*.py /home/zdaemon +ADD zdaemon/zudaemon /home/zdaemon + +ADD deploy/cube-xfer.sh /home/zdaemon +ADD deploy/zdaemon-docker-start.sh /home/zdaemon +ADD deploy/crontab /home/zdaemon/crontab + +WORKDIR /home/zdaemon +RUN pip install -r requirements.txt +RUN crontab crontab + +# To function you need to mount: +# /home/zdaemon/zuliprc +# /home/zdaemon/zdaemon.json +# /home/zdaemon/triggers.yaml +# /home/zdaemon/data +# and. optionally, /home/zdaemon/www (for the cube html file) + +ENTRYPOINT ["sh", "zdaemon-docker-start.sh"] diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..fb639db --- /dev/null +++ b/LICENSE @@ -0,0 +1,29 @@ +BSD 3-Clause License + +Copyright (c) 2024, AB Tech +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + +* Redistributions of source code must retain the above copyright notice, this + list of conditions and the following disclaimer. + +* Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. + +* Neither the name of the copyright holder nor the names of its + contributors may be used to endorse or promote products derived from + this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE +FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, +OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..cd98101 --- /dev/null +++ b/README.md @@ -0,0 +1,68 @@ +This is zdaemon. + +It is the python version, since few have survived looking at the legacy perl version. + +# Configuration + +Configuration of maintainers, directories, and class name need to be made in `zdaemon.json` (see sample file). Additionally, a `zuliprc` from the bot config on zulip must be provided (possibly via the `--config-file` parameter) + +It is recommended you get a baseline set of the data files from production to get a good sample of data to work with. Failing this, you will need to create `ppdata.sqlite` and `cube.sqlite` using the schemas described in `plusplus.py` and `cube.py` respectively. You will also want to have at least one cube slurped before things start to act normal. Finally, sending a single cube may be required to fully fill in the data. + +# Docker Deployment + +Deployments need to provide the `zuliprc`, `zdaemon.json`, and `triggers.yaml` config files, as well as the data directory where zdaemon will keep its databases. + + - Build the image + - `docker build -t zdaemon .` + - Run the image (something like this interactive/attached version). Note the config files can be read only. + - `docker run -v ./data:/home/zdaemon/data -v ./zdaemon.json:/home/zdaemon/zdaemon.json:ro -v /home/zdaemon/.zuliprc:/home/zdaemon/zuliprc:ro -v ./triggers.yaml:/home/zdaemon/triggers.yaml:ro -w /home/zdaemon -it zdaemon` + +There is a sample Docker Compose YAML file you can also examine, `compose.yml.sample`. + +# Slack Bot Config + +To configure the slack bot, create a new app at https://api.slack.com. The repository has a sample manifest describing the needed scopes and permissions in `slack-bot.yaml.sample` (but you probably want to change the bot name). + +# Triggers + +The production triggers file is not available in the public repository, however a sample file +(`triggers.yaml.sample`) is available to see how to create new triggers. + +# Credits + +- zdaemon was originally authored by Kevin Miller in December of 1998. +- From about 2005-2023, minor updates including transitioning it to support Zulip were performed by Adam Pennington and Chris Tuttle +- In 2024 a full rewrite to Python was done by Rob Siemborski. Shortly thereafter, support for slack was added. +- Perry Naseck has also made various contributions, including the external config for triggers. + +# License + +``` +Copyright (c) 2024, AB Tech +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + +* Redistributions of source code must retain the above copyright notice, this + list of conditions and the following disclaimer. + +* Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. + +* Neither the name of the copyright holder nor the names of its + contributors may be used to endorse or promote products derived from + this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE +FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, +OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. +``` diff --git a/compose.yml.sample b/compose.yml.sample new file mode 100644 index 0000000..172ec90 --- /dev/null +++ b/compose.yml.sample @@ -0,0 +1,40 @@ +# Copyright (c) 2024, AB Tech +# All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions are met: +# +# * Redistributions of source code must retain the above copyright notice, this +# list of conditions and the following disclaimer. +# +# * Redistributions in binary form must reproduce the above copyright notice, +# this list of conditions and the following disclaimer in the documentation +# and/or other materials provided with the distribution. +# +# * Neither the name of the copyright holder nor the names of its +# contributors may be used to endorse or promote products derived from +# this software without specific prior written permission. +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +# DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE +# FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +# DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +# SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +# CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, +# OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +version: "3.8" +services: + app: + image: ghcr.io/abtech/zdaemon:main + restart: always + tty: True + volumes: + - ./mounts/zdaemon-legacy/home/zdaemon/zdaemon.json:/home/zdaemon/zdaemon.json:ro + - ./mounts/zdaemon-legacy/home/zdaemon/zuliprc:/home/zdaemon/zuliprc:ro + - ./mounts/zdaemon-legacy/home/zdaemon/triggers.yaml:/home/zdaemon/triggers.yaml:ro + - ./mounts/zdaemon-legacy/home/zdaemon/www:/home/zdaemon/www + - ./mounts/zdaemon-legacy/home/zdaemon/data:/home/zdaemon/data diff --git a/deploy/crontab b/deploy/crontab new file mode 100644 index 0000000..8534343 --- /dev/null +++ b/deploy/crontab @@ -0,0 +1,36 @@ +# Copyright (c) 2024, AB Tech +# All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions are met: +# +# * Redistributions of source code must retain the above copyright notice, this +# list of conditions and the following disclaimer. +# +# * Redistributions in binary form must reproduce the above copyright notice, +# this list of conditions and the following disclaimer in the documentation +# and/or other materials provided with the distribution. +# +# * Neither the name of the copyright holder nor the names of its +# contributors may be used to endorse or promote products derived from +# this software without specific prior written permission. +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +# DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE +# FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +# DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +# SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +# CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, +# OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +# Note: The container runs cron as UTC, not Eastern. + +# Send Cubes +50 * * * * cd /home/zdaemon && python zsendcube.py --config-file=zuliprc +20 13-23 * * 1-5 cd /home/zdaemon && python zsendcube.py --config-file=zuliprc + +# Generate HTML Summary +*/5 * * * * cd /home/zdaemon && /bin/sh cube-xfer.sh diff --git a/deploy/cube-xfer.sh b/deploy/cube-xfer.sh new file mode 100755 index 0000000..324c0ec --- /dev/null +++ b/deploy/cube-xfer.sh @@ -0,0 +1,51 @@ +#! /bin/sh +# Copyright (c) 2024, AB Tech +# All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions are met: +# +# * Redistributions of source code must retain the above copyright notice, this +# list of conditions and the following disclaimer. +# +# * Redistributions in binary form must reproduce the above copyright notice, +# this list of conditions and the following disclaimer in the documentation +# and/or other materials provided with the distribution. +# +# * Neither the name of the copyright holder nor the names of its +# contributors may be used to endorse or promote products derived from +# this software without specific prior written permission. +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +# DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE +# FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +# DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +# SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +# CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, +# OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +ZULIP_CONFIG_FLAG=--config-file=zuliprc +ROOTDIR=/home/zdaemon +LOGFILE=$ROOTDIR/data/htmlcube.log +OUTPUTDIR=$ROOTDIR/www +OUTPUTFILE=$OUTPUTDIR/cube.html + +# Log that we ran. +date >> $LOGFILE + +# Create output directory if it is missing +mkdir -p $OUTPUTDIR + +# Stderr redirected to the log file, just in case. +python $ROOTDIR/html-cubes.py $ZULIP_CONFIG_FLAG > $OUTPUTFILE.new 2>> $LOGFILE + +if [ -s $OUTPUTFILE.new ]; then + # all good + mv $OUTPUTFILE.new $OUTPUTFILE +else + # file is empty, notify maintainer but don't move it + echo zero byte $OUTPUTFILE.new in cube html output | python $ROOTDIR/notify-maintainer.py $ZULIP_CONFIG_FLAG +fi diff --git a/deploy/zdaemon-docker-start.sh b/deploy/zdaemon-docker-start.sh new file mode 100644 index 0000000..f550102 --- /dev/null +++ b/deploy/zdaemon-docker-start.sh @@ -0,0 +1,31 @@ +#!/bin/sh +# Copyright (c) 2024, AB Tech +# All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions are met: +# +# * Redistributions of source code must retain the above copyright notice, this +# list of conditions and the following disclaimer. +# +# * Redistributions in binary form must reproduce the above copyright notice, +# this list of conditions and the following disclaimer in the documentation +# and/or other materials provided with the distribution. +# +# * Neither the name of the copyright holder nor the names of its +# contributors may be used to endorse or promote products derived from +# this software without specific prior written permission. +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +# DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE +# FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +# DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +# SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +# CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, +# OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +crond +python zudaemon --config-file zuliprc diff --git a/other/rss-bot b/other/rss-bot new file mode 100755 index 0000000..233b382 --- /dev/null +++ b/other/rss-bot @@ -0,0 +1,227 @@ +#!/usr/bin/env python2.7 +# -*- coding: utf-8 -*- +# RSS integration for Zulip +# +# Copyright (c) 2024, AB Tech +# Copyright (c) 2014, Zulip, Inc. +# All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions are met: +# +# * Redistributions of source code must retain the above copyright notice, this +# list of conditions and the following disclaimer. +# +# * Redistributions in binary form must reproduce the above copyright notice, +# this list of conditions and the following disclaimer in the documentation +# and/or other materials provided with the distribution. +# +# * Neither the name of the copyright holder nor the names of its +# contributors may be used to endorse or promote products derived from +# this software without specific prior written permission. +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +# DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE +# FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +# DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +# SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +# CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, +# OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +import calendar +import errno +import hashlib +from HTMLParser import HTMLParser +import logging +import optparse +import os +import sys +import time +import urlparse + +import feedparser +import zulip +VERSION = "0.9" +RSS_DATA_DIR = os.path.expanduser(os.path.join('~', '.cache', 'zulip-rss')) +OLDNESS_THRESHOLD = 30 # days + +usage = """Usage: Send summaries of RSS entries for your favorite feeds to Zulip. + +This bot requires the feedparser module. + +To use this script: + +1. Create an RSS feed file containing 1 feed URL per line (default feed + file location: ~/.cache/zulip-rss/rss-feeds) +2. Subscribe to the stream that will receive RSS updates (default stream: rss) +3. create a ~/.zuliprc as described on https://zulip.com/api#api_keys +4. Test the script by running it manually, like this: + +/usr/local/share/zulip/integrations/rss/rss-bot + +You can customize the location on the feed file and recipient stream, e.g.: + +/usr/local/share/zulip/integrations/rss/rss-bot --feed-file=/path/to/my-feeds --stream=my-rss-stream + +4. Configure a crontab entry for this script. A sample crontab entry for +processing feeds stored in the default location and sending to the default +stream every 5 minutes is: + +*/5 * * * * /usr/local/share/zulip/integrations/rss/rss-bot""" + +parser = optparse.OptionParser(usage) +parser.add_option('--stream', + dest='stream', + help='The stream to which to send RSS messages.', + default="rss", + action='store') +parser.add_option('--data-dir', + dest='data_dir', + help='The directory where feed metadata is stored', + default=os.path.join(RSS_DATA_DIR), + action='store') +parser.add_option('--feed-file', + dest='feed_file', + help='The file containing a list of RSS feed URLs to follow, one URL per line', + default=os.path.join(RSS_DATA_DIR, "rss-feeds"), + action='store') +parser.add_option_group(zulip.generate_option_group(parser)) +(opts, args) = parser.parse_args() + +def mkdir_p(path): + # Python doesn't have an analog to `mkdir -p` < Python 3.2. + try: + os.makedirs(path) + except OSError, e: + if e.errno == errno.EEXIST and os.path.isdir(path): + pass + else: + raise + +try: + mkdir_p(opts.data_dir) +except OSError: + # We can't write to the logfile, so just print and give up. + print >>sys.stderr, "Unable to store RSS data at %s." % (opts.data_dir,) + exit(1) + +log_file = os.path.join(opts.data_dir, "rss-bot.log") +log_format = "%(asctime)s: %(message)s" +logging.basicConfig(format=log_format) + +formatter = logging.Formatter(log_format) +file_handler = logging.FileHandler(log_file) +file_handler.setFormatter(formatter) + +logger = logging.getLogger(__name__) +logger.setLevel(logging.DEBUG) +logger.addHandler(file_handler) + +def log_error_and_exit(error): + logger.error(error) + logger.error(usage) + exit(1) + +class MLStripper(HTMLParser): + def __init__(self): + self.reset() + self.fed = [] + + def handle_data(self, data): + self.fed.append(data) + + def get_data(self): + return ''.join(self.fed) + +def strip_tags(html): + stripper = MLStripper() + stripper.feed(html) + return stripper.get_data() + +def compute_entry_hash(entry): +# entry_time = entry.get("published", entry.get("updated")) + entry_id = entry.get("id", entry.get("link")) + return hashlib.md5(entry_id).hexdigest() + +def elide_subject(subject): + MAX_TOPIC_LENGTH = 60 + if len(subject) > MAX_TOPIC_LENGTH: + subject = subject[:MAX_TOPIC_LENGTH - 3].rstrip() + '...' + return subject + +def send_zulip(entry, feed_name): + try: + content = "**[%s](%s)**\n%s\n%s" % (entry.title, + entry.link, + strip_tags(entry.summary), + entry.link) + message = {"type": "stream", + "to": opts.stream, + "subject": elide_subject(feed_name), + "content": content, + } + return client.send_message(message) + except: + return {'result': 'failure'} +try: + with open(opts.feed_file, "r") as f: + feed_urls = [feed.strip() for feed in f.readlines()] +except IOError: + log_error_and_exit("Unable to read feed file at %s." % (opts.feed_file,)) + +# client = zulip.Client(email=opts.user, api_key=opts.api_key, +# site=opts.site, client="ZulipRSS/" + VERSION) +client = zulip.init_from_options(opts) +first_message = True + +for feed_url in feed_urls: + feed_file = os.path.join(opts.data_dir, urlparse.urlparse(feed_url).netloc) + + try: + with open(feed_file, "r") as f: + old_feed_hashes = dict((line.strip(), True) for line in f.readlines()) + except IOError: + old_feed_hashes = {} + + new_hashes = [] + data = feedparser.parse(feed_url) + + for entry in data.entries: + entry_hash = compute_entry_hash(entry) + # An entry has either been published or updated. + entry_time = entry.get("published_parsed", entry.get("updated_parsed")) + if entry_time is not None and (time.time() - calendar.timegm(entry_time)) > OLDNESS_THRESHOLD * 60 * 60 * 24: + # As a safeguard against misbehaving feeds, don't try to process + # entries older than some threshold. + continue + if entry_hash in old_feed_hashes: + # We've already seen this. No need to process any older entries. + continue + if (not old_feed_hashes) and (len(new_hashes) >= 3): + # On a first run, pick up the 3 most recent entries. An RSS feed has + # entries in reverse chronological order. + break + + feed_name = data.feed.title or feed_url + + response = send_zulip(entry, feed_name) + if response["result"] != "success": + logger.error("Error processing %s" % (feed_url,)) + logger.error(response) + if first_message: + # This is probably some fundamental problem like the stream not + # existing or something being misconfigured, so bail instead of + # getting the same error for every RSS entry. + log_error_and_exit("Failed to process first message") + # Go ahead and move on -- perhaps this entry is corrupt. + new_hashes.append(entry_hash) + first_message = False + + with open(feed_file, "a") as f: + for hash in new_hashes: + f.write(hash + "\n") + if len(new_hashes) != 0: + logger.info("Sent zulips for %d %s entries" % (len(new_hashes), feed_url)) diff --git a/other/sendfortune.sh b/other/sendfortune.sh new file mode 100755 index 0000000..3abc57a --- /dev/null +++ b/other/sendfortune.sh @@ -0,0 +1,30 @@ +#! /bin/sh +# Copyright (c) 2024, AB Tech +# All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions are met: +# +# * Redistributions of source code must retain the above copyright notice, this +# list of conditions and the following disclaimer. +# +# * Redistributions in binary form must reproduce the above copyright notice, +# this list of conditions and the following disclaimer in the documentation +# and/or other materials provided with the distribution. +# +# * Neither the name of the copyright holder nor the names of its +# contributors may be used to endorse or promote products derived from +# this software without specific prior written permission. +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +# DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE +# FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +# DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +# SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +# CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, +# OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +/usr/games/fortune -o | /usr/bin/python2.7 /home/zdaemon/zulip-0.2.1/bin/zulip-send --stream dirtychannel --subject fortune &>/dev/null diff --git a/perl-convert/ppdbconvert b/perl-convert/ppdbconvert new file mode 100644 index 0000000..f373245 --- /dev/null +++ b/perl-convert/ppdbconvert @@ -0,0 +1,139 @@ +#!/usr/bin/perl +# Convert the PP database (and Last PP database) from GDBM to sqllite +# +# Copyright (c) 2024, AB Tech +# All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions are met: +# +# * Redistributions of source code must retain the above copyright notice, this +# list of conditions and the following disclaimer. +# +# * Redistributions in binary form must reproduce the above copyright notice, +# this list of conditions and the following disclaimer in the documentation +# and/or other materials provided with the distribution. +# +# * Neither the name of the copyright holder nor the names of its +# contributors may be used to endorse or promote products derived from +# this software without specific prior written permission. +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +# DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE +# FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +# DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +# SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +# CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, +# OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +use Encode; +use GDBM_File; +use DBI; + +my $GDBM_PPDATA_DB = "data/ppdata.db"; +my $GDBM_LASTPP_DB = "data/lastpp.db"; +my $SQLITE_OUTPUT = "ppdata.sqlite"; + +my $DSN = "DBI:SQLite:dbname=$SQLITE_OUTPUT"; + +if (-e $SQLITE_OUTPUT) { + print "Target $SQLITE_OUTPUT already exists.\n"; + print "Continuing is not safe, aborting.\n"; + exit(1); +} + +# Open Databases +my %ppdata; +my %lastpp; +tie %ppdata, 'GDBM_File', $GDBM_PPDATA_DB, GDBM_READER, 0640 || die ("can't tie ppdata: $!"); +tie %lastpp, 'GDBM_File', $GDBM_LASTPP_DB, GDBM_READER, 0640 || die ("can't tie lastpp: $!"); +my $DBH = DBI->connect($DSN, "", "", { RaiseError => 1 }) || die ("can't open sqllite: $DBI::errstr"); + +print "Ready\n"; + +print "Creating Tables\n"; + +$DBH->do("CREATE TABLE ppdata (thing TEXT PRIMARY KEY NOT NULL, + score INTEGER NOT NULL);") + || die("cannot create ppdata table: $DBI::errstr"); +$DBH->do("CREATE TABLE lastpp (username TEXT NOT NULL, + thing TEXT NOT NULL, + direction INTEGER NOT NULL, + timestamp INTEGER NOT NULL, + PRIMARY KEY(username, thing, direction));") + || die("cannot create lastpp table: $DBI::errstr"); + + +print "Tables Created\n"; + +print scalar(keys %ppdata) . " pp entries.\n"; +print scalar(keys %lastpp) . " lastpp entries.\n"; +print "--\n"; + +my $PPD_INSERT = qq( INSERT INTO ppdata (thing, score) VALUES (:thing, :score); ); +foreach my $k (keys %ppdata) { + $testString = $k; + eval { decode( 'UTF-8', $testString, Encode::FB_CROAK ) }; + if ($@) { + print "DECODE ERROR ON '$k' -- skipping\n"; + next; + } + print "$k: $ppdata{$k}..."; + my $h = $DBH->prepare($PPD_INSERT); + $h->bind_param(":thing", $k); + $h->bind_param(":score", $ppdata{$k}); + $h->execute() || die ("could note execute statement: $DBI::errstr"); + $h->finish(); + print "ok\n"; +} +print "--\n"; + +my $LASTPP_INSERT = qq ( INSERT INTO lastpp + (username, thing, direction, timestamp) + VALUES (:username, :thing, :direction, :timestamp); ); +foreach my $key (keys %lastpp) { + $testString = $key; + eval { decode( 'UTF-8', $testString, Encode::FB_CROAK ) }; + if ($@) { + print "DECODE ERROR ON (lastpp) '$key' -- skipping\n"; + next; + } + + # TODO: This might actually be skippable, since its only an hour + # window anyway. + # + # TODO: Need to be sure we don't have any usernames with a dot. + # + # Keys are username.thing.direction + # Thing can have dots. Direction is only -1 or 1 + if(! ($key =~ m/^([^\.]+)\.(.+)\.(-1|1)$/)) { + die "lastpp key '$key' doesn't match pattern. Abort!"; + } + my $username = $1; + my $thing = $2; + my $direction = $3; + + print "$key: [$username, $thing, $direction]: $lastpp{$key}..."; + + my $h = $DBH->prepare($LASTPP_INSERT); + $h->bind_param(":username", $username); + $h->bind_param(":thing", $thing); + $h->bind_param(":direction", $direction); + $h->bind_param(":timestamp", $lastpp{$key}); + $h->execute() || die ("could note execute statement: $DBI::errstr"); + $h->finish(); + + print "ok\n"; +} + +print "Done\n"; + +# Close Databases Cleanly +$DBH->disconnect(); +untie %ppdata; +untie %lastpp; + + diff --git a/slack-bot.yaml.sample b/slack-bot.yaml.sample new file mode 100644 index 0000000..a830d0d --- /dev/null +++ b/slack-bot.yaml.sample @@ -0,0 +1,56 @@ +# Copyright (c) 2024, AB Tech +# All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions are met: +# +# * Redistributions of source code must retain the above copyright notice, this +# list of conditions and the following disclaimer. +# +# * Redistributions in binary form must reproduce the above copyright notice, +# this list of conditions and the following disclaimer in the documentation +# and/or other materials provided with the distribution. +# +# * Neither the name of the copyright holder nor the names of its +# contributors may be used to endorse or promote products derived from +# this software without specific prior written permission. +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +# DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE +# FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +# DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +# SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +# CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, +# OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +display_information: + name: example-zdaemon +features: + bot_user: + display_name: example-zdaemon + always_online: false +oauth_config: + scopes: + bot: + - channels:history + - channels:read + - chat:write + - groups:history + - groups:read + - reactions:write + - users.profile:read + - users:read.email + - users:read +settings: + event_subscriptions: + bot_events: + - message.channels + - message.groups + interactivity: + is_enabled: true + org_deploy_enabled: false + socket_mode_enabled: true + token_rotation_enabled: false diff --git a/triggers.yaml.sample b/triggers.yaml.sample new file mode 100644 index 0000000..88f4be8 --- /dev/null +++ b/triggers.yaml.sample @@ -0,0 +1,118 @@ +# Copyright (c) 2024, AB Tech +# All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions are met: +# +# * Redistributions of source code must retain the above copyright notice, this +# list of conditions and the following disclaimer. +# +# * Redistributions in binary form must reproduce the above copyright notice, +# this list of conditions and the following disclaimer in the documentation +# and/or other materials provided with the distribution. +# +# * Neither the name of the copyright holder nor the names of its +# contributors may be used to endorse or promote products derived from +# this software without specific prior written permission. +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +# DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE +# FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +# DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +# SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +# CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, +# OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +# Timeout for specials in seconds. Applies to Slack only. Triggers marked with +# "enforce_special_timeout: True" will not fire if any special has fired +# previously within this timeout. +trigger_timeout_s: 60 + + +triggers: + # Trigger keys: + # test: A string containing a Jinja template that will be assumed to match + # if it evaluates to "True". All other results will fail to match. + # legacy_instance: Instance to reply to (applies to Zulip only). Replies + # will always be in-thread on Slack. + # response: A dictionary containing keys denoting probability as integers + # summing to no more than 100. The trigger will pick a random int + # from 0 to 99. A key of 10 will fire 10% of the time. A key of + # "default" may be used as convenience when too lazy to calculate + # the remaining values. The "default" key is optional--you can + # set a trigger to only respond at all some percentage of the + # time. Values in this dictionary are strings which may contain + # Jinja templates. + # enforce_special_timeout: Optional (default False). If another trigger + # with "enforce_special_timeout: True" has fired + # within the last "trigger_timeout_s" seconds (see + # above), then the trigger will be ignored. Applies + # to Slack only. + # send_cubes_count: Optional (default 0). Number of cubes to send after + # sending a response. On zulip, they will be sent to + # zdaemon, cube. On slack, they will be be placed within + # the thread. + # Available Jinja variables: + # instance: Name of the instance on Zulip or simply "slack" + # channel: Channel ID on Slack or None on Zulip + # sender: Message sender's username (should be used for matching and + # plusplus) + # display_sender: Username as an @ lookup on Slack or normal on Zulip (this + # should be used in responses) + # message: Message content + # Available non-standard Jinja filters: + # value | regex_match(find="", ignorecase=False) + # value | regex_search(find="", ignorecase=False) + + - test: >- + {{ sender | regex_match('(some_user|another_user)', ignorecase=True) and + message | regex_search('said something', ignorecase=True) }} + legacy_instance: said.something + response: + default: THEY SAID SOMETHING! + - test: >- + {{ message | regex_search('something special', ignorecase=True) }} + enforce_special_timeout: True + legacy_instance: something.special + response: + default: | + This is a special response of some sort. + It won't happen again until trigger_timeout_s runs out. + - test: >- + {{ message | regex_search('something really special', ignorecase=True) }} + enforce_special_timeout: True + legacy_instance: something.special + response: + 10: | + This really special response happens 10% of the time, otherwise there is no response + - test: >- + {{ message | regex_search('chance', ignorecase=True) }} + enforce_special_timeout: True + legacy_instance: chance + response: + 30: | + This happens 30% of the time + 20: | + This happens 20% of the time + default: | + This happens the remaining 50% of the time + - test: >- + {{ message | regex_search('ping', ignorecase=True) }} + enforce_special_timeout: True + legacy_instance: "{{ instance }}" + response: + default: | + Pong {{ display_sender }}! + - test: >- + {{ instance == 'slack' and + message | regex_search('I want cubes') }} + enforce_special_timeout: True + legacy_instance: "{{ instance }}" + response: + default: | + This special sends two cubes but only on Slack + ...or on a Zulip instance called "slack" + send_cubes_count: 2 diff --git a/zdaemon.json.sample b/zdaemon.json.sample new file mode 100644 index 0000000..aa6e925 --- /dev/null +++ b/zdaemon.json.sample @@ -0,0 +1,12 @@ +{ + "ZDAEMON_ROOT": "/home/zdaemon", + "ABTECH_CLASS": "abtech", + "ZDAEMON_CLASS": "zdaemon", + "GHOSTS_CLASS": "ghosts", + "MY_ID": "cube-bot@andrew.cmu.edu", + "MAINTAINER": "zdaemon@abtech.org", + "SLACK_APP_TOKEN": "", + "SLACK_BOT_TOKEN": "", + "SLACK_CUBE_CHANNEL_ID": "" + "SLACK_CHANNEL_WHITELIST": [] +} diff --git a/zdaemon/__init__.py b/zdaemon/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/zdaemon/common.py b/zdaemon/common.py new file mode 100644 index 0000000..5271d17 --- /dev/null +++ b/zdaemon/common.py @@ -0,0 +1,419 @@ +# Copyright (c) 2024, AB Tech +# All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions are met: +# +# * Redistributions of source code must retain the above copyright notice, this +# list of conditions and the following disclaimer. +# +# * Redistributions in binary form must reproduce the above copyright notice, +# this list of conditions and the following disclaimer in the documentation +# and/or other materials provided with the distribution. +# +# * Neither the name of the copyright holder nor the names of its +# contributors may be used to endorse or promote products derived from +# this software without specific prior written permission. +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +# DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE +# FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +# DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +# SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +# CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, +# OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +from cachetools import cached, TTLCache +import logging +import re +import time +import unicodedata as ud + +from slack_sdk.errors import SlackApiError + +import config as cfg + +# time of the call to init_common_config in seconds since epoch. +ZDAEMON_START_TIME = 0 + +# For Sending Messages +_ZULIP_CLIENT = None +_SLACK_CLIENT = None +_log = logging.getLogger("zdaemon-common") + + +def init_common_config(zulip_client, slack_client): + global _ZULIP_CLIENT, _SLACK_CLIENT + _ZULIP_CLIENT = zulip_client + _SLACK_CLIENT = slack_client + + global ZDAEMON_START_TIME + ZDAEMON_START_TIME = int(time.time()) + + +def runzulip(handler): + '''Runs the zulip listner loop. Not expected to return.''' + if _ZULIP_CLIENT is None: + raise Exception("_ZULIP_CLIENT not configure in runzulip") + + _ZULIP_CLIENT.call_on_each_message(handler) + + +def sendpersonalz(who, msg): + if _ZULIP_CLIENT is None: + raise Exception("sendpersonalz: _ZULIP_CLIENT not configured") + + request = { + "type": "private", + "to": [who], + "content": msg + } + response = _ZULIP_CLIENT.send_message(request) + if (response["result"] == "success"): + return True + else: + _log.error("sendpersonalz zulip error: " + response["msg"]) + return False + + +def sendz(zclass, instance, message, unfurl=False): + ''' Send a zulip message to the given class and instance. + + The unfurl parameter is ignored.''' + if _ZULIP_CLIENT is None: + raise Exception("sendz: _ZULIP_CLIENT not configured") + + # Send a stream message + request = { + "type": "stream", + "to": zclass, + "topic": instance, + "content": message + } + response = _ZULIP_CLIENT.send_message(request) + if (response["result"] == "success"): + return True + else: + _log.error("sendz zulip error: " + response["msg"]) + return False + + +def _sendsErrorCheck(res, channel_id, thread_ts, message): + if res['ok'] != True: + error_thread = '[NONE]' + if thread_ts is not None: + error_thread = thread_ts + raise Exception('postMessage failure without Slack Api Exception: %s [%s,%s,%s]' % \ + (res['error'], channel_id, message, error_thread)) + + if 'message' not in res: + raise Exception('post message response was ok but did not include message? [%s, %s]' % \ + channel_id, message) + + +def sendsText(channel_id, message, thread_ts=None, unfurl=True): + '''Send a raw text (not block) message to the specified slack + channel id and (if supplied) thread. + + Remember, userids are used as channels for DMs, so there is no sendpersonalsText + + Returns the slack-canonicalized message object on success. + Returns None on failure. + Certain unusual failures will raise an exception. + ''' + if _SLACK_CLIENT is None: + raise Exception("_SLACK_CLIENT not configured") + + try: + res = _SLACK_CLIENT.chat_postMessage( + channel=channel_id, + thread_ts=thread_ts, + unfurl_links=unfurl, + unfurl_media=unfurl, + text=message) + + _sendsErrorCheck(res, channel_id, thread_ts, message) + + return res['message'] + except SlackApiError as e: + _log.error("sendsText Slack Error: " + e.response["error"]) + return None + + +def sendsBlock(channel_id, message_blocks, fallback=None, thread_ts=None, unfurl=True): + '''Sends a block message to the specific slack channel id and (if supplied) thread + + Remember, userids are used as channels for DMs, so there is no sendpersonalsBlock + + fallback is optional slack fallback text (suppresses a warning) + ''' + if _SLACK_CLIENT is None: + raise Exception("_SLACK_CLIENT not configured") + + try: + res = _SLACK_CLIENT.chat_postMessage( + channel=channel_id, + thread_ts=thread_ts, + text=fallback, + unfurl_links=unfurl, + unfurl_media=unfurl, + blocks=message_blocks) + + _sendsErrorCheck(res, channel_id, thread_ts, message_blocks) + + return res + except SlackApiError as e: + _log.error("sendsBlock Slack Error: " + e.response["error"]) + return None + + +def slackReact(message, emojiname): + '''Applies the given reaction to the specified message.''' + if _SLACK_CLIENT is None: + raise Exception("_SLACK_CLIENT not configured") + + try: + _SLACK_CLIENT.reactions_add(channel=message["channel"], + name=emojiname, + timestamp=message["ts"]) + except SlackApiError as e: + _log.error("sendsBlock Slack Error: " + e.response["error"]) + + +# Cache bot userid lookups for a day (if we really need to change bot ids, just kick zdaemon) +@cached(cache=TTLCache(maxsize=64, ttl=86400)) +def get_slack_bot_userid(botid): + if _SLACK_CLIENT is None: + raise Exception("_SLACK_CLIENT not configured") + + res = _SLACK_CLIENT.bots_info(bot=botid) + if not res['ok']: + raise Exception("OK:False when fetching bot %s (result: %s)" % (botid, res)) + + return res['bot']['user_id'] + + +# Cache user lookups for an hour. +@cached(cache=TTLCache(maxsize=512, ttl=3600)) +def get_slack_user_profile(userid): + if _SLACK_CLIENT is None: + raise Exception("_SLACK_CLIENT not configured") + + res = _SLACK_CLIENT.users_profile_get(user=userid) + if not res['ok']: + raise Exception("OK:False when fetching profile %s (result: %s)" % (userid, res)) + return res['profile'] + + +def get_slack_user_email(userid, lhs_only=True): + '''Returns the user's slack profile's email. By default only returns the left hand side. + + Hard-codes the slack bridge bot to be "bridge-bot@ABTECH.ORG" + ''' + if userid == cfg.SLACK_BRIDGE_BOT_ID: + return "bridge-bot@ABTECH.ORG" + + profile_result = get_slack_user_profile(userid) + + if 'email' not in profile_result: + raise Exception("no email in profile for %s, is it a bot? do we have users:read.email scope?" % userid) + + email = profile_result['email'] + if lhs_only: + email_split = email.split('@') + return realID(email_split[0]) + else: + return email + + +@cached(cache=TTLCache(maxsize=128, ttl=3600)) +def get_slack_user_by_email(email): + '''Returns the user object for the supplied email. Returns None if not found, but raises an exception on other errors.''' + if _SLACK_CLIENT is None: + raise Exception("_SLACK_CLIENT not configured") + + user_result = _SLACK_CLIENT.users_lookupByEmail(email=email) + if user_result['ok'] == 'false' and user_result['error'] == 'users_not_found': + return None + elif user_result['ok'] == 'false': + raise Exception("user lookup failed in get_slack_user_by_email: %s" % user_result['error']) + else: + return user_result['user'] + + +# TODO: If we need channel data more frequently, make channel caching a class so we +# can also precompute the forward and reverse maps. A cache flush in this case +# would also need to be triggered by a miss in what is currently get_slack_channel_data. +# +# We cache this for a day since we aren't currently sensitive to changes in it after +# startup when we load the config. +# +# Dear Slack: Your pagination system is ridiculous. I should not +# need to cursor at all with a limit of 1000 to see 25 total visible channels. +@cached(cache=TTLCache(maxsize=1, ttl=86400)) +def get_slack_channel_list(): + '''Returns full data of all channels we can see''' + if _SLACK_CLIENT is None: + raise Exception("_SLACK_CLIENT not configured") + + res = _SLACK_CLIENT.conversations_list( + limit=1000, + exclude_archived=True, + types="public_channel,private_channel") + if res['ok'] == 'false': + raise Exception("get_slack_channel_list api call error: %s" % res['error']) + channel_result = res['channels'] + + while ('response_metadata' in res and + 'next_cursor' in res['response_metadata'] and + res['response_metadata']['next_cursor'] != ""): + cursor = res['response_metadata']['next_cursor'] + + res = _SLACK_CLIENT.conversations_list( + limit=1000, + cursor=cursor, + exclude_archived=True, + types="public_channel,private_channel") + if res['ok'] == 'false': + raise Exception("get_slack_channel_list api cursor call error: %s" % res['error']) + + channel_result = channel_result + res['channels'] + + return channel_result + + +def _validate_channel_data(c): + '''Validate that a channel is usable in our maps.''' + if 'name' not in c: + # Skip any channel without a name, since we won't be able to map it anyway. + return False + if 'id' not in c: + raise Exception("channel '%s' without an id in get_slack_channel_map ?" % c['name']) + + return True + + +def get_slack_channel_data(id): + '''Return the channel object for the given channel id''' + channels = get_slack_channel_list() + for c in channels: + if c['id'] == id: + return c + + return None + + +def get_slack_channel_nametoid_map(): + '''Returns a map of channel name (without hash) -> channel id''' + channels = get_slack_channel_list() + res = {} + for c in channels: + if not _validate_channel_data(c): + continue + + res[c['name']] = c['id'] + + return res + + +def get_slack_channel_idtoname_map(): + '''Returns a map of channel id -> name (without hash)''' + channels = get_slack_channel_list() + res = {} + for c in channels: + if not _validate_channel_data(c): + continue + + res[c['id']] = c['name'] + + return res + + +def get_slack_message_permalink(channel, ts): + '''Returns the permalink for the given message on the given channel.''' + if _SLACK_CLIENT is None: + raise Exception("_SLACK_CLIENT not configured") + + res = _SLACK_CLIENT.chat_getPermalink(channel=channel, message_ts=ts) + + if res['ok'] == 'false': + raise Exception("get_slack_message_permalink api call error: %s" % res['error']) + if 'permalink' not in res: + raise Exception("get_slack_message_permalink: no permalink in ok result? %s" % res) + + return res['permalink'] + + +def get_slack_thread(event): + '''Return the parent thread of a message event. No API calls involved.''' + if (event['type'] != 'message'): + raise Exception("get_slack_thread: not a message event (%s)" % event) + + if 'thread_ts' in event: + return event['thread_ts'] + else: + return event['ts'] + + +def realID(id): + """Converts the passed identifier into a more canonical form. + Mostly deals with the same user across many realms. + """ + name = id.rstrip() + m = re.match(r'(\w+)@ABTECH.ORG', name) + if m: + name = m.group(1) + else: + m = re.match(r'(\w+)@ANDREW.CMU.EDU', name) + if m: + name = m.group(1) + + # If we don't have a realm at this point, + # canonicalize to lowercase. + if not re.match(r'(\w+)@(\w+)', name): + name = name.lower() + + return name + + +def sendToMaintainer(message): + ''' Sends a personal message to the maintainer(s) ''' + # TODO: Multiple Maintainers. + + if _ZULIP_CLIENT is None and _SLACK_CLIENT is None: + raise Exception("no configured clients in sendToMaintainer?") + + if _ZULIP_CLIENT is not None: + sendpersonalz(cfg.MAINTAINER, message) + + if _SLACK_CLIENT is not None: + # TODO: There's an argument to be made that on slack there should just be a dedicated group + # channel to log these errors to so multiple people can see it rather than getting DMs, but + # just match zulip behavior for now. + # + # Never unfurl maintainer messages since we don't want to see random images from slack + # message data. + sendsText(cfg.SLACK_MAINTAINER, message, unfurl=False) + + +def is_maintainer(email): + ''' Returns true if the provided fully qualified email address is + a maintainer + ''' + # TODO: Multiple Maintainers. + return email == cfg.MAINTAINER + + +def hasRTLCharacters(thing): + ''' Returns True if there are RTL characters inside of the thing. ''' + for c in list(thing): + # see https://stackoverflow.com/a/75739782/3399890 + # Basically if we see any strong RTL character or the RTL control characters + # assume we need to isolate it. + if ud.bidirectional(c) in ['R', 'AL', 'RLE', 'RLI']: + return True + + return False diff --git a/zdaemon/config.py b/zdaemon/config.py new file mode 100644 index 0000000..84aa540 --- /dev/null +++ b/zdaemon/config.py @@ -0,0 +1,268 @@ +# Copyright (c) 2024, AB Tech +# All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions are met: +# +# * Redistributions of source code must retain the above copyright notice, this +# list of conditions and the following disclaimer. +# +# * Redistributions in binary form must reproduce the above copyright notice, +# this list of conditions and the following disclaimer in the documentation +# and/or other materials provided with the distribution. +# +# * Neither the name of the copyright holder nor the names of its +# contributors may be used to endorse or promote products derived from +# this software without specific prior written permission. +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +# DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE +# FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +# DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +# SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +# CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, +# OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +# System +import json +import os + +# Zdaemon +import common +import cube +import plusplus + +# Slack/Zulip +from slack_sdk import WebClient +import zulip + +# Globally Interesting Variables +ZDAEMON_ROOT = "/home/zdaemon" +ZDAEMON_DATA_DIR = ZDAEMON_ROOT + "/data" +ABTECH_CLASS = "abtech" +ZDAEMON_CLASS = "zdaemon" +GHOSTS_CLASS = "ghosts" +MY_ID = "cube-bot@andrew.cmu.edu" +MAINTAINER = "zdaemon@abtech.org" +SLACK_MAINTAINER = None # userid of the Slack user with the MAINTAINER address as of startup. + +SLACK_ENABLE = False +SLACK_APP_TOKEN = "" +SLACK_BOT_TOKEN = "" +SLACK_CUBE_CHANNEL_ID = "" # Forced to uppercase +SLACK_BRIDGE_BOT_ID = "" + +SENDCUBE_ENABLE = True + +# Map of our allowed channels from their ID to their name without a hash. +# +# computed at startup and _not_ recomputed over time, renaming a channel will +# not affect this until zdaemon restarts. +SLACK_CHANNEL_WHITELIST_MAP = {} + +_CONFIG_FILE = None + +def add_zdaemon_arguments(parser): + zulip.add_default_arguments(parser) + + group = parser.add_argument_group("zdaemon runtime configuration") + group.add_argument("--zconfig-file", dest="zconfig_file", + help="Zdaemon JSON config file (default: zdaemon.json)", + default='zdaemon.json') + return parser + + +def init_zdaemon_config(options, load_channels=True, config_file_only=False): + ''' Call this to set up most of the zdaemon config, both from command + line options (provided in the parameter) and from the JSON file. + + A slack or zulip client will be created and passed to common.py. + + load_channels controls if, on slack, we initialize the channel + whitelist which can be expensive if there are a large number + of channels on the server (and isn't needed for, say, sending a cube) + + config_file_only controls if we are only reading the config file or if we + should also initialize a web client and potentially make additional network calls. + + if config_file_only is true, load_channels must be false. + ''' + global ZDAEMON_ROOT + global ABTECH_CLASS + global ZDAEMON_CLASS, GHOSTS_CLASS + global MY_ID, MAINTAINER + global ZDAEMON_DATA_DIR + + global SLACK_ENABLE, SLACK_APP_TOKEN, SLACK_BOT_TOKEN + global SLACK_CUBE_CHANNEL_ID + global SLACK_BRIDGE_BOT_ID + + global SENDCUBE_ENABLE + + global _CONFIG_FILE + _CONFIG_FILE = options.zconfig_file + + slack_client = None + slack_channel_whitelist = [] + + if not os.path.isfile(_CONFIG_FILE): + print("Cannot find config file %s, using default values." % _CONFIG_FILE) + else: + file_data = {} + with open(_CONFIG_FILE, "r") as f: + file_data = json.load(f) + + have_slack_app_token = False + have_slack_bot_token = False + + if "ZDAEMON_ROOT" in file_data: + ZDAEMON_ROOT = file_data["ZDAEMON_ROOT"] + if "ABTECH_CLASS" in file_data: + ABTECH_CLASS = file_data["ABTECH_CLASS"] + if "ZDAEMON_CLASS" in file_data: + ZDAEMON_CLASS = file_data["ZDAEMON_CLASS"] + if "GHOSTS_CLASS" in file_data: + GHOSTS_CLASS = file_data["GHOSTS_CLASS"] + if "MY_ID" in file_data: + MY_ID = file_data["MY_ID"] + if "MAINTAINER" in file_data: + MAINTAINER = file_data["MAINTAINER"] + + if "SLACK_APP_TOKEN" in file_data and file_data["SLACK_APP_TOKEN"] != "": + have_slack_app_token = True + SLACK_APP_TOKEN = file_data["SLACK_APP_TOKEN"] + if "SLACK_BOT_TOKEN" in file_data and file_data["SLACK_BOT_TOKEN"] != "": + have_slack_bot_token = True + SLACK_BOT_TOKEN = file_data["SLACK_BOT_TOKEN"] + if "SLACK_CUBE_CHANNEL_ID" in file_data: + SLACK_CUBE_CHANNEL_ID = file_data["SLACK_CUBE_CHANNEL_ID"].upper() + if "SLACK_CHANNEL_WHITELIST" in file_data: + wl = file_data["SLACK_CHANNEL_WHITELIST"] + if not isinstance(wl, list): + raise Exception("SLACK_CHANNEL_WHITELIST is present but doesn't appear to be a list") + slack_channel_whitelist = wl + if "SLACK_BRIDGE_BOT_ID" in file_data: + SLACK_BRIDGE_BOT_ID = file_data["SLACK_BRIDGE_BOT_ID"] + + + if "SENDCUBE_ENABLE" in file_data: + sce = file_data["SENDCUBE_ENABLE"] + if not isinstance(sce, bool): + raise Exception("SENDCUBE_ENABLE is present but isn't a bool") + SENDCUBE_ENABLE = sce + + # Computed config variables. + ZDAEMON_DATA_DIR = ZDAEMON_ROOT + "/data" + + # Give the data dir to the modules so they + # can precompute their file names. + cube.init_cube_config(ZDAEMON_DATA_DIR) + plusplus.init_pp_config(ZDAEMON_DATA_DIR) + + if config_file_only: + if load_channels: + raise Exception("load_channels True when config_file_only True is not valid!") + return + + if have_slack_app_token != have_slack_bot_token: + # We have one and not the other, misconfigured! + raise Exception("Only one of Slack APP token and BOT TOKEN provided") + elif have_slack_app_token and have_slack_bot_token: + SLACK_ENABLE = True + + # Create Web Client + slack_client = zulip_client = None + if SLACK_ENABLE: + slack_client = WebClient(token=SLACK_BOT_TOKEN) + else: + zulip_client = zulip.init_from_options(options) + + common.init_common_config(zulip_client, slack_client) + + # Must be done after init_common_config, as it needs the web client. + if SLACK_ENABLE: + _init_slack_computed_config(slack_channel_whitelist, load_channels) + + +def _init_slack_computed_config(channel_whitelist, load_channels): + '''Slack config items that need to be computed once we are talking to the API.''' + global SLACK_CHANNEL_WHITELIST_MAP + global SLACK_MAINTAINER + + maintainer_obj = common.get_slack_user_by_email(MAINTAINER) + if maintainer_obj is None: + raise Exception("slack maintainer '%s' wasn't found via lookup" % MAINTAINER) + elif 'id' not in maintainer_obj: + raise Exception("maintainer object missing user id for '%s' [%s]??" % (MAINTAINER, maintainer_obj)) + else: + SLACK_MAINTAINER = maintainer_obj['id'] + + ######################################################### + ## Below here only work on loading the channel whitelist! + if not load_channels: + return + + if len(channel_whitelist) < 1: + raise Exception("Empty SLACK_CHANNEL_WHITELIST when slack is enabled.") + if SLACK_CUBE_CHANNEL_ID == "": + raise Exception("Empty SLACK_CUBE_CHANNEL_ID when slack is enabled") + + channel_name_map = common.get_slack_channel_nametoid_map() + channel_id_map = common.get_slack_channel_idtoname_map() + + # The cube channel must always be on the whitelist, so we stuff its id onto + # the end just in case. It will be uniquified in the loop regardless. + channel_whitelist.append(SLACK_CUBE_CHANNEL_ID) + + for c in channel_whitelist: + id = "" + name = "" + if c[0] == '#': + # If the first character is a hash, remove the hash and treat it as a name. + name = c.replace(c[0], "", 1) + if name not in channel_name_map: + raise Exception("Channel '%s' not found in available slack channels [%s]" % (c, channel_name_map)) + id = channel_name_map[name] + elif c[0] == 'C' or c[0] == 'G': + # Channel ID + id = c + if c not in channel_id_map: + raise Exception("Channel '%s' not found in available slack channels [%s]" % (c, channel_id_map)) + name = channel_id_map[c] + else: + raise Exception("I don't know what to do with channel '%s' in _init_slack_computed_config.\n" \ + "Channels must start with a # or be a raw Channel ID." % c) + + SLACK_CHANNEL_WHITELIST_MAP[id] = name + + + +def print_config(): + print("Using zdaemon config file: %s" % _CONFIG_FILE) + print("Using Config:\n----") + print("MAINTAINER: '%s'" % MAINTAINER) + + if not SLACK_ENABLE: + print("\n*** ZULIP CONFIG ***") + print("ZDAEMON_ROOT: '%s'" % ZDAEMON_ROOT) + print("ABTECH_CLASS: '%s'" % ABTECH_CLASS) + print("ZDAEMON_CLASS: '%s'" % ZDAEMON_CLASS) + print("GHOSTS_CLASS: '%s'" % GHOSTS_CLASS) + print("MY_ID: '%s'" % MY_ID) + else: + print("\n*** Zulip DISABLED due to presense of SLACK_BOT_TOKEN and SLACK_APP_TOKEN") + + if SLACK_ENABLE: + print("\n*** SLACK CONFIG ***") + print("SLACK_APP_TOKEN: '%s'" % SLACK_APP_TOKEN) + print("SLACK_BOT_TOKEN: '%s'" % SLACK_BOT_TOKEN) + print("SLACK_CUBE_CHANNEL_ID: '%s'" % SLACK_CUBE_CHANNEL_ID) + print("SLACK_MAINTAINER: '%s'" % SLACK_MAINTAINER) + print("SLACK CHANNEL WHITELIST: '%s'" % SLACK_CHANNEL_WHITELIST_MAP) + + print("\nAll Visible Channels: %s" % common.get_slack_channel_nametoid_map()) + else: + print("\n*** Slack Not Configured ***\n") diff --git a/zdaemon/cube.py b/zdaemon/cube.py new file mode 100644 index 0000000..1f0391e --- /dev/null +++ b/zdaemon/cube.py @@ -0,0 +1,1056 @@ +# Cube handling library. +# +# Copyright (c) 2024, AB Tech +# All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions are met: +# +# * Redistributions of source code must retain the above copyright notice, this +# list of conditions and the following disclaimer. +# +# * Redistributions in binary form must reproduce the above copyright notice, +# this list of conditions and the following disclaimer in the documentation +# and/or other materials provided with the distribution. +# +# * Neither the name of the copyright holder nor the names of its +# contributors may be used to endorse or promote products derived from +# this software without specific prior written permission. +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +# DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE +# FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +# DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +# SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +# CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, +# OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +import functools +import json +import os +import re +import sqlite3 +import subprocess +import time + +from random import randrange + +import config as cfg +from common import is_maintainer, realID, sendz +from common import sendsBlock, sendsText +from common import get_slack_thread, get_slack_user_email +from common import get_slack_message_permalink, get_slack_channel_data + +# sqlite Schema +# +# CREATE TABLE CUBES (ID INT PRIMARY KEY NOT NULL, +# SUCKS INT NOT NULL, +# SLURP_DATE DATE NOT NULL, +# SLURP_BY CHAR(100) NOT NULL); +# CREATE TABLE LASTSUCKS (username TEXT NOT NULL, +# cube INT NOT NULL, +# direction INT NOT NULL, +# timestamp INT NOT NULL, +# PRIMARY KEY(username, cube, direction)); + +# These must be initialized by calling init_cube_config +# before this module will work properly. +CUBEDIR = None +_CUBE_LOG_FILE = None +_LAST_CUBE_JSON_FILE = None # Metadata about most recent cube. +_CUBE_SQLITE_FILE = None + +def init_cube_config(zdaemon_data_dir): + global CUBEDIR + global _CUBE_LOG_FILE + global _CUBE_SQLITE_FILE + global _LAST_CUBE_JSON_FILE + + CUBEDIR = zdaemon_data_dir + "/cubes" + + _CUBE_LOG_FILE = zdaemon_data_dir + "/cube.log" + _CUBE_SQLITE_FILE = zdaemon_data_dir + "/cube.sqlite" + _LAST_CUBE_JSON_FILE = zdaemon_data_dir + "/cube.last.json" + + +def _getCountWithCursor(cur): + '''Returns the number of cubes, using the provided cursor. + + Useful for transactions. + ''' + res = cur.execute("SELECT MAX(id) AS count FROM cubes;").fetchall() + if (len(res) != 1): + raise Exception("Not exactly one row in cube _getCountWithCursor?") + + return int(res[0]['count']) + + +def getCount(): + ''' Returns the number of cubes (also, the number for the highest numbered cube)''' + dbh = _getDBHandle() + try: + res = _getCountWithCursor(dbh.cursor()) + finally: + dbh.close() + return res + + +def getLastCubeMetadata(): + '''Returns stored metadata about the last sent cube + (what data is available depends on what backend is in use) + ''' + if _LAST_CUBE_JSON_FILE is None: + raise Exception("need to call init_cube_config") + + try: + with open(_LAST_CUBE_JSON_FILE, "r") as f: + return json.load(f) + except FileNotFoundError: + # If we don't have a _LAST_CUBE_JSON_FILE, then we make something + # up and hopefully a cube will eventually be sent to recreate the file. + # + # Obviously this will cause some trouble if we don't even have + # 1 cube yet, but this is not the only thing that will cause + # trouble in that case. + return { 'cube_num': 1, 'scorable': False } + + +def getCubeContent(cube_num): + ''' Returns the cube content for the provided cube number + Will through file-related exceptions if the file doesn't exist''' + if CUBEDIR is None: + raise Exception("need to call init_cube_config") + + with open(("%s/cube.%d" % (CUBEDIR, cube_num)), "r", errors='replace') as f: + return f.read() + + +def _getDBHandle(): + ''' Get a DB handle, just the way we like it. ''' + if _CUBE_SQLITE_FILE is None: + raise Exception("need to call init_cube_config") + + dbh = sqlite3.connect(_CUBE_SQLITE_FILE) + dbh.row_factory = sqlite3.Row + + return dbh + + +class SendableCube: + '''Object for a cube that can be sent. + + Construct with a number to select a specific cube or -1 for random + This will enforce sucks score rate limiting on random pulls. + + Members: + cube_num: ID of cube + score: Sucks/Rocks score + slurp_date: Slurp date as seconds since epoch + slurp_date_string: Slurp date as a human readable string + cube_text: Text of the cube + _tracking_done: Tracks if we have logged the cube and + updated _LAST_CUBE_JSON_FILE or not. + ''' + def __init__(self, cube_num = -1, scorable=False): + '''Loads the specified (or a random) cube into class members. + + If scorable is false, we will mark nosucks at the time the cube is sent. + ''' + dbh = _getDBHandle() + cubelist = [] + + try: + res = dbh.cursor() + if cube_num == -1: + # Random cube. + # Make sure we filter by a sucks score (0->11) + suck_limit = randrange(12) + stmt = """SELECT id, sucks, slurp_date, + datetime(slurp_date, 'unixepoch') as SLURP_DATE_STRING + FROM cubes + WHERE sucks <= :suck_limit + ORDER BY RANDOM() + LIMIT 1;""" + res.execute(stmt, {"suck_limit": suck_limit}) + else: + stmt = """SELECT id, sucks, slurp_date, + datetime(slurp_date, 'unixepoch') as SLURP_DATE_STRING + FROM cubes WHERE id=:id;""" + res = dbh.execute(stmt, {"id": cube_num}) + + cubelist = res.fetchall() + finally: + dbh.close() + + if (len(cubelist) != 1): + raise Exception("SendableCube: %d results for lookup of %d" % (len(cubelist), cube_num)) + row = cubelist[0] + + self.cube_num = row['id'] # needed for random cubes + self.score = row['sucks'] + self.slurp_date = row['slurp_date'] + self.slurp_date_string = row['slurp_date_string'] + self.cube_text = getCubeContent(self.cube_num) + self.scorable = scorable + + self._tracking_done = False + + + def _trackCube(self, metadata={}): + if (self._tracking_done): + # If we get called twice, that's an error. + raise Exception("_trackCube called twice for same cube: %d" % self.cube_num) + else: + # Update cube log + # TODO: Why do we bother to store the slurp date here? + with open(_CUBE_LOG_FILE, "a") as f: + f.write("%d:%d\n" % (self.cube_num, self.slurp_date)) + + # Update metadata json + metadata['cube_num'] = self.cube_num + metadata['scorable'] = self.scorable + with open(_LAST_CUBE_JSON_FILE, "w") as f: + f.write(json.dumps(metadata)) + + self._tracking_done = True + + + def sendZulip(self): + '''Sends this cube to zulip and tracks it.''' + # Prepare Message to Send + msg = self.cube_text + msg += "\n\n" + msg += "| |%s %d(%d)|\n" % (self.slurp_date_string, self.cube_num, self.score) + msg += "| --- | ---:|" # needed because zulip tables need 2 rows + + # Send Cube + sendz(cfg.ZDAEMON_CLASS, 'cube', msg) + + # Track it! + self._trackCube() + + + def _getSlackBlocks(self): + '''Returns the blocks necessary for a slack message.''' + return [ + { + "type": "section", + "text": { + "type": "mrkdwn", + "text": self.cube_text + }, + }, + { + "type": "context", + "elements": [ + { + "type": "mrkdwn", + "text": "_ %d(%d)_" % \ + (self.slurp_date, self.slurp_date_string, self.cube_num, self.score) + } + ] + } + ] + + + def sendSlack(self, channel=None, thread_ts=None): + '''Sends this cube to the cubes channel & tracks it.''' + if _LAST_CUBE_JSON_FILE is None: + raise Exception("need to call init_cube_config") + + if channel is None: + channel = cfg.SLACK_CUBE_CHANNEL_ID + + res = sendsBlock(channel, self._getSlackBlocks(), + fallback=self.cube_text, + thread_ts=thread_ts) + + metadata = {} + if res is not None: + metadata['ts'] = res['ts'] # the message + metadata['thread_ts'] = get_slack_thread(res['message']) # the message or its parent + metadata['channel'] = res['channel'] + metadata['permalink'] = get_slack_message_permalink(res['channel'], res['ts']) + else: + raise Exception("sendsBlock no message in SendableCube.sendSlack?") + + if not self.scorable: + last_cube = getLastCubeMetadata() + if last_cube['scorable']: + # If we are not scorable, but the last cube was, we need to announce + # that voting has closed early, and point at the new cube. + if('channel' in last_cube and 'ts' in last_cube): + msg = "Voting closed early due to gimme'd cube. See <%s|here>." \ + % metadata['permalink'] + sendsText(last_cube['channel'], msg, + thread_ts=last_cube['thread_ts']) + else: + # TODO log an error somewhere, but don't raise an exception, + # this is noncritical. + pass + + self._trackCube(metadata) + + +def sendCube(cube_to_send = -1): + ''' Sends A Cube to Zulip. Deprecated. + + Pass a number to select or -1 for random + Will enforce sucks score rate on random pulls. + ''' + SendableCube(cube_to_send).sendZulip() + + +def cubeService(sendReply): + ''' Return the paths of particular files. + Not really sure why this is a public interface. + ''' + if _CUBE_SQLITE_FILE is None: + raise Exception("need to call init_cube_config") + + msg = "Cube count: [deprecated]\n" \ + "Cube db (gdbm): [deprecated]\n" \ + "Cube sqlite: %s\n" \ + "Cube log: %s\n" \ + "Last cube: [deprecated]\n" \ + "Last cube JSON: %s\n" \ + "Nosucks file: [deprecated]\n" \ + "Lockfile: [deprecated]\n" \ + % (_CUBE_SQLITE_FILE, + _CUBE_LOG_FILE, + _LAST_CUBE_JSON_FILE) + + sendReply(msg) + + +def _cubeSucks(op, last_cube, sender, reply): + '''Performs a sucks or rocks operation. + op: 1 or -1 (-1 is "rocks"). Other values will raise an exception. + + Note: Will respond on cube.sucks and cube.rocks respectively. + + reply(message) is a function that will respond to the correct place. + ''' + if _CUBE_SQLITE_FILE is None: + raise Exception("need to call init_cube_config") + + op_text_name = "unknown" + if op == -1: + op_text_name = "rocks" + elif op == 1: + op_text_name = "sucks" + else: + raise Exception("bad cubeSucks op: %s" % op) + + nowtime = int(time.time()) + + ## NOTE: + # Using SQLITE for the lastsucks tracking is _NEW_ + # in the python zdaemon, previously this used a + # flat file and shelling out to grep. + # + # The new implementation also doesn't bother keeping + # obsolete events around or recording events to + # a sucks.debug log -- we only track the most + # recent event for any (user, direction, cube) triplet. + + dbh = _getDBHandle() + stmt = """SELECT timestamp FROM lastsucks + WHERE username=:sender AND + direction=:op AND + cube=:cube;""" + rows = dbh.execute(stmt, {"sender": sender, + "op": op, + "cube": last_cube}).fetchall() + + if (len(rows) > 1): + dbh.close() + raise Exception("cubeSucks: %d rows for (%s, %d, %s)" % sender, op, last_cube) + elif (len(rows) == 1): + # Was it more than an hour ago? + lastsucks = rows[0]['timestamp'] + + # XXX Election edition (skip this check) + if (nowtime - lastsucks < 3600): + dbh.close() + reply("Bad mojo %s, not updating the sucks db. Please wait 1 hour." % sender) + return + + # OK, We're good to proceed. Execute the operation. + # Use a transaction to get both tables in one go. + new_score = 0 + cur = dbh.cursor() + cur.execute("BEGIN") + try: + cur.execute("UPDATE cubes SET sucks=sucks + :op WHERE ID = :id;", + {"op": op, "id": last_cube}) + cur.execute("""INSERT OR REPLACE + INTO lastsucks(username, + direction, + cube, + timestamp) + VALUES (:sender, :op, :cube, :timestamp);""", + {"sender": sender, + "op": op, + "cube": last_cube, + "timestamp": nowtime}) + + # Update done, get the new score. + cur.execute("SELECT sucks FROM cubes WHERE ID=:id", + {"id": last_cube}) + rows = cur.fetchall() + if (len(rows) != 1): + raise Exception( + "cubeSucks error getting new score for cube %d" % last_cube) + else: + new_score = rows[0]['sucks'] + + cur.execute("COMMIT") + except Exception as e: + cur.execute("ROLLBACK") + raise e + finally: + dbh.close() + + msg = "Okay, I recorded %s's %s vote. Cube #%d currently has %d sucks votes.\n\n" \ + "(The higher its score the less likely it is to be chosen, >11 = ignore)" \ + % (sender, op_text_name, last_cube, new_score) + + reply(msg) + + +def cubeSucksZulip(op, sender, reply): + metadata = getLastCubeMetadata() + # Preprocess the scorable check since the message is + # dependent on the backend. + # + # XXX Election Edition (skip this check) + if (not metadata['scorable']): + reply("4 hits with a wet noodle %s, you cannot change sucks votes after a cube.gimme." % sender) + return + + _cubeSucks(op, metadata['cube_num'], sender, reply) + + +# TODO: On slack it is plausible that we could not need +# to close voting following a gimme, since the confusion +# of all-cubes-to-one-instance doesn't exist on slack, +# and it is always clear that a gimme triggered a cube, +# as it will be threaded with the command. +# +# This would mean a change to "last cube" handling, +# possibly (but not necessarily) across the board +# (including for cube.info) +def cubeSucksSlack(op, message, reply): + '''Slack preprocessing of cube sucks/rocks''' + # We don't need the actual message content here, but we do + # need its metadata. + # + # Effectively, this prefilters the use of sucks/rocks on slack + # to only be in valid places. + # + # Rules: + # Sucks/Rocks is _only_ allowed on the cubes channel + # Sucks/Rocks is _only_ allowed at the top level of the channel + # or in the thread of the most recently scorable cube. + if cfg.SLACK_CUBE_CHANNEL_ID == "": + raise Exception("Empty SLACK_CUBE_CHANNEL_ID in cubeSucksSlack. Check config.") + + cube_channel_name = '<#%s>' % cfg.SLACK_CUBE_CHANNEL_ID + + # Allowable channel? + if message['channel'].upper() != cfg.SLACK_CUBE_CHANNEL_ID: + reply("Sorry, I can only process sucks/rocks votes on the %s channel." % cube_channel_name) + return + + # Allowable thread? + last_cube = getLastCubeMetadata() + if 'thread_ts' in message: + if (last_cube['channel'].upper() != cfg.SLACK_CUBE_CHANNEL_ID or + last_cube['thread_ts'] != message['thread_ts']): + permalink_string = 'most recent votable cube' + if 'permalink' in last_cube: + # if we have a link, use it + permalink_string = '<%s|%s>' % (last_cube['permalink'], permalink_string) + reply("Sorry, you can only issue a vote directly in the %s channel itself " \ + "or in the thread of the %s." % \ + (cube_channel_name, permalink_string)) + return + + # At this point we are being commanded in the cubes channel + # or in the thread of the most recent cube on that channel. + + # Check if the most recent cube was scorable. + # + # This also allows us to use an embedded slack name in the response. + # (since we use the email LHS as the db key) + # + # XXX Election Edition (skip this check) + if not last_cube['scorable']: + last_cube_str = "most recent cube" + if 'permalink' in last_cube: + last_cube_str = "<%s|%s>" % (last_cube['permalink'], last_cube_str) + + reply("4 hits with a wet noodle <@%s>, no cubes are currently open for votes.\n\n" \ + "(Was the %s a gimme?)" % (message['user'], last_cube_str)) + return + + # TODO: It is possible that we should actually send any reply + # to the cube thread _also_, but that starts to get noisy and messy + # so we will wait to see what user response looks like. + sender = get_slack_user_email(message['user']) + _cubeSucks(op, last_cube['cube_num'], sender, reply) + + +def _processCubeGimme(message): + '''Returns a tuple of (SendableCube, cube_num) based or None if a number was provide that could not be found. + If mesage does not contain a number, or is None, returns a random cube (cube_num is left to None in this case) + + Tags nosucks file if needed. + ''' + cube_num = None + m = re.search(r'(-?\d+)', message) + if m: + cube_num = int(m.group(1)) + + if cube_num is not None: + max_cube = getCount() + if (cube_num <= 0 or cube_num > max_cube): + return (None, cube_num) + else: + return (SendableCube(cube_num), cube_num) + else: + return (SendableCube(), None) + + +def cubeGimmeZulip(sender, message): + '''Zulip Processing of message to cube.gimme''' + (cube, cube_num) = _processCubeGimme(message) + + if cube is not None: + cube.sendZulip() + else: + # Shouldn't be reachable if cube_num is not None + sendz(cfg.ZDAEMON_CLASS, "cube", + "Well, %s, you must be on crack because I can't find cube %d." \ + % (sender, cube_num)) + + +def cubeGimmeSlack(message): + '''Slack Procesing of !cubegimme (num)''' + (cube, cube_num) = _processCubeGimme(message['text']) + if cube is not None: + cube.sendSlack(channel=message['channel'], thread_ts=get_slack_thread(message)) + else: + sendsText(message['channel'], "Well, <@%s>, you must be on crack because I can't find cube %d." \ + % (message['user'], cube_num), + thread_ts=get_slack_thread(message)) + + # TODO: We also need to go find the most recently legitimately sent cube (if any) and update its thread + # with a note that voting has closed, and a link to the message we just sent. + + +def cubeInfo(message, sendReply, slack_channel=None): + '''Handles cube.info, optionally taking a numbered cube insteaed + of processing the most recent cube. + + message is the full text of the input message (we use the first number if there) + sendReply(msg) sends a reply to the correct place. + slack_channel indicates our current channel for the request, on slack only, to help + protect private channels. + ''' + cube_num = -1 + last_cube_metadata = {} + m = re.search(r'(-?\d+)', message) + cube_text_preface = "The wisdom contained within:" + + if m: + cube_num = int(m.group(1)) + else: + # We are being asked for info about the most recent cube. + last_cube_metadata = getLastCubeMetadata() + + # If there is a 'channel' component of the metadata, and we have a slack_channel, + # we need to see if the last send was private. If it is, we only respond + # successfully if this is the _same_ channel. Otherwise, we can at best send + # a link to it and suggest the user retry. + if (slack_channel is not None and + 'channel' in last_cube_metadata and + last_cube_metadata['channel'] != slack_channel): + last_cube_channel = get_slack_channel_data(last_cube_metadata['channel']) + if last_cube_channel['is_private']: + private_channel_text = 'private channel' + if 'permalink' in last_cube_metadata: + # In this case, we are willing to unfurl the permalink to anyone who can + # see it. + private_channel_text = "<%s|%s>" % (last_cube_metadata['permalink'], + private_channel_text) + sendReply( + "Sorry, the most recent cube was sent to a %s.\n" + "You would need to retry this request there." % private_channel_text) + return + + cube_num = last_cube_metadata['cube_num'] + + missed_it_string = "missed it" + if 'permalink' in last_cube_metadata: + # If we have a link, might as well show it. We tell it not to unfurl + # when the message is sent to avoid duplication though. + # + # (note: This is a slack mrkdwn format link, but + # zulip doesn't store permalinks anyway so it will never see this) + missed_it_string = "<%s|%s>" % (last_cube_metadata['permalink'], + missed_it_string) + + cube_text_preface = "For those of you that %s:" % missed_it_string + + dbh = _getDBHandle() + stmt = """SELECT sucks, + datetime(slurp_date, 'unixepoch') as SLURP_DATE_STRING, + SLURP_BY + FROM cubes + WHERE id=:id;""" + res = dbh.execute(stmt, {"id": cube_num}) + cubelist = res.fetchall() + dbh.close() + + if (len(cubelist) != 1): + raise Exception("cubeInfo: %d results for lookup of %d" % (len(cubelist), cube_num)) + + row = cubelist[0] + score = row['sucks'] + slurp_date = row['SLURP_DATE_STRING'] + slurper = row['SLURP_BY'] + + cube_text = getCubeContent(cube_num) + + # Unfurl set to false (ignored on zulip), so that we + # don't display the cube content in line twice on slack. + sendReply( + "Cube %d was slurped on %s by %s.\n" \ + "It has %d sucks votes.\n\n%s\n" \ + "%s" % (cube_num, slurp_date, slurper, score, + cube_text_preface, cube_text), + unfurl=False) + + +def slurpCube(sender, message, mistakeMessage, sendReply, display_sender=None): + '''Slurp a cube!''' + # NOTE: Perl zdaemon used a flock() here to protect + # the database and cubecount file together. However, + # we just query the database for the cube count. + # + # So, as long as we write the text of the cube to the + # filesystem first, we'll always have a consistent state. + # If we crash before the database is updated, then we do + # leave an extra cube file around, but we don't ever + # reference it and it will be overwritten on the + # next slurp. + + # Transaction here because we want a very specific + # handling of the id field (no gaps, always use the + # next one). + if display_sender is None: + display_sender = sender + + m = re.search(r'\w', message) + if not m: + sendReply('Give me a little more to work with please, %s' % display_sender) + return + + next_cube = -1 + dbh = _getDBHandle() + cur = dbh.cursor() + cur.execute("BEGIN") + try: + next_cube = _getCountWithCursor(cur) + 1 + + with open(("%s/cube.%d" % (CUBEDIR, next_cube)), "w") as f: + f.write(message) + + stmt = """INSERT INTO cubes + (id, sucks, slurp_date, slurp_by) + VALUES + (:id, 0, strftime('%s', 'now'), :slurp_by);""" + cur.execute(stmt, {"id": next_cube, "slurp_by": sender}) + + cur.execute("COMMIT") + except Exception as e: + cur.execute("ROLLBACK") + raise e + finally: + dbh.close() + + sendReply( + "Cube slurped. You're #%d.\n\n" \ + "Mistake? %s" % (next_cube, mistakeMessage)) + + +LAST_UNSLURP_TIME = 0 +def unslurpCube(sender, fullsender, sendReply): + '''Unslurp a cube. + Restrictions: + can only be done by original slurper + can only be done within 60 minutes of original slurp + + MAINTAINER can get around those restrictions (up to 1 day) + + Once one unslurp succeeds, we won't process anything for 60 seconds. This + somewhat protects us from slack replaying message events to us if a user + tried several times after seeing no response (especially if an admin!), and + then the bot comes up and sees all the replays. Sadly, the websocket API + doesn't appear to provide retry detection directly while the bot is down + (TODO: if it does -- including when the bot is just down, please use it + instead and just reject _all_ retries!). This timeout is not persistent + across restarts. + + For DB consistency reasons, we only allow unslurping of the + most recent cube. (count and max cube # must match) + + Note that this can unslurp multiple cubes in sequence. + Thats probably fine. + ''' + cube_num = -1 + admin_override = False + + global LAST_UNSLURP_TIME + this_unslurp_time = int(time.time()) + if LAST_UNSLURP_TIME + 60 > this_unslurp_time: + sendReply("Proccessed a successful unslurp too recently, they're a big deal.\n" + "Give it a minute and try again.") + return + + dbh = _getDBHandle() + cur = dbh.cursor() + cur.execute("BEGIN") + try: + # What cube are we unslurping? + cube_num = _getCountWithCursor(cur) + + # Load its DB entry + res = cur.execute( + """SELECT strftime('%s', 'now') - slurp_date AS interval, + slurp_by + FROM cubes + WHERE id=:id;""", {"id": cube_num}) + rows = res.fetchall() + if len(rows) != 1: + cur.execute("ROLLBACK") + sendReply('Hmmm. I was not able to find cube #%d' % cube_num) + return + + original_sender = rows[0]['slurp_by'] + time_interval = int(rows[0]['interval']) + is_admin = is_maintainer(fullsender) + + if (original_sender != sender and not is_admin): + cur.execute("ROLLBACK") + sendReply( + "Attempt to unslurp cube #%d by %s failed.\n\n" \ + "Sorry, only the original sender, %s can unslurp this cube." \ + % (cube_num, sender, original_sender)) + return + elif (original_sender != sender and is_admin): + admin_override = True + + if (time_interval > 3600 and not is_admin): + cur.execute("ROLLBACK") + sendReply( + "Attempt to unslurp cube #%d by %s failed.\n\n" \ + "Sorry, you can only unslurp a cube within an hour of slurping it.\n" \ + "It has been %d seconds." \ + % (cube_num, sender, time_interval)) + return + elif (time_interval > 86400 and is_admin): + cur.execute("ROLLBACK") + # Protect from dumb admins. + sendReply( + "Attempt to unslurp cube #%d by %s failed.\n\n" \ + "Sorry, even admins can only unslurp a cube within a day of it being slurped.\n" \ + "It has been %d seconds." \ + % (cube_num, sender, time_interval)) + return + elif (time_interval > 3600 and is_admin): + admin_override = True + + # We are good to remove it. Wiping from the DB is sufficient. + cur.execute("DELETE FROM CUBES WHERE id=:id", {"id": cube_num}) + cur.execute("COMMIT") + + # Ok, mark that we succeeded. + LAST_UNSLURP_TIME = this_unslurp_time + except Exception as e: + cur.execute("ROLLBACK") + raise e + finally: + dbh.close() + + # TODO: This leaves the cube text on the filesystem, but it + # will be overwritten on the next slurp. For now, that + # is acceptable, but it does make it compelling to put the + # cube text directly in the database instead. + + sendReply( + "Cube #%d unslurped by %s.%s" \ + % (cube_num, sender, " (Admin Override)" if admin_override else "")) + + +def cubeActivity(sendReply): + dbh = _getDBHandle() + + stmt = """SELECT count(*) AS count, count(*)/10 AS dots, + strftime("%Y",SLURP_DATE,'unixepoch') AS year + FROM cubes + GROUP BY year + ORDER BY year;""" + rows = dbh.execute(stmt).fetchall() + dbh.close() + + msg = "```\n" + for row in rows: + year = int(row['year']) + count = int(row['count']) + dotstring = "" + for i in range(row['dots']): + dotstring += ">" + msg += "%d: %s (%d)\n" % (year, dotstring, count) + msg += "```" + + sendReply(msg) + + +def cubeStats(sendReply): + dbh = _getDBHandle() + + # TODO: It might be possible to do this all + # in a query instead of manually, but the + # aggregate sum of cubes score is a problem. + stmt = """SELECT sucks, slurp_by FROM cubes;""" + rows = dbh.execute(stmt).fetchall() + dbh.close() + + stats = {} + sucks = {} + sucks_cubed = {} + for row in rows: + user = realID(row['slurp_by']) + sucks_val = row['sucks'] + + stats.setdefault(user, 0) + sucks.setdefault(user, 0) + sucks_cubed.setdefault(user, 0) + + stats[user] += 1 + sucks[user] += sucks_val + sucks_cubed[user] += (sucks_val * sucks_val * sucks_val) + + msg = "The database has %d cubes as follows:\n" % getCount() + + # List of users sorted by most slurps. + users = sorted(stats.keys(), key=stats.get, reverse=True) + + for user in users: + count = stats[user] + user_line = "%s: %d (s/r avg: %.3f, %.3f)\n" \ + % (user, count, + sucks[user]/count, + sucks_cubed[user]/count) + msg += user_line + + sendReply(msg) + + +def cubeQuery(pattern, sendReply): + '''Case-sensitve query of the cube database. + + pattern is the pattern to match. it will be greatly simplified for shell passing. + sendReply(msg) sends the results to the right place. + ''' + # TODO: This interface is one of the big questions about bringing + # cube content into sqlite. It would mean we can't use grep, and + # instead are limited to sqlite "LIKE" This is probably ok + # given how we constrain the regexp already. + # + # It would also mean we don't need to filter out cubes + # that have technicaly been unslurped. + + pattern = pattern.rstrip() + clean_pattern = re.sub(r'[^A-Za-z0-9 \.\*]+', '', pattern) + + # Need shell interpretation for the wildcards. + # grep -l limits output to filenames. + u = subprocess.Popen( + "grep -l '%s' %s/cube.*" % (clean_pattern, CUBEDIR), + shell=True, encoding='ascii', stdout=subprocess.PIPE) + output, _ = u.communicate() + files = output.splitlines() + + try: + dbh = _getDBHandle() + cur = dbh.cursor() + max_cube = _getCountWithCursor(cur) + msg = "All cubes matching /%s/:\n----\n" % clean_pattern + for filename in files: + cube_num = -1 + cube_text = "" + m = re.search(r'/cube\.(\d+)$', filename) + if m: + cube_num = int(m.group(1)) + else: + # Don't know what this file was, but skip it. + continue + + if (cube_num > max_cube): + # Technically this cube does not exist, skip. + # (can be leftover after an unslurp) + continue + + cube_text = getCubeContent(cube_num) + + rows = cur.execute('SELECT sucks FROM cubes WHERE id=:id;', {"id": cube_num}).fetchall() + sucks_score = "<>" + if (len(rows) == 1): + # Not having exactly 1 row here is an error, + # but we can ignore it somewhat cleanly. + sucks_score = rows[0]['sucks'] + + msg += "*Cube #%d [Sucks Score: %s]:*\n%s\n----\n" % (cube_num, sucks_score, cube_text) + finally: + dbh.close() + + sendReply(msg) + +# Parse out arguments for !cubeslurp on Slack. +def slackSlurpCube(message, sendResponse): + display_sender = '<@%s>' % message['user'] + m = re.search(r"^!(slurpcube|cubeslurp)\s+(.+)$", message['text'], re.DOTALL) + if m: + sender = get_slack_user_email(message['user']) + text = m.group(2) + slurpCube(sender, text, "Use the `!unslurpcube` command", sendResponse, + display_sender = display_sender) + else: + sendResponse("I don't quite know what you want me to slurp there, %s." % display_sender) + + +# Parse out arguments for !cubequery on Slack. +def slackCubeQuery(text, sendResponse): + m = re.search(r"^!(cubequery)\s+(.+)$", text) + if m: + cubeQuery(m.group(2), sendResponse) + else: + sendResponse("Please supply a pattern to search for.") + + +# does all the cube hooks.. +def cubeCheck(zclass, instance, sender, fullsender, message): + if (zclass != cfg.ABTECH_CLASS and + zclass != cfg.ZDAEMON_CLASS): + # Irrelevant class for cubes, no need to proceed. + return + + if (zclass == cfg.ZDAEMON_CLASS and + (instance == "cube.gimme" or instance == "cube.gimmie")): + cubeGimmeZulip(sender, message) + return + + if (zclass == cfg.ZDAEMON_CLASS and + instance == "cube.info"): + sendResponse = functools.partial(sendz, cfg.ZDAEMON_CLASS, "cube.info") + cubeInfo(message, sendResponse) + return + + if (instance == "cube.sucks"): + sendResponse = functools.partial(sendz, cfg.ZDAEMON_CLASS, "cube.sucks") + cubeSucksZulip(1, sender, sendResponse) + return + + if (instance == "cube.rocks"): + sendResponse = functools.partial(sendz, cfg.ZDAEMON_CLASS, "cube.rocks") + cubeSucksZulip(-1, sender, sendResponse) + return + + if (instance == "cube.slurp"): + sendResponse = functools.partial(sendz, cfg.ZDAEMON_CLASS, "cube.slurped") + slurpCube(sender, message, "Send a message to the cube.unslurp instance.", sendResponse) + return + + if (instance == "cube.unslurp"): + sendResponse = functools.partial(sendz, cfg.ZDAEMON_CLASS, "cube.unslurp") + unslurpCube(sender, fullsender, sendResponse) + return + + if (zclass == cfg.ZDAEMON_CLASS and + instance == "cube.stats"): + sendResponse = functools.partial(sendz, cfg.ZDAEMON_CLASS, "cube.stats") + cubeStats(sendResponse) + return + + if (zclass == cfg.ZDAEMON_CLASS and + instance == "cube.activity"): + sendResponse = functools.partial(sendz, cfg.ZDAEMON_CLASS, "cube.activity") + cubeActivity(sendResponse) + return + + if (zclass == cfg.ZDAEMON_CLASS and + instance == "cube.query"): + sendResponse = functools.partial(sendz, cfg.ZDAEMON_CLASS, "cube.query") + + # On zulip, the entire message is the pattern. + cubeQuery(message, sendResponse) + return + + if (zclass == cfg.ZDAEMON_CLASS and + instance == "cube.service"): + sendResponse = functools.partial(sendz, cfg.ZDAEMON_CLASS, "cube.service") + cubeService(sendResponse) + return + + +def cubeSlackRouter(message): + text = message['text'] + thread = get_slack_thread(message) + + # TODO: Might not always be correct + sendResponse = functools.partial(sendsText, message['channel'], thread_ts=thread) + + if(re.search(r"^!(cubegimme|cubegimmie)($|\s)", text)): + cubeGimmeSlack(message) + + if (re.search(r"^!cubeinfo($|\s)", text)): + # TODO: Intercept parameterless command being sent to a thread other than the + # most recent cube thread. + cubeInfo(text, sendResponse, slack_channel=message['channel']) + + if (re.search(r"^!cubesucks($|\s)", text)): + cubeSucksSlack(1, message, sendResponse) + + if (re.search(r"^!cuberocks($|\s)", text)): + cubeSucksSlack(-1, message, sendResponse) + + if (re.search(r"^!(slurpcube|cubeslurp)($|\s)", text)): + slackSlurpCube(message, sendResponse) + + if (re.search(r"^!(csa|c\.s\.a\.?)($|\s)", text)): + sendResponse("OK <@%s>, I almost slurped that, but I didn't." % message['user']) + + if (re.search(r"^!(unslurpcube|cubeunslurp)($|\s)", text)): + userid = message['user'] + sender = get_slack_user_email(userid) + fullsender = get_slack_user_email(userid, lhs_only=False) + unslurpCube(sender, fullsender, sendResponse) + + if (re.search(r"^!(cubestats)($|\s)", text)): + cubeStats(sendResponse) + + if (re.search(r"^!(cubeactivity)($|\s)", text)): + cubeActivity(sendResponse) + + if (re.search(r"^!(cubequery)($|\s+)", text)): + slackCubeQuery(text, sendResponse) + + if (re.search(r"^!(cubeservice)($|\s)", text)): + cubeService(sendResponse) diff --git a/zdaemon/html-cubes.py b/zdaemon/html-cubes.py new file mode 100644 index 0000000..6984b87 --- /dev/null +++ b/zdaemon/html-cubes.py @@ -0,0 +1,88 @@ +#!/usr/bin/python3 +# Generate the web page with a summary of all cubes. +# Abuses internal cube module functions. +# +# Copyright (c) 2024, AB Tech +# All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions are met: +# +# * Redistributions of source code must retain the above copyright notice, this +# list of conditions and the following disclaimer. +# +# * Redistributions in binary form must reproduce the above copyright notice, +# this list of conditions and the following disclaimer in the documentation +# and/or other materials provided with the distribution. +# +# * Neither the name of the copyright holder nor the names of its +# contributors may be used to endorse or promote products derived from +# this software without specific prior written permission. +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +# DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE +# FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +# DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +# SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +# CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, +# OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +import argparse + +import config as cfg +import cube +from common import realID + +parser = argparse.ArgumentParser() +cfg.add_zdaemon_arguments(parser) + +options = parser.parse_args() +cfg.init_zdaemon_config(options, load_channels=False, config_file_only=True) + +cubelist = [] + +try: + dbh = cube._getDBHandle() + + stmt = '''SELECT id, sucks, + datetime(slurp_date, 'unixepoch') AS SLURP_DATE_STRING, + SLURP_BY + FROM cubes + ORDER BY id;''' + cubelist = dbh.execute(stmt).fetchall() +finally: + dbh.close() + +print("") +print("") +print("Cube Database") +print("") +print("

ABTech Cubes

\n") +print("This is just a dump of the cubes database.
\n") + +for row in cubelist: + print("" % row["id"]) + print("" % row["id"]) + print("" % (realID(row['slurp_by']), row['slurp_date_string'])) + + sucks_color = "" + if row['sucks'] >= 12: + sucks_color = " bgcolor=indianred" + elif row['sucks'] >= 6: + sucks_color = " bgcolor=lightsalmon" + elif row['sucks'] <= -12: + sucks_color = " bgcolor=green" + elif row['sucks'] <= -6: + sucks_color = " bgcolor=palegreen" + + print("Sucks score: %d\n" % (sucks_color, row['sucks'])) + + cube_file = cube.CUBEDIR + "/cube.%d" % row["id"] + with open (cube_file, "r", errors='replace') as f: + cube_text = f.read() + print("") + + print("
# %dSlurped by %s on %s
" + ''.join(cube_text) + "

\n") diff --git a/zdaemon/notify-maintainer.py b/zdaemon/notify-maintainer.py new file mode 100644 index 0000000..4bfaf41 --- /dev/null +++ b/zdaemon/notify-maintainer.py @@ -0,0 +1,48 @@ +#!/usr/bin/python3 +# Simple script to send stdin to a zdaemon maintainer. +# +# Copyright (c) 2024, AB Tech +# All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions are met: +# +# * Redistributions of source code must retain the above copyright notice, this +# list of conditions and the following disclaimer. +# +# * Redistributions in binary form must reproduce the above copyright notice, +# this list of conditions and the following disclaimer in the documentation +# and/or other materials provided with the distribution. +# +# * Neither the name of the copyright holder nor the names of its +# contributors may be used to endorse or promote products derived from +# this software without specific prior written permission. +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +# DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE +# FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +# DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +# SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +# CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, +# OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +import argparse +import sys + +import config as cfg +from common import sendToMaintainer + +parser = argparse.ArgumentParser() +cfg.add_zdaemon_arguments(parser) + +options = parser.parse_args() + +# Don't load the channel whitelist, we don't need them to +# just send a message. +cfg.init_zdaemon_config(options, load_channels=False) + +message = sys.stdin.read() +sendToMaintainer(message) diff --git a/zdaemon/plusplus.py b/zdaemon/plusplus.py new file mode 100644 index 0000000..152038e --- /dev/null +++ b/zdaemon/plusplus.py @@ -0,0 +1,414 @@ +# Copyright (c) 2024, AB Tech +# All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions are met: +# +# * Redistributions of source code must retain the above copyright notice, this +# list of conditions and the following disclaimer. +# +# * Redistributions in binary form must reproduce the above copyright notice, +# this list of conditions and the following disclaimer in the documentation +# and/or other materials provided with the distribution. +# +# * Neither the name of the copyright holder nor the names of its +# contributors may be used to endorse or promote products derived from +# this software without specific prior written permission. +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +# DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE +# FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +# DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +# SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +# CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, +# OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +from datetime import datetime + +import functools +import pytz +import re +import sqlite3 +import time +import unicodedata as ud + +import config as cfg +from common import realID, sendz, sendsText, get_slack_thread, get_slack_user_email, hasRTLCharacters +from random import randrange + +# sqlite Schema +# +# CREATE TABLE ppdata (thing TEXT PRIMARY KEY NOT NULL, +# score INTEGER NOT NULL); +# CREATE TABLE lastpp (username TEXT NOT NULL, +# thing TEXT NOT NULL, +# direction INTEGER NOT NULL, +# timestamp INTEGER NOT NULL, +# PRIMARY KEY(username, thing, direction)); + + +_PP_SQLITE_FILE = None +_PP_QUERY_INSTANCE = "plusplus.query" +_PIT_TIMEZONE = pytz.timezone('America/New_York') + +def init_pp_config(zdaemon_data_dir): + global _PP_SQLITE_FILE + + _PP_SQLITE_FILE = zdaemon_data_dir + "/ppdata.sqlite" + + +def _getDBHandle(): + ''' Get a DB handle, just the way we like it. ''' + if _PP_SQLITE_FILE is None: + raise Exception("need to call init_pp_config first") + + dbh = sqlite3.connect(_PP_SQLITE_FILE) + dbh.row_factory = sqlite3.Row + + # If legacy badly encoded plusplus entries are allowed + # into the database, you will need to enable this. + # + # dbh.text_factory = lambda b: b.decode(errors = 'ignore') + + return dbh + + +def getPlusplusStats(): + '''Returns a dict with a 'count' and 'sum' field that + describes the plusplus database. + ''' + dbh = _getDBHandle() + try: + rows = dbh.execute("SELECT COUNT(*) AS count, SUM(score) AS sum FROM ppdata;").fetchall() + if (len(rows) != 1): + raise Exception("getPlusplusStats not exactly 1 row") + else: + return rows[0] + finally: + dbh.close() + + +def _renderPlusplusResultLine(thing, value): + '''Renders a plusplus result, accounting the fact that there may be RTL characters. + If there _are_ rtl characters in the thing, then it will force the whole + string LTR, the thing to be its first strong character, and the colon on to + again be LTR. + ''' + if hasRTLCharacters(thing): + return "\u2066\u2068%s\u2069\u2066: %d\u2069\u2069\n" % (thing, value) + else: + return "%s: %d\n" % (thing, value) + + +def doPlusplusQuery(message, reply): + '''Handle plusplus.query lookups. + + reply(message) sends the reply to the right place. + ''' + # TODO: Do we really need full regexp support? Doing that + # requires us to read the entire database each time. Thankfully + # we can cursor through it instead of a fetchall(), but it'd + # be better if sqlite could just do the lift for us -- either + # with the REGEXP extension or just allow only SQL LIKE queries. + m = re.search(r'([-]?)\{(.+)\}', message) + if not m: + reply("You need to wrap query parameters in { }, as in:\n" \ + "{zdaemon}") + return + + sort_direction = "DESC" + if m.group(1) == '-': + sort_direction = "ASC" + pattern = re.compile(m.group(2)) + + results = [] + dbh = _getDBHandle() + try: + stmt = "SELECT thing, score FROM ppdata ORDER BY score %s;" % sort_direction + cur = dbh.execute(stmt) + + BATCH_SIZE = 500 + while True: + rows = cur.fetchmany(BATCH_SIZE) + if not rows: + break + for row in rows: + if pattern.search(row['thing']): + results.append(row) + finally: + dbh.close() + + msg = "Things matching /%s/:\n" % pattern.pattern + for row in results: + msg += _renderPlusplusResultLine(row['thing'], row['score']) + reply(msg) + + +def _ppquery(cursor, thing): + '''Does a single lookup using the provided cursor for thing. + Returns its score if it exists, and None otherwise. + ''' + rows = cursor.execute( + "SELECT score FROM ppdata WHERE thing=:thing", + {"thing": thing}).fetchall() + + if (len(rows) == 0): + return None + elif (len(rows) > 1): + raise Exception("ppquery: %s has more than one row?" % thing) + else: + return int(rows[0]['score']) + + +def _lastpptime_query(cursor, id, thing, inc): + '''Returns the last time that id modified thing + with an inc operation. + + inc is 1 or -1 + + Uses the provided cursor. + + Returns 0 (e.g. the epoch) if no entry found. + ''' + if inc != 1 and inc != -1: + raise Exception("_lastpptime_query: bad increment %s" % inc) + + rows = cursor.execute( + """SELECT timestamp FROM lastpp + WHERE username=:id AND direction=:inc AND thing=:thing""", + {"id": id, "inc": inc, "thing": thing}).fetchall() + + if (len(rows) == 0): + return 0 + elif (len(rows) > 1): + raise Exception("_lastpptimequery multiple rows for %s, %d, %s" + % (id, inc, thing)) + else: + return rows[0]['timestamp'] + + +def _plusplus(cursor, sender, display_sender, + inc, thing, reply): + '''Does a single ++ or -- operation using the provided cursor. + + cursor is the DB cursor + sender is the unqualified sender (e.g. email LHS) + display_sender is the string to use in message responses for the sender + (except when as a target for a plusplus) + inc is 1 or -1 + thing is the thing + Error or humor replies are sent via reply(message) + + returns new value or None if nothing changed. + ''' + if inc != 1 and inc != -1: + raise Exception("_plusplus: bad increment %s" % inc) + + thing_id = realID(thing) + self_pp_penalty = False + + # Detect someone plusplussing a slack entity, and forbid. + # Note that this code still runs on zulip, but it probably is an error there as well. + m = re.match(r'<([@#])([a-z0-9]+)(|.*)?>', thing_id) + if m: + type = m.group(1) + entity = m.group(2) + + hint = "" + if type == '@': + hint = " If this is a user, please use their andrew id." + elif type == '#': + hint = " If this is a channel, consider omitting the hash mark." + + reply("It looks like you might be trying to plusplus the slack entity: %s, " + "but this is not supported.%s" % (entity, hint)) + return None + + # Self Plusplus Penalty + if (thing_id == sender and inc == 1): + reply("Whoa, @bold(loser) trying to plusplus themselves.\n" \ + "Changing to %s--" % thing_id) + inc = -1 + self_pp_penalty = True + + # This is zdaemon's show + if (thing_id == 'zdaemon'): + if (inc == -1): + reply("Are YOU disrespecting me, %s? Huh? Are you?\n" \ + "I think you are!" % display_sender) + return None + elif (inc == 1): + reply("Oooh. I just love it when you do that! :)\n\n" \ + "What are you doing later, %s?" % display_sender) + + # Don't talk about her age either. + if (thing_id == 'zdaemon.age'): + reply("It's impolite to talk about a daemon's age.\n" \ + "How do you like it, %s?" % display_sender) + thing = '%s.age' % sender + reply("%s++" % thing) + res = _plusplus(cursor, 'zdaemon', 'zdaemon', 1, + thing, reply) + if res is not None: + # Multiple pp's in a row will fail, act cool. + reply('%s: %d' % (thing, res)) + return None + + # Hitting zdaemon is rude + if (thing_id == 'zdaemon.whap'): + if (inc == 1): + msg = "Hey, that hurt, %s!" % display_sender + if (randrange(100) >= 50): + msg = "You'd better watch out, %s." % display_sender + reply(msg) + elif (inc == -1): + if (randrange(100) >= 50): + reply("Thank you, %s. You will be spared..." % display_sender) + + last_action_time = _lastpptime_query(cursor, sender, thing_id, inc) + allowed_at_seconds = last_action_time + 3600 + nowtime = int(time.time()) + + # XXX Election Edition - disable this check + if (allowed_at_seconds > nowtime and not self_pp_penalty): + # 60 Minute Rule + allowed_at = datetime.fromtimestamp(allowed_at_seconds, _PIT_TIMEZONE) + allowed_at_str = allowed_at.strftime("%H:%M:%S") + reply("@bold(Not) changing %s (60min rule until %s) for %s." \ + % (thing_id, allowed_at_str, sender)) + return None + + cursor.execute(""" + INSERT OR REPLACE INTO ppdata (thing, score) + VALUES (:thing, + COALESCE((SELECT score + :inc FROM ppdata WHERE thing=:thing),:inc));""", + {"thing": thing_id, "inc": inc}) + ret = _ppquery(cursor, thing_id) + if ret is None: + raise Exception("_plusplus: %s doesn't exist after I just changed it?" % thing_id) + + # Update lastpp time + cursor.execute("""INSERT OR REPLACE + INTO lastpp(username, + thing, + direction, + timestamp) + VALUES (:sender, :thing, :inc, :timestamp);""", + {"sender": sender, + "thing": thing_id, + "inc": inc, + "timestamp": nowtime}) + + return ret + + +def scanPlusPlus(sender, message, reply, display_sender=None): + #log("In scanplusplus: %s" % message) + + if display_sender is None: + display_sender = sender + + results = {} + haystack = message + + if (re.search(r'(\+\+|--|\~\~)$', haystack)): + # For the pattern we use below to work, we can't have + # an op as the very end of the string. So append a dot. + haystack += "." + + pattern = re.compile(r'([^\s]{2,})(\+\+|--|\~\~)[\!\:\;\?\.\,\)\]\}\s]+([\w\W]*)', + flags=(re.I | re.S)) + + dbh = _getDBHandle() + cur = dbh.cursor() + cur.execute("BEGIN") + try: + + m = pattern.search(haystack) + while m is not None: + haystack = m.group(3) + thing = m.group(1).lower() + op = m.group(2) + + # print ("%s / %s" % (thing, op)) + + if (thing == "year"): + results['year'] = datetime.now(_PIT_TIMEZONE).year + elif (thing == "month"): + results['month'] = datetime.now(_PIT_TIMEZONE).month + elif (thing == "day"): + results["day"] = datetime.now(_PIT_TIMEZONE).day + elif (thing == "hour"): + results["hour"] = datetime.now(_PIT_TIMEZONE).hour + elif (thing == "minute"): + results["minute"] = datetime.now(_PIT_TIMEZONE).minute + elif (thing == "second"): + results["second"] = datetime.now(_PIT_TIMEZONE).second + elif (thing == "life"): + results["life"] = 0 + elif (thing == "18290"): + results["18290"] = 290 + else: + res = None + if (op == "~~"): + res = _ppquery(cur, thing) + elif (op == "++"): + res = _plusplus(cur, sender, display_sender, + 1, thing, reply) + elif (op == "--"): + res = _plusplus(cur, sender, display_sender, + -1, thing, reply) + + if (res is not None): + results[thing] = res + + m = pattern.search(haystack) + + cur.execute("COMMIT") + except Exception as e: + cur.execute("ROLLBACK") + raise e + finally: + dbh.close() + + if (len(results.keys()) > 0): + # Nonzero results, so we respond. + msg = "" + for k in results.keys(): + msg += _renderPlusplusResultLine(k, results[k]) + reply(msg) + + +def checkPP(zclass, instance, sender, message): + if (zclass == cfg.ZDAEMON_CLASS and instance == _PP_QUERY_INSTANCE): + sendResponse = functools.partial(sendz, cfg.ZDAEMON_CLASS, _PP_QUERY_INSTANCE) + doPlusplusQuery(message, sendResponse) + + if (zclass == cfg.ZDAEMON_CLASS or + zclass == cfg.ABTECH_CLASS or + zclass == cfg.GHOSTS_CLASS): + # Determine where our replys will go. + reply_class = cfg.ZDAEMON_CLASS + if zclass == cfg.GHOSTS_CLASS: + reply_class = cfg.GHOSTS_CLASS + + sendResponse = functools.partial(sendz, reply_class, "plusplus") + scanPlusPlus(sender, message, sendResponse) + + +def slack_plusplus_router(message): + '''Process the given message event for plusplus responses.''' + sender = get_slack_user_email(message['user']) + display_sender = "<@%s>" % message['user'] + thread = get_slack_thread(message) + sendResponse = functools.partial(sendsText, message['channel'], thread_ts=thread) + + text = message['text'] + if(re.search(r"^!(ppquery|plusplusquery)($|\s)", text)): + doPlusplusQuery(message['text'], sendResponse) + + scanPlusPlus(sender, message['text'], sendResponse, + display_sender=display_sender) diff --git a/zdaemon/requirements.txt b/zdaemon/requirements.txt new file mode 100644 index 0000000..515dac6 --- /dev/null +++ b/zdaemon/requirements.txt @@ -0,0 +1,18 @@ +cachetools==5.3.2 +certifi==2023.11.17 +charset-normalizer==3.3.2 +click==8.1.7 +cowsay==6.1 +distro==1.9.0 +idna==3.6 +Jinja2==3.1.3 +MarkupSafe==2.1.5 +pytz==2023.4 +PyYAML==6.0.1 +requests==2.31.0 +simplejson==3.19.2 +slack-bolt==1.18.1 +slack_sdk==3.27.0 +typing_extensions==4.9.0 +urllib3==2.1.0 +zulip==0.9.0 diff --git a/zdaemon/triggers.py b/zdaemon/triggers.py new file mode 100644 index 0000000..46da6a0 --- /dev/null +++ b/zdaemon/triggers.py @@ -0,0 +1,266 @@ +# Message triggers +# +# Copyright (c) 2024, AB Tech +# All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions are met: +# +# * Redistributions of source code must retain the above copyright notice, this +# list of conditions and the following disclaimer. +# +# * Redistributions in binary form must reproduce the above copyright notice, +# this list of conditions and the following disclaimer in the documentation +# and/or other materials provided with the distribution. +# +# * Neither the name of the copyright holder nor the names of its +# contributors may be used to endorse or promote products derived from +# this software without specific prior written permission. +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +# DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE +# FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +# DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +# SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +# CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, +# OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +import functools +import re +import time +from random import randrange +from typing import Callable + +from jinja2 import Environment +from jinja2.exceptions import TemplateSyntaxError +from yaml import load as yaml_load + +try: + from yaml import CSafeLoader as YamlSafeLoader +except ImportError: + from yaml import YamlSafeLoader + +from cube import SendableCube +from common import get_slack_thread, get_slack_user_email, sendsText + +# Modified from Home Assistant's custom regex filters for Jinja +# https://github.com/home-assistant/core/blob/33ff6b5b6ee3d92f4bb8deb9594d67748ea23d7c/homeassistant/helpers/template.py#L2106-L2143 +_regex_cache = functools.cache( + re.compile +) # Unbounded cache since finite number of regexes per config + + +def template_regex_match(value, find="", ignorecase=False): + """Match value using regex.""" + if not isinstance(value, str): + value = str(value) + flags = re.I if ignorecase else 0 + return bool(_regex_cache(find, flags).match(value)) + + +def template_regex_search(value, find="", ignorecase=False): + """Search using regex.""" + if not isinstance(value, str): + value = str(value) + flags = re.I if ignorecase else 0 + return bool(_regex_cache(find, flags).search(value)) + + +class ZdaemonMessageMatchTriggers: + """ + --Combination DRINKBOT and DUHBOT-- + ABTech Drinking Game by adamp@abtech.org + Based on suggestions by class abtech + This will need to get taken out if it slows down zdaemon too much. + It lenghtens the amount of processing per zephyr quite a bit. + -rjs3 in 2024: lol + """ + + def __init__(self, trigger_config_path: str = "triggers.yaml"): + self.jinja_env = Environment() + self.jinja_env.filters = self.jinja_env.filters | { + "regex_match": template_regex_match, + "regex_search": template_regex_search, + } + # Only load trigger config once at class initialization + with open(trigger_config_path, "r", encoding="utf-8") as stream: + self.trigger_config = yaml_load(stream, Loader=YamlSafeLoader) + self.timeout_s = self.trigger_config["trigger_timeout_s"] + self.special_last_trigger_s = ( + -1 * self.timeout_s + ) # You know, just in case computer time is 0 + if not self.check_all_syntax(): + raise RuntimeError("Syntax error in one or more trigger templates") + + def check_and_record_timeout(self) -> bool: + """Check if we have done a "special" recently. If so, + return False. If not, mark the special var + return True. + + timeout is specified in seconds in __init__(). + + NOTE: This check is irrelevant for Zulip since the class is re- + initialized at every message event. + """ + nowtime_s = int(time.time()) + + if self.special_last_trigger_s + self.timeout_s >= nowtime_s: + # Skip, we just did something. + return False + + self.special_last_trigger_s = nowtime_s + + return True + + def slack_check_msg(self, message_obj) -> None: + """ + Take in a Slack message and pass it to check_msg() + """ + sender = get_slack_user_email(message_obj["user"]) + channel = message_obj[ + "channel" + ] # I want to assume this is a string, but of course it won't be + ts = get_slack_thread(message_obj) + text = message_obj["text"] + display_sender = f'<@{ message_obj["user"] }>' + + def respond_with_cube(): + SendableCube().sendSlack(channel=channel, thread_ts=ts) + + def respond_text(_, text): + sendsText(channel, text, thread_ts=message_obj["ts"]) + + self.check_msg( + "slack", + sender, + text, + respond_with_cube, + respond_text, + display_sender=display_sender, + channel=channel, + ) + + def check_msg( # pylint: disable=too-many-arguments + self, + instance: str, + sender: str, + message: str, + send_cube: Callable, + reply: Callable, + display_sender: str = None, + channel: str = None, + ) -> None: + """ + All autoresponses + + instance is the zulip topic of the message, but is only used for some + replies and for one of the "quiet" checks (which has slightly different + behavior when instance is "slack") sender is the LHS of the email of + the sender message is the message we are checking. + + send_cube is a zero-argument function to send a random cube to the + appropriate place. reply takes (instance, message), though you are free + to ignore instances. + + display_sender is text to use instead of sender in responses, if None, + we use sender. + """ + if display_sender is None: + display_sender = sender + template_vars = { + "instance": instance, + "channel": channel, + "sender": sender, + "display_sender": display_sender, + "message": message, + } + for trigger in self.trigger_config["triggers"]: + # We render each trigger test's Jinja with the context vars for a + # message and then the Jinja expression (which is in a string) + # returns True or False. We expect an exact match of "True" instead + # of casting so that erroneous tests that return anything other + # than "False" do not trigger. + if ( + self.jinja_env.from_string(trigger["test"]).render( + **template_vars + ) + == "True" + ): + if ( + "enforce_special_timeout" not in trigger + or not trigger["enforce_special_timeout"] + or self.check_and_record_timeout() + ): + self.send_response(trigger, template_vars, reply) + if "send_cubes_count" in trigger: + for _ in range(trigger["send_cubes_count"]): + time.sleep(1) + send_cube() + + def send_response( + self, trigger: dict, template_vars: dict, reply: Callable + ) -> None: + """ + Given a match trigger, send a message response + """ + reply_template = None + rand_num = randrange(100) + total_count = 0 + for probability, template in trigger["response"].items(): + if probability != "default": + if total_count <= rand_num < total_count + probability: + reply_template = template + break + total_count += probability + if reply_template is None and "default" in trigger["response"]: + reply_template = trigger["response"]["default"] + if reply_template is not None: + reply_msg = self.jinja_env.from_string(reply_template).render( + **template_vars + ) + reply_instance = self.jinja_env.from_string( + trigger["legacy_instance"] + ).render(**template_vars) + reply(reply_instance, reply_msg) + + def check_all_syntax(self) -> bool: + """ + Check all test, legacy_instance, and response options for syntax errors + """ + result = True + for trigger in self.trigger_config["triggers"]: + if not self.check_syntax("test", trigger["test"]): + result = False + + if not self.check_syntax( + "legacy_instance", trigger["legacy_instance"] + ): + result = False + + for key, response in trigger["response"].items(): + if not self.check_syntax(f'response "{key}"', response): + result = False + return result + + def check_syntax(self, name: str, template: str) -> bool: + """ + Check a template for syntax errors. + """ + template_vars = { + "instance": "slack", + "channel": "123456", + "sender": "zdaemon", + "display_sender": "<@zdaemon>", + "message": "I'm just a test!", + } + try: + self.jinja_env.from_string(template).render(**template_vars) + except TemplateSyntaxError as err: + # Log the template so we know which one has a syntax error + print(f"Syntax error in { name } template: { template }") + print(err) + return False + return True diff --git a/zdaemon/zdaemon.py b/zdaemon/zdaemon.py new file mode 100644 index 0000000..7db20a2 --- /dev/null +++ b/zdaemon/zdaemon.py @@ -0,0 +1,338 @@ +# Base level Zdaemon services +# Routing, Ping, Help +# +# Copyright (c) 2024, AB Tech +# All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions are met: +# +# * Redistributions of source code must retain the above copyright notice, this +# list of conditions and the following disclaimer. +# +# * Redistributions in binary form must reproduce the above copyright notice, +# this list of conditions and the following disclaimer in the documentation +# and/or other materials provided with the distribution. +# +# * Neither the name of the copyright holder nor the names of its +# contributors may be used to endorse or promote products derived from +# this software without specific prior written permission. +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +# DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE +# FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +# DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +# SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +# CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, +# OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +from time import time, gmtime, strftime +import functools +import re +import socket +import subprocess +import unicodedata as ud + +import cowsay + +import common # For startup time +import config as cfg +import cube +from common import sendz, sendsText +from common import get_slack_thread, get_slack_channel_data, slackReact, get_slack_bot_userid +from common import hasRTLCharacters +from cube import cubeCheck +from plusplus import checkPP, getPlusplusStats, slack_plusplus_router + +_PROG_ID = '$Id$' + +def zdaemon_router(zclass, instance, sender, fullsender, message, triggers): + ''' Primary router for zdaemon features ''' + + # TODO: Sanitize message (e.g. backticks) + # TODO: Log the Message? + # print ("router: %s %s" % (zclass, instance)) + + if zclass == cfg.ZDAEMON_CLASS and (instance == 'ping' or instance == 'service.query'): + sendz(cfg.ZDAEMON_CLASS, 'ping', ping_text()) + if zclass == cfg.ZDAEMON_CLASS and instance == 'ping.help': + sendz(cfg.ZDAEMON_CLASS, 'ping.help', pinghelp_text()) + + # handle drink & duh checks + if (zclass == cfg.ZDAEMON_CLASS or zclass == cfg.ABTECH_CLASS): + sendCube = functools.partial(cube.sendCube, -1) + reply = functools.partial(sendz, zclass) + triggers.check_msg(instance, sender, message, sendCube, reply) + + # handle cubes + cubeCheck(zclass, instance, sender, fullsender, message) + + # handle plusplus + checkPP(zclass, instance, sender, message) + + +def ping_text(slack=False): + if common.ZDAEMON_START_TIME is None: + raise Exception("ping_text with Nonetype ZDAEMON_START_TIME? init_common_config please!") + + # Get current uptime + u = subprocess.Popen('uptime', + stdout=subprocess.PIPE, + encoding='ascii') + uptime, _ = u.communicate() + + ppstats = getPlusplusStats() + + msg = "zdaemon %s\n" % _PROG_ID + msg += "*Cubes*: %d\n" % cube.getCount() + msg += "*Plusplus*: %d (totaling %d)\n" % (ppstats['count'], ppstats['sum']) + msg += "*Pronouns*: she/her\n" + msg += "*Home Address*: %s\n" % socket.gethostname() + msg += "*Server Uptime*: %s\n" % uptime.rstrip() + msg += "*Last Restart*: %s\n" % strftime("%a, %d %b %Y %H:%M:%S +0000", gmtime(common.ZDAEMON_START_TIME)) + + lifetime_seconds = int(time()) - common.ZDAEMON_START_TIME + lifetime_days, lifetime_seconds = divmod(lifetime_seconds, 86400) + lifetime_hours, lifetime_seconds = divmod(lifetime_seconds, 3600) + lifetime_minutes, lifetime_seconds = divmod(lifetime_seconds, 60) + + msg += "\nIt has been %d Days, %d Hours, %d Minutes, and %d Seconds without a restart.\n" % \ + (lifetime_days, lifetime_hours, lifetime_minutes, lifetime_seconds) + + if slack: + msg += "\n*For more info*: Send the `!pinghelp` command, and get a response completely about zephyr.\n" + msg += "*For actual help*: Send `!zdhelp`" + else: + msg += "\n*For more info*: Send a zulip message to the zdaemon stream with the subject ping.help, and get a response completely about zephyr." + + return msg + + +def pinghelp_text(): + msg = "zdaemon operations:\n" + msg += "Ping: zwrite -c zdaemon -i ping -r ABTECH.ORG\n" + msg += "\tEnsures zdaemon is operating.\n" + msg += "Plusplus Query: zwrite -c zdaemon -i plusplus zdaemon@ABTECH.ORG -m '{foo}'\n" + msg += "\tWhere foo is the query. Optional -{foo} syntax sorts in reverse order.\n" + msg += "Cube Gimme: zwrite -c zdaemon -i cube.gimme -r ABTECH.ORG\n" + msg += "\tInclude cube number, or no cube number for a random cube.\n" + msg += "Other cube instances (replace cube.gimme):\n" + msg += "\tcube.info: See information about last cube sent.\n" + msg += "\tcube.sucks: Cause the cube to gain a 'sucks' vote.\n" + msg += "\tcube.rocks: Cause the cube to lose a 'sucks' vote.\n" + msg += "\tcube.stats: General cube statistics" + return msg + + +### Slack Handling Below +def slack_ping(message): + sendsText(message['channel'], ping_text(slack=True), thread_ts=get_slack_thread(message)) + + +def slack_pinghelp(message): + sendsText(message['channel'], pinghelp_text(), thread_ts=get_slack_thread(message)) + + +def slack_zdhelp(message): + msg = "zdaemon Slack Commands:\n" + msg += "`!ping`: Check to see if I'm alive\n" + msg += "`!zdhelp`: See this message again\n" + msg += "`!ppquery {regex}`: Search the plusplus database for the given regex. Regex must be in braces. `-{regex}` will sort in reverse order.\n" + msg += "`!cubegimme [num]`: Return a cube. Optionally, provide a specific cube to view instead.\n" + msg += "`!cubeinfo [num]`: Show metadata about the most recently sent cube. Optionally, provide a specific cube.\n" + msg += "`!cubesucks` and `!cuberocks`: Express your feelings about the most recent auto-sent cube. Only available in <#%s>.\n" % cfg.SLACK_CUBE_CHANNEL_ID + msg += "`!cubeslurp (text)`: Submit wisdom for posterity. Leading newlines/whitespace ignored.\n" + msg += "`!csa (text)`: Almost submit wisdom for posterity.\n" + msg += "`!cubestats`: Show the cube scoreboard.\n" + msg += "`!cubeactivity`: Show a history of cubes by year.\n" + msg += "`!cubequery (string)`: Scan the cube database for the given string.\n" + sendsText(message['channel'], msg, thread_ts=get_slack_thread(message)) + + +def slack_gny(message): + '''GNY only supported on slack, since slack lacks instances. + + Undocumented command. + + Who doesn't like cowsay? + ''' + name = None + m = re.search(r"^!gny\s+([^\s]+)\s*.*$", message["text"]) + + if m: + name = m.group(1) + else: + sendsText(message['channel'], + "If you want to gratuitously yell a name, pick a name to yell!", + thread_ts=get_slack_thread(message)) + return + + # If we have been asked to display RTL characters, we need to isolate literally + # every line that cowsay produces to be LTR, as well as a special isolate around + # the text in question. + # + # This is a bit of bidi black magic. Unlike with plusplus, where we isolate based + # on the first strong character, here the input text has _always_ been given in an + # LTR context (since it must be preceded immediately by a strong-LTR string, '!gny'), + # so to mirror what the command said, we're going to isolate it into an LTR context all + # its own again, and let the codepoints fall where they may. + # + # Furthermore, slack-on-the-web appears to render the entire block as RTL if we don't + # individually isolate each line of the cow (since the cow is mostly neutral), oddly, + # slack-on-mobile doesn't appear to do this. Either way, we isolate each line independently + # to force LTR display. + # + # Note that cowsay appears to count the RTL control characters when creating its + # speech bubble, but this error is minor and also not unique to RTL, + # since '!gny @Foo' will do something similar, as we only count the ID, not the + # actual name. + hasRTL = hasRTLCharacters(name) + if hasRTL: + name = "\u2066%s\u2069" % (name) + + msg = cowsay.get_output_string('cow', name.upper() + ' !!!') + + rtlSafeMsg = "" + if hasRTL: + for line in msg.split('\n'): + rtlSafeMsg = rtlSafeMsg + '\u2066' + line + '\u2069\n' + else: + rtlSafeMsg = msg + + sendsText(message['channel'], '```\n' + rtlSafeMsg + "\n```", thread_ts=get_slack_thread(message)) + + +def slack_rip(message): + '''RIP only supported on slack, since slack lacks instances. + + Undocumented command. + + Yes this literally only adds a reaction, since the command is really only a + replacement for the use of the RIP zulip/zephyr instance. + ''' + slackReact(message, "headstone") + + +# Handles all message events from slack. +# +# It would be great if we could use say() to respond, but we can't, since we don't always +# respond in-thread or send to the same thread, and multiple things can respond. So, instead +# callers should used the sends utility functions to do what they need. +def zdaemon_slack_router(triggers, ack, say, message): + # Always ack immediately, since multiple things can respond and some are slow. + # Need to do this even if we are ignoring the message. + ack() + + # DEBUG + # print ("zdaemon_slack_router event: %s[%s]/%s" % + # (message['type'], message['channel_type'], + # message['subtype'] if 'subtype' in message else 'None')) + + + bridge_bot_message = False + if ('subtype' in message and + message['subtype'] == 'bot_message' and + 'bot_id' in message): + # Need to look up the bot and add the user id to the message. + userid = get_slack_bot_userid(message['bot_id']) + message['user'] = userid + + if userid == cfg.SLACK_BRIDGE_BOT_ID: + bridge_bot_message = True + + # DEBUG + # print("bot id: %s userid: %s" % (message['bot_id'], userid)) + + # We only want to respond to real messages. Most subtypes will be awkward for us to handle, + # so only do true messages (no subtype) and replies for now. (note: slack doesn't actually appear + # to send the message_replied subtype as of Sep 2024) + # + # This gets rid of bot_message explicity (so we avoid loops with other bots), which is nice, + # but also removes troublesome things like message_changed and message_deleted which would + # need their own very specific handling. + # + # This does mean additional complexity is required if we want to work over a bridge, such + # as the zulip bridge. In the case of the bridge, we explicitly whitelist the configured + # user id of the bridge and no other bots. + # + # file_share is also a type we need to handle, as if you post a picture with text, + # that's what you get. We ignore the attachments though. In this case, if the event + # has only a file and no other content, the text field will be empty (which is fine). + # + # thread_broadcast subtypes are sent when a message is sent both to its thread and its + # channel. These seem to be paried with a message_changed, which thankfully don't + # seem to be relevant for our purposes. + # + # Note: You really want to do this first before any other check, since we might get a bot + # loop via the other sanity checks below. + if ('subtype' in message and + (message['subtype'] not in ['thread_broadcast', + 'message_replied', + 'file_share', + 'bot_message'] or + (message['subtype'] == 'bot_message' and not bridge_bot_message))): + # DEBUG + # print("zdaemon_slack_router ignoring message: %s" % message) + return + + # Don't handle anything sent privately. + if (message['channel_type'] == 'im'): + # This response is not threaded and that is fine. + say("Sorry, I don't respond to DMs. Talk to me in a channel!") + return + + if (message['channel'] not in cfg.SLACK_CHANNEL_WHITELIST_MAP): + # #general / is_general is the roach motel of slack. + # If it isn't whitelisted, just silently do nothing. + channel_data = get_slack_channel_data(message['channel']) + if (channel_data is not None and + 'is_general' in channel_data and + channel_data['is_general']): + return + + # Otherwise whine loudly. + say("I'm sorry, but I'm only allowed to play in specific channels.\n" \ + "If you think I should be in this channel, please talk to a HoT and one of my handlers.\n" \ + "I will continue to respond with this message until I am removed from this channel.") + return + + + # Bridge bot mesages need to have the preamble sliced off. Yuck. + if bridge_bot_message: + # This regex needs to avoid newlines for the first dot, and accept newlines + # for the group, so we only use re.M, not re.DOTALL + m = re.match(r"\*.+\*: ([\s\S]*)", message['text'], re.M) + if m: + message['text'] = m.group(1) + else: + raise Exception("Could not strip preamble from bridge bot message: %s" % message) + + # DEBUG + # print("zdaemon_slack_router handling message: %s" % message) + + text = message['text'] + + # Bang commands first. + # + # TODO: should these be case insensitive? + if (re.search(r"^!ping($|\s)", text)): + slack_ping(message) + if (re.search(r"^!pinghelp($|\s)", text)): + slack_pinghelp(message) + if (re.search(r"^!zdhelp($|\s)", text)): + slack_zdhelp(message) + + # TODO: Move these to their own "instance replacement" file, + # probably along with csa (from cube). They don't really fit + # in cube or plusplus, and they are a bit too special case for generic triggers. + if (re.search(r"^!gny($|\s)", text, flags=re.I)): + slack_gny(message) + if (re.search(r"^!rip($|\s)", text, flags=re.I)): + slack_rip(message) + + triggers.slack_check_msg(message) + cube.cubeSlackRouter(message) + slack_plusplus_router(message) diff --git a/zdaemon/zsendcube.py b/zdaemon/zsendcube.py new file mode 100644 index 0000000..a369477 --- /dev/null +++ b/zdaemon/zsendcube.py @@ -0,0 +1,56 @@ +#!/usr/bin/python3 +# Copyright (c) 2024, AB Tech +# All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions are met: +# +# * Redistributions of source code must retain the above copyright notice, this +# list of conditions and the following disclaimer. +# +# * Redistributions in binary form must reproduce the above copyright notice, +# this list of conditions and the following disclaimer in the documentation +# and/or other materials provided with the distribution. +# +# * Neither the name of the copyright holder nor the names of its +# contributors may be used to endorse or promote products derived from +# this software without specific prior written permission. +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +# DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE +# FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +# DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +# SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +# CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, +# OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +import argparse + +import config as cfg +import cube +import sys + +parser = argparse.ArgumentParser() +cfg.add_zdaemon_arguments(parser) + +options = parser.parse_args() + +# Load the config +# +# Don't load the channel whitelist, we don't need them to +# just send a cube. +cfg.init_zdaemon_config(options, load_channels=False) + +if not cfg.SENDCUBE_ENABLE: + sys.exit(0) + +# Scorable cube means sucks/rocks is enabled. +send_me = cube.SendableCube(scorable=True) + +if cfg.SLACK_ENABLE: + send_me.sendSlack() +else: + send_me.sendZulip() diff --git a/zdaemon/zudaemon b/zdaemon/zudaemon new file mode 100755 index 0000000..d4fb78c --- /dev/null +++ b/zdaemon/zudaemon @@ -0,0 +1,149 @@ +#!/usr/bin/python3 +# -*- coding: utf-8 -*- +# Copyright (c) 2024, AB Tech +# Copyright (c) 2012 Zulip, Inc. +# All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions are met: +# +# * Redistributions of source code must retain the above copyright notice, this +# list of conditions and the following disclaimer. +# +# * Redistributions in binary form must reproduce the above copyright notice, +# this list of conditions and the following disclaimer in the documentation +# and/or other materials provided with the distribution. +# +# * Neither the name of the copyright holder nor the names of its +# contributors may be used to endorse or promote products derived from +# this software without specific prior written permission. +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +# DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE +# FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +# DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +# SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +# CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, +# OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +# System +import argparse +import functools +from subprocess import Popen, PIPE, STDOUT +import socket +import traceback + +# Zulip +import zulip + +# Slack +from slack_bolt import App +from slack_bolt.adapter.socket_mode import SocketModeHandler +from slack_bolt.error import BoltUnhandledRequestError +from slack_bolt.response import BoltResponse + +# Zdaemon +import config as cfg +import common as zd +from zdaemon import zdaemon_router, zdaemon_slack_router +from triggers import ZdaemonMessageMatchTriggers + +usage = """zudaemon --zconfig-file= [options] + +Zdaemon listener for Zulip and/or Slack. +""" +parser = argparse.ArgumentParser(usage=usage) +cfg.add_zdaemon_arguments(parser) + +group = parser.add_argument_group("zudaemon control options") +group.add_argument("--triggers-file", dest="triggers_file", + help="Location of the yaml triggers file.", + default="./triggers.yaml") + +options = parser.parse_args() + +cfg.init_zdaemon_config(options) + +triggers = ZdaemonMessageMatchTriggers(trigger_config_path=options.triggers_file) + +# OK, we're ready to start. Display our config. +print ("Zudaemon Listener Started for: %s" % ("Slack" if cfg.SLACK_ENABLE else "Zulip")) +cfg.print_config() + + +def get_exception_message(e): + traceback_text = ''.join(traceback.format_exception(e)) + return "**ZDAEMON Top Level Exception Caught: " + str(e) + "\n" + traceback_text + + +def zdaemon_zulip_handler(message): + '''Runs a single message through the python zdaemon implementation.''' + try: + sender_email = message['sender_email'] + + # We only do anything if this is not a message we sent. + if sender_email != cfg.MY_ID: + if message['type'] == 'private': + # Assumes no actual stream called "private" + stream = 'private' + else: + stream = message['display_recipient'] + sender_split = sender_email.split('@') + sender = zd.realID(sender_split[0]) + instance = message['subject'] + content = message['content'] + + # TODO Logging? + # print ('%s / %s / %s / %s' % (sender, instance, content, sender_split)) + + zdaemon_router(stream, instance, sender, sender_email, content, triggers) + except Exception as e: + msg = get_exception_message(e) + zd.sendToMaintainer(msg) + print(msg) + + +def handle_slack_errors(error): + # If we start seeing unhandled events, we can mask them with code like the following. + # However, since we effectively process every message event and don't subscribe to others, + # unhandled events are probably an error for us. + #if isinstance(error, BoltUnhandledRequestError): + # # Debug logging? This will be spammy since every message will generate one. + # return BoltResponse(status=200, body="") + #else: + msg = get_exception_message(error) + zd.sendToMaintainer(msg) + print(msg) + + return BoltResponse(status=500, body="Something Went Wrong") + + +# Final slack config. +app = None +if cfg.SLACK_ENABLE: + # Slack Implementation + # Ignores self events by default, but we will make double-sure anyway. + # Ironically raising an error for unhandled request allows us to reduce errors. + app = App(token = cfg.SLACK_BOT_TOKEN, + ignoring_self_events_enabled=True, + raise_error_for_unhandled_request=True) + + # Register all the commands and other handlers + zdaemon_slack_router_triggers = functools.partial(zdaemon_slack_router, triggers) + app.event('message')(zdaemon_slack_router_triggers) + app.error(handle_slack_errors) + + +# Ready to go! +hello_string = "zdaemon started (%s)" % socket.gethostname() +zd.sendToMaintainer(hello_string) + +# These are blocking calls, and will continuously poll for new messages +if cfg.SLACK_ENABLE: + handler = SocketModeHandler(app, cfg.SLACK_APP_TOKEN) + handler.start() +else: + zd.runzulip(zdaemon_zulip_handler)