Compare commits
10 Commits
7d3137dc61
...
d93b1141d7
Author | SHA1 | Date | |
---|---|---|---|
![]() |
d93b1141d7 | ||
![]() |
cc2fa6f35b | ||
![]() |
05bbf2f8d1 | ||
![]() |
f3c0e0c363 | ||
![]() |
2e3793278d | ||
![]() |
43d450811c | ||
![]() |
b2800252cf | ||
![]() |
4974f97f6b | ||
![]() |
e6b8a0445c | ||
![]() |
8292edb3b6 |
1
.gitignore
vendored
1
.gitignore
vendored
@ -2,3 +2,4 @@ venv
|
||||
__pycache__
|
||||
*.json
|
||||
data.db
|
||||
signal-cli-config
|
||||
|
17
Dockerfile
Normal file
17
Dockerfile
Normal 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
88
README.md
Normal 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.
|
@ -1,5 +1,5 @@
|
||||
{
|
||||
"apiurl": "http://localhost:8080",
|
||||
"apiurl": "http://signal-cli-rest-api:8080",
|
||||
"number": "+4900000000001",
|
||||
"lab_cleaning_signal_base_group": "group.00000000000000000000000000000000000000000000000000000000000="
|
||||
}
|
||||
|
@ -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
0
data/.keep
Normal file
15
docker-compose.yml
Normal file
15
docker-compose.yml
Normal 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
37
list_signal_groups.py
Normal 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
69
main.py
@ -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))
|
||||
|
||||
|
@ -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")
|
||||
|
||||
|
Loading…
x
Reference in New Issue
Block a user