Sends doula coverage claims via Telnyx fax API every hour, logs every attempt, and generates a printable HTML report for HR. Includes both a Linux CLI with cron scheduling and a Windows GUI (tkinter) that can be packaged as a portable exe via PyInstaller. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
296 lines
9.5 KiB
Python
Executable file
296 lines
9.5 KiB
Python
Executable file
#!/usr/bin/env python3
|
|
"""AutoFax - Send a fax every hour via Telnyx and log results."""
|
|
|
|
import json
|
|
import sys
|
|
import time
|
|
from datetime import datetime, timezone
|
|
from pathlib import Path
|
|
|
|
import requests
|
|
|
|
import config
|
|
|
|
API = "https://api.telnyx.com/v2"
|
|
|
|
|
|
def headers():
|
|
return {"Authorization": f"Bearer {config.TELNYX_API_KEY}"}
|
|
|
|
|
|
def find_claim_pdf() -> Path:
|
|
pdfs = sorted(config.CLAIMS_DIR.glob("*.pdf"))
|
|
if not pdfs:
|
|
print("ERROR: No PDF found in claims/ directory.", file=sys.stderr)
|
|
sys.exit(1)
|
|
return pdfs[0]
|
|
|
|
|
|
def upload_media(pdf_path: Path) -> str:
|
|
"""Upload PDF to Telnyx media storage. Returns the media_name."""
|
|
media_name = f"autofax-claim-{pdf_path.stem}"
|
|
with open(pdf_path, "rb") as f:
|
|
resp = requests.post(
|
|
f"{API}/media",
|
|
headers={**headers(), "Content-Type": "application/pdf"},
|
|
params={"media_name": media_name},
|
|
data=f.read(),
|
|
)
|
|
resp.raise_for_status()
|
|
return media_name
|
|
|
|
|
|
def send_fax(pdf_path: Path) -> dict:
|
|
"""Upload PDF and send a fax. Returns result dict."""
|
|
timestamp = datetime.now(timezone.utc).isoformat()
|
|
try:
|
|
media_name = upload_media(pdf_path)
|
|
|
|
resp = requests.post(
|
|
f"{API}/faxes",
|
|
headers={**headers(), "Content-Type": "application/json"},
|
|
json={
|
|
"connection_id": config.TELNYX_CONNECTION_ID,
|
|
"media_name": media_name,
|
|
"to": config.FAX_TO_NUMBER,
|
|
"from": config.TELNYX_FROM_NUMBER,
|
|
},
|
|
)
|
|
resp.raise_for_status()
|
|
fax_data = resp.json().get("data", {})
|
|
|
|
return {
|
|
"timestamp": timestamp,
|
|
"status": fax_data.get("status", "queued"),
|
|
"fax_id": fax_data.get("id"),
|
|
"to": config.FAX_TO_NUMBER,
|
|
"from": config.TELNYX_FROM_NUMBER,
|
|
"file": pdf_path.name,
|
|
"error": None,
|
|
}
|
|
except requests.HTTPError as e:
|
|
error_body = ""
|
|
try:
|
|
error_body = e.response.json()
|
|
except Exception:
|
|
error_body = e.response.text[:500]
|
|
return {
|
|
"timestamp": timestamp,
|
|
"status": "send_failed",
|
|
"fax_id": None,
|
|
"to": config.FAX_TO_NUMBER,
|
|
"from": config.TELNYX_FROM_NUMBER,
|
|
"file": pdf_path.name,
|
|
"error": f"{e} | {error_body}",
|
|
}
|
|
except Exception as e:
|
|
return {
|
|
"timestamp": timestamp,
|
|
"status": "send_failed",
|
|
"fax_id": None,
|
|
"to": config.FAX_TO_NUMBER,
|
|
"from": config.TELNYX_FROM_NUMBER,
|
|
"file": pdf_path.name,
|
|
"error": str(e),
|
|
}
|
|
|
|
|
|
def check_fax_status(fax_id: str) -> dict | None:
|
|
if not fax_id:
|
|
return None
|
|
try:
|
|
resp = requests.get(f"{API}/faxes/{fax_id}", headers=headers())
|
|
resp.raise_for_status()
|
|
return resp.json().get("data", {})
|
|
except Exception:
|
|
return None
|
|
|
|
|
|
def load_log() -> list[dict]:
|
|
if config.LOG_FILE.exists():
|
|
return json.loads(config.LOG_FILE.read_text())
|
|
return []
|
|
|
|
|
|
def save_log(entries: list[dict]):
|
|
config.REPORTS_DIR.mkdir(parents=True, exist_ok=True)
|
|
config.LOG_FILE.write_text(json.dumps(entries, indent=2))
|
|
|
|
|
|
def notify(subject: str, message: str, priority: str = "default"):
|
|
if not config.NTFY_URL:
|
|
return
|
|
try:
|
|
hdrs = {"Title": subject, "Priority": priority}
|
|
if config.NTFY_TOKEN:
|
|
hdrs["Authorization"] = config.NTFY_TOKEN
|
|
requests.post(config.NTFY_URL, data=message.encode(), headers=hdrs)
|
|
except Exception:
|
|
pass
|
|
|
|
|
|
def generate_report(entries: list[dict]):
|
|
config.REPORTS_DIR.mkdir(parents=True, exist_ok=True)
|
|
|
|
total = len(entries)
|
|
failed = sum(1 for e in entries if e["status"] not in ("queued", "sending", "sent", "delivered"))
|
|
succeeded = sum(1 for e in entries if e["status"] == "delivered")
|
|
pending = total - failed - succeeded
|
|
|
|
rows = ""
|
|
for i, e in enumerate(entries, 1):
|
|
status = e["status"]
|
|
if status in ("delivered", "sent"):
|
|
status_class = "success"
|
|
status_display = status.upper()
|
|
elif status in ("queued", "sending"):
|
|
status_class = "pending"
|
|
status_display = status.upper()
|
|
else:
|
|
status_class = "failed"
|
|
status_display = "FAILED"
|
|
|
|
error_col = e.get("error") or e.get("failure_reason") or "-"
|
|
ts = e["timestamp"]
|
|
try:
|
|
dt = datetime.fromisoformat(ts)
|
|
ts_display = dt.strftime("%b %d, %Y %I:%M %p UTC")
|
|
except Exception:
|
|
ts_display = ts
|
|
|
|
rows += f"""
|
|
<tr>
|
|
<td>{i}</td>
|
|
<td>{ts_display}</td>
|
|
<td>{e.get('to', '-')}</td>
|
|
<td class="{status_class}">{status_display}</td>
|
|
<td>{e.get('fax_id') or '-'}</td>
|
|
<td class="error">{error_col}</td>
|
|
</tr>"""
|
|
|
|
html = f"""<!DOCTYPE html>
|
|
<html>
|
|
<head>
|
|
<meta charset="utf-8">
|
|
<title>Fax Transmission Report</title>
|
|
<style>
|
|
body {{ font-family: Arial, sans-serif; margin: 40px; color: #333; }}
|
|
h1 {{ color: #1a1a2e; border-bottom: 2px solid #1a1a2e; padding-bottom: 10px; }}
|
|
.summary {{ background: #f5f5f5; padding: 20px; border-radius: 8px; margin: 20px 0; }}
|
|
.summary span {{ font-weight: bold; }}
|
|
table {{ border-collapse: collapse; width: 100%; margin: 20px 0; font-size: 14px; }}
|
|
th {{ background: #1a1a2e; color: white; padding: 12px 8px; text-align: left; }}
|
|
td {{ padding: 10px 8px; border-bottom: 1px solid #ddd; }}
|
|
tr:nth-child(even) {{ background: #f9f9f9; }}
|
|
.success {{ color: #2d8a4e; font-weight: bold; }}
|
|
.failed {{ color: #c0392b; font-weight: bold; }}
|
|
.pending {{ color: #e67e22; font-weight: bold; }}
|
|
.error {{ font-size: 12px; color: #888; max-width: 300px; word-wrap: break-word; }}
|
|
.footer {{ margin-top: 30px; font-size: 12px; color: #888; border-top: 1px solid #ddd; padding-top: 10px; }}
|
|
@media print {{ body {{ margin: 20px; }} }}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<h1>Fax Transmission Report</h1>
|
|
|
|
<div class="summary">
|
|
<p>This report documents automated fax transmission attempts to the insurance
|
|
company for doula coverage claim processing.</p>
|
|
<p>
|
|
Total attempts: <span>{total}</span> |
|
|
Delivered: <span class="success">{succeeded}</span> |
|
|
Failed: <span class="failed">{failed}</span> |
|
|
Pending: <span>{pending}</span>
|
|
</p>
|
|
<p>Fax destination: <span>{entries[0]['to'] if entries else 'N/A'}</span></p>
|
|
<p>Report generated: <span>{datetime.now(timezone.utc).strftime('%B %d, %Y at %I:%M %p UTC')}</span></p>
|
|
</div>
|
|
|
|
<table>
|
|
<thead>
|
|
<tr>
|
|
<th>#</th>
|
|
<th>Date & Time</th>
|
|
<th>Fax Number</th>
|
|
<th>Status</th>
|
|
<th>Confirmation ID</th>
|
|
<th>Error Details</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{rows}
|
|
</tbody>
|
|
</table>
|
|
|
|
<div class="footer">
|
|
<p>Generated by AutoFax automated fax system. Each row represents one
|
|
transmission attempt of the doula coverage claim (5 pages). Faxes are sent
|
|
once per hour. A status of FAILED indicates the receiving fax machine did
|
|
not accept the transmission.</p>
|
|
</div>
|
|
</body>
|
|
</html>"""
|
|
|
|
report_path = config.REPORTS_DIR / "fax_report.html"
|
|
report_path.write_text(html)
|
|
return report_path
|
|
|
|
|
|
def update_previous_statuses(entries: list[dict]) -> list[dict]:
|
|
for entry in entries:
|
|
if entry["status"] in ("queued", "sending"):
|
|
status_data = check_fax_status(entry.get("fax_id"))
|
|
if status_data:
|
|
new_status = status_data.get("status", entry["status"])
|
|
if new_status != entry["status"]:
|
|
entry["status"] = new_status
|
|
if new_status == "failed":
|
|
entry["failure_reason"] = status_data.get("failure_reason", "Unknown")
|
|
return entries
|
|
|
|
|
|
def main():
|
|
pdf_path = find_claim_pdf()
|
|
|
|
entries = load_log()
|
|
entries = update_previous_statuses(entries)
|
|
|
|
print(f"Sending fax: {pdf_path.name} -> {config.FAX_TO_NUMBER}")
|
|
result = send_fax(pdf_path)
|
|
entries.append(result)
|
|
|
|
# Wait briefly and check initial status
|
|
if result["fax_id"] and result["status"] == "queued":
|
|
time.sleep(15)
|
|
status_data = check_fax_status(result["fax_id"])
|
|
if status_data:
|
|
result["status"] = status_data.get("status", result["status"])
|
|
if result["status"] == "failed":
|
|
result["failure_reason"] = status_data.get("failure_reason", "Unknown")
|
|
|
|
save_log(entries)
|
|
report_path = generate_report(entries)
|
|
|
|
# Notify via ntfy
|
|
status_labels = {
|
|
"delivered": "Fax Delivered",
|
|
"sent": "Fax Sent",
|
|
"queued": "Fax Queued",
|
|
"sending": "Fax Sending",
|
|
}
|
|
subject = status_labels.get(result["status"], "Fax Failed")
|
|
msg = f"To: {result['to']}\nStatus: {result['status']}\nFile: {result['file']}"
|
|
if result.get("error"):
|
|
msg += f"\nError: {result['error']}"
|
|
priority = "high" if result["status"] not in ("queued", "sending", "sent", "delivered") else "default"
|
|
notify(subject, msg, priority)
|
|
|
|
print(f"Status: {result['status']}")
|
|
print(f"Report: {report_path}")
|
|
|
|
if result["status"] == "send_failed":
|
|
sys.exit(1)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|