medicafe 0.251023.2__py3-none-any.whl → 0.251027.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of medicafe might be problematic. Click here for more details.

MediBot/__init__.py CHANGED
@@ -19,7 +19,7 @@ Smart Import Integration:
19
19
  medibot_main = get_components('medibot_main')
20
20
  """
21
21
 
22
- __version__ = "0.251023.2"
22
+ __version__ = "0.251027.0"
23
23
  __author__ = "Daniel Vidaud"
24
24
  __email__ = "daniel@personalizedtransformation.com"
25
25
 
@@ -38,7 +38,14 @@ def get_default_config():
38
38
  'endpoint_url': '',
39
39
  'auth_token': '',
40
40
  'insecure_http': False,
41
- 'max_bundle_bytes': 2097152
41
+ 'max_bundle_bytes': 2097152,
42
+ 'email': {
43
+ 'enabled': False,
44
+ 'to': '',
45
+ 'subject_prefix': 'MediCafe Error Report',
46
+ 'max_bundle_bytes': 1572864,
47
+ 'transport': 'gmail_api'
48
+ }
42
49
  },
43
50
  # STRATEGIC NOTE (COB Configuration): COB library is fully implemented and ready
44
51
  # To enable COB functionality, add the following configuration:
MediCafe/__init__.py CHANGED
@@ -27,7 +27,7 @@ Smart Import System:
27
27
  api_suite = get_api_access()
28
28
  """
29
29
 
30
- __version__ = "0.251023.2"
30
+ __version__ = "0.251027.0"
31
31
  __author__ = "Daniel Vidaud"
32
32
  __email__ = "daniel@personalizedtransformation.com"
33
33
 
@@ -1,17 +1,20 @@
1
- import os, sys, time, json, zipfile, hashlib, platform
1
+ import base64
2
+ import hashlib
3
+ import json
4
+ import os
5
+ import platform
6
+ import sys
7
+ import time
8
+ import zipfile
2
9
 
3
- try:
4
- import requests
5
- except Exception:
6
- requests = None
7
-
8
- from MediCafe.MediLink_ConfigLoader import load_configuration, log as mc_log
10
+ import requests
9
11
 
12
+ from email.mime.application import MIMEApplication
13
+ from email.mime.multipart import MIMEMultipart
14
+ from email.mime.text import MIMEText
10
15
 
11
- ASCII_SAFE_REPLACEMENTS = [
12
- ('"', '"'),
13
- ("'", "'"),
14
- ]
16
+ from MediCafe.MediLink_ConfigLoader import load_configuration, log as mc_log
17
+ from MediLink.MediLink_Gmail import get_access_token
15
18
 
16
19
 
17
20
  def _safe_ascii(text):
@@ -58,21 +61,33 @@ def _get_latest_log_path(local_storage_path):
58
61
 
59
62
 
60
63
  def _redact(text):
