Compare commits

...

10 Commits

Author SHA1 Message Date
lemoer
d93b1141d7 README: add info how to create a task 2024-12-29 00:42:11 +01:00
lemoer
cc2fa6f35b Task: add unfulfillable_message_sent property 2024-12-29 00:40:25 +01:00
lemoer
05bbf2f8d1 main: move db to data/ such that docker does not create a directory 2024-12-29 00:33:42 +01:00
lemoer
f3c0e0c363 Add docker and README.md 2024-12-28 23:52:19 +01:00
lemoer
2e3793278d LabCleaningBot: additional instructions after join 2024-12-28 18:37:44 +01:00
lemoer
43d450811c LabCleaningBot: send hint for pad to group 2024-12-28 18:32:26 +01:00
lemoer
b2800252cf Task: add pad_url to task 2024-12-28 18:10:22 +01:00
lemoer
4974f97f6b add list_signal_groups.py 2024-12-28 16:53:02 +01:00
lemoer
e6b8a0445c LabCleaningBot: improve verbosity for users 2024-12-28 14:13:15 +01:00
lemoer
8292edb3b6 LabCleaningBot: add assert_is_base_group_admin() 2024-12-28 14:12:35 +01:00
10 changed files with 259 additions and 24 deletions

1
.gitignore vendored
View File

@ -2,3 +2,4 @@ venv
__pycache__
*.json
data.db
signal-cli-config

17
Dockerfile Normal file
View File

@ -0,0 +1,17 @@
# 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 Normal file
View File

@ -0,0 +1,88 @@
# 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.

View File

@ -1,5 +1,5 @@
{
"apiurl": "http://localhost:8080",
"apiurl": "http://signal-cli-rest-api:8080",
"number": "+4900000000001",
"lab_cleaning_signal_base_group": "group.00000000000000000000000000000000000000000000000000000000000="
}

View File

@ -1,18 +1,54 @@
#!/usr/bin/env python
import requests
from result import Err, Ok, Result
from models import Task
import argparse
from sqlmodel import Session, SQLModel, create_engine, select
import datetime
from sqlmodel import Session, SQLModel, create_engine
from main import *
import sys
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__":
parser = argparse.ArgumentParser(description='Create a 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('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('--pad-template-url', type=str, help='URL to pad that contains the task description (will be copied)')
args = parser.parse_args()
@ -20,9 +56,6 @@ if __name__ == "__main__":
print("Invalid due date.", file=sys.stderr)
exit(1)
engine = create_engine("sqlite:///data.db")
SQLModel.metadata.create_all(engine)
with Session(engine) as session:
task = Task(
name=args.name,
@ -30,6 +63,15 @@ if __name__ == "__main__":
due=args.due,
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.commit()
session.refresh(task)

0
data/.keep Normal file
View File

15
docker-compose.yml Normal file
View File

@ -0,0 +1,15 @@
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"

37
list_signal_groups.py Normal file
View File

@ -0,0 +1,37 @@
#!/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)

69
main.py
View File

@ -312,6 +312,12 @@ class LabCleaningBot:
except websockets.exceptions.ConnectionClosed:
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):
# This method is called by the async receiver for each message
envelope = message.envelope
@ -354,23 +360,27 @@ class LabCleaningBot:
match accept_result:
case Ok(AcceptInTime()) | Ok(AcceptAfterRejectAllowed()):
response_msg = """Thank you! You accepted the request.
response_msg = f"""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.
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])
number_of_additional_requests_to_be_sent = request.task.additional_requests_to_be_sent()
number_of_additional_people_needed = request.task.required_number_of_participants - len(request.task.accepted_requests())
if number_of_additional_requests_to_be_sent < 0:
pad_hint = self.pad_hint(request.task)
if number_of_additional_people_needed < 0:
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.",
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,
recipients=[request.task.chatgroup]))
elif number_of_additional_requests_to_be_sent < 1:
elif number_of_additional_people_needed < 1:
self.api.send_message(SendMessageSimple(
message="Enough participants have accepted the task. You can now start coordinating your task.",
message="Enough participants have accepted the task. You can now start coordinating your task." + pad_hint,
recipients=[request.task.chatgroup]))
case Err(AcceptAfterRejectExpired()):
@ -416,7 +426,12 @@ If due to any reason you can not participate, please just change your "👍" rea
# Create a group for the task if it does not exist
if chatgroup is None:
create_result = self.api.create_group(CreateGroupRequest(name=task.name, members=[self.api.number]))
group_creation_request = 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):
return Err(create_result.unwrap_err())
@ -439,9 +454,9 @@ If due to any reason you can not participate, please just change your "👍" rea
# 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
# requests.
unfulfillable_tasks = []
while True:
self.assert_is_base_group_admin()
sync_result = self.sync_members_as_active_users(session)
@ -457,10 +472,13 @@ If due to any reason you can not participate, please just change your "👍" rea
reqs = task.create_additional_requests(now(), session)
if is_err(reqs):
if task not in unfulfillable_tasks:
if not task.unfulfillable_message_sent and len(task.accepted_requests()) > 0:
print("Could not fulfill task: " + task.name)
additional_requested_users = [r.user.name for r in task.requested_requests()]
unfulfillable_tasks.append(task)
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]))
task.unfulfillable_message_sent = True
reqs = reqs.unwrap_err()
else:
@ -470,7 +488,7 @@ If due to any reason you can not participate, please just change your "👍" rea
seconds_to_due = (task.due - request.requested_at).total_seconds()
text = f"""Hi!
You have been requested to participate in the task: {task.name} (starts in {format_seconds(seconds_to_due)}).
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)}).
To accept the request, react with 👍. To reject, react with any other emoji.
@ -498,25 +516,40 @@ You have time to answer for {format_seconds(task.timeout)}."""
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):
api = SignalAPI(config.apiurl, config.number)
bot = LabCleaningBot(api, config.lab_cleaning_signal_base_group)
bot.assert_is_base_group_admin()
print("Bot started.")
await asyncio.gather(
bot.receiver(session),
bot.sync_members_and_tasks(session)
)
with open("config.json", "r") as f:
config = Config.model_validate(json.load(f))
engine = create_engine("sqlite:///data/data.db")
SQLModel.metadata.create_all(engine)
if __name__ == "__main__":
with open("config.json", "r") as f:
config = Config.model_validate(json.load(f))
engine = create_engine("sqlite:///data.db")
SQLModel.metadata.create_all(engine)
with Session(engine) as session:
asyncio.run(main(config, session))

View File

@ -85,6 +85,8 @@ class Task(SQLModel, table=True):
due: datetime.datetime
timeout: int # in seconds
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")