From 1a4a5f121146ead5b3ce01d7608398f0ea94ee50 Mon Sep 17 00:00:00 2001 From: LittleBit Date: Sat, 13 Dec 2025 01:44:35 -0600 Subject: [PATCH] ntfy overhaul --- README.md | 25 +-- example-config.yaml | 49 +++--- teablewatcher.py | 390 +++++++++++++++++++++++++++----------------- 3 files changed, 276 insertions(+), 188 deletions(-) diff --git a/README.md b/README.md index 4c1a6db..e14644e 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # Teable Watcher -A Python script that uses Teable's API to detect changes to tables and sends an email when a new record is added.\ -The script runs in a loop, checking for changes at a set interval. When a change is detected, an email is sent to the configured email addresses. +A Python script that uses Teable's API to detect changes to tables and sends a notification using ntfy when a new record is added.\ +The script runs in a loop, checking for changes at a set interval. When a change is detected, a notification is sent to the configured ntfy topic. ## Running the script You can run this script standalone or with Docker. @@ -39,23 +39,14 @@ This command will pull the latest images and then start the container in the bac ## Configuration -Keys in the `config.yaml` file: -- teable_url: The URL of the Teable instance. This should be https://app.teable.io if you are using the official instance. -- teable_api_key: Your Teable API key. To generate this, sign into Teable and click your name (bottom left) -> Access Token -> Personal access tokens -> Create new token. Make sure to give the token all "read" scopes, and give it permission for the spaces and/or bases you want to watch. -- check_interval: The interval in seconds to check for changes. The example is set to 60 seconds. -- email_sender_settings: The settings for the email sender. This includes the SMTP server, port, username, password, and the sender email address. - - smtp_server: The SMTP server to use. Usually this will be `smtp.` followed by the domain of the email address. - - smtp_port: This is usually 465 or 587. - - smtp_user: The username for the SMTP server. Usually this is the same as the sender email address. - - smtp_pass: The password for the SMTP server. - - sender_email: The email address to send the email from. - - sender_name: The name that emails will appear to be from. -- receivers: A list of email addresses to send the email to. -- watched_bases: Put a Base ID here (starts with `bse`) to watch an entire base. -- ignored_tables: If watching entire bases, you can ignore individual tables by putting the base ID and table ID (starts with `tbl`) here. -- watched_tables: Put a base ID and table ID here to watch a specific table. +See `example-config.yaml`. + +## About ntfy +ntfy (https://ntfy.sh, [binwiederhier/ntfy](https://github.com/binwiederhier/ntfy)) is an open-source push notification server with clients for iOS and Android, and desktop access in a web browser. ## Credits This project was designed to provide notifications for [Teable](https://teable.io), an open-source GUI database. Find its GitHub repository: [teableio/teable](https://github.com/teableio/teable). Teable is licensed under a combination of MIT and AGPL-3.0 licenses. See their repository for more information. +ntfy is dual-licensed with Apache-2.0 and GPL-2.0. See its repository for details. + This project is licensed under the MIT License. See the [LICENSE](LICENSE) file for the full license text. \ No newline at end of file diff --git a/example-config.yaml b/example-config.yaml index c2ea23a..a4fcf4b 100644 --- a/example-config.yaml +++ b/example-config.yaml @@ -1,23 +1,28 @@ -teable_url: "https://app.teable.ai" # URL of your Teable instance; if using the official instance this should be "https://app.teable.ai" -teable_api_key: "KEY_HERE" # get an API key by clicking your name (bottom left) -> Access Token -> Personal access tokens -> Create new token -check_interval: 60 # how often to check for changes in seconds -email_sender_settings: - smtp_server: "smtp.example.com" # SMTP server to use for sending emails - smtp_port: 465 # Port to use for SMTP, usually 465 or 587 - smtp_user: "someone@example.com" # this is usually the same as the sender email - smtp_pass: "PASSWORD_HERE" # password for the SMTP server - sender_email: "someone@example.com" # email address to send emails from - sender_name: "Teable" # the name that emails will appear to be from -receivers: # list of emails to send to - - "someone_else@example.com" -watched_bases: # list of bases to watch, this will watch all tables in a base - - "bseXXXXXXXXXXXXX" # base ID -ignored_tables: # list of tables to ignore when watching entire bases - - baseid: "bseXXXXXXXX" - tableid: "tblXXXXXXXX" -watched_tables: # list of tables to watch individually - - baseid: "bseXXXXXXXX" - tableid: "tblXXXXXXXX" +teable_url: "https://app.teable.ai" # Your Teable instance, the official one is app.teable.ai +teable_api_key: "KEY_HERE" +check_interval: 60 # Check every minute -# Base IDs and Table IDs can be found in the URL when viewing tables in Teable -# Base IDs start with "bse" and Table IDs start with "tbl" +# --- NTFY SETTINGS --- +ntfy_settings: + server: "https://ntfy.sh" # Your ntfy server, the official instance is ntfy.sh + topic: "your_unique_teable_topic" # Enter the same topic here as in your ntfy app + username: null # Set if required + password: null # Set if required +# --------------------- + +# List of bases to watch (all tables in these bases are watched unless ignored) +watched_bases: + - "bseYGyDTy956wcYdtwK" + +# List of base/table pairs to ignore (used for tables inside watched_bases) +# ignored_tables: +# - baseid: "bseYGyDTy956wcYdtwK" +# tableid: "tblxFXUgEuhwN3q6BI3" +# - baseid: "bseYGyDTy956wcYdtwK" +# tableid: "tbllD0gUfrgihtFWmHk" + +# List of individual tables to watch. This is the new, clean structure. +# watched_tables: +# - baseid: "bseNewBaseID" +# tableid: "tblIndividualTableID" +# name: "Important Projects" # Optional friendly name \ No newline at end of file diff --git a/teablewatcher.py b/teablewatcher.py index de6e8f6..7595742 100644 --- a/teablewatcher.py +++ b/teablewatcher.py @@ -1,127 +1,156 @@ -from email.mime.multipart import MIMEMultipart -from email.mime.text import MIMEText import requests import json -import yaml -import time -import smtplib import os +import yaml # <-- NEW IMPORT +import time # <-- NEW IMPORT for main loop -# Configuration file +# Define the path to the configuration file config_file = os.path.join(os.path.dirname(__file__), "data/config.yaml") -config = yaml.safe_load(open(config_file, "r")) + +# ================================================================= +# CONFIGURATION LOADING +# ================================================================= + +def load_config(): + """Loads configuration from the YAML file.""" + try: + with open(config_file, "r") as f: + config = yaml.safe_load(f) + return config + except FileNotFoundError: + print(f"Error: Configuration file not found at {config_file}") + exit(1) + except yaml.YAMLError as e: + print(f"Error parsing YAML configuration: {e}") + exit(1) + +# Load configuration immediately +config = load_config() + +# ================================================================= +# TEABLE CONFIGURATION (VALUES NOW FROM YAML) +# ================================================================= # Teable instance URL; if using the official instance this should be app.teable.io teable_url = config['teable_url'] # API key; generate this by clicking on your name -> Access Token -# Permissions: Read base, Query base, Read table, Read record, Read field api_key = config['teable_api_key'] -# Array of Base IDs to watch; putting a base ID here will watch all tables in that base +# Array of Base IDs to watch base_watches = config['watched_bases'] -# If watching entire bases, put a base-table pair here to ignore a specific table -ignored_tables = config['ignored_tables'] +# Dictionary of base IDs and a LIST of table IDs to ignore within that base. +# IMPORTANT: This is different from the other script's structure. +ignored_tables_raw = config.get('ignored_tables', {}) +ignored_tables = {} +# The old script uses a dict of {base_id: [list of table_ids]}, so we process the YAML list here +for item in ignored_tables_raw: + base_id = item['baseid'] + table_id = item['tableid'] + if base_id not in ignored_tables: + ignored_tables[base_id] = [] + ignored_tables[base_id].append(table_id) + + +# Dictionary for specific table watches, mapping Base IDs to a list of specific Table IDs. +table_watches = config.get('watched_tables_individual', {}) +# The YAML structure is a list of objects, we convert it to the script's required dict structure: +# { "baseId": [ {"id": "tableId1", "name": "Table Name 1"}, ... ] } +table_watches_processed = {} +for item in config.get('watched_tables', []): + base_id = item['baseid'] + table_id = item['tableid'] + table_name = item.get('name', table_id) # Use ID as name if name is missing + + if base_id not in table_watches_processed: + table_watches_processed[base_id] = [] + + table_watches_processed[base_id].append({"id": table_id, "name": table_name}) -# Base-table pairs to watch individually -table_watches = config['watched_tables'] +table_watches = table_watches_processed # Records cache folder; this will store the previous state of the table to compare against +# Using the value from the new config structure for consistency records_cache_dir = os.path.join(os.path.dirname(__file__), "data/records-cache") -# Email settings -mail_debug = 0 -mail_server = config['email_sender_settings']['smtp_server'] -smtp_port = config['email_sender_settings']['smtp_port'] -sender_email = config['email_sender_settings']['sender_email'] -smtp_user = config['email_sender_settings']['smtp_user'] -smtp_pass = config['email_sender_settings']['smtp_pass'] -sender_name = config['email_sender_settings']['sender_name'] - -# List of emails to send to -recipient_emails = config['receivers'] - -check_interval = int(config['check_interval']) - -# Reload configuration -def reload_config(): - global teable_url, api_key, base_watches, ignored_tables, table_watches - global mail_server, smtp_port, sender_email, smtp_user, smtp_pass, sender_name, recipient_emails - - print("Reloading configuration") - config = yaml.safe_load(open(config_file, "r")) - - teable_url = config['teable_url'] - api_key = config['teable_api_key'] - base_watches = config['watched_bases'] - ignored_tables = config['ignored_tables'] - table_watches = config['watched_tables'] - mail_server = config['email_sender_settings']['smtp_server'] - smtp_port = config['email_sender_settings']['smtp_port'] - sender_email = config['email_sender_settings']['sender_email'] - smtp_user = config['email_sender_settings']['smtp_user'] - smtp_pass = config['email_sender_settings']['smtp_pass'] - sender_name = config['email_sender_settings']['sender_name'] - recipient_emails = config['receivers'] - +# ================================================================= +# NTFY CONFIGURATION (VALUES NOW FROM YAML) +# ================================================================= -# Email logic -mailsender = None -def mailconnect(): - global mailsender - # Initialize the SMTP connection here - mailsender = smtplib.SMTP_SSL(mail_server, smtp_port) - mailsender.login(smtp_user, smtp_pass) +ntfy_settings = config['ntfy_settings'] +ntfy_topic = ntfy_settings['topic'] +ntfy_url = ntfy_settings['server'] +ntfy_username = ntfy_settings.get('username') +ntfy_password = ntfy_settings.get('password') -def mailsend(subject, body): - for receiver in recipient_emails: - mailsend_logic(subject, body, receiver) +check_interval = int(config.get('check_interval', 60)) -def mailsend_logic(subject, body, receiver): - message = MIMEMultipart() - message['From'] = f"{sender_name} <{sender_email}>" - message['To'] = receiver - message['Subject'] = f"Teable Watcher: {subject}" - body_text = MIMEText(f"{body}\n\nTeable Watcher\na tool by LittleBit", 'plain') - message.attach(body_text) +# Function to send an ntfy notification +def send_ntfy_notification(title, body, click_url=None): + """Sends a notification using the ntfy service via HTTP POST.""" + headers = { + "Title": "Teable Watcher: " + title, + "Priority": "5", # 'urgent' for new records + "Tags": "bell,teable,new_record", + "Content-Type": "text/plain" + } + + # Add the Click header if a URL is provided + if click_url: + headers["Click"] = click_url + + auth = None + if ntfy_username and ntfy_password: + auth = (ntfy_username, ntfy_password) try: - mailsender.send_message(msg=message) - print("Email sent successfully!") - except smtplib.SMTPException as e: - print(f"Error sending email: {e}") - print("Resetting connection to email server") - mailconnect() - try: - mailsender.send_message(msg=message) - print("Email sent successfully after reconnecting!") - except smtplib.SMTPException as e: - print(f"Failed to send email after reconnecting: {e}") + # The message body is sent as the POST data + response = requests.post( + f"{ntfy_url}/{ntfy_topic}", + data=body.encode('utf-8'), + headers=headers, + auth=auth + ) + response.raise_for_status() + print(f"ntfy notification sent successfully to topic '{ntfy_topic}'!") + except requests.exceptions.RequestException as e: + print(f"Error sending ntfy notification: {e}") + +# ================================================================= +# INITIALIZATION & EXECUTION +# ================================================================= + +def run_watcher(): + """The main logic for checking Teable and sending notifications.""" -# Checking connection to Teable -def connection_check(): + # Checking connection to Teable print("Checking connection to Teable") response = None if len(base_watches) > 0: - response = requests.get(f"{teable_url}/api/base/{base_watches[0]}", headers={"Authorization": f"Bearer {api_key}"}) + response = requests.get(f"{teable_url}/api/base/{base_watches[0]}/table", headers={"Authorization": f"Bearer {api_key}"}) elif len(table_watches) > 0: - response = requests.get(f"{teable_url}/api/table/{table_watches[0]}/record", headers={"Authorization": f"Bearer {api_key}"}) + # Pick the first table from the first base in the table_watches dict for the check + first_base_id = list(table_watches.keys())[0] + first_table_id = table_watches[first_base_id][0]['id'] + if first_table_id: + response = requests.get(f"{teable_url}/api/table/{first_table_id}/record", headers={"Authorization": f"Bearer {api_key}"}) + else: + print("Table watch configuration error.") + return else: print("Nothing to watch.") - return False + return if response.status_code != 200: print(f"Error connecting to Teable: {response.text}") - return False + return else: print("Connected to Teable") - return True - -# Main logic -def main(): + + # Get the list of files in the records cache folder cache_files = [] try: cache_files = os.listdir(records_cache_dir) @@ -131,107 +160,170 @@ def main(): os.makedirs(records_cache_dir) cache_files = [] + # ================================================================= + # WATCH BASES + # ================================================================= for base in base_watches: print(f"Watching base {base}") try: response = requests.get(f"{teable_url}/api/base/{base}/table", headers={"Authorization": f"Bearer {api_key}"}) - if response.status_code != 200: - print(f"Error getting tables in base {base}: {response.text}") - continue except requests.exceptions.RequestException as e: print(f"Exception occurred while getting tables in base {base}: {e}") continue + + if response.status_code != 200: + print(f"Error getting tables in base {base}: {response.text}") + continue tables = json.loads(response.text) + + # Get the list of ignored table IDs for the current base + tables_to_ignore = ignored_tables.get(base, []) + for table in tables: - if {"baseid": base, "tableid": table["id"]} in ignored_tables: - print(f"Ignoring table {table['id']}") + # Check if the table should be ignored + if table["id"] in tables_to_ignore: + print(f"Ignoring table {table['name']} based on config.") continue print(f"Watching table {table['name']} as part of base {base}") + # Fetch records for the current table try: response = requests.get(f"{teable_url}/api/table/{table['id']}/record", headers={"Authorization": f"Bearer {api_key}"}) - if response.status_code != 200: - print(f"Error getting records in table {table['tableid']}: {response.text}") - continue except requests.exceptions.RequestException as e: print(f"Exception occurred while getting records in table {table['name']}: {e}") continue + if response.status_code != 200: + print(f"Error fetching records for table {table['name']}: {response.text}") + continue + records = json.loads(response.text) # Check if the records cache file exists cache_file = f"{base}-{table['id']}.json" - cache_file_fullpath = f"{records_cache_dir}/{cache_file}" + cache_file_fullpath = os.path.join(records_cache_dir, cache_file) + + cache = None if cache_file in cache_files: - with open(cache_file_fullpath, "r") as f: - cache = json.load(f) - else: - print(f"Cache file {cache_file} not found; this table will be skipped until the next run.") - print(f"Creating cache file.") - + try: + with open(cache_file_fullpath, "r") as f: + cache = json.load(f) + except (json.JSONDecodeError, FileNotFoundError): + print(f"Error reading JSON from cache file {cache_file}. Skipping comparison.") + + if not cache: + print(f"Cache file {cache_file} not found or corrupted; creating cache for next run.") with open(cache_file_fullpath, "w") as f: json.dump(records, f) - continue # Compare the records to the cache + cache_record_ids = {r['id'] for r in cache['records']} + for record in records['records']: - if record not in cache['records']: - print(f"New record in {table['name']}: {record}") - mailsend(f"New record in {table['name']}", f"Teable Watcher has detected a new record in {table['name']}.\nGo to the record: {teable_url}/base/{base}/{table['id']}\n\nRecord JSON output:\n{record}") + if record['id'] not in cache_record_ids: + print(f"New record detected in {table['name']}: {record['id']}") + + # Construct the Teable URL for the notification click action + record_url = f"{teable_url}/base/{base}/{table['id']}" + + # Construct a more readable notification body + notification_body = ( + f"A new record has been added to the table '{table['name']}'.\n\n" + f"Record Link: {record_url}\n\n" + f"Record Details:\n{json.dumps(record, indent=2)}" + ) + + send_ntfy_notification( + f"New Record in {table['name']}", + notification_body, + click_url=record_url # Pass the URL for the Click action + ) - # Write the records to the cache + # Write the current state to the cache for the next run with open(cache_file_fullpath, "w") as f: json.dump(records, f) - for table in table_watches: - print(f"Watching table {table['tableid']}") - try: - response = requests.get(f"{teable_url}/api/table/{table['tableid']}/record", headers={"Authorization": f"Bearer {api_key}"}) + # ================================================================= + # WATCH INDIVIDUAL TABLES + # ================================================================= + for base_id, tables_info in table_watches.items(): + for table_info in tables_info: + # Extract info from the new dictionary structure + table_id = table_info.get('id') + table_name = table_info.get('name', 'Unknown Table') + + if not table_id: + print(f"Skipping malformed table watch config in base {base_id}: {table_info}") + continue + + print(f"Watching table {table_name} in base {base_id}") + try: + response = requests.get(f"{teable_url}/api/table/{table_id}/record", headers={"Authorization": f"Bearer {api_key}"}) + except requests.exceptions.RequestException as e: + print(f"Exception occurred while getting records in table {table_name}: {e}") + continue + if response.status_code != 200: - print(f"Error getting records in table {table['tableid']}: {response.text}") + print(f"Error fetching records for table {table_name}: {response.text}") continue - except requests.exceptions.RequestException as e: - print(f"Exception occurred while getting records in table {table['name']}: {e}") - continue - - records = json.loads(response.text) - - # Check if the records cache file exists - cache_file = f"{table['baseid']}-{table['tableid']}.json" - cache_file_fullpath = f"{records_cache_dir}/{cache_file}" - if cache_file in cache_files: - with open(cache_file_fullpath, "r") as f: - cache = json.load(f) - else: - print(f"Cache file {cache_file} not found; this table will be skipped until the next run.") - print(f"Creating cache file.") - with open(cache_file_fullpath, "w") as f: - json.dump(records, f) + records = json.loads(response.text) - continue - - # Compare the records to the cache - for record in records['records']: - if record not in cache['records']: - print(f"New record in {table['name']}: {record}") - mailsend(f"New record in {table['name']}", f"Teable Watcher has detected a new record in {table['name']}.\nGo to the record: {teable_url}/base/{table['base']}/{table['id']}\n\nRecord JSON output:\n{record}") - - # Write the records to the cache - with open(cache_file_fullpath, "w") as f: - json.dump(records, f) + # Check if the records cache file exists + cache_file = f"{base_id}-{table_id}.json" + cache_file_fullpath = os.path.join(records_cache_dir, cache_file) -# main loop -mailconnect() + cache = None + if cache_file in cache_files: + try: + with open(cache_file_fullpath, "r") as f: + cache = json.load(f) + except (json.JSONDecodeError, FileNotFoundError): + print(f"Error reading JSON from cache file {cache_file}. Skipping comparison.") + + if not cache: + print(f"Cache file {cache_file} not found or corrupted; creating cache for next run.") + with open(cache_file_fullpath, "w") as f: + json.dump(records, f) + continue + + # Compare the records to the cache + cache_record_ids = {r['id'] for r in cache['records']} + + for record in records['records']: + if record['id'] not in cache_record_ids: + print(f"New record detected in {table_name}: {record['id']}") + + # Construct the Teable URL for the notification click action + record_url = f"{teable_url}/base/{base_id}/{table_id}" + # Construct a more readable notification body + notification_body = ( + f"A new record has been added to the table '{table_name}'.\n\n" + f"Record Link: {record_url}\n\n" + f"Record Details:\n{json.dumps(record, indent=2)}" + ) + + send_ntfy_notification( + f"New Record in {table_name}", + notification_body, + click_url=record_url # Pass the URL for the Click action + ) + + # Write the records to the cache + with open(cache_file_fullpath, "w") as f: + json.dump(records, f) + + print("Monitoring round complete.") + +# ================================================================= +# MAIN EXECUTION LOOP (Added for continuous Docker operation) +# ================================================================= + +print(f"Starting Teable Watcher. Checking every {check_interval} seconds.") while True: - reload_config() - if connection_check(): - main() - print(f"Looping again in {check_interval} seconds.") - time.sleep(check_interval) - else: - print(f"Retrying in {check_interval} seconds.") - time.sleep(check_interval) \ No newline at end of file + run_watcher() + print(f"Waiting for {check_interval} seconds until next check.") + time.sleep(check_interval) \ No newline at end of file -- 2.51.2