Compare commits
No commits in common. "d93b1141d743f00dcb1adfbb77913bd27fecda50" and "7d3137dc61d768737b911684f952339946e456f2" have entirely different histories.
d93b1141d7
...
7d3137dc61
1
.gitignore
vendored
1
.gitignore
vendored
@ -2,4 +2,3 @@ venv
|
|||||||
__pycache__
|
__pycache__
|
||||||
*.json
|
*.json
|
||||||
data.db
|
data.db
|
||||||
signal-cli-config
|
|
||||||
|
17
Dockerfile
17
Dockerfile
@ -1,17 +0,0 @@
|
|||||||
# Basis-Image (kann je nach Python-Version angepasst werden)
|
|
||||||
FROM python:3.13-slim
|
|
||||||
|
|
||||||
# Arbeitsverzeichnis erstellen
|
|
||||||
WORKDIR /app
|
|
||||||
|
|
||||||
# Requirements in den Container kopieren
|
|
||||||
COPY requirements.txt requirements.txt
|
|
||||||
|
|
||||||
# Abhängigkeiten installieren
|
|
||||||
RUN pip install --no-cache-dir -r requirements.txt
|
|
||||||
|
|
||||||
# Quellcode in den Container kopieren
|
|
||||||
COPY . .
|
|
||||||
|
|
||||||
# Flask Server starten
|
|
||||||
CMD ["python", "-u", "main.py"]
|
|
88
README.md
88
README.md
@ -1,88 +0,0 @@
|
|||||||
# Lab signal bot
|
|
||||||
|
|
||||||
## Starting
|
|
||||||
|
|
||||||
### Configuration
|
|
||||||
|
|
||||||
Create a config:
|
|
||||||
```
|
|
||||||
cp config.json.example config.json
|
|
||||||
vi config.json
|
|
||||||
```
|
|
||||||
|
|
||||||
(Inside the docker container, "http://signal-cli-rest-api:8080" can be resolved to the host of the other docker container. Outside not.)
|
|
||||||
|
|
||||||
### Link the signal-cli bot
|
|
||||||
|
|
||||||
Start containers:
|
|
||||||
|
|
||||||
```
|
|
||||||
docker compose up -d
|
|
||||||
```
|
|
||||||
|
|
||||||
In the beginning, this will only start the `signal-cli-rest-api` docker container. The other container will fail, since the `signal-cli` is not linked to the account. If you already activated the phone number with another signal device, you can register the new device.
|
|
||||||
|
|
||||||
Join the docker do this:
|
|
||||||
```
|
|
||||||
docker exec -it lab-signal-bot-signal-cli-rest-api-1 /bin/bash
|
|
||||||
```
|
|
||||||
|
|
||||||
Change user:
|
|
||||||
```
|
|
||||||
su signal-api
|
|
||||||
```
|
|
||||||
|
|
||||||
Link the new device:
|
|
||||||
```
|
|
||||||
signal-cli --config /home/.local/share/signal-cli link
|
|
||||||
```
|
|
||||||
|
|
||||||
Exit the docker shell.
|
|
||||||
|
|
||||||
Restart docker container:
|
|
||||||
```
|
|
||||||
docker restart lab-signal-bot-signal-cli-rest-api-1
|
|
||||||
```
|
|
||||||
|
|
||||||
From host, list signal groups:
|
|
||||||
```
|
|
||||||
python list_signal_groups.py --api-url http://localhost:8080/ -i
|
|
||||||
```
|
|
||||||
|
|
||||||
Now, you can set the identifier of the group you want in the config.json:
|
|
||||||
```
|
|
||||||
vi config.json
|
|
||||||
```
|
|
||||||
|
|
||||||
Rebuild containers:
|
|
||||||
```
|
|
||||||
docker compose up --build -d
|
|
||||||
```
|
|
||||||
|
|
||||||
Change the ownership of the db, such that the host user 1000 can write it:
|
|
||||||
```
|
|
||||||
sudo chown -R 1000 data/
|
|
||||||
```
|
|
||||||
|
|
||||||
Install venv outside of docker:
|
|
||||||
```
|
|
||||||
pip -m venv venv
|
|
||||||
. venv/bin/activate
|
|
||||||
pip install -r requirements.txt
|
|
||||||
```
|
|
||||||
|
|
||||||
### Creating a task
|
|
||||||
|
|
||||||
Create a task:
|
|
||||||
```
|
|
||||||
python create_task.py "Küche aufräumen" 3 "in 40 minutes" --pad-template-url "https://pad.leinelab.org/bEvDjtyyQIGgZso_B7RIpw"
|
|
||||||
```
|
|
||||||
|
|
||||||
This:
|
|
||||||
- Creates a task called "Küche aufräumen".
|
|
||||||
- Requests 3 people from the base group for it.
|
|
||||||
- The task starts in 40 minutes.
|
|
||||||
- A detailed description of the tasks is found in the hedgedoc pad with url https://pad.leinelab.org/bEvDjtyyQIGgZso_B7RIpw. It will be used as a template for a new pad.
|
|
||||||
|
|
||||||
Implicitly, this means:
|
|
||||||
- People have 1 day to answer their participation requests for the task until the next person is asked. If a different timeout is desired, `--timeout-seconds X` can be specified for the task creation.
|
|
@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"apiurl": "http://signal-cli-rest-api:8080",
|
"apiurl": "http://localhost:8080",
|
||||||
"number": "+4900000000001",
|
"number": "+4900000000001",
|
||||||
"lab_cleaning_signal_base_group": "group.00000000000000000000000000000000000000000000000000000000000="
|
"lab_cleaning_signal_base_group": "group.00000000000000000000000000000000000000000000000000000000000="
|
||||||
}
|
}
|
||||||
|
@ -1,54 +1,18 @@
|
|||||||
#!/usr/bin/env python
|
#!/usr/bin/env python
|
||||||
|
|
||||||
import requests
|
|
||||||
from result import Err, Ok, Result
|
|
||||||
from models import Task
|
from models import Task
|
||||||
import argparse
|
import argparse
|
||||||
from sqlmodel import Session, SQLModel, create_engine
|
from sqlmodel import Session, SQLModel, create_engine, select
|
||||||
from main import *
|
import datetime
|
||||||
|
|
||||||
import sys
|
import sys
|
||||||
import dateparser
|
import dateparser
|
||||||
|
|
||||||
def create_hedgedoc_copy(old_url: str) -> Result[str, str]:
|
|
||||||
# remove query params and #
|
|
||||||
old_url = old_url.split("?")[0].split("#")[0]
|
|
||||||
|
|
||||||
# remove trailing slash
|
|
||||||
if old_url[-1] == "/":
|
|
||||||
old_url = old_url[:-1]
|
|
||||||
|
|
||||||
download_url = old_url + "/download"
|
|
||||||
response = requests.get(download_url)
|
|
||||||
|
|
||||||
if response.status_code != 200:
|
|
||||||
return Err("Failed to download the document.")
|
|
||||||
|
|
||||||
old_content = response.text
|
|
||||||
content = old_content + "\n\n---\n\nThe template for this pad can be found [here](" + old_url + "). If there is something in the tasks that should be changed, you can alter the template carefully."
|
|
||||||
|
|
||||||
pad_base_url = "/".join(old_url.split("/")[0:3])
|
|
||||||
|
|
||||||
# do not follow redirects
|
|
||||||
response = requests.post(
|
|
||||||
pad_base_url + "/new",
|
|
||||||
data=content,
|
|
||||||
headers={"Content-Type": "text/markdown"},
|
|
||||||
allow_redirects=False)
|
|
||||||
|
|
||||||
if response.status_code != 302:
|
|
||||||
return Err(response.text)
|
|
||||||
|
|
||||||
return Ok(response.headers['Location'])
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
parser = argparse.ArgumentParser(description='Create a task')
|
parser = argparse.ArgumentParser(description='Create a task')
|
||||||
parser.add_argument('name', type=str, help='Name of the task')
|
parser.add_argument('name', type=str, help='Name of the task')
|
||||||
parser.add_argument('number_of_participants', type=int, help='Number of participants')
|
parser.add_argument('number_of_participants', type=int, help='Number of participants')
|
||||||
parser.add_argument('due', type=dateparser.parse, help='Due date of the task')
|
parser.add_argument('due', type=dateparser.parse, help='Due date of the task')
|
||||||
parser.add_argument('--timeout_seconds', type=int, help='Timeout in seconds (default = 1 day)', default=24*3600)
|
parser.add_argument('--timeout_seconds', type=int, help='Timeout in seconds (default = 1 day)', default=24*3600)
|
||||||
parser.add_argument('--pad-template-url', type=str, help='URL to pad that contains the task description (will be copied)')
|
|
||||||
|
|
||||||
args = parser.parse_args()
|
args = parser.parse_args()
|
||||||
|
|
||||||
@ -56,6 +20,9 @@ if __name__ == "__main__":
|
|||||||
print("Invalid due date.", file=sys.stderr)
|
print("Invalid due date.", file=sys.stderr)
|
||||||
exit(1)
|
exit(1)
|
||||||
|
|
||||||
|
engine = create_engine("sqlite:///data.db")
|
||||||
|
SQLModel.metadata.create_all(engine)
|
||||||
|
|
||||||
with Session(engine) as session:
|
with Session(engine) as session:
|
||||||
task = Task(
|
task = Task(
|
||||||
name=args.name,
|
name=args.name,
|
||||||
@ -63,15 +30,6 @@ if __name__ == "__main__":
|
|||||||
due=args.due,
|
due=args.due,
|
||||||
timeout=args.timeout_seconds)
|
timeout=args.timeout_seconds)
|
||||||
|
|
||||||
if args.pad_template_url is not None:
|
|
||||||
result = create_hedgedoc_copy(args.pad_template_url)
|
|
||||||
|
|
||||||
if result.is_err():
|
|
||||||
print("Error: " + result.unwrap_err(), file=sys.stderr)
|
|
||||||
exit(1)
|
|
||||||
|
|
||||||
task.pad_url = result.unwrap()
|
|
||||||
|
|
||||||
session.add(task)
|
session.add(task)
|
||||||
session.commit()
|
session.commit()
|
||||||
session.refresh(task)
|
session.refresh(task)
|
||||||
|
@ -1,15 +0,0 @@
|
|||||||
services:
|
|
||||||
signal-cli-rest-api:
|
|
||||||
image: bbernhard/signal-cli-rest-api:latest-dev
|
|
||||||
environment:
|
|
||||||
- MODE=json-rpc #supported modes: json-rpc, native, normal
|
|
||||||
#- AUTO_RECEIVE_SCHEDULE=0 22 * * * #enable this parameter on demand (see description below)
|
|
||||||
ports:
|
|
||||||
- "8080:8080" #map docker port 8080 to host port 8080.
|
|
||||||
volumes:
|
|
||||||
- "./signal-cli-config:/home/.local/share/signal-cli"
|
|
||||||
lab-bot:
|
|
||||||
build: .
|
|
||||||
volumes:
|
|
||||||
- "./config.json:/app/config.json"
|
|
||||||
- "./data:/app/data"
|
|
@ -1,37 +0,0 @@
|
|||||||
#!/usr/bin/env python
|
|
||||||
|
|
||||||
from main import config, SignalAPI
|
|
||||||
from result import is_err
|
|
||||||
import argparse
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
|
|
||||||
parser = argparse.ArgumentParser(description='List all groups')
|
|
||||||
parser.add_argument('-i', "--show-group-ids", action='store_true', help='show group ids')
|
|
||||||
parser.add_argument('--api-url', type=str, default=None, help='API URL')
|
|
||||||
|
|
||||||
args = parser.parse_args()
|
|
||||||
|
|
||||||
if args.api_url is not None:
|
|
||||||
config.apiurl = args.api_url
|
|
||||||
|
|
||||||
api = SignalAPI(config.apiurl, config.number)
|
|
||||||
|
|
||||||
groups_result = api.get_groups()
|
|
||||||
|
|
||||||
if is_err(groups_result):
|
|
||||||
print("Error: " + groups_result.unwrap_err())
|
|
||||||
exit(1)
|
|
||||||
|
|
||||||
groups = groups_result.unwrap()
|
|
||||||
|
|
||||||
for group in groups:
|
|
||||||
suffix = ""
|
|
||||||
if api.number in group.admins:
|
|
||||||
suffix = " (admin)"
|
|
||||||
|
|
||||||
print(group.name + suffix)
|
|
||||||
|
|
||||||
if args.show_group_ids:
|
|
||||||
print(" id: " + group.id)
|
|
||||||
|
|
67
main.py
67
main.py
@ -312,12 +312,6 @@ class LabCleaningBot:
|
|||||||
except websockets.exceptions.ConnectionClosed:
|
except websockets.exceptions.ConnectionClosed:
|
||||||
print("Websockets connection closed. Reestablishing connection.")
|
print("Websockets connection closed. Reestablishing connection.")
|
||||||
|
|
||||||
def pad_hint(self, task: Task) -> str:
|
|
||||||
if task.pad_url is not None:
|
|
||||||
return "\n\nA list of the tasks is linked the description of the group or can be found here: " + task.pad_url + "."
|
|
||||||
else:
|
|
||||||
return ""
|
|
||||||
|
|
||||||
def message_received(self, message: Message, session: Session):
|
def message_received(self, message: Message, session: Session):
|
||||||
# This method is called by the async receiver for each message
|
# This method is called by the async receiver for each message
|
||||||
envelope = message.envelope
|
envelope = message.envelope
|
||||||
@ -360,27 +354,23 @@ class LabCleaningBot:
|
|||||||
|
|
||||||
match accept_result:
|
match accept_result:
|
||||||
case Ok(AcceptInTime()) | Ok(AcceptAfterRejectAllowed()):
|
case Ok(AcceptInTime()) | Ok(AcceptAfterRejectAllowed()):
|
||||||
response_msg = f"""Thank you! You accepted the request.
|
response_msg = """Thank you! You accepted the request.
|
||||||
|
|
||||||
I will add you to a signal group dedicated for this task. You can not directly leave the signal group until the task has started.
|
I will add you to a signal group dedicated for this task. You can not directly leave the signal group until the task has started.
|
||||||
|
|
||||||
If due to any reason you can not participate, please just change your "👍" reaction above to something else or remove the reaction. I will let the others know that you can not participate, remove you from the group and ask another person to overtake your task.
|
If due to any reason you can not participate, please just change your "👍" reaction above to something else or remove the reaction. I will let the others know that you can not participate, remove you from the group and ask another person to overtake your task."""
|
||||||
|
|
||||||
As soon as {request.task.required_number_of_participants} people joined the task, I will let you know. Then you can start coordinate with your group."""
|
|
||||||
|
|
||||||
self.api.add_group_members(request.task.chatgroup, [source])
|
self.api.add_group_members(request.task.chatgroup, [source])
|
||||||
|
|
||||||
number_of_additional_people_needed = request.task.required_number_of_participants - len(request.task.accepted_requests())
|
number_of_additional_requests_to_be_sent = request.task.additional_requests_to_be_sent()
|
||||||
|
|
||||||
pad_hint = self.pad_hint(request.task)
|
if number_of_additional_requests_to_be_sent < 0:
|
||||||
|
|
||||||
if number_of_additional_people_needed < 0:
|
|
||||||
self.api.send_message(SendMessageSimple(
|
self.api.send_message(SendMessageSimple(
|
||||||
message="This task now has more participants than necessary. This can happen if a person has first rejected a request and then accepted it. You can handle this situation as you like." + pad_hint,
|
message="This task now has more participants than necessary. This can happen if a person has first rejected a request and then accepted it. You can handle this situation as you like.",
|
||||||
recipients=[request.task.chatgroup]))
|
recipients=[request.task.chatgroup]))
|
||||||
elif number_of_additional_people_needed < 1:
|
elif number_of_additional_requests_to_be_sent < 1:
|
||||||
self.api.send_message(SendMessageSimple(
|
self.api.send_message(SendMessageSimple(
|
||||||
message="Enough participants have accepted the task. You can now start coordinating your task." + pad_hint,
|
message="Enough participants have accepted the task. You can now start coordinating your task.",
|
||||||
recipients=[request.task.chatgroup]))
|
recipients=[request.task.chatgroup]))
|
||||||
|
|
||||||
case Err(AcceptAfterRejectExpired()):
|
case Err(AcceptAfterRejectExpired()):
|
||||||
@ -426,12 +416,7 @@ As soon as {request.task.required_number_of_participants} people joined the task
|
|||||||
|
|
||||||
# Create a group for the task if it does not exist
|
# Create a group for the task if it does not exist
|
||||||
if chatgroup is None:
|
if chatgroup is None:
|
||||||
group_creation_request = CreateGroupRequest(name=task.name, members=[self.api.number])
|
create_result = self.api.create_group(CreateGroupRequest(name=task.name, members=[self.api.number]))
|
||||||
|
|
||||||
if task.pad_url is not None:
|
|
||||||
group_creation_request.description = task.pad_url
|
|
||||||
|
|
||||||
create_result = self.api.create_group(group_creation_request)
|
|
||||||
|
|
||||||
if is_err(create_result):
|
if is_err(create_result):
|
||||||
return Err(create_result.unwrap_err())
|
return Err(create_result.unwrap_err())
|
||||||
@ -454,9 +439,9 @@ As soon as {request.task.required_number_of_participants} people joined the task
|
|||||||
# Async routine that syncs active members using sync_members_as_active_users(),
|
# Async routine that syncs active members using sync_members_as_active_users(),
|
||||||
# sends out new requests for tasks to active users and handles timeouts for the
|
# sends out new requests for tasks to active users and handles timeouts for the
|
||||||
# requests.
|
# requests.
|
||||||
|
unfulfillable_tasks = []
|
||||||
|
|
||||||
while True:
|
while True:
|
||||||
self.assert_is_base_group_admin()
|
|
||||||
|
|
||||||
sync_result = self.sync_members_as_active_users(session)
|
sync_result = self.sync_members_as_active_users(session)
|
||||||
|
|
||||||
@ -472,13 +457,10 @@ As soon as {request.task.required_number_of_participants} people joined the task
|
|||||||
reqs = task.create_additional_requests(now(), session)
|
reqs = task.create_additional_requests(now(), session)
|
||||||
|
|
||||||
if is_err(reqs):
|
if is_err(reqs):
|
||||||
if not task.unfulfillable_message_sent and len(task.accepted_requests()) > 0:
|
if task not in unfulfillable_tasks:
|
||||||
print("Could not fulfill task: " + task.name)
|
print("Could not fulfill task: " + task.name)
|
||||||
additional_requested_users = [r.user.name for r in task.requested_requests()]
|
|
||||||
|
|
||||||
self.api.send_message(SendMessageSimple(message=f"It was planned to do this task with {task.required_number_of_participants} participants. There are currently {len(additional_requested_users)} unanswered requests. However, currently, no additional users are left to request for this task. Please try to fulfill the tasks as good as you are able to within your group or ask other people directly if they can join your group." + self.pad_hint(task), recipients=[task.chatgroup]))
|
unfulfillable_tasks.append(task)
|
||||||
|
|
||||||
task.unfulfillable_message_sent = True
|
|
||||||
|
|
||||||
reqs = reqs.unwrap_err()
|
reqs = reqs.unwrap_err()
|
||||||
else:
|
else:
|
||||||
@ -488,7 +470,7 @@ As soon as {request.task.required_number_of_participants} people joined the task
|
|||||||
seconds_to_due = (task.due - request.requested_at).total_seconds()
|
seconds_to_due = (task.due - request.requested_at).total_seconds()
|
||||||
text = f"""Hi!
|
text = f"""Hi!
|
||||||
|
|
||||||
You have been requested to participate in the task: {task.name} ({task.required_number_of_participants} participants desired, starts in {format_seconds(seconds_to_due)}).
|
You have been requested to participate in the task: {task.name} (starts in {format_seconds(seconds_to_due)}).
|
||||||
|
|
||||||
To accept the request, react with 👍. To reject, react with any other emoji.
|
To accept the request, react with 👍. To reject, react with any other emoji.
|
||||||
|
|
||||||
@ -516,39 +498,24 @@ You have time to answer for {format_seconds(task.timeout)}."""
|
|||||||
|
|
||||||
await asyncio.sleep(1)
|
await asyncio.sleep(1)
|
||||||
|
|
||||||
def assert_is_base_group_admin(self):
|
|
||||||
group_info_result = self.api.get_group(self.base_group)
|
|
||||||
|
|
||||||
if is_err(group_info_result):
|
|
||||||
print("Error, could not get info about base_group " + self.base_group + ": " + group_info_result.unwrap_err())
|
|
||||||
exit(1)
|
|
||||||
|
|
||||||
group_info = group_info_result.unwrap()
|
|
||||||
if config.number not in group_info.admins:
|
|
||||||
print("Error: Bot is not an admin of the base group.")
|
|
||||||
exit(1)
|
|
||||||
|
|
||||||
|
|
||||||
async def main(config: Config, session: Session):
|
async def main(config: Config, session: Session):
|
||||||
api = SignalAPI(config.apiurl, config.number)
|
api = SignalAPI(config.apiurl, config.number)
|
||||||
|
|
||||||
bot = LabCleaningBot(api, config.lab_cleaning_signal_base_group)
|
bot = LabCleaningBot(api, config.lab_cleaning_signal_base_group)
|
||||||
bot.assert_is_base_group_admin()
|
|
||||||
|
|
||||||
print("Bot started.")
|
|
||||||
|
|
||||||
await asyncio.gather(
|
await asyncio.gather(
|
||||||
bot.receiver(session),
|
bot.receiver(session),
|
||||||
bot.sync_members_and_tasks(session)
|
bot.sync_members_and_tasks(session)
|
||||||
)
|
)
|
||||||
|
|
||||||
with open("config.json", "r") as f:
|
if __name__ == "__main__":
|
||||||
|
|
||||||
|
with open("config.json", "r") as f:
|
||||||
config = Config.model_validate(json.load(f))
|
config = Config.model_validate(json.load(f))
|
||||||
|
|
||||||
engine = create_engine("sqlite:///data/data.db")
|
engine = create_engine("sqlite:///data.db")
|
||||||
SQLModel.metadata.create_all(engine)
|
SQLModel.metadata.create_all(engine)
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
|
|
||||||
with Session(engine) as session:
|
with Session(engine) as session:
|
||||||
asyncio.run(main(config, session))
|
asyncio.run(main(config, session))
|
||||||
|
@ -85,8 +85,6 @@ class Task(SQLModel, table=True):
|
|||||||
due: datetime.datetime
|
due: datetime.datetime
|
||||||
timeout: int # in seconds
|
timeout: int # in seconds
|
||||||
chatgroup: str | None = None # None = to be created
|
chatgroup: str | None = None # None = to be created
|
||||||
pad_url: str | None = None # optional pad url
|
|
||||||
unfulfillable_message_sent: bool = False
|
|
||||||
|
|
||||||
participation_requests: list["ParticipationRequest"] = Relationship(back_populates="task")
|
participation_requests: list["ParticipationRequest"] = Relationship(back_populates="task")
|
||||||
|
|
||||||
|
Loading…
x
Reference in New Issue
Block a user