61
- # Best-effort ASCII redaction: mask obvious numeric IDs and bearer tokens
62
- try:
63
- text = _safe_ascii(text)
64
- import re
65
- patterns = [
66
- (r'\b(\d{3}-?\d{2}-?\d{4})\b', '***-**-****'),
67
- (r'\b(\d{9,11})\b', '*********'),
68
- (r'Bearer\s+[A-Za-z0-9\-._~+/]+=*', 'Bearer ***'),
69
- (r'Authorization:\s*[^\n\r]+', 'Authorization: ***'),
70
- ]
71
- for pat, rep in patterns:
72
- text = re.sub(pat, rep, text)
73
- return text
74
- except Exception:
75
- return text
64
+ # Best-effort ASCII redaction: mask common secrets in logs and JSON
65
+ try:
66
+ text = _safe_ascii(text)
67
+ import re
68
+ patterns = [
69
+ # SSN-like
70
+ (r'\b(\d{3}-?\d{2}-?\d{4})\b', '***-**-****'),
71
+ # 9-11 digit numeric IDs
72
+ (r'\b(\d{9,11})\b', '*********'),
73
+ # Authorization headers
74
+ (r'Authorization:\s*Bearer\s+[A-Za-z0-9\-._~+/]+=*', 'Authorization: Bearer ***'),
75
+ (r'Authorization:\s*[^\n\r]+', 'Authorization: ***'),
76
+ # JSON token fields
77
+ (r'("access_token"\s*:\s*")([^"]+)(")', r'\1***\3'),
78
+ (r'("refresh_token"\s*:\s*")([^"]+)(")', r'\1***\3'),
79
+ (r'("id_token"\s*:\s*")([^"]+)(")', r'\1***\3'),
80
+ (r'("X-Auth-Token"\s*:\s*")([^"]+)(")', r'\1***\3'),
81
+ # URL query params: token=..., access_token=..., auth=...
82
+ (r'(token|access_token|auth|authorization)=([^&\s]+)', r'\1=***'),
83
+ # Bearer fragments in JSON or text
84
+ (r'Bearer\s+[A-Za-z0-9\-._~+/]+=*', 'Bearer ***'),
85
+ ]
86
+ for pat, rep in patterns:
87
+ text = re.sub(pat, rep, text)
88
+ return text
89
+ except Exception:
90
+ return text
76
91
 
77
92
 
78
93
  def _ensure_dir(path):
@@ -84,18 +99,10 @@ def _ensure_dir(path):
84
99
  return False
85
100
 
86
101
 
87
- def _compute_report_id(zip_path):
88
- try:
89
- h = hashlib.sha256()
90
- with open(zip_path, 'rb') as f:
91
- chunk = f.read(256 * 1024)
92
- h.update(chunk)
93
- return 'mc-{}-{}'.format(int(time.time()), h.hexdigest()[:12])
94
- except Exception:
95
- return 'mc-{}-{}'.format(int(time.time()), '000000000000')
102
+ # _compute_report_id removed (unused)
96
103
 
97
104
 
98
- def collect_support_bundle(include_traceback=True, max_log_lines=2000):
105
+ def collect_support_bundle(include_traceback=True, max_log_lines=500):
99
106
  config, _ = load_configuration()
100
107
  medi = config.get('MediLink_Config', {})
101
108
  local_storage_path = medi.get('local_storage_path', '.')
@@ -120,7 +127,7 @@ def collect_support_bundle(include_traceback=True, max_log_lines=2000):
120
127
  except Exception:
121
128
  traceback_txt = ''
122
129
 
