From 102b74e90a3ba3f1e4e30860fd9181e47d8ebd63 Mon Sep 17 00:00:00 2001 From: "Luke Tainton (ltainton)" Date: Wed, 5 Apr 2023 20:57:31 +0100 Subject: [PATCH] Initial --- .coveragerc | 4 +- .env.default | 5 + .github/workflows/ci.yml | 2 - .github/workflows/docker.yml | 19 ++++ Dockerfile | 17 +++ README.md | 13 ++- tests/test_main.py => app/__init__.py | 0 app/commands/__init__.py | 0 app/commands/exit.py | 26 +++++ app/commands/submit_task.py | 144 ++++++++++++++++++++++++++ app/main.py | 34 +++++- app/utils/__init__.py | 0 app/utils/config.py | 36 +++++++ app/utils/datetime.py | 7 ++ app/utils/n8n.py | 26 +++++ docker-compose.yml | 10 ++ requirements-dev.txt | 2 +- requirements.txt | 22 ++++ sonar-project.properties | 6 +- tests/test_config.py | 29 ++++++ tests/test_datetime.py | 20 ++++ 21 files changed, 409 insertions(+), 13 deletions(-) create mode 100644 .env.default create mode 100644 .github/workflows/docker.yml create mode 100644 Dockerfile rename tests/test_main.py => app/__init__.py (100%) create mode 100644 app/commands/__init__.py create mode 100644 app/commands/exit.py create mode 100644 app/commands/submit_task.py create mode 100644 app/utils/__init__.py create mode 100644 app/utils/config.py create mode 100644 app/utils/datetime.py create mode 100644 app/utils/n8n.py create mode 100644 docker-compose.yml create mode 100644 tests/test_config.py create mode 100644 tests/test_datetime.py diff --git a/.coveragerc b/.coveragerc index fa2ee67..999351b 100644 --- a/.coveragerc +++ b/.coveragerc @@ -1,3 +1,5 @@ [coverage:run] relative_files = True -branch = True \ No newline at end of file +branch = True +omit = + */__init__.py diff --git a/.env.default b/.env.default new file mode 100644 index 0000000..2991382 --- /dev/null +++ b/.env.default @@ -0,0 +1,5 @@ +ADMIN_EMAIL="" +ADMIN_FIRST_NAME="" +BOT_NAME="" +N8N_WEBHOOK_URL="" +WEBEX_API_KEY="" \ No newline at end of file diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index bfea3be..f829649 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,7 +1,5 @@ name: CI on: - push: - branches: [ main ] pull_request: types: [opened, synchronize, reopened] paths-ignore: diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml new file mode 100644 index 0000000..8fd1363 --- /dev/null +++ b/.github/workflows/docker.yml @@ -0,0 +1,19 @@ +name: Build +on: + push: + branches: [ main ] + + build: + name: GitHub Container Registry + runs-on: ubuntu-latest + needs: [prepare-data] + steps: + - uses: actions/checkout@v2 + - name: Login to GitHub Container Registry + run: echo ${{ secrets.GITHUB_TOKEN }} | docker login ghcr.io -u luketainton --password-stdin + - name: Build image for GitHub Package Registry + run: docker build . --file Dockerfile --tag ghcr.io/luketainton/roboluke-tasks:${{ GITHUB_SHA::7 }} --tag ghcr.io/luketainton/roboluke-tasks:latest + - name: Push image to GitHub Package Registry + run: | + docker push ghcr.io/luketainton/roboluke-tasks:latest + docker push ghcr.io/luketainton/roboluke-tasks:${{ GITHUB_SHA::7 }} diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..b36c552 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,17 @@ +FROM python:3.11 +LABEL maintainer="Luke Tainton " +USER root + +ENV PYTHONPATH="/run:/usr/local/lib/python3.11/lib-dynload:/usr/local/lib/python3.11/site-packages:/usr/local/lib/python3.11" +WORKDIR /run + +RUN mkdir -p /.local && \ + chmod -R 777 /.local && \ + pip install -U pip + +COPY requirements.txt /run/requirements.txt +RUN pip install --no-cache-dir -r requirements.txt + +ENTRYPOINT ["python3", "-B", "-m", "app.main"] + +COPY app /run/app diff --git a/README.md b/README.md index 86b71e3..52be22e 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,18 @@ -# template +# RoboLuke - Tasks ## Description +Add tasks to a Wekan to do list via Webex and n8n. ## How to install +1. Clone the repository +2. Copy `.env.default` to `.env` +3. Edit `.env` as required: + - `ADMIN_EMAIL` - comma-separated list of admin (who owns the to-do list) email addresses + - `ADMIN_FIRST_NAME` - admin first name + - `BOT_NAME` - Webex bot name + - `N8N_WEBHOOK_URL` - n8n webhook URL + - `WEBEX_API_KEY` - Webex API key ## How to use +1. Install Docker and Docker Compose +2. Run `docker-compose up -d` diff --git a/tests/test_main.py b/app/__init__.py similarity index 100% rename from tests/test_main.py rename to app/__init__.py diff --git a/app/commands/__init__.py b/app/commands/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/commands/exit.py b/app/commands/exit.py new file mode 100644 index 0000000..2bc52cd --- /dev/null +++ b/app/commands/exit.py @@ -0,0 +1,26 @@ +#!/usr/bin/env python3 + +import logging + +from webex_bot.models.command import Command + +log: logging.Logger = logging.getLogger(__name__) + + +class ExitCommand(Command): + def __init__(self) -> None: + super().__init__( + command_keyword="exit", + help_message="Exit", + delete_previous_message=True, + ) + self.sender: str = "" + + def pre_execute(self, message, attachment_actions, activity) -> None: + pass + + def execute(self, message, attachment_actions, activity) -> None: + pass + + def post_execute(self, message, attachment_actions, activity) -> None: + pass diff --git a/app/commands/submit_task.py b/app/commands/submit_task.py new file mode 100644 index 0000000..85c2fa0 --- /dev/null +++ b/app/commands/submit_task.py @@ -0,0 +1,144 @@ +#!/usr/bin/env python3 + +import logging + +from webex_bot.models.command import Command +from webex_bot.models.response import Response, response_from_adaptive_card +from webexteamssdk.models.cards import ( + AdaptiveCard, + Column, + ColumnSet, + Date, + FontSize, + FontWeight, + Text, + TextBlock, +) +from webexteamssdk.models.cards.actions import Submit + +from app.utils.config import config +from app.utils.n8n import submit_task + +log: logging.Logger = logging.getLogger(__name__) + + +class SubmitTaskCommand(Command): + def __init__(self) -> None: + super().__init__( + command_keyword="submit_feedback_dstgmyn", + help_message="Submit Task", + chained_commands=[SubmitTaskCallback()], + delete_previous_message=True, + ) + self.sender: str = "" + + def pre_execute(self, message, attachment_actions, activity) -> None: + self.sender = activity.get("actor").get("id") + + def execute(self, message, attachment_actions, activity) -> Response: + + card_body: list = [ + ColumnSet( + columns=[ + Column( + items=[ + TextBlock( + "Submit Task", + weight=FontWeight.BOLDER, + size=FontSize.MEDIUM, + ), + TextBlock( + f"Add a task to {config.admin_first_name}'s To Do list. All fields are required.", + wrap=True, + isSubtle=True, + ), + ], + width=2, + ) + ] + ), + ColumnSet( + columns=[ + Column( + width=2, + items=[ + Text(id="issue_title", placeholder="Summary", maxLength=100), + Text( + id="issue_description", + placeholder="Description", + maxLength=1000, + isMultiline=True, + ), + Date(id="completion_date", placeholder="Completion Date"), + ], + ), + ] + ), + ] + + if self.sender in config.admin_emails: + card_body.append( + ColumnSet( + columns=[ + Column( + width=1, + items=[ + Text( + id="issue_requester", + placeholder="Requester Email (leave blank to submit for yourself)", + maxLength=100, + ), + ], + ), + ] + ), + ) + + card: AdaptiveCard = AdaptiveCard( + body=card_body, + actions=[ + Submit( + title="Submit", + data={ + "callback_keyword": "submit_task_callback_rbamzfyx", + "sender": self.sender, + }, + ), + Submit(title="Cancel", data={"command_keyword": "exit"}), + ], + ) + return response_from_adaptive_card(card) + + +class SubmitTaskCallback(Command): + def __init__(self) -> None: + super().__init__( + card_callback_keyword="submit_task_callback_rbamzfyx", delete_previous_message=True + ) + self.msg: str = "" + + def pre_execute(self, message, attachment_actions, activity) -> None: + issue_title: str = attachment_actions.inputs.get("issue_title") + issue_description: str = attachment_actions.inputs.get("issue_description") + completion_date: str = attachment_actions.inputs.get("completion_date") + + sender: str = attachment_actions.inputs.get("sender") + issue_requester: str = attachment_actions.inputs.get("issue_requester") or sender + + if not issue_title or not issue_description or not completion_date: + self.msg = "Please complete all fields." + return + + result: bool = submit_task( + requestor=issue_requester, + summary=issue_title, + description=issue_description, + completion_date=completion_date, + ) + + self.msg = ( + "Submitting your task..." if result else "Failed to submit task. Please try again." + ) + + def execute(self, message, attachment_actions, activity) -> str: + return self.msg diff --git a/app/main.py b/app/main.py index a6053f8..b84f46a 100644 --- a/app/main.py +++ b/app/main.py @@ -1,8 +1,32 @@ -#!/usr/local/bin/python3 +#!/usr/bin/env python3 + +from webex_bot.webex_bot import WebexBot + +from app.commands.exit import ExitCommand +from app.commands.submit_task import SubmitTaskCommand +from app.utils.config import config + + +def create_bot() -> WebexBot: + # Create a Bot Object + webex_bot: WebexBot = WebexBot( + bot_name=config.bot_name, + teams_bot_token=config.webex_token, + approved_domains=["cisco.com"], + ) + webex_bot.commands.clear() + webex_bot.add_command(SubmitTaskCommand()) + webex_bot.add_command(ExitCommand()) + webex_bot.help_command = SubmitTaskCommand() + webex_bot.help_command.delete_previous_message = True + + return webex_bot -def main(): - # Commands here - if __name__ == "__main__": - main() + try: + bot: WebexBot = create_bot() + bot.run() + except KeyboardInterrupt: + print("Shutting down bot...") + exit() diff --git a/app/utils/__init__.py b/app/utils/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/utils/config.py b/app/utils/config.py new file mode 100644 index 0000000..7b49e27 --- /dev/null +++ b/app/utils/config.py @@ -0,0 +1,36 @@ +#!/usr/bin/env python3 + + +import os + + +class Config: + def __init__(self) -> None: + self.__bot_name: str = os.environ["BOT_NAME"] + self.__webex_token: str = os.environ["WEBEX_API_KEY"] + self.__admin_first_name: str = os.environ["ADMIN_FIRST_NAME"] + self.__admin_emails: list = os.environ["ADMIN_EMAIL"].split(",") + self.__n8n_webhook_url: str = os.environ["N8N_WEBHOOK_URL"] + + @property + def bot_name(self) -> str: + return self.__bot_name + + @property + def webex_token(self) -> str: + return self.__webex_token + + @property + def admin_first_name(self) -> str: + return self.__admin_first_name + + @property + def admin_emails(self) -> list: + return self.__admin_emails + + @property + def n8n_webhook_url(self) -> str: + return self.__n8n_webhook_url + + +config: Config = Config() diff --git a/app/utils/datetime.py b/app/utils/datetime.py new file mode 100644 index 0000000..8526bb1 --- /dev/null +++ b/app/utils/datetime.py @@ -0,0 +1,7 @@ +#!/usr/bin/env python3 + +from datetime import datetime + + +def timestamp_to_date(timestamp: int) -> str: + return datetime.utcfromtimestamp(timestamp).strftime("%Y-%m-%d") diff --git a/app/utils/n8n.py b/app/utils/n8n.py new file mode 100644 index 0000000..eb5dc06 --- /dev/null +++ b/app/utils/n8n.py @@ -0,0 +1,26 @@ +import requests + +from app.utils.config import config + + +def __n8n_post(data: dict) -> bool: + headers: dict = {"Content-Type": "application/json"} + resp: requests.Response = requests.post( + url=config.n8n_webhook_url, + headers=headers, + json=data, + timeout=10, + verify=False, + ) + return bool(resp.status_code == 200) + + +def submit_task(summary, description, completion_date, requestor) -> bool: + print(f"submit_task: {completion_date=}") + data: dict = { + "requestor": requestor, + "title": summary, + "description": description, + "completiondate": completion_date, + } + return __n8n_post(data=data) diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..ee034ed --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,10 @@ +--- +version: "3" +services: + app: + build: + context: . + dockerfile: Dockerfile + restart: unless-stopped + env_file: .env +... \ No newline at end of file diff --git a/requirements-dev.txt b/requirements-dev.txt index 5783865..e5359dc 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -1,5 +1,5 @@ black coverage +isort pylint -pylint-exit pytest diff --git a/requirements.txt b/requirements.txt index e69de29..9d677a3 100644 --- a/requirements.txt +++ b/requirements.txt @@ -0,0 +1,22 @@ +appdirs==1.4.4 +backoff==2.1.2 +certifi==2022.6.15 +charset-normalizer==2.1.0 +coloredlogs==15.0.1 +future==0.18.2 +humanfriendly==10.0 +idna==3.3 +pycodestyle==2.9.1 +PyJWT==2.4.0 +python-dateutil==2.8.2 +python-dotenv==0.21.0 +PyYAML==6.0 +requests==2.28.1 +requests-toolbelt==0.9.1 +six==1.16.0 +toml==0.10.2 +urllib3==1.26.11 +webex-bot==0.3.3 +webexteamssdk==1.6.1 +websockets==10.2 +xmltodict==0.13.0 diff --git a/sonar-project.properties b/sonar-project.properties index c2c2f6d..0c41f9c 100644 --- a/sonar-project.properties +++ b/sonar-project.properties @@ -1,8 +1,8 @@ sonar.organization=luketainton -sonar.projectKey=luketainton_ -sonar.projectName= +sonar.projectKey=luketainton_roboluke-tasks +sonar.projectName=roboluke-tasks sonar.projectVersion=1.0 -sonar.python.version=3.10 +sonar.python.version=3.11 sonar.python.coverage.reportPaths=coverage.xml sonar.python.pylint.reportPaths=lintreport.txt sonar.python.xunit.reportPath=testresults.xml diff --git a/tests/test_config.py b/tests/test_config.py new file mode 100644 index 0000000..ab41d77 --- /dev/null +++ b/tests/test_config.py @@ -0,0 +1,29 @@ +#!/usr/bin/env python3 + +"""Provides test cases for app/utils/config.py.""" + +import os + + +vars: dict = { + "BOT_NAME": "TestBot", + "WEBEX_API_KEY": "testing", + "ADMIN_FIRST_NAME": "Test", + "ADMIN_EMAIL": "test@test.com", + "N8N_WEBHOOK_URL": "https://n8n.test.com/webhook/abcdefg", +} + + +for var, value in vars.items(): + os.environ[var] = value + +# needs to be imported AFTER environment variables are set +from app.utils.config import config # pragma: no cover + + +def test_config() -> None: + assert config.bot_name == vars["BOT_NAME"] + assert config.webex_token == vars["WEBEX_API_KEY"] + assert config.admin_first_name == vars["ADMIN_FIRST_NAME"] + assert config.admin_emails == vars["ADMIN_EMAIL"].split(",") + assert config.n8n_webhook_url == vars["N8N_WEBHOOK_URL"] diff --git a/tests/test_datetime.py b/tests/test_datetime.py new file mode 100644 index 0000000..263cc3d --- /dev/null +++ b/tests/test_datetime.py @@ -0,0 +1,20 @@ +#!/usr/bin/env python3 + +"""Provides test cases for app/utils/datetime.py.""" + +import pytest + +from app.utils.datetime import timestamp_to_date # pragma: no cover + + +def test_correct() -> None: + timestamp: int = 1680722218 + result: str = timestamp_to_date(timestamp) + assert result == "2023-04-05" + + +def test_invalid() -> None: + timestamp: str = "hello" + with pytest.raises(TypeError) as excinfo: + timestamp_to_date(timestamp) + assert "'str' object cannot be interpreted as an integer" in str(excinfo.value)