This commit is contained in:
Luke Tainton (ltainton) 2023-04-05 20:57:31 +01:00
parent be6546e4cc
commit 102b74e90a
No known key found for this signature in database
21 changed files with 409 additions and 13 deletions

View File

@ -1,3 +1,5 @@
[coverage:run]
relative_files = True
branch = True
omit =
*/__init__.py

5
.env.default Normal file
View File

@ -0,0 +1,5 @@
ADMIN_EMAIL=""
ADMIN_FIRST_NAME=""
BOT_NAME=""
N8N_WEBHOOK_URL=""
WEBEX_API_KEY=""

View File

@ -1,7 +1,5 @@
name: CI
on:
push:
branches: [ main ]
pull_request:
types: [opened, synchronize, reopened]
paths-ignore:

19
.github/workflows/docker.yml vendored Normal file
View File

@ -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 }}

17
Dockerfile Normal file
View File

@ -0,0 +1,17 @@
FROM python:3.11
LABEL maintainer="Luke Tainton <luke@tainton.uk>"
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

View File

@ -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`

0
app/commands/__init__.py Normal file
View File

26
app/commands/exit.py Normal file
View File

@ -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

144
app/commands/submit_task.py Normal file
View File

@ -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

View File

@ -1,8 +1,32 @@
#!/usr/local/bin/python3
#!/usr/bin/env python3
def main():
# Commands here
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
if __name__ == "__main__":
main()
try:
bot: WebexBot = create_bot()
bot.run()
except KeyboardInterrupt:
print("Shutting down bot...")
exit()

0
app/utils/__init__.py Normal file
View File

36
app/utils/config.py Normal file
View File

@ -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()

7
app/utils/datetime.py Normal file
View File

@ -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")

26
app/utils/n8n.py Normal file
View File

@ -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)

10
docker-compose.yml Normal file
View File

@ -0,0 +1,10 @@
---
version: "3"
services:
app:
build:
context: .
dockerfile: Dockerfile
restart: unless-stopped
env_file: .env
...

View File

@ -1,5 +1,5 @@
black
coverage
isort
pylint
pylint-exit
pytest

View File

@ -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

View File

@ -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

29
tests/test_config.py Normal file
View File

@ -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"]

20
tests/test_datetime.py Normal file
View File

@ -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)