├── wren
├── __init__.py
├── http_server.py
├── cli.py
├── telegram.py
├── matrix.py
└── core.py
├── .gitignore
├── LICENSE
├── pyproject.toml
└── README.md
/wren/__init__.py:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | env3
2 | __pycache__
3 | *.egg-info/
4 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2024 Yo'av Moshe
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
23 |
--------------------------------------------------------------------------------
/pyproject.toml:
--------------------------------------------------------------------------------
1 | [project]
2 | name = "wren-tools"
3 | version = "0.5.0"
4 | description = "The simplest task management system with the most advanced features"
5 | readme = "README.md"
6 | requires-python = ">=3.11"
7 | dependencies = [
8 | "croniter==2.0.1",
9 | "pathvalidate==3.2.0",
10 | "platformdirs==4.1.0",
11 | "python-dateutil==2.8.2",
12 | "pytz==2023.3.post1",
13 | ]
14 | classifiers = ["License :: OSI Approved :: MIT License"]
15 | keywords = ["note taking", "todo", "to do", "task management"]
16 |
17 | [project.optional-dependencies]
18 | telegram = [
19 | "telebot==0.0.5",
20 | "tzlocal==5.2",
21 | "idna==3.6",
22 | "APScheduler==3.10.4",
23 | "pyTelegramBotAPI==4.14.1",
24 | "certifi==2023.11.17 ",
25 | "charset-normalizer==3.3.2",
26 | ]
27 | http = ["bottle==0.12.25"]
28 | matrix = ["APScheduler==3.10.4", "simplematrixbotlib==2.10.3"]
29 | llm = ["litellm==1.66.3"]
30 |
31 | [project.urls]
32 | Homepage = "https://github.com/bjesus/wren"
33 |
34 | [project.scripts]
35 | wren = "wren.cli:main"
36 |
37 | [build-system]
38 | requires = ["setuptools"]
39 | build-backend = "setuptools.build_meta"
40 |
41 | [tool.setuptools]
42 | license-files = []
43 |
--------------------------------------------------------------------------------
/wren/http_server.py:
--------------------------------------------------------------------------------
1 | try:
2 | from bottle import route, run, request, abort, auth_basic
3 | except:
4 | print("Please install the HTTP server dependencies: pip install 'wren-notes[http]'")
5 | exit(1)
6 | from wren.core import (
7 | create_new_task,
8 | mark_task_done,
9 | get_tasks,
10 | get_task_content,
11 | config,
12 | )
13 | from json import dumps
14 |
15 | back = "
back"
16 | create_form = "
task created successfully: {filename}
" + back 70 | else: 71 | abort(400, "couldn't create empty task
") 72 | if filename: 73 | return {"task": filename} 74 | else: 75 | abort(400, dumps({"error": "task is empty"})) 76 | 77 | 78 | @route("/" + content + "" + back 84 | return {"content": content} 85 | 86 | 87 | @route("/
" + response + "
" + back 93 | return {"result": response} 94 | 95 | 96 | def start_server(): 97 | run(host="localhost", port=8080) 98 | -------------------------------------------------------------------------------- /wren/cli.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | import os 4 | import argparse 5 | import subprocess 6 | from random import choice 7 | from wren.core import ( 8 | create_new_task, 9 | get_summary, 10 | get_task_content, 11 | get_task_file, 12 | get_tasks, 13 | mark_task_done, 14 | prepend_to_filename, 15 | notes_dir, 16 | config_file, 17 | data_dir, 18 | __version__, 19 | ) 20 | 21 | editor = os.environ.get("EDITOR", "vi") 22 | 23 | 24 | def create_file(name): 25 | filename = create_new_task(name) 26 | print("created task:", filename) 27 | 28 | 29 | def list_files(s="", done=False): 30 | tasks = get_tasks(s, done) 31 | print("".join(map(lambda t: "➜ " + t + "\n", tasks))[:-1]) 32 | 33 | 34 | def print_random(): 35 | tasks = get_tasks("") 36 | print(choice(tasks)) 37 | 38 | 39 | def print_summary(): 40 | summary = get_summary() 41 | print(summary) 42 | 43 | 44 | def edit_content(name): 45 | found, filename = get_task_file(name) 46 | if found: 47 | filepath = os.path.join(notes_dir, filename) 48 | subprocess.run([editor, filepath]) 49 | 50 | 51 | def read_content(name): 52 | content = get_task_content(name) 53 | print(content) 54 | 55 | 56 | def prepend_to_task(name, text): 57 | content = prepend_to_filename(name, text) 58 | print(content) 59 | 60 | 61 | def mark_done(name): 62 | message = mark_task_done(name) 63 | print(message) 64 | 65 | 66 | def main(): 67 | parser = argparse.ArgumentParser() 68 | parser.add_argument("task", nargs="*", help="a new task to be created") 69 | parser.add_argument( 70 | "-l", 71 | "--ls", 72 | "--list", 73 | type=str, 74 | help="List all current tasks. add -d to list done tasks", 75 | nargs="?", 76 | const="", 77 | default=None, 78 | ) 79 | parser.add_argument( 80 | "-d", 81 | "--done", 82 | metavar="foo", 83 | type=str, 84 | nargs="?", 85 | const="", 86 | help="Mark a task as done", 87 | ) 88 | parser.add_argument( 89 | "-r", "--read", metavar="foo", type=str, help="Read a task content" 90 | ) 91 | parser.add_argument( 92 | "-e", "--edit", metavar="foo", type=str, help="Edit a task content" 93 | ) 94 | parser.add_argument( 95 | "--prepend", 96 | metavar="foo", 97 | type=str, 98 | help="Prepend something to the task's filename", 99 | ) 100 | 101 | parser.add_argument( 102 | "-o", "--one", action="store_true", help="Print one random task" 103 | ) 104 | parser.add_argument( 105 | "-s", "--summary", action="store_true", help="Generate a summary" 106 | ) 107 | parser.add_argument("--telegram", action="store_true", help="Start Telegram bot") 108 | parser.add_argument("--matrix", action="store_true", help="Start Matrix bot") 109 | parser.add_argument("--http", action="store_true", help="Start HTTP server") 110 | parser.add_argument("--version", action="store_true", help="Show Wren version") 111 | 112 | args = parser.parse_args() 113 | 114 | if args.ls != None: 115 | list_files(args.ls, args.done != None) 116 | elif args.version: 117 | print("Wren " + __version__) 118 | print("\nconfig: " + config_file) 119 | print("data directory: " + data_dir) 120 | elif args.http: 121 | from wren.http_server import start_server 122 | 123 | start_server() 124 | elif args.telegram: 125 | from wren.telegram import start_bot 126 | 127 | start_bot() 128 | elif args.matrix: 129 | from wren.matrix import start_bot 130 | 131 | start_bot() 132 | elif args.one: 133 | print_random() 134 | elif args.edit: 135 | edit_content(args.edit) 136 | elif args.prepend: 137 | prepend_to_task(" ".join(args.task), args.prepend) 138 | elif args.summary: 139 | print_summary() 140 | elif args.read: 141 | read_content(args.read) 142 | elif args.done: 143 | mark_done(args.done) 144 | else: 145 | if args.task: 146 | create_file(" ".join(args.task)) 147 | else: 148 | list_files("", args.done != None) 149 | 150 | 151 | if __name__ == "__main__": 152 | main() 153 | -------------------------------------------------------------------------------- /wren/telegram.py: -------------------------------------------------------------------------------- 1 | try: 2 | import telebot 3 | from apscheduler.schedulers.background import BackgroundScheduler 4 | from apscheduler.triggers.cron import CronTrigger 5 | except: 6 | print( 7 | "Please install the Telegram bot dependencies: pip install 'wren-notes[telegram]'" 8 | ) 9 | exit(1) 10 | import os 11 | import json 12 | from platformdirs import user_data_dir 13 | from croniter import croniter 14 | from wren.core import ( 15 | create_new_task, 16 | get_summary, 17 | mark_task_done, 18 | get_tasks, 19 | get_task_content, 20 | config, 21 | ) 22 | 23 | bot = telebot.TeleBot(config["telegram_token"]) 24 | allowed_chats = config["allowed_telegram_chats"] 25 | scheduler = BackgroundScheduler() 26 | 27 | data_dir = user_data_dir("wren", "wren") 28 | schedules_path = os.path.join(data_dir, "schedules.json") 29 | 30 | 31 | def only_allowed_chats(func): 32 | def authenticate(*args, **kwargs): 33 | chat_id = args[0].chat.id 34 | if chat_id not in allowed_chats: 35 | bot.send_message( 36 | chat_id, 37 | "please add this chat id to your allowed chats: " + str(chat_id), 38 | ) 39 | return 40 | result = func(*args, **kwargs) 41 | return result 42 | 43 | return authenticate 44 | 45 | 46 | def get_all_schedules(): 47 | try: 48 | with open(schedules_path, "r") as file: 49 | return json.load(file) 50 | except FileNotFoundError: 51 | return [] 52 | 53 | 54 | @bot.message_handler(commands=["list"]) 55 | @only_allowed_chats 56 | def list_tasks(message): 57 | filter = " ".join(message.text.split(" ")[1:]) 58 | tasks = get_tasks(filter) 59 | response = "".join(map(lambda t: "- " + t + "\n", tasks)) 60 | bot.send_message(message.chat.id, response) 61 | 62 | 63 | @bot.message_handler(commands=["summary"]) 64 | @only_allowed_chats 65 | def summary(message): 66 | summary = get_summary() 67 | bot.send_message(message.chat.id, summary) 68 | 69 | 70 | @bot.message_handler(commands=["done", "d", "do", "don"]) 71 | @only_allowed_chats 72 | def mark_as_done(message): 73 | name = " ".join(message.text.split(" ")[1:]) 74 | response = mark_task_done(name) 75 | bot.send_message(message.chat.id, response) 76 | 77 | 78 | @bot.message_handler(commands=["read"]) 79 | @only_allowed_chats 80 | def read_task(message): 81 | name = " ".join(message.text.split(" ")[1:]) 82 | response = get_task_content(name) 83 | bot.send_message(message.chat.id, response) 84 | 85 | 86 | @bot.message_handler(commands=["help"]) 87 | @only_allowed_chats 88 | def help(message): 89 | bot.send_message( 90 | message.chat.id, 91 | "wren\n\n- enter any text to save it as task\n- mark a task as done by `/done foo`", 92 | ) 93 | 94 | 95 | @bot.message_handler(commands=["start"]) 96 | def start(message): 97 | bot.set_my_commands( 98 | [ 99 | telebot.types.BotCommand(command="help", description="Help"), 100 | telebot.types.BotCommand(command="summary", description="Summary"), 101 | telebot.types.BotCommand(command="list", description="List"), 102 | telebot.types.BotCommand(command="schedule", description="Schedule"), 103 | ] 104 | ) 105 | bot.set_chat_menu_button( 106 | message.chat.id, telebot.types.MenuButtonCommands("commands") 107 | ) 108 | bot.send_message(message.chat.id, "wren.\n\nuse /help to get help") 109 | 110 | 111 | @bot.message_handler(commands=["schedule"]) 112 | @only_allowed_chats 113 | def create_scheduled_message(message): 114 | schedule = " ".join(message.text.split(" ")[1:]) 115 | job = [message.chat.id, schedule] 116 | 117 | # read current schedules 118 | schedules = get_all_schedules() 119 | 120 | if not schedule: 121 | my_schedules = [s[1] for s in schedules if s[0] == message.chat.id] 122 | if my_schedules: 123 | bot.send_message( 124 | message.chat.id, 125 | f'your summaries are scheduled for: {", ".join(my_schedules)}', 126 | ) 127 | else: 128 | bot.send_message(message.chat.id, f"you got nothing scheduled!") 129 | return 130 | 131 | if croniter.is_valid(schedule): 132 | # save new schedule 133 | schedules.extend([job]) 134 | with open(schedules_path, "w") as file: 135 | json.dump(schedules, file, indent=2) 136 | 137 | # register it 138 | scheduler.add_job( 139 | send_summary, 140 | CronTrigger.from_crontab(schedule), 141 | kwargs={"chat_id": message.chat.id}, 142 | ) 143 | bot.send_message(message.chat.id, f"Added a schedule: {schedule}") 144 | else: 145 | bot.send_message(message.chat.id, f"Invalid cron format: {schedule}") 146 | 147 | 148 | @bot.message_handler( 149 | func=lambda message: len(message.text) > 3 and not message.text.startswith("/") 150 | ) 151 | @only_allowed_chats 152 | def add(message): 153 | filename = create_new_task(message.text) 154 | bot.send_message(message.chat.id, "added: " + filename) 155 | 156 | 157 | @bot.message_handler(func=lambda message: True) 158 | @only_allowed_chats 159 | def reply_no(message): 160 | bot.reply_to(message, "not sure what you want. seek /help") 161 | 162 | 163 | def send_summary(chat_id): 164 | bot.send_message(chat_id, get_summary()) 165 | 166 | 167 | schedules = get_all_schedules() 168 | for schedule in schedules: 169 | scheduler.add_job( 170 | send_summary, 171 | CronTrigger.from_crontab(schedule[1]), 172 | kwargs={"chat_id": schedule[0]}, 173 | ) 174 | 175 | 176 | def start_bot(): 177 | print("Starting telegram bot") 178 | scheduler.start() 179 | bot.infinity_polling(timeout=10, long_polling_timeout=5) 180 | -------------------------------------------------------------------------------- /wren/matrix.py: -------------------------------------------------------------------------------- 1 | try: 2 | import simplematrixbotlib as botlib 3 | from apscheduler.schedulers.background import BackgroundScheduler 4 | from apscheduler.triggers.cron import CronTrigger 5 | except: 6 | print( 7 | "Please install the Matrix bot dependencies: pip install 'wren-notes[matrix]'" 8 | ) 9 | exit(1) 10 | import os 11 | import json 12 | from croniter import croniter 13 | from platformdirs import user_data_dir 14 | from wren.core import ( 15 | create_new_task, 16 | get_summary, 17 | mark_task_done, 18 | get_tasks, 19 | get_task_content, 20 | config, 21 | ) 22 | 23 | creds = botlib.Creds( 24 | config["matrix_homeserver"], config["matrix_localpart"], config["matrix_password"] 25 | ) 26 | bot = botlib.Bot(creds) 27 | PREFIX = "!" 28 | COMMANDS = ["list", "summary", "done", "done", "do", "d", "read", "help", "schedule"] 29 | scheduler = BackgroundScheduler() 30 | 31 | data_dir = user_data_dir("wren", "wren") 32 | schedules_path = os.path.join(data_dir, "schedules.json") 33 | 34 | 35 | def get_all_schedules(): 36 | try: 37 | with open(schedules_path, "r") as file: 38 | return json.load(file) 39 | except FileNotFoundError: 40 | return [] 41 | 42 | 43 | @bot.listener.on_message_event 44 | async def list_tasks(room, message): 45 | match = botlib.MessageMatch(room, message, bot, PREFIX) 46 | body = message.body 47 | filter = " ".join(body.split(" ")[1:]) 48 | tasks = get_tasks(filter) 49 | if len(tasks) > 0: 50 | response = "".join(map(lambda t: "- " + t + "\n", tasks)) 51 | else: 52 | response = "no current tasks\nenter any text to save it as task" 53 | if match.prefix() and match.command("list") and match.is_not_from_this_bot(): 54 | await bot.api.send_text_message(room.room_id, response) 55 | 56 | 57 | @bot.listener.on_message_event 58 | async def summary(room, message): 59 | match = botlib.MessageMatch(room, message, bot, PREFIX) 60 | summary = get_summary() 61 | if match.prefix() and match.command("summary"): 62 | await bot.api.send_text_message(room.room_id, summary) 63 | 64 | 65 | @bot.listener.on_message_event 66 | async def mark_as_done(room, message): 67 | match = botlib.MessageMatch(room, message, bot, PREFIX) 68 | if ( 69 | match.prefix() 70 | and match.command("done") 71 | or match.command("don") 72 | or match.command("do") 73 | or match.command("d") 74 | and match.is_not_from_this_bot() 75 | ): 76 | text = message.body 77 | name = " ".join(text.split(" ")[1:]) 78 | response = mark_task_done(name) 79 | await bot.api.send_text_message(room.room_id, response) 80 | 81 | 82 | @bot.listener.on_message_event 83 | async def read_task(room, message): 84 | match = botlib.MessageMatch(room, message, bot, PREFIX) 85 | if match.prefix() and match.command("read") and match.is_not_from_this_bot(): 86 | text = message.body 87 | name = " ".join(text.split(" ")[1:]) 88 | response = get_task_content(name) 89 | await bot.api.send_text_message(room.room_id, response) 90 | 91 | 92 | @bot.listener.on_message_event 93 | async def help(room, message): 94 | match = botlib.MessageMatch(room, message, bot, PREFIX) 95 | if match.prefix() and match.command("help") and match.is_not_from_this_bot(): 96 | await bot.api.send_text_message( 97 | room.room_id, 98 | "wren\n\n- enter any text to save it as task\n- mark a task as done by `/done foo`", 99 | ) 100 | 101 | 102 | @bot.listener.on_message_event 103 | async def create_scheduled_message(room, message): 104 | match = botlib.MessageMatch(room, message, bot, PREFIX) 105 | 106 | if match.prefix() and match.command("schedule") and match.is_not_from_this_bot(): 107 | text = message.body 108 | schedule = " ".join(text.split(" ")[1:6]) 109 | task = " ".join(text.split(" ")[6:]) 110 | job = [room.room_id, schedule, task] 111 | 112 | # read current schedules 113 | schedules = get_all_schedules() 114 | nl = "\n* " 115 | if not schedule: 116 | my_schedules = [ 117 | f"{s[2]} at {s[1]}" for s in schedules if s[0] == room.room_id 118 | ] 119 | if my_schedules: 120 | await bot.api.send_text_message( 121 | room.room_id, 122 | f"your scheduled summaries are:{nl}{nl.join(my_schedules)}", 123 | ) 124 | else: 125 | await bot.api.send_text_message( 126 | room.room_id, f"you got nothing scheduled!" 127 | ) 128 | return 129 | 130 | if croniter.is_valid(schedule): 131 | # save new schedule 132 | schedules.extend([job]) 133 | with open(schedules_path, "w") as file: 134 | json.dump(schedules, file, indent=2) 135 | 136 | # register schedule 137 | scheduler.add_job( 138 | send_summary, 139 | CronTrigger.from_crontab(schedule), 140 | kwargs={"room": room.room_id}, 141 | ) 142 | await bot.api.send_text_message( 143 | room.room_id, f"Added a schedule: {job[1]}: {job[2]}" 144 | ) 145 | else: 146 | await bot.api.send_text_message( 147 | room.room_id, f"Invalid cron format: {schedule}" 148 | ) 149 | 150 | 151 | @bot.listener.on_message_event 152 | async def add(room, message): 153 | match = botlib.MessageMatch(room, message, bot, PREFIX) 154 | if not match.prefix() and match.is_not_from_this_bot(): 155 | text = message.body 156 | filename = create_new_task(text) 157 | await bot.api.send_text_message(room.room_id, "added: " + filename) 158 | 159 | 160 | @bot.listener.on_message_event 161 | async def reply_no(room, message): 162 | match = botlib.MessageMatch(room, message, bot, PREFIX) 163 | command = message.body.split()[0] 164 | if match.prefix() and match.is_not_from_this_bot() and command[1:] not in COMMANDS: 165 | await bot.api.send_text_message( 166 | room.room_id, 167 | "not sure what you want. seek !help" + " entered: " + message.body, 168 | ) 169 | 170 | 171 | async def send_summary(room): 172 | await bot.api.send_text_message(room.room_id, get_summary()) 173 | 174 | 175 | schedules = get_all_schedules() 176 | for schedule in schedules: 177 | print("scheduled task: " + schedule[2] + " at " + schedule[1]) 178 | scheduler.add_job( 179 | send_summary, 180 | CronTrigger.from_crontab(schedule[1]), 181 | kwargs={"room": schedule[0]}, 182 | ) 183 | 184 | 185 | def start_bot(): 186 | print("Starting matrix bot") 187 | scheduler.start() 188 | bot.run() 189 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |6 | a note taking application and a to-do management system that is ridiculously simple, yet very advanced. 7 |
8 |
9 |
10 |