-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathteablewatcher.py
More file actions
329 lines (266 loc) · 13.4 KB
/
Copy pathteablewatcher.py
File metadata and controls
329 lines (266 loc) · 13.4 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
import requests
import json
import os
import yaml # <-- NEW IMPORT
import time # <-- NEW IMPORT for main loop
# Define the path to the configuration file
config_file = os.path.join(os.path.dirname(__file__), "data/config.yaml")
# =================================================================
# 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
api_key = config['teable_api_key']
# Array of Base IDs to watch
base_watches = config['watched_bases']
# 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})
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")
# =================================================================
# NTFY CONFIGURATION (VALUES NOW FROM YAML)
# =================================================================
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')
check_interval = int(config.get('check_interval', 60))
# 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:
# 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
print("Checking connection to Teable")
response = None
if len(base_watches) > 0:
response = requests.get(f"{teable_url}/api/base/{base_watches[0]}/table", headers={"Authorization": f"Bearer {api_key}"})
elif len(table_watches) > 0:
# 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
if response.status_code != 200:
print(f"Error connecting to Teable: {response.text}")
return
else:
print("Connected to Teable")
# Get the list of files in the records cache folder
cache_files = []
try:
cache_files = os.listdir(records_cache_dir)
print(f"Files in records cache folder: {cache_files}")
except FileNotFoundError:
print(f"Records cache folder '{records_cache_dir}' not found.")
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}"})
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:
# 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}"})
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 = os.path.join(records_cache_dir, cache_file)
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}/{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 current state to the cache for the next run
with open(cache_file_fullpath, "w") as f:
json.dump(records, f)
# =================================================================
# 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 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_id}-{table_id}.json"
cache_file_fullpath = os.path.join(records_cache_dir, cache_file)
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:
run_watcher()
print(f"Waiting for {check_interval} seconds until next check.")
time.sleep(check_interval)