Initial
This commit is contained in:
parent
be6546e4cc
commit
102b74e90a
@ -1,3 +1,5 @@
|
|||||||
[coverage:run]
|
[coverage:run]
|
||||||
relative_files = True
|
relative_files = True
|
||||||
branch = True
|
branch = True
|
||||||
|
omit =
|
||||||
|
*/__init__.py
|
||||||
|
5
.env.default
Normal file
5
.env.default
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
ADMIN_EMAIL=""
|
||||||
|
ADMIN_FIRST_NAME=""
|
||||||
|
BOT_NAME=""
|
||||||
|
N8N_WEBHOOK_URL=""
|
||||||
|
WEBEX_API_KEY=""
|
2
.github/workflows/ci.yml
vendored
2
.github/workflows/ci.yml
vendored
@ -1,7 +1,5 @@
|
|||||||
name: CI
|
name: CI
|
||||||
on:
|
on:
|
||||||
push:
|
|
||||||
branches: [ main ]
|
|
||||||
pull_request:
|
pull_request:
|
||||||
types: [opened, synchronize, reopened]
|
types: [opened, synchronize, reopened]
|
||||||
paths-ignore:
|
paths-ignore:
|
||||||
|
19
.github/workflows/docker.yml
vendored
Normal file
19
.github/workflows/docker.yml
vendored
Normal 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
17
Dockerfile
Normal 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
|
13
README.md
13
README.md
@ -1,7 +1,18 @@
|
|||||||
# template
|
# RoboLuke - Tasks
|
||||||
|
|
||||||
## Description
|
## Description
|
||||||
|
Add tasks to a Wekan to do list via Webex and n8n.
|
||||||
|
|
||||||
## How to install
|
## 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
|
## How to use
|
||||||
|
1. Install Docker and Docker Compose
|
||||||
|
2. Run `docker-compose up -d`
|
||||||
|
0
app/commands/__init__.py
Normal file
0
app/commands/__init__.py
Normal file
26
app/commands/exit.py
Normal file
26
app/commands/exit.py
Normal 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
144
app/commands/submit_task.py
Normal 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
|
34
app/main.py
34
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__":
|
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
0
app/utils/__init__.py
Normal file
36
app/utils/config.py
Normal file
36
app/utils/config.py
Normal 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
7
app/utils/datetime.py
Normal 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
26
app/utils/n8n.py
Normal 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
10
docker-compose.yml
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
---
|
||||||
|
version: "3"
|
||||||
|
services:
|
||||||
|
app:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
restart: unless-stopped
|
||||||
|
env_file: .env
|
||||||
|
...
|
@ -1,5 +1,5 @@
|
|||||||
black
|
black
|
||||||
coverage
|
coverage
|
||||||
|
isort
|
||||||
pylint
|
pylint
|
||||||
pylint-exit
|
|
||||||
pytest
|
pytest
|
||||||
|
@ -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
|
@ -1,8 +1,8 @@
|
|||||||
sonar.organization=luketainton
|
sonar.organization=luketainton
|
||||||
sonar.projectKey=luketainton_
|
sonar.projectKey=luketainton_roboluke-tasks
|
||||||
sonar.projectName=
|
sonar.projectName=roboluke-tasks
|
||||||
sonar.projectVersion=1.0
|
sonar.projectVersion=1.0
|
||||||
sonar.python.version=3.10
|
sonar.python.version=3.11
|
||||||
sonar.python.coverage.reportPaths=coverage.xml
|
sonar.python.coverage.reportPaths=coverage.xml
|
||||||
sonar.python.pylint.reportPaths=lintreport.txt
|
sonar.python.pylint.reportPaths=lintreport.txt
|
||||||
sonar.python.xunit.reportPath=testresults.xml
|
sonar.python.xunit.reportPath=testresults.xml
|
||||||
|
29
tests/test_config.py
Normal file
29
tests/test_config.py
Normal 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
20
tests/test_datetime.py
Normal 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)
|
Loading…
Reference in New Issue
Block a user