From 63a167bc4be7aa3a5634a07553121159d6947fe7 Mon Sep 17 00:00:00 2001 From: Andrew Herrington Date: Thu, 29 Oct 2020 22:30:07 -0500 Subject: [PATCH 1/7] feat: new plugins DonationManager and SaDevWebsite This is a two for one PR. Two new plugins! SADevsWebsite: This plugin provides helper methods to manage the SA Devs Website. Right now, it offers a context manager to get you a temporary clone of the website's repo and a way to do a PR to the website. Changing files is an exercise left up to the other plugin writer. DonationManager is for SA Devs Season of Giving. It gives members a way to record a donation with its ./donation command and gives admin tools to manage the lifecycle of a submitted donation. Admins can approve, change, and delete donations. Approved donations are pr'd to the website in groups once every hour by a poller and a slack channel topic is updated with our new donation total. --- DonationManager/donation-manager.plug | 10 + DonationManager/donation-manager.py | 426 +++++++++++++++++++++++++ DonationManager/requirements.txt | 2 + DonationManager/templates/blog-post.md | 71 +++++ SADevsWebsite/README.md | 14 + SADevsWebsite/requirements.txt | 2 + SADevsWebsite/sadevs-website.plug | 9 + SADevsWebsite/sadevs-website.py | 137 ++++++++ 8 files changed, 671 insertions(+) create mode 100644 DonationManager/donation-manager.plug create mode 100644 DonationManager/donation-manager.py create mode 100644 DonationManager/requirements.txt create mode 100644 DonationManager/templates/blog-post.md create mode 100644 SADevsWebsite/README.md create mode 100644 SADevsWebsite/requirements.txt create mode 100644 SADevsWebsite/sadevs-website.plug create mode 100644 SADevsWebsite/sadevs-website.py diff --git a/DonationManager/donation-manager.plug b/DonationManager/donation-manager.plug new file mode 100644 index 0000000..f849e45 --- /dev/null +++ b/DonationManager/donation-manager.plug @@ -0,0 +1,10 @@ +[Core] +Name = DonationManager +Module = donation-manager +DependsOn = SADevsWebsite + +[Documentation] +Description = Manages donations for our SA Devs Season of Giving. + +[Python] +Version = 3 diff --git a/DonationManager/donation-manager.py b/DonationManager/donation-manager.py new file mode 100644 index 0000000..40d65dd --- /dev/null +++ b/DonationManager/donation-manager.py @@ -0,0 +1,426 @@ +import os +from datetime import datetime +from hashlib import sha512 +from threading import RLock +from typing import Any +from typing import Dict +from typing import List + +from decouple import config as get_config +from errbot import arg_botcmd +from errbot import botcmd +from errbot import BotPlugin +from errbot.templating import tenv +from wrapt import synchronized + +DONOR_LOCK = RLock() +RECORDED_LOCK = RLock() +CONFIRMATION_LOCK = RLock() + + +def get_config_item( + key: str, config: Dict, overwrite: bool = False, **decouple_kwargs +) -> Any: + """ + Checks config to see if key was passed in, if not gets it from the environment/config file + + If key is already in config and overwrite is not true, nothing is done. Otherwise, config var is added to config + at key + """ + if key not in config and not overwrite: + config[key] = get_config(key, **decouple_kwargs) + + +class DonationManager(BotPlugin): + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.website_plugin = None + + def configure(self, configuration: Dict) -> None: + """ + Configures the plugin + """ + self.log.debug("Starting Config") + if configuration is None: + configuration = dict() + + get_config_item("DONATION_MANAGER_CHANNEL", configuration) + configuration["DM_CHANNEL_ID"] = self._bot.channelname_to_channelid( + configuration["DONATION_MANAGER_CHANNEL"] + ) + configuration["DM_CHANNEL_IDENTIFIER"] = self.build_identifier( + configuration["DONATION_MANAGER_CHANNEL"] + ) + get_config_item("DONATION_MANAGER_REPORT_CHANNEL", configuration) + configuration["DM_REPORT_CHANNEL_ID"] = self._bot.channelname_to_channelid( + configuration["DONATION_MANAGER_REPORT_CHANNEL"] + ) + configuration["DM_REPORT_CHANNEL_IDENTIFIER"] = self.build_identifier( + configuration["DONATION_MANAGER_REPORT_CHANNEL"] + ) + get_config_item( + "DM_RECORD_POLLER_INTERVAL", configuration, cast=int, default=3600 + ) + super().configure(configuration) + + def activate(self): + super().activate() + with synchronized(CONFIRMATION_LOCK): + try: + self["to_be_confirmed"] + except KeyError: + self["to_be_confirmed"] = dict() + with synchronized(RECORDED_LOCK): + try: + self["to_be_recorded"] + except KeyError: + self["to_be_recorded"] = dict() + with synchronized(DONOR_LOCK): + try: + self["donations"] + except KeyError: + self["donations"] = dict() + self["donation_total"] = self._total_donations() + self.website_plugin = self.get_plugin("SADevsWebsite") + self.start_poller( + self.config["DM_RECORD_POLLER_INTERVAL"], self._record_donations + ) + + def deactivate(self): + super().deactivate() + + @arg_botcmd("amount", type=str) + @arg_botcmd("--make-public", action="store_true", default=False) + def donation(self, msg, amount: str, make_public: bool) -> str: + """ + Record a donation for SA Devs Season of Giving + """ + + if "files" not in msg.extras["slack_event"]: + return ( + "Error: No Receipt.\nPlease attach a PDF receipt of your donation when reporting it using Slack's " + "file attachment to your `./donation` message" + ) + + if "$" not in amount: + return ( + "Error: Please include your amount as a $##.##. i.e. $20.99. You can also use whole numbers like " + "$20" + ) + + amount_float = float(amount.replace("$", "")) + if amount_float <= 0: + return "Error: Donation amount has to be a positive number." + + file_url = msg.extras["slack_event"]["files"][0]["url_private"] + donation_id = sha512( + f"{msg.frm}-{amount}-{file_url}".encode("utf-8") + ).hexdigest()[-8:] + + try: + self._add_donation_for_confirmation( + donation_id, amount_float, file_url, msg.frm, make_public + ) + except Exception as err: + return f"Error: {err}" + + return_msg = f"Your donation of ${amount_float:.2f} has been reported and is being reviewed." + if not make_public: + return_msg = ( + f"{return_msg}\nSince you have elected for your donation to be private, we won't publicize " + f"your name." + ) + return_msg = f"{return_msg}\nThank you so very much for your generosity!" + return return_msg + + @botcmd(admin_only=True) + @arg_botcmd("user", type=str) + @arg_botcmd("amount", type=str) + @arg_botcmd("--make-public", action="store_true", default=False) + def admin_donation(self, msg, amount: str, user: str, make_public: bool) -> str: + """ + As an admin, record a donation for a user that's having issues + """ + if "files" not in msg.extras["slack_event"]: + file_url = "" + else: + file_url = msg.extras["slack_event"]["files"][0]["url_private"] + + if "$" not in amount: + return ( + "Error: Please include your amount as a $##.##. i.e. $20.99. You can also use whole numbers like " + "$20" + ) + + amount_float = float(amount.replace("$", "")) + if amount_float <= 0: + return "Error: Donation amount has to be a positive number." + + donation_id = sha512(f"{user}-{amount}-{file_url}".encode("utf-8")).hexdigest()[ + -8: + ] + user = self.build_identifier(user) + try: + self._add_donation_for_confirmation( + donation_id, amount_float, file_url, user, make_public + ) + except Exception as err: + return f"Error: {err}" + + return_msg = f"The donation of ${amount_float:.2f} has been reported and is ready for review." + if not make_public: + return_msg = ( + f"{return_msg}\nSince you have elected for this donation to be private, we won't publicize " + f"the username." + ) + return return_msg + + @botcmd(admin_only=True) + @arg_botcmd("donation_id", type=str) + def donation_confirm(self, msg, donation_id: str) -> str: + """ + As an admin, confirm a donation + """ + with synchronized(CONFIRMATION_LOCK): + to_be_confirmed = self["to_be_confirmed"] + donation = to_be_confirmed.pop(donation_id, None) + if donation is None: + return f"Error: {donation_id} is not in our donation database." + self["to_be_confirmed"] = to_be_confirmed + + with synchronized(RECORDED_LOCK): + to_be_recorded = self["to_be_recorded"] + to_be_recorded[donation_id] = donation + self["to_be_recorded"] = to_be_recorded + + return f"Donation {donation_id} confirmed. Be on the look out for a PR updating the website" + + @botcmd(admin_only=True) + @arg_botcmd("amount", type=str) + @arg_botcmd("donation_id", type=str) + def donation_change(self, msg, donation_id: str, amount: str) -> str: + """ + As an admin, change a donation amount + """ + if "$" not in amount: + return ( + "Error: Please include your amount as a $##.##. i.e. $20.99. You can also use whole numbers like " + "$20" + ) + + amount_float = float(amount.replace("$", "")) + if amount_float <= 0: + return "Error: Donation amount has to be a positive number." + + with synchronized(CONFIRMATION_LOCK): + to_be_confirmed = self["to_be_confirmed"] + donation = to_be_confirmed.pop(donation_id, None) + if donation is None: + return f"Error: {donation_id} is not in our donation database." + self["to_be_confirmed"] = to_be_confirmed + + self._add_donation_for_confirmation( + donation_id=donation_id, + amount=amount_float, + file_url=donation["file_url"], + user=donation["user"], + make_public=donation["user"] is not None, + ) + return ( + f"Donation {donation_id} has been updated. You can now confirm it with " + f"`./donation confirm {donation_id}`" + ) + + @botcmd(admin_only=True) + @arg_botcmd("donation_id", type=str) + def donation_delete(self, msg, donation_id: str) -> str: + """ + As an admin, delete a donation either because its spam or needs to be re-submitted + """ + with synchronized(CONFIRMATION_LOCK): + try: + to_be_confirmed = self["to_be_confirmed"] + to_be_confirmed.pop(donation_id) + self["to_be_confirmed"] = to_be_confirmed + return f"Removed pending donation {donation_id}" + except KeyError: + pass + + with synchronized(RECORDED_LOCK): + try: + to_be_recorded = self["to_be_recorded"] + to_be_recorded.pop(donation_id) + self["to_be_recorded"] = to_be_recorded + return f"Removed recorded donation {donation_id}" + except KeyError: + pass + + with synchronized(DONOR_LOCK): + try: + donations = self["donations"] + donations.pop(donation_id) + self["donations"] = donations + return ( + f"Removed pr'd donation {donation_id}. This won't remove the donation from the page until a pr" + f"is redone with new donations list. You can do this with ./rebuild donations list" + ) + except KeyError: + return f"Donation {donation_id} is not found." + return f"Donation {donation_id} is not in our donations lists" + + @botcmd(admin_only=True) + def list_donations(self, msg, _) -> str: + """Lists all the donations we have""" + + yield "*Donations still needing confirmation*:" + with synchronized(CONFIRMATION_LOCK): + for id, donation in self["to_be_confirmed"].items(): + yield f"{id}: {donation['user']} - {donation['amount']} - {donation['file_url']}" + + yield "*Donations waiting to be recorded:*" + with synchronized(RECORDED_LOCK): + yield "\n".join( + [ + f"{id}: {donation['user']} - {donation['amount']}" + for id, donation in self["to_be_recorded"].items() + ] + ) + + yield "*Confirmed Donations*:" + with synchronized(DONOR_LOCK): + yield "\n".join( + [ + f"{id}: {donation['user']} - {donation['amount']}" + for id, donation in self["donations"].items() + ] + ) + + @botcmd(admin_only=True) + def rebuild_donations_list(self, msg, *_, **__) -> str: + """ + Rebuilds the websites donations list with the current data + """ + self._record_donations(force=True) + + def _update_blog_post( + self, clone_path: str, donations: Dict, donation_total: float + ) -> List[str]: + """ + Updates the blog post from our template using the donations dict and total + """ + blog_post = ( + tenv() + .get_template("blog-post.md") + .render(total=donation_total, donations=donations) + ) + article_path = os.path.join( + clone_path, "content/articles/SADevs-season-of-giving-2020.md" + ) + with open(article_path, "w") as file: + file.write(blog_post) + + return [article_path] + + def _add_donation_for_confirmation( + self, + donation_id: str, + amount: float, + file_url: str, + user: str, + make_public: bool, + ) -> None: + """ + Adds a donation to be confirmed + """ + if not make_public: + user = None + else: + if type(user) != str: + user = self._get_user_real_name(user) + + with synchronized(CONFIRMATION_LOCK): + try: + to_be_confirmed = self["to_be_confirmed"] + except KeyError: + to_be_confirmed = dict() + if donation_id in to_be_confirmed: + raise KeyError( + "Donation is not unique. Did you already add this donation? If this is in error, " + "reach out to the admins" + ) + + to_be_confirmed[donation_id] = { + "amount": amount, + "file_url": file_url, + "user": user, + } + self["to_be_confirmed"] = to_be_confirmed + + self.send( + self.config["DM_CHANNEL_IDENTIFIER"], + text=f"New donation:\n" + f"Amount: ${amount:.2f}\n" + f"File URL: {file_url}\n" + f"User: {user}\n\n" + f"To approve this donation run `./donation confirm {donation_id}`\n" + f"To change this donation run `./donation change {donation_id} [new amount]`", + ) + + def _get_user_real_name(self, user) -> str: + return self._bot.api_call("users.info", {"user": user.userid})["user"][ + "profile" + ]["real_name"] + + @synchronized(DONOR_LOCK) + def _total_donations(self): + """Totals donation amounts into self['donation_total']""" + total = 0 + for _, donation in self["donations"].items(): + total += donation["amount"] + return total + + @synchronized(RECORDED_LOCK) + def _record_donations(self, force: bool = False) -> None: + """ + Poller that records a donation and turns it into a PR + """ + if len(self["to_be_recorded"].keys()) == 0 and not force: + return + timestamp = int(datetime.now().timestamp()) + + with synchronized(RECORDED_LOCK): + to_be_recorded = self["to_be_recorded"] + self["to_be_recorded"] = dict() + + with synchronized(DONOR_LOCK): + new_donations = {**self["donations"], **to_be_recorded} + self["donations"] = new_donations + + self["donation_total"] = self._total_donations() + branch_name = f"new-donations-{timestamp}" + with self.website_plugin.temp_website_clone( + checkout_branch=branch_name + ) as website_clone: + file_list = self._update_blog_post( + website_clone, new_donations, self["donation_total"] + ) + pr = self.website_plugin.open_website_pr( + website_clone, + file_list, + f"updating with new donations {timestamp}", + f"Donation Manager: New Donations {timestamp}", + "New donations", + ) + + self.send( + self.config["DM_CHANNEL_IDENTIFIER"], text=f"New donation PR:\n" f"{pr}" + ) + + self.log.debug(self.config["DM_REPORT_CHANNEL_ID"]) + self._bot.api_call( + "conversations.setTopic", + { + "channel": self.config["DM_REPORT_CHANNEL_ID"], + "topic": f"Total Donations in SA Dev's Season of Giving: ${self['donation_total']:.2f}", + }, + ) diff --git a/DonationManager/requirements.txt b/DonationManager/requirements.txt new file mode 100644 index 0000000..02713f1 --- /dev/null +++ b/DonationManager/requirements.txt @@ -0,0 +1,2 @@ +python-decouple +wrapt>=1.12.1 diff --git a/DonationManager/templates/blog-post.md b/DonationManager/templates/blog-post.md new file mode 100644 index 0000000..9c2a0c7 --- /dev/null +++ b/DonationManager/templates/blog-post.md @@ -0,0 +1,71 @@ +Title: SA Devs Season of Giving +Date: 11-01-2020 +Tags: season-of-giving, donations, charity +Slug: season-of-giving-2020 +Authors: Andrew Herrington +Summary: Let's come together as a community to do some good this season! + +# SA Devs Season of Giving + +## What is Season of Giving +During the month of November, let's work together as a group to give to those who can use some help. Donate to your +favorite charity (we've got some suggestions below), submit your receipt to SA Devs, and we'll amplify your donation. At +the end of the month, the SA Devs org will match up to $2000 of community donations to the SA Food Bank. + +Want to sponsor some matching funds? You can sponsor a day, a week, or the entire month and any amount/total. Reach out +to the admins and we'll add your matching and notify you of totals! + +We also understand some people might want to donate in other ways and we want to celebrate that too! If you donate your +time, canned goods, or do any other charitable work and you'd like it celebrated as part of SA Devs Season of Giving, +reach out to the admins and we'll add it to our donation list! + +### Suggested Charities +These are charities that we feel are doing good work in our community. Don't feel like you have to donate to these +organizations, but if you're looking for a place to donate these are good choices! + +Do you have an organization you like to work with? Reach out to the admins and we'll add it to our list! + +#### San Antonio Food Bank + + +#### Hill Country Daily Bread + + +####Get Up Community Center + + + +### How to record a donation + +1. Take a screenshot or PDF of your donation receipt ++ Open a DM to `@sadevbot` ++ Send `./donation {amount} --make-public` and attach your receipt to the message +If you want to keep your donation private (the admins will still see your name/info but we won't put it on the site) +leave out --make-public + +Make sure your amount matches the amount on your receipt. You can enter it as $##.## i.e. $20.00 (or even just $20). + +Sadevbot will send your donation on to the admins for review and it'll get added to the website (just below here in +the donations list). Private donations will just list an amount. The bot will also update totals in +`#sadevs-season-of-giving-2020` regularly. + +### What to do if you have an issue +Bot didn't work? Donation didn't show up? Messed up entering a donation? Something else? + +DM any of the admins and we can help. We can record your donation manually, delete a donation for you to resubmit, or +kick the bot into working again. + +## Donation Total And List + +Our Donation Total: {{ total }} + +### Donations + +{% for _, donation in donations.items() -%} +{% if donation['user'] == None -%} +{% set user = "Private" -%} +{% else -%} +{% set user = donation['user'] -%} +{% endif -%} +* {{ user }} - ${{ "%.2f"|format(donation['amount']) }} +{% endfor -%} diff --git a/SADevsWebsite/README.md b/SADevsWebsite/README.md new file mode 100644 index 0000000..8ced487 --- /dev/null +++ b/SADevsWebsite/README.md @@ -0,0 +1,14 @@ +# LocalWebserver +This plugin is a fork of Errbot's [webserver plugin](https://github.com/errbotio/errbot/blob/master/errbot/core_plugins/webserver.py). + +It was decided to fork and modify the upstream webserver plugin for SaDevbot because of how the upstream configures. For +a core plugin like the webserver, I want the config to be done outside of bot interactions itself - as env vars. I also +removed any SSL from the webserver - ssl termination will be handled elsewhere. + +# Restrictions +On Sadevbot, webhooks will not be accessible outside of the bot's container by default. Further config will be required +to setup ingress and internet accessibility. + +# Config +* WEBSERVER_HTTP_HOST: Str, What host to setup the webserver on. Default 127.0.0.1 +* WEBSERVER_HTTP_PORT: Int, What port to setup the webserver on. Default 3142 diff --git a/SADevsWebsite/requirements.txt b/SADevsWebsite/requirements.txt new file mode 100644 index 0000000..95671fb --- /dev/null +++ b/SADevsWebsite/requirements.txt @@ -0,0 +1,2 @@ +delegator.py +python-decouple diff --git a/SADevsWebsite/sadevs-website.plug b/SADevsWebsite/sadevs-website.plug new file mode 100644 index 0000000..ab09976 --- /dev/null +++ b/SADevsWebsite/sadevs-website.plug @@ -0,0 +1,9 @@ +[Core] +Name = SADevsWebsite +Module = sadevs-website + +[Documentation] +Description = This plugin lets sadevbot interact with the SA Devs Website + +[Python] +Version = 3 diff --git a/SADevsWebsite/sadevs-website.py b/SADevsWebsite/sadevs-website.py new file mode 100644 index 0000000..e00745c --- /dev/null +++ b/SADevsWebsite/sadevs-website.py @@ -0,0 +1,137 @@ +import json +import os +from contextlib import contextmanager +from tempfile import TemporaryDirectory +from typing import Any +from typing import Dict +from typing import List + +import delegator +from decouple import config as get_config +from errbot import BotPlugin + + +class GitError(Exception): + pass + + +class GithubError(Exception): + pass + + +def get_config_item( + key: str, config: Dict, overwrite: bool = False, **decouple_kwargs +) -> Any: + """ + Checks config to see if key was passed in, if not gets it from the environment/config file + + If key is already in config and overwrite is not true, nothing is done. Otherwise, config var is added to config + at key + """ + if key not in config and not overwrite: + config[key] = get_config(key, **decouple_kwargs) + + +class SADevsWebsite(BotPlugin): + class GitException(Exception): + pass + + def configure(self, configuration: Dict) -> None: + """ + Configures the plugin + """ + self.log.debug("Starting Config") + if configuration is None: + configuration = dict() + + # name of the channel to post in + get_config_item( + "WEBSITE_GIT_URL", + configuration, + default="git@github.com:SADevs/sadevs.github.io.git", + ) + get_config_item("WEBSITE_GIT_BASE_BRANCH", configuration, default="website") + get_config_item("GITHUB_TOKEN", configuration) + super().configure(configuration) + + def activate(self): + super().activate() + + def deactivate(self): + super().deactivate() + + @contextmanager + def temp_website_clone(self, checkout_branch: str = None) -> str: + """ + Contextmanager that offers a temporary clone of the websites gitrepo that can be used to make changes to the + site + """ + with TemporaryDirectory() as directory: + web_repo_dir = os.path.join(directory, "sadevs-website") + clone_result = self._run_git_cmd( + directory, f"clone {self.config['WEBSITE_GIT_URL']} sadevs-website" + ) + self.log.debug(clone_result) + if checkout_branch is not None: + checkout_result = self._run_git_cmd( + web_repo_dir, f"checkout -b {checkout_branch}" + ) + self.log.debug(checkout_result) + yield web_repo_dir + + def open_website_pr( + self, + website_repo_path: str, + files_changed: List[str], + commit_msg: str, + pr_title: str, + pr_body: str, + ) -> str: + """Opens a PR to the website for the changed files. Returns the PR url""" + file_list_str = " ".join(files_changed) + commit_result = self._run_git_cmd( + website_repo_path, f"commit {file_list_str} -m '{commit_msg}'" + ) + self.log.debug(commit_result) + push = self._run_git_cmd(website_repo_path, "push origin HEAD") + self.log.debug(push) + pr_url = self._run_gh_cli_cmd( + website_repo_path, f'pr create --title "{pr_title}" --body "{pr_body}"' + ) + return pr_url + + def _run_cmd( + self, + cmd: str, + cwd: str, + timeout: int, + exception_type: Exception, + env: Dict = None, + ) -> str: + """Runs a command using delegator and returns stdout. Rasies an exception of exception_type if rc != 0""" + if env is None: + env = dict() + env = {**env, **os.environ.copy()} + command = delegator.run(cmd, block=True, cwd=cwd, timeout=timeout, env=env) + + self.log.debug("CMD %s run as PID %s", cmd, command.pid) + if not command.ok: + try: + output = command.out() + except TypeError: + output = "" + raise exception_type(json.dumps({"stdout": output, "stderr": command.err})) + return command.out + + def _run_git_cmd(self, repo_path: str, cmd: str) -> str: + git_result = self._run_cmd(f"git {cmd}", repo_path, 120, GitError) + return git_result + + def _get_gh_user(self) -> str: + """Auths to the GH api with the PAT. Used to get the current username + validate our GH token works""" + api_response = self._run_gh_cli_cmd("/tmp", "api user") + return json.loads(api_response)["login"] + + def _run_gh_cli_cmd(self, repo_path: str, cmd: str) -> str: + gh_result = self._run_cmd(f"gh {cmd}", repo_path, 120, GithubError) + return gh_result From b4d511c845e8646735ca1cf2dae9ec6f23a18649 Mon Sep 17 00:00:00 2001 From: Andrew Herrington Date: Thu, 5 Nov 2020 15:41:06 -0600 Subject: [PATCH 2/7] ci: make CI install plugin deps --- .github/workflows/tests.yml | 3 +++ ci/install_plugin_deps.sh | 4 ++++ 2 files changed, 7 insertions(+) create mode 100644 ci/install_plugin_deps.sh diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 68b9085..169e9fc 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -37,6 +37,9 @@ jobs: - name: Install Dependencies run: | python3 -m pip install -r test-requirements.txt + - name: Install Plugin Dependencies + run: | + bash ci/install_plugin_deps.sh - name: Unit tests run: | coverage run -m pytest . diff --git a/ci/install_plugin_deps.sh b/ci/install_plugin_deps.sh new file mode 100644 index 0000000..cddbf4c --- /dev/null +++ b/ci/install_plugin_deps.sh @@ -0,0 +1,4 @@ +#!/bin/bash +for file in $(find . -name "requirements.txt"); do + python3 -m pip install -r $file +done From ed1658428f870c06bd2199666fc2264a7578061a Mon Sep 17 00:00:00 2001 From: Andrew Date: Thu, 5 Nov 2020 16:07:41 -0600 Subject: [PATCH 3/7] docs: update CONTRIBUTING.md This adds info about how ConventionalCommits work. --- CONTRIBUTING.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index dbd2ee9..ec6b29f 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -17,6 +17,8 @@ Fork the repo, do your work in your repo, then PR your changes back to the SADev # Unit tests/CI [Unit tests](https://errbot.readthedocs.io/en/latest/user_guide/plugin_development/testing.html) go in tests/. Install test-requirements.txt and then run tests with "pytest" +# Commit Linting +This repo uses convenctional commits. Check out https://www.conventionalcommits.org/en/v1.0.0/ for more info on how to use conventional commits. # I have an idea for a plugin AWESOME! Reach out to the admins (or just @drlordrevandrew) to discuss it. As long as there's no glaring issues (someone else working on something similar, conflict of interest, or a violation of our rules) we'll give you the greenlight you can get started! From 51488998e63a43c6bc475f681ef0871046a3ddbf Mon Sep 17 00:00:00 2001 From: Omar Quimbaya Date: Thu, 5 Nov 2020 15:01:37 -0600 Subject: [PATCH 4/7] fix: wording on channel creation Changed wording of the channel creation message. ci: make CI install plugin deps fix: change wording on channel create and test to pass --- .github/workflows/tests.yml | 3 +++ ChannelMonitor/channel-monitor.py | 2 +- ci/install_plugin_deps.sh | 4 ++++ tests/test_channel_monitor.py | 2 +- 4 files changed, 9 insertions(+), 2 deletions(-) create mode 100644 ci/install_plugin_deps.sh diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 68b9085..169e9fc 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -37,6 +37,9 @@ jobs: - name: Install Dependencies run: | python3 -m pip install -r test-requirements.txt + - name: Install Plugin Dependencies + run: | + bash ci/install_plugin_deps.sh - name: Unit tests run: | coverage run -m pytest . diff --git a/ChannelMonitor/channel-monitor.py b/ChannelMonitor/channel-monitor.py index 50fbe36..d1e9654 100644 --- a/ChannelMonitor/channel-monitor.py +++ b/ChannelMonitor/channel-monitor.py @@ -161,7 +161,7 @@ def _build_log(channel: str, user: str, action: str, timestamp: str) -> Dict: "user": user, "action": action, "timestamp": timestamp, - "string_repr": f"{timestamp}: {channel} was {action}'d by {user}", + "string_repr": f"{timestamp}: {user} {action}d {channel}.", } @staticmethod diff --git a/ci/install_plugin_deps.sh b/ci/install_plugin_deps.sh new file mode 100644 index 0000000..cddbf4c --- /dev/null +++ b/ci/install_plugin_deps.sh @@ -0,0 +1,4 @@ +#!/bin/bash +for file in $(find . -name "requirements.txt"); do + python3 -m pip install -r $file +done diff --git a/tests/test_channel_monitor.py b/tests/test_channel_monitor.py index 9c095be..ebcbbf2 100644 --- a/tests/test_channel_monitor.py +++ b/tests/test_channel_monitor.py @@ -45,7 +45,7 @@ def test_build_log(testbot): assert log["user"] == USER assert log["action"] == "create" assert log["timestamp"] == 12345 - assert log["string_repr"] == f"12345: {CHANNEL} was create'd by {USER}" + assert log["string_repr"] == f"12345: {USER} created {CHANNEL}." def test_log_channel_change(testbot): From 67b5ab947b9f094a605c1ac4d77d401da3d4e991 Mon Sep 17 00:00:00 2001 From: Omar Quimbaya Date: Thu, 5 Nov 2020 17:38:22 -0600 Subject: [PATCH 5/7] docs: updated contributing.md with how to write commit message --- .gitignore | 3 +++ CONTRIBUTING.md | 5 +++++ 2 files changed, 8 insertions(+) diff --git a/.gitignore b/.gitignore index 6b9d1d7..88c5c07 100644 --- a/.gitignore +++ b/.gitignore @@ -136,3 +136,6 @@ config.py errbot.log data/ plugins/ + +# vscode +.vscode/ diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index dbd2ee9..97794d5 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -14,6 +14,11 @@ Create a new folder with the name of your plugin then follow https://errbot.read # Git Workflow Fork the repo, do your work in your repo, then PR your changes back to the SADevs repo. + +# Commit Messages +Follow the guide [here](https://www.conventionalcommits.org/en/v1.0.0/#summary) and make your commit messages meaningful. Commit subject lines should be less than 72 characters long and the body less than 80 characters. + + # Unit tests/CI [Unit tests](https://errbot.readthedocs.io/en/latest/user_guide/plugin_development/testing.html) go in tests/. Install test-requirements.txt and then run tests with "pytest" From fb9d5dbeecb72008e950eea40d25a0879846e494 Mon Sep 17 00:00:00 2001 From: Andrew Herrington Date: Fri, 13 Nov 2020 09:40:25 -0600 Subject: [PATCH 6/7] fix: fix template format of total The total was displaying as a float with a single decimal which does not make sense for a USD amount. This uses format to make it a nice pretty dollar amount --- DonationManager/templates/blog-post.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/DonationManager/templates/blog-post.md b/DonationManager/templates/blog-post.md index 9c2a0c7..ecfc0ea 100644 --- a/DonationManager/templates/blog-post.md +++ b/DonationManager/templates/blog-post.md @@ -57,7 +57,7 @@ kick the bot into working again. ## Donation Total And List -Our Donation Total: {{ total }} +Our Donation Total: ${{ "%.2f" | format(total) }} ### Donations From 5942b80cdc3f9fab0c892af4a4ad284426c9c223 Mon Sep 17 00:00:00 2001 From: Andrew Herrington Date: Sun, 21 Feb 2021 15:03:00 -0600 Subject: [PATCH 7/7] feat: add Channel Archive Janitor --- ChannelMonitor/channel-monitor.py | 210 +++++++++++++++++++++++++ DonationManager/donation-manager.py | 18 ++- LocalWebserver/README.md | 2 +- LocalWebserver/local-webserver.py | 13 +- LocalWebserver/requirements.txt | 2 +- pytest.ini | 5 +- test-requirements.txt | 9 +- tests/test_channel_monitor.py | 228 ++++++++++++++++++++++++++++ tests/test_local_webserver.py | 4 +- 9 files changed, 470 insertions(+), 21 deletions(-) diff --git a/ChannelMonitor/channel-monitor.py b/ChannelMonitor/channel-monitor.py index d1e9654..06472f5 100644 --- a/ChannelMonitor/channel-monitor.py +++ b/ChannelMonitor/channel-monitor.py @@ -1,6 +1,9 @@ +import json +import time from collections import OrderedDict from datetime import datetime from datetime import timedelta +from pathlib import Path from threading import RLock from time import mktime from typing import Any @@ -16,6 +19,7 @@ from wrapt import synchronized CAL_LOCK = RLock() +CAR_LOCK = RLock() def get_config_item( @@ -54,6 +58,40 @@ def configure(self, configuration: Dict) -> None: get_config_item( "CHANMON_LOG_JANITOR_INTERVAL", configuration, default=600, cast=int ) + + get_config_item( + "CHANNEL_ARCHIVE_WHITELIST", + configuration, + default="", + cast=lambda v: [s for s in v.split(",")], + ) + get_config_item( + "CHANNEL_ARCHIVE_MESSAGE_TEMPLATE_PATH", + configuration, + default="/config/channel_archive_template.json", + ) + configuration[ + "CHANNEL_ARCHIVE_MESSAGE_TEMPLATES" + ] = self._get_message_templates( + configuration["CHANNEL_ARCHIVE_MESSAGE_TEMPLATE_PATH"] + ) + get_config_item( + "CHANNEL_ARCHIVE_AT_LEAST_AGE", configuration, default="45", cast=int + ) + configuration["CHANNEL_ARCHIVE_AT_LEAST_AGE_SECONDS"] = ( + configuration["CHANNEL_ARCHIVE_AT_LEAST_AGE"] * 24 * 60 * 60 + ) + + get_config_item( + "CHANNEL_ARCHIVE_LAST_MESSAGE", configuration, default="30", cast=int + ) + configuration["CHANNEL_ARCHIVE_LAST_MESSAGE_SECONDS"] = ( + configuration["CHANNEL_ARCHIVE_LAST_MESSAGE"] * 24 * 60 * 60 + ) + + get_config_item( + "CHANNEL_ARCHIVE_JANITOR_INTERVAL", configuration, default=3600, cast=float + ) super().configure(configuration) def activate(self): @@ -67,11 +105,27 @@ def activate(self): datetime.now().strftime("%Y-%m-%d"): list() } + try: + self["channel_archive_whitelist"] + except KeyError: + self["channel_archive_whitelist"] = self.config["CHANNEL_ARCHIVE_WHITELIST"] + self.start_poller( self.config["CHANMON_LOG_JANITOR_INTERVAL"], self._log_janitor, args=(self.config["CHANMON_LOG_DAYS"]), ) + # Dry run poller + self.start_poller( + self.config["CHANNEL_ARCHIVE_JANITOR_INTERVAL"], + self._channel_janitor, + args=(True), + ) + # archive poller + self.start_poller( + self.config["CHANNEL_ARCHIVE_JANITOR_INTERVAL"] + 3600, + self._channel_janitor, + ) def deactivate(self): self.stop_poller(self._log_janitor, args=(self.config["CHANMON_LOG_DAYS"])) @@ -187,6 +241,155 @@ def _send_log_to_slack(self, log: Dict) -> None: """Sends a log to a slack channel""" self.send(self.config["CHANMON_CHANNEL_ID"], log["string_repr"]) + def _send_archive_message(self, channel: Dict, dry_run: bool) -> None: + """Sends a templated message to channel, based on dry_run + + Arguments: + channel {Dict} -- Channel object + dry_run {bool} -- whether or not this is a dry run + """ + if dry_run: + message = self.config["CHANNEL_ARCHIVE_MESSAGE_TEMPLATES"]["dry_run"] + else: + message = self.config["CHANNEL_ARCHIVE_MESSAGE_TEMPLATES"]["archive"] + + self.send(self.build_identifier(channel["id"]), message) + + def _archive_channel(self, channel: Dict, dry_run: bool) -> None: + """Sends a message to each channel to be archived and archives it, based on dry_run + + Arguments: + channel {Dict} -- Channel object + dry_run {bool} -- Whether this is a dry_run or not + """ + self._send_archive_message(channel, dry_run) + if not dry_run: + response = self._bot.api_call( + "conversations.archive", data={"channel": channel["id"]} + ) + if not response["ok"]: + self.warn_admins( + f"Tried to archive channel {channel['name']} and hit an error: {response['error']}" + ) + + def _should_archive(self, channel: Dict) -> bool: + """Checks if a channel should be archived based on our config + + Arguments: + channel {Dict} -- channel object + + Returns: + bool -- if the channel should be archived + """ + now = int(time.time()) + + # check data we have first, before hitting the slack API again + + # if somehow we get an archived channel, this prevents the error + if channel["is_archived"]: + self.log.debug("channel is archived") + return False + + # only care about slack channels, nothing else + if not channel["is_channel"]: + self.log.debug("channel isn't a channel") + return False + + # check if this is the general channel and cannot be archived + if channel["is_general"]: + self.log.debug("channel is general") + return False + + # check if name whitelisted + if channel["name"] in self["channel_archive_whitelist"]: + self.log.debug("channel name is in whitelist") + return False + + # check if id whitelisted + if channel["id"] in self["channel_archive_whitelist"]: + self.log.debug("channel id is whitelisted") + return False + + # check if the channel is old enough to be archived + if ( + now - channel["created"] + < self.config["CHANNEL_ARCHIVE_AT_LEAST_AGE_SECONDS"] + ): + self.log.debug("channel isn't old enough to archive") + return False + + # check min members + if ( + self.config["CHANNEL_ARCHIVE_MEMBER_COUNT"] != 0 + and channel["num_members"] > self.config["CHANNEL_ARCHIVE_MEMBER_COUNT"] + ): + self.log.debug("channel has too many members to archive") + return False + + # get the ts of the last message in the channel + messages = self._bot.api_call( + "conversations.history", data={"inclusive": 0, "oldest": 0, "count": 50} + ) + if "latest" in messages: + ts = messages["latest"] + self.log.debug(f"Got {ts} from latest") + else: + # if we don't have a latest from the api, try to get the last message in the messages + # If there are no messages, return an absurdly small timestamp (arbitrarily 100) + ts = ( + messages["messages"][-1]["ts"] if len(messages["messages"]) > 0 else 100 + ) + self.log.debug(f"No latest, got TS from message {ts}") + + # check if its been too long since a message in the channel + if now - ts > self.config["CHANNEL_ARCHIVE_LAST_MESSAGE_SECONDS"]: + self.log.debug("channel's last message isn't recent, archiving") + return True + + self.log.debug("shouldarchive is falling through") + return False + + def _get_all_channels(self) -> List[Dict]: + """ + Gets a list of all slack channels from the slack api + + Returns: + List[Dict] -- List of slack channel objects + """ + channels = self._bot.api_call( + "conversations.list", data={"exclude_archived": 1} + ) + return channels["channels"] + + @staticmethod + def _get_message_templates(file_path: str) -> Dict: + """Reads templates from a file or returns defaults + + Arguments: + file_path {str} -- path of the template file to read + + Returns: + Dict -- message templates + """ + if not Path(file_path).is_file(): + return { + "dry_run": "Warning: This channel will be archived on the next archive run due to inactivity. " + + "To prevent this, post a mesage in this channel or whitelist it with `./whitelist #[channel_name]`", + "archive": "This channel is being archived due to inactivity. If you feel this is a mistake you can " + + ".", + } + with open(file_path, mode="r") as fh: + data = json.load(fh) + + if "archive" not in data: + raise Exception("Missing Archive template in template file") + + if "dry_run" not in data: + data["dry_run"] = data["archive"] + + return data + # Poller methods @synchronized(CAL_LOCK) def _log_janitor(self, days_to_keep: int) -> None: @@ -206,3 +409,10 @@ def _log_janitor(self, days_to_keep: int) -> None: if len(cal_log[key]) == 0 and key != today: cal_log.pop(key) self["channel_action_log"] = cal_log + + @synchronized(CAR_LOCK) + def _channel_janitor(self, dry_run: bool = False) -> None: + """Poller that cleans up channels that are old""" + for channel in self._get_all_channels(): + if self._should_archive(channel): + self._archive_channel(channel, dry_run) diff --git a/DonationManager/donation-manager.py b/DonationManager/donation-manager.py index 40d65dd..43382ef 100644 --- a/DonationManager/donation-manager.py +++ b/DonationManager/donation-manager.py @@ -45,16 +45,22 @@ def configure(self, configuration: Dict) -> None: configuration = dict() get_config_item("DONATION_MANAGER_CHANNEL", configuration) - configuration["DM_CHANNEL_ID"] = self._bot.channelname_to_channelid( - configuration["DONATION_MANAGER_CHANNEL"] - ) + configuration["DM_CHANNEL_IDENTIFIER"] = self.build_identifier( configuration["DONATION_MANAGER_CHANNEL"] ) get_config_item("DONATION_MANAGER_REPORT_CHANNEL", configuration) - configuration["DM_REPORT_CHANNEL_ID"] = self._bot.channelname_to_channelid( - configuration["DONATION_MANAGER_REPORT_CHANNEL"] - ) + if self._bot.mode != "test": + configuration["DM_CHANNEL_ID"] = self._bot.channelname_to_channelid( + configuration["DONATION_MANAGER_CHANNEL"] + ) + + configuration["DM_REPORT_CHANNEL_ID"] = self._bot.channelname_to_channelid( + configuration["DONATION_MANAGER_REPORT_CHANNEL"] + ) + else: + configuration["DM_CHANNEL_ID"] = "testing" + configuration["DM_REPORT_CHANNEL_ID"] = "testing" configuration["DM_REPORT_CHANNEL_IDENTIFIER"] = self.build_identifier( configuration["DONATION_MANAGER_REPORT_CHANNEL"] ) diff --git a/LocalWebserver/README.md b/LocalWebserver/README.md index a80aed2..8ced487 100644 --- a/LocalWebserver/README.md +++ b/LocalWebserver/README.md @@ -11,4 +11,4 @@ to setup ingress and internet accessibility. # Config * WEBSERVER_HTTP_HOST: Str, What host to setup the webserver on. Default 127.0.0.1 -* WEBSERVER_HTTP_PORT: Int, What port to setup the webserver on. Default 3142 \ No newline at end of file +* WEBSERVER_HTTP_PORT: Int, What port to setup the webserver on. Default 3142 diff --git a/LocalWebserver/local-webserver.py b/LocalWebserver/local-webserver.py index f43ec0f..6f8881e 100644 --- a/LocalWebserver/local-webserver.py +++ b/LocalWebserver/local-webserver.py @@ -1,14 +1,15 @@ from threading import Thread +from typing import Any +from typing import Dict -from webtest import TestApp +from decouple import config as get_config +from errbot import botcmd +from errbot import BotPlugin +from errbot import webhook from errbot.core_plugins import flask_app +from webtest import TestApp from werkzeug.serving import ThreadedWSGIServer -from errbot import botcmd, BotPlugin, webhook - -from typing import Dict, Any -from decouple import config as get_config - TEST_REPORT = """*** Test Report URL : %s Detected your post as : %s diff --git a/LocalWebserver/requirements.txt b/LocalWebserver/requirements.txt index 8db2356..fd81a45 100644 --- a/LocalWebserver/requirements.txt +++ b/LocalWebserver/requirements.txt @@ -1 +1 @@ -python-decouple \ No newline at end of file +python-decouple diff --git a/pytest.ini b/pytest.ini index fc4860f..6ea6c5d 100644 --- a/pytest.ini +++ b/pytest.ini @@ -2,4 +2,7 @@ norecursedirs = .github .git .idea testpaths = tests env = - D:WEBSERVER_HTTP_PORT=3142 \ No newline at end of file + D:WEBSERVER_HTTP_PORT=3142 + D:GITHUB_TOKEN=testing + D:DONATION_MANAGER_CHANNEL=test + D:DONATION_MANAGER_REPORT_CHANNEL=test diff --git a/test-requirements.txt b/test-requirements.txt index 0c19c29..79fa7d8 100644 --- a/test-requirements.txt +++ b/test-requirements.txt @@ -1,8 +1,9 @@ + +-r LocalWebserver/requirements.txt +-r ChannelMonitor/requirements.txt +coverage errbot pytest pytest-env +pytest-mock pytest-xdist -coverage - --r LocalWebserver/requirements.txt --r ChannelMonitor/requirements.txt diff --git a/tests/test_channel_monitor.py b/tests/test_channel_monitor.py index ebcbbf2..cad7302 100644 --- a/tests/test_channel_monitor.py +++ b/tests/test_channel_monitor.py @@ -1,5 +1,13 @@ +import copy +import json import logging +import os +import time from datetime import datetime +from tempfile import TemporaryDirectory +from uuid import uuid4 + +import pytest extra_plugin_dir = "." @@ -8,6 +16,8 @@ CHANNEL = "#test" USER = "@tester" +TEST_TEMPLATE = {"dry_run": "DRY", "archive": "ARCHIVE"} + def test_print_channel_log(testbot): plugin = testbot.bot.plugin_manager.get_plugin_obj_by_name("ChannelMonitor") @@ -80,3 +90,221 @@ def test_get_logs_text(testbot): assert "12345" in logs_text[0] assert "78901" in logs_text[0] assert "#test2" in logs_text[0] + + +def test_send_archive_message_dry_run(testbot): + plugin = testbot.bot.plugin_manager.get_plugin_obj_by_name("ChannelMonitor") + plugin._send_archive_message({"id": "C012AB3CD"}, dry_run=True) + message = testbot.pop_message() + assert ( + plugin.config["CHANNEL_ARCHIVE_MESSAGE_TEMPLATES"]["dry_run"][0:10] in message + ) + + +def test_send_archive_message(testbot): + plugin = testbot.bot.plugin_manager.get_plugin_obj_by_name("ChannelMonitor") + plugin._send_archive_message({"id": "C012AB3CD"}, dry_run=False) + message = testbot.pop_message() + assert ( + plugin.config["CHANNEL_ARCHIVE_MESSAGE_TEMPLATES"]["archive"][0:10] in message + ) + + +def test_archive_channel_dry_run_success(testbot, mocker): + plugin = testbot.bot.plugin_manager.get_plugin_obj_by_name("ChannelMonitor") + plugin._bot.api_call = mocker.MagicMock(return_value={"ok": True}) + + plugin._archive_channel({"id": "C012AB3CD"}, dry_run=True) + message = testbot.pop_message() + assert ( + plugin.config["CHANNEL_ARCHIVE_MESSAGE_TEMPLATES"]["dry_run"][0:10] in message + ) + + +def test_archive_channel_failure(testbot, mocker): + plugin = testbot.bot.plugin_manager.get_plugin_obj_by_name("ChannelMonitor") + plugin._bot.api_call = mocker.MagicMock(return_value={"ok": False, "error": "test"}) + + plugin._archive_channel({"id": "C012AB3CD", "name": "Test"}, dry_run=False) + message = testbot.pop_message() + assert ( + plugin.config["CHANNEL_ARCHIVE_MESSAGE_TEMPLATES"]["archive"][0:10] in message + ) + message = testbot.pop_message() + assert "Tried to archive channel test and hit an error: test"[0:10] in message + + +def test_archive_channel_success(testbot, mocker): + plugin = testbot.bot.plugin_manager.get_plugin_obj_by_name("ChannelMonitor") + plugin._bot.api_call = mocker.MagicMock(return_value={"ok": True, "error": "test"}) + + plugin._archive_channel({"id": "C012AB3CD", "name": "Test"}, dry_run=False) + message = testbot.pop_message() + assert ( + plugin.config["CHANNEL_ARCHIVE_MESSAGE_TEMPLATES"]["archive"][0:10] in message + ) + + +def test_should_archive(testbot, mocker): + plugin = testbot.bot.plugin_manager.get_plugin_obj_by_name("ChannelMonitor") + + plugin["channel_archive_whitelist"] = ["whitelisted", "C012AB3CD"] + plugin.config["CHANNEL_ARCHIVE_MEMBER_COUNT"] = 10 + + # archived channels should be false + assert plugin._should_archive({"is_archived": True}) is False + + # not a channel should be false + assert plugin._should_archive({"is_archived": False, "is_channel": False}) is False + + # general channel should be false + assert ( + plugin._should_archive( + {"is_archived": False, "is_channel": True, "is_general": True} + ) + is False + ) + + # whitelisted channel should be false + assert ( + plugin._should_archive( + { + "is_archived": False, + "is_channel": True, + "is_general": False, + "name": "whitelisted", + } + ) + is False + ) + + # whitelisted id should be false + assert ( + plugin._should_archive( + { + "is_archived": False, + "is_channel": True, + "is_general": False, + "name": "test", + "id": "C012AB3CD", + } + ) + is False + ) + + # brand new channel should be false + assert ( + plugin._should_archive( + { + "is_archived": False, + "is_channel": True, + "is_general": False, + "name": "test", + "id": "C012AB3", + "created": time.time(), + } + ) + is False + ) + + plugin._bot.api_call = mocker.MagicMock( + return_value={"ok": True, "latest": time.time()} + ) + + # channel with latest right now should be false + assert ( + plugin._should_archive( + { + "is_archived": False, + "is_channel": True, + "is_general": False, + "name": "test", + "id": "C012AB3", + "created": 100, + "num_members": 12, + } + ) + is False + ) + + plugin._bot.api_call = mocker.MagicMock( + return_value={"ok": True, "messages": [{"ts": time.time()}]} + ) + # channel with message right now should be false + assert ( + plugin._should_archive( + { + "is_archived": False, + "is_channel": True, + "is_general": False, + "name": "test", + "id": "C012AB3", + "created": 100, + "num_members": 1, + } + ) + is False + ) + + plugin._bot.api_call = mocker.MagicMock(return_value={"ok": True, "messages": []}) + # channel with no messages should be true + assert ( + plugin._should_archive( + { + "is_archived": False, + "is_channel": True, + "is_general": False, + "name": "test", + "id": "C012AB3", + "created": 100, + "num_members": 1, + } + ) + is True + ) + + plugin._bot.api_call = mocker.MagicMock( + return_value={"ok": True, "messages": [{"ts": 478059599}]} + ) + # channel with no messages since 1985-02-24 should be true + assert ( + plugin._should_archive( + { + "is_archived": False, + "is_channel": True, + "is_general": False, + "name": "test", + "id": "C012AB3", + "created": 100, + "num_members": 1, + } + ) + is True + ) + + +def test_get_message_templates(testbot): + plugin = testbot.bot.plugin_manager.get_plugin_obj_by_name("ChannelMonitor") + + with TemporaryDirectory() as directory: + temp_path = os.path.join(directory, str(uuid4())) + with open(temp_path, "w") as fh: + fh.write(json.dumps(TEST_TEMPLATE)) + result = plugin._get_message_templates(temp_path) + assert result["dry_run"] == "DRY" + assert result["archive"] == "ARCHIVE" + + bad_template = copy.deepcopy(TEST_TEMPLATE) + bad_template.pop("archive") + with open(temp_path, "w") as fh: + fh.write(json.dumps(bad_template)) + with pytest.raises(Exception): + plugin._get_message_templates(temp_path) + + missing_template = copy.deepcopy(TEST_TEMPLATE) + missing_template.pop("dry_run") + with open(temp_path, "w") as fh: + fh.write(json.dumps(missing_template)) + + result = plugin._get_message_templates(temp_path) + assert result["dry_run"] == result["archive"] diff --git a/tests/test_local_webserver.py b/tests/test_local_webserver.py index 2f5bc1f..e073bb6 100644 --- a/tests/test_local_webserver.py +++ b/tests/test_local_webserver.py @@ -1,11 +1,11 @@ import json import logging import os +import socket +from time import sleep import pytest import requests -import socket -from time import sleep extra_plugin_dir = "."