123
- meta = {
130
+ meta = {
124
131
  'app_version': _safe_ascii(_get_version()),
125
132
  'python_version': _safe_ascii(sys.version.split(' ')[0]),
126
133
  'platform': _safe_ascii(platform.platform()),
@@ -140,12 +147,25 @@ def collect_support_bundle(include_traceback=True, max_log_lines=2000):
140
147
  z.writestr('log_tail.txt', log_tail)
141
148
  if traceback_txt:
142
149
  z.writestr('traceback.txt', traceback_txt)
143
- z.writestr('README.txt', _readme_text())
144
- return zip_path
150
+ # Get latest WinSCP log
151
+ upload_log = os.path.join(local_storage_path, 'winscp_upload.log')
152
+ download_log = os.path.join(local_storage_path, 'winscp_download.log')
153
+ winscp_logs = [(p, os.path.getmtime(p)) for p in [upload_log, download_log] if os.path.exists(p)]
154
+ if winscp_logs:
155
+ latest_winscp = max(winscp_logs, key=lambda x: x[1])[0]
156
+ winscp_tail = _tail_file(latest_winscp, max_log_lines) if latest_winscp else ''
157
+ winscp_tail = _redact(winscp_tail)
158
+ else:
159
+ winscp_tail = ''
160
+ if winscp_tail:
161
+ z.writestr('winscp_log_tail.txt', winscp_tail)
145
162
  except Exception as e:
146
163
  mc_log('Error creating support bundle: {}'.format(e), level='ERROR')
147
164
  return None
148
165
 
166
+ # Do not delete oversize bundles here; sender will enforce size based on config
167
+ return zip_path
168
+
149
169
 
150
170
  def _first_line(text):
151
171
  try:
@@ -158,14 +178,6 @@ def _first_line(text):
158
178
  return ''
159
179
 
160
180
 
161
- def _readme_text():
162
- return (
163
- "MediCafe Support Bundle\n\n"
164
- "This archive contains a redacted log tail, optional traceback, and metadata.\n"
165
- "You may submit this bundle automatically from the app or send it manually to support.\n"
166
- )
167
-
168
-
169
181
  def _get_version():
170
182
  try:
171
183
  from MediCafe import __version__
@@ -174,105 +186,6 @@ def _get_version():
174
186
  return 'unknown'
175
187
 
176
188
 
177
- def submit_support_bundle(zip_path):
178
- config, _ = load_configuration()
179
- medi = config.get('MediLink_Config', {})
180
- rep = medi.get('error_reporting', {}) if isinstance(medi, dict) else {}
181
- endpoint_url = _safe_ascii(rep.get('endpoint_url', ''))
182
- auth_token = _safe_ascii(rep.get('auth_token', ''))
183
- insecure = bool(rep.get('insecure_http', False))
184
- max_bytes = int(rep.get('max_bundle_bytes', 2097152))
185
-
186
- if not requests:
187
- print("[ERROR] requests module not available; cannot submit report.")
188
- return False
189
- if not endpoint_url:
190
- print("[ERROR] error_reporting.endpoint_url not configured.")
191
- return False
192
- if not os.path.exists(zip_path):
193
- print("[ERROR] Bundle not found: {}".format(zip_path))
194
- return False
195
- try:
196
- size = os.path.getsize(zip_path)
197
- if size > max_bytes:
198
- print("[INFO] Bundle size {} exceeds cap {}; rebuilding smaller not implemented here.".format(size, max_bytes))
199
- except Exception:
200
- pass
201
-
202
- report_id = _compute_report_id(zip_path)
203
- headers = {
204
- 'X-Auth-Token': auth_token or '',
205
- 'X-Report-Id': report_id,
206
- 'User-Agent': 'MediCafe-Reporter/1.0'
207
- }
208
-
209
- # Prepare meta.json stream derived from inside the zip for server convenience
210
- meta_json = '{}'
211
- try:
212
- with zipfile.ZipFile(zip_path, 'r') as z:
213
- if 'meta.json' in z.namelist():
214
- meta_json = z.read('meta.json')
215
- except Exception:
216
- meta_json = '{}'
217
-
218
- try:
219
- bundle_fh = open(zip_path, 'rb')
220
- files = {
221
- 'meta': ('meta.json', meta_json, 'application/json'),
222
- 'bundle': (os.path.basename(zip_path), bundle_fh, 'application/zip')
223
- }
224
- r = requests.post(endpoint_url, headers=headers, files=files, timeout=(10, 20), verify=(not insecure))
225
- code = getattr(r, 'status_code', None)
226
- if code == 200:
227
- print("[SUCCESS] Report submitted. ID: {}".format(report_id))
228
- return True
229
- elif code == 401:
230
- print("[ERROR] Unauthorized (401). Check error_reporting.auth_token.")
231
- return False
232
- elif code == 403:
233
- print("[ERROR] Forbidden (403). The receiver denied access. Verify REPORT_TOKEN and receiver permissions.")
234
- return False
235
- elif code == 413:
236
- print("[ERROR] Too large (413). Consider reducing max log lines.")
237
- return False
238
- else:
239
- print("[ERROR] Submission failed with status {}".format(code))
240
- return False
241
- except Exception as e:
242
- print("[ERROR] Submission exception: {}".format(e))
243
- return False
244
- finally:
245
- try:
246
- bundle_fh.close()
247
- except Exception:
248
- pass
249
-
250
-
251
- def flush_queued_reports():
252
- config, _ = load_configuration()
253
- medi = config.get('MediLink_Config', {})
254
- local_storage_path = medi.get('local_storage_path', '.')
255
- queue_dir = os.path.join(local_storage_path, 'reports_queue')
256
- if not os.path.isdir(queue_dir):
257
- return 0, 0
258
- count_ok = 0
259
- count_total = 0
260
- for name in sorted(os.listdir(queue_dir)):
261
- if not name.endswith('.zip'):
262
- continue
263
- zip_path = os.path.join(queue_dir, name)
264
- count_total += 1
265
- print("Attempting upload of queued report: {}".format(name))
266
- ok = submit_support_bundle(zip_path)
267
- if ok:
268
- try:
269
- os.remove(zip_path)
270
- except Exception:
271
- pass
272
- count_ok += 1
273
- return count_ok, count_total
274
-
275
-
276
189
  def capture_unhandled_traceback(exc_type, exc_value, exc_traceback):
277
190
  try:
278
191
  config, _ = load_configuration()
@@ -284,7 +197,165 @@ def capture_unhandled_traceback(exc_type, exc_value, exc_traceback):
284
197
  text = _redact(text)
285
198
  with open(trace_path, 'w') as f:
286
199
  f.write(text)
287
- print("An error occurred. A traceback was saved to {}".format(trace_path))
200
+ print("An error occurred. A traceback was saved to {}".format(trace_path))
288
201
  except Exception:
289
- pass
202
+ try:
203
+ mc_log('Failed to capture traceback to file', level='WARNING')
204
+ except Exception:
205
+ pass
206
+
207
+ def submit_support_bundle_email(zip_path=None, include_traceback=True):
208
+ if not zip_path:
209
+ zip_path = collect_support_bundle(include_traceback)
210
+ if not zip_path:
211
+ mc_log("Failed to create bundle.", level="ERROR")
212
+ return False
213
+ bundle_size = os.path.getsize(zip_path)
214
+ config, _ = load_configuration()
215
+ email_config = config.get('MediLink_Config', {}).get('error_reporting', {}).get('email', {})
216
+ # Determine max size from config with default 1.5MB
217
+ try:
218
+ max_bytes = int(email_config.get('max_bundle_bytes', 1572864))
219
+ except Exception:
220
+ max_bytes = 1572864
221
+ if bundle_size > max_bytes:
222
+ mc_log("Bundle too large ({} bytes > {} bytes) - leaving in queue.".format(bundle_size, max_bytes), level="WARNING")
223
+ return False
224
+ # Feature is always available; proceed if recipients and token are available
225
+ # Normalize and validate recipients
226
+ to_emails = _normalize_recipients(email_config.get('to', []))
227
+ if not to_emails:
228
+ mc_log("No valid recipients configured in error_reporting.email.to", level="ERROR")
229
+ return False
230
+ subject_prefix = email_config.get('subject_prefix', 'MediCafe Error Report')
231
+ access_token = get_access_token()
232
+ if not access_token:
233
+ mc_log("No access token - authenticate first.", level="ERROR")
234
+ return False
235
+ mc_log("Building email...", level="INFO")
236
+ msg = MIMEMultipart()
237
+ msg['To'] = ', '.join(to_emails)
238
+ msg['Subject'] = '{} - {}'.format(subject_prefix, time.strftime('%Y%m%d_%H%M%S'))
239
+ with open(zip_path, 'rb') as f:
240
+ attach = MIMEApplication(f.read(), _subtype='zip')
241
+ attach.add_header('Content-Disposition', 'attachment', filename=os.path.basename(zip_path))
242
+ msg.attach(attach)
243
+ body = "Error report attached."
244
+ msg.attach(MIMEText(body, 'plain'))
245
+ raw = base64.urlsafe_b64encode(msg.as_bytes()).decode()
246
+ mc_log("Sending report...", level="INFO")
247
+ headers = {'Authorization': 'Bearer {}'.format(access_token), 'Content-Type': 'application/json'}
248
+ data = {'raw': raw}
249
+ resp = requests.post('https://gmail.googleapis.com/gmail/v1/users/me/messages/send', headers=headers, json=data)
250
+ if resp.status_code == 200:
251
+ mc_log("Report sent successfully!", level="INFO")
252
+ try:
253
+ os.remove(zip_path)
254
+ except Exception:
255
+ pass
256
+ return True
257
+ else:
258
+ # Handle auth errors by prompting re-consent using existing OAuth helpers
259
+ if resp.status_code in (401, 403):
260
+ try:
261
+ from MediLink.MediLink_Gmail import get_authorization_url, open_browser_with_executable
262
+ auth_url = get_authorization_url()
263
+ mc_log("Gmail send unauthorized ({}). Opening browser for re-consent.".format(resp.status_code), level="WARNING")
264
+ try:
265
+ print("Your Google session needs to be refreshed to gain gmail.send permission. A browser will open to re-authorize.")
266
+ except Exception:
267
+ pass
268
+ open_browser_with_executable(auth_url)
269
+ except Exception:
270
+ pass
271
+ mc_log("Failed to send: {} - {}".format(resp.status_code, _redact(resp.text)), level="ERROR")
272
+ # Preserve bundle in queue for manual retry
273
+ return False
274
+
275
+
276
+ def _normalize_recipients(to_field):
277
+ try:
278
+ # Flatten to a list of strings
279
+ if isinstance(to_field, str):
280
+ candidates = [p.strip() for p in to_field.split(',')]
281
+ elif isinstance(to_field, list):
282
+ candidates = [str(p).strip() for p in to_field]
283
+ else:
284
+ candidates = []
285
+ # Basic email regex: local@domain.tld
286
+ import re
287
+ email_re = re.compile(r'^[^@\s]+@[^@\s]+\.[^@\s]+$')
288
+ valid = []
289
+ for addr in candidates:
290
+ if not addr:
291
+ continue
292
+ if email_re.match(addr):
293
+ valid.append(addr)
294
+ else:
295
+ try:
296
+ mc_log("Invalid email recipient skipped: {}".format(addr), level="WARNING")
297
+ except Exception:
298
+ pass
299
+ return valid
300
+ except Exception:
301
+ return []
302
+
303
+
304
+ def list_queued_bundles():
305
+ try:
306
+ config, _ = load_configuration()
307
+ medi = config.get('MediLink_Config', {})
308
+ local_storage_path = medi.get('local_storage_path', '.')
309
+ queue_dir = os.path.join(local_storage_path, 'reports_queue')
310
+ if not os.path.isdir(queue_dir):
311
+ return []
312
+ files = [os.path.join(queue_dir, f) for f in os.listdir(queue_dir) if f.endswith('.zip')]
313
+ files.sort()
314
+ return files
315
+ except Exception:
316
+ return []
317
+
318
+
319
+ def submit_all_queued_bundles():
320
+ sent = 0
321
+ failed = 0
322
+ try:
323
+ queued = list_queued_bundles()
324
+ for z in queued:
325
+ try:
326
+ ok = submit_support_bundle_email(zip_path=z, include_traceback=False)
327
+ if ok:
328
+ sent += 1
329
+ else:
330
+ failed += 1
331
+ except Exception:
332
+ failed += 1
333
+ except Exception:
334
+ pass
335
+ return sent, failed
336
+
337
+
338
+ def delete_all_queued_bundles():
339
+ deleted = 0
340
+ try:
341
+ for z in list_queued_bundles():
342
+ try:
343
+ os.remove(z)
344
+ deleted += 1
345
+ except Exception:
346
+ pass
347
+ except Exception:
348
+ pass
349
+ return deleted
350
+
351
+ def email_error_report_flow():
352
+ try:
353
+ sent = submit_support_bundle_email(zip_path=None, include_traceback=True)
354
+ return 0 if sent else 1
355
+ except Exception as e:
356
+ mc_log("[ERROR] Exception during email report flow: {0}".format(e), level="ERROR")
357
+ return 1
358
+
359
+ if __name__ == "__main__":
360
+ raise SystemExit(email_error_report_flow())
290
361
 
@@ -84,6 +84,7 @@ SCOPES = ' '.join([
84
84
  'https://www.googleapis.com/auth/gmail.modify',
85
85
  'https://www.googleapis.com/auth/gmail.compose',
86
86
  'https://www.googleapis.com/auth/gmail.readonly',
87
+ 'https://www.googleapis.com/auth/gmail.send',
87
88
  'https://www.googleapis.com/auth/script.external_request',
88
89
  'https://www.googleapis.com/auth/userinfo.email',
89
90
  'https://www.googleapis.com/auth/script.scriptapp',
@@ -114,11 +115,34 @@ def get_authorization_url():
114
115
  def exchange_code_for_token(auth_code, retries=3):
115
116
  return oauth_exchange_code_for_token(auth_code, CREDENTIALS_PATH, REDIRECT_URI, log, retries=retries)
116
117
 
118
+ def _mask_token_value(value):
119
+ try:
120
+ s = str(value or '')
121
+ if len(s) <= 8:
122
+ return '***'
123
+ return s[:4] + '…' + s[-4:]
124
+ except Exception:
125
+ return '***'
126
+
127
+
128
+ def _mask_token_payload(token_data):
129
+ try:
130
+ if not isinstance(token_data, dict):
131
+ return '{"token":"***"}'
132
+ masked = dict(token_data)
133
+ for k in ('access_token', 'refresh_token', 'id_token'):
134
+ if k in masked:
135
+ masked[k] = _mask_token_value(masked.get(k))
136
+ return masked
137
+ except Exception:
138
+ return '{"token":"***"}'
139
+
140
+
117
141
  def get_access_token():
118
142
  if os.path.exists(TOKEN_PATH):
119
143
  with open(TOKEN_PATH, 'r') as token_file:
120
144
  token_data = json.load(token_file)
121
- log("Loaded token data:\n {}".format(token_data))
145
+ log("Loaded token data (masked):\n {}".format(_mask_token_payload(token_data)))
122
146
  if 'access_token' in token_data and 'expires_in' in token_data:
123
147
  try:
124
148
  token_time = token_data.get('token_time', time.time())
@@ -127,7 +151,7 @@ def get_access_token():
127
151
  log("KeyError while accessing token data: {}".format(e))
128
152
  return None
129
153
  if token_expiry_time > time.time():
130
- log("Access token is still valid. Expires in {} seconds.".format(token_expiry_time - time.time()))
154
+ log("Access token is still valid. Expires in {} seconds.".format(int(token_expiry_time - time.time())))
131
155
  return token_data['access_token']
132
156
  else:
133
157
  log("Access token has expired. Current time: {}, Expiry time: {}".format(time.time(), token_expiry_time))
@@ -136,10 +160,10 @@ def get_access_token():
136
160
  new_token_data['token_time'] = time.time()
137
161
  with open(TOKEN_PATH, 'w') as token_file:
138
162
  json.dump(new_token_data, token_file)
139
- log("Access token refreshed successfully. New token data: {}".format(new_token_data))
163
+ log("Access token refreshed successfully. New token data (masked): {}".format(_mask_token_payload(new_token_data)))
140
164
  return new_token_data['access_token']
141
165
  else:
142
- log("Failed to refresh access token. New token data: {}".format(new_token_data))
166
+ log("Failed to refresh access token. Response (masked): {}".format(_mask_token_payload(new_token_data)))
143
167
  return None
144
168
  log("Access token not found. Please authenticate.")
145
169
  return None