Compare commits

..

No commits in common. "d93b1141d743f00dcb1adfbb77913bd27fecda50" and "7d3137dc61d768737b911684f952339946e456f2" have entirely different histories.

10 changed files with 24 additions and 259 deletions

1
.gitignore vendored
View File

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

View File

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

View File

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

View File

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

View File

@ -1,54 +1,18 @@
#!/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
from main import *
from sqlmodel import Session, SQLModel, create_engine, select
import datetime
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()
@ -56,6 +20,9 @@ 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,
@ -63,15 +30,6 @@ 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)

View File

View File

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

View File

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

69
main.py
View File

@ -312,12 +312,6 @@ 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
@ -360,27 +354,23 @@ class LabCleaningBot:
match accept_result:
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.
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."""
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."""
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_people_needed < 0:
if number_of_additional_requests_to_be_sent < 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." + 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]))
elif number_of_additional_people_needed < 1:
elif number_of_additional_requests_to_be_sent < 1:
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]))
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
if chatgroup is None:
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)
create_result = self.api.create_group(CreateGroupRequest(name=task.name, members=[self.api.number]))
if is_err(create_result):
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(),
# 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)
@ -472,13 +457,10 @@ As soon as {request.task.required_number_of_participants} people joined the task
reqs = task.create_additional_requests(now(), session)
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)
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]))
task.unfulfillable_message_sent = True
unfulfillable_tasks.append(task)
reqs = reqs.unwrap_err()
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()
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.
@ -516,40 +498,25 @@ 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,8 +85,6 @@ 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")