docs: apply fleet-template (16-artifact scaffold)
Adds missing standard artifacts: - README.md (if missing) - AGENTS.md (AI agent contract) - PLAN.md (current sprint) - STATUS.md (where we are) - DEVELOPMENT.md (dev workflow) - DEPLOYMENT.md (deploy procedure) - TESTING.md (test strategy) - DECISIONS.md (ADR index + templates) - .github/CODEOWNERS - .github/workflows/ci.yml Preserves all existing artifacts. Refs: RugMunchMedia/fleet-template
This commit is contained in:
commit
47ba268131
310 changed files with 38429 additions and 0 deletions
361
destinations.py
Normal file
361
destinations.py
Normal file
|
|
@ -0,0 +1,361 @@
|
|||
"""Pry — One-Click Business Integrations.
|
||||
Native write destinations: Google Sheets, Slack, Airtable, email."""
|
||||
|
||||
import json
|
||||
import logging
|
||||
from datetime import UTC, datetime
|
||||
from typing import Any
|
||||
from urllib.parse import quote
|
||||
|
||||
import httpx
|
||||
|
||||
from client import get_client
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# ── Slack ──
|
||||
|
||||
|
||||
async def write_to_slack(
|
||||
webhook_url: str,
|
||||
message: str,
|
||||
title: str = "Pry Data",
|
||||
color: str = "#36a64f",
|
||||
) -> dict[str, Any]:
|
||||
"""Send data to a Slack webhook as a formatted message."""
|
||||
if not webhook_url:
|
||||
return {"success": False, "error": "Slack webhook URL is required"}
|
||||
|
||||
# Truncate message if too long (Slack limit: 40000 chars)
|
||||
if len(message) > 35000:
|
||||
message = message[:35000] + "\n\n*(truncated — data too large for Slack)*"
|
||||
|
||||
payload = {
|
||||
"attachments": [
|
||||
{
|
||||
"color": color,
|
||||
"title": title,
|
||||
"text": message,
|
||||
"footer": "Pry — Web Intelligence",
|
||||
"ts": datetime.now(UTC).timestamp(),
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
try:
|
||||
client = await get_client()
|
||||
resp = await client.post(webhook_url, json=payload, timeout=15)
|
||||
if resp.status_code >= 400:
|
||||
error_body = resp.text[:200]
|
||||
logger.error(
|
||||
"slack_write_failed",
|
||||
extra={"status": resp.status_code, "error": error_body},
|
||||
)
|
||||
return {"success": False, "error": f"Slack returned {resp.status_code}: {error_body}"}
|
||||
return {"success": True, "destination": "slack"}
|
||||
except httpx.InvalidURL:
|
||||
return {"success": False, "error": "Invalid Slack webhook URL"}
|
||||
except httpx.RequestError as e:
|
||||
return {"success": False, "error": f"Slack request failed: {str(e)[:200]}"}
|
||||
|
||||
|
||||
async def write_to_slack_csv(
|
||||
webhook_url: str,
|
||||
csv_data: str,
|
||||
title: str = "Pry Data Export",
|
||||
) -> dict[str, Any]:
|
||||
"""Send CSV data to Slack as a formatted code block."""
|
||||
if len(csv_data) > 35000:
|
||||
csv_data = csv_data[:35000] + "\n...(truncated)"
|
||||
|
||||
message = f"```\n{csv_data}\n```"
|
||||
return await write_to_slack(webhook_url, message, title=title)
|
||||
|
||||
|
||||
# ── Google Sheets (via public API — requires service account) ──
|
||||
|
||||
|
||||
async def write_to_googlesheets(
|
||||
spreadsheet_id: str,
|
||||
range_name: str,
|
||||
values: list[list[Any]],
|
||||
credentials_json: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Write data to Google Sheets using the Sheets API.
|
||||
|
||||
Requires a service account credentials JSON string.
|
||||
Falls back to a public CSV-export approach if no credentials.
|
||||
"""
|
||||
if credentials_json:
|
||||
return await _write_to_sheets_api(spreadsheet_id, range_name, values, credentials_json)
|
||||
|
||||
# Without credentials, generate a shareable CSV link
|
||||
csv_lines = "\n".join([",".join(str(c) for c in row) for row in values])
|
||||
data_url = f"https://docs.google.com/spreadsheets/d/{spreadsheet_id}/edit"
|
||||
return {
|
||||
"success": True,
|
||||
"destination": "googlesheets",
|
||||
"note": "Credentials required for automatic write. CSV preview available.",
|
||||
"csv_preview": csv_lines[:500],
|
||||
"manual_url": data_url,
|
||||
}
|
||||
|
||||
|
||||
async def _write_to_sheets_api(
|
||||
spreadsheet_id: str,
|
||||
range_name: str,
|
||||
values: list[list[Any]],
|
||||
credentials_json: str,
|
||||
) -> dict[str, Any]:
|
||||
"""Write to Google Sheets using the Sheets API v4."""
|
||||
try:
|
||||
creds = json.loads(credentials_json)
|
||||
access_token = creds.get("access_token") or creds.get("token") or ""
|
||||
if not access_token:
|
||||
return {"success": False, "error": "No access_token found in credentials"}
|
||||
|
||||
client = await get_client()
|
||||
|
||||
body = {"values": values, "majorDimension": "ROWS"}
|
||||
url = f"https://sheets.googleapis.com/v4/spreadsheets/{spreadsheet_id}/values/{quote(range_name)}?valueInputOption=USER_ENTERED"
|
||||
|
||||
resp = await client.put(
|
||||
url,
|
||||
json=body,
|
||||
headers={"Authorization": f"Bearer {access_token}"},
|
||||
timeout=30,
|
||||
)
|
||||
if resp.status_code < 400:
|
||||
return {
|
||||
"success": True,
|
||||
"destination": "googlesheets",
|
||||
"updated_cells": len(values) * len(values[0]) if values else 0,
|
||||
}
|
||||
return {
|
||||
"success": False,
|
||||
"error": f"Sheets API error: {resp.status_code} {resp.text[:200]}",
|
||||
}
|
||||
|
||||
except json.JSONDecodeError:
|
||||
return {"success": False, "error": "Invalid credentials JSON"}
|
||||
except Exception as e:
|
||||
return {"success": False, "error": str(e)[:200]}
|
||||
|
||||
|
||||
# ── Airtable ──
|
||||
|
||||
|
||||
async def write_to_airtable(
|
||||
base_id: str,
|
||||
table_name: str,
|
||||
records: list[dict[str, Any]],
|
||||
api_key: str = "",
|
||||
) -> dict[str, Any]:
|
||||
"""Write records to an Airtable base/table."""
|
||||
if not api_key:
|
||||
return {"success": False, "error": "Airtable API key required"}
|
||||
|
||||
client = await get_client()
|
||||
|
||||
# Airtable API accepts up to 10 records per request
|
||||
all_results: list[dict[str, Any]] = []
|
||||
batch_size = 10
|
||||
for i in range(0, len(records), batch_size):
|
||||
batch = records[i : i + batch_size]
|
||||
payload = {"records": [{"fields": r} for r in batch]}
|
||||
|
||||
try:
|
||||
resp = await client.post(
|
||||
f"https://api.airtable.com/v0/{base_id}/{quote(table_name)}",
|
||||
json=payload,
|
||||
headers={
|
||||
"Authorization": f"Bearer {api_key}",
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
timeout=30,
|
||||
)
|
||||
if resp.status_code < 400:
|
||||
data = resp.json()
|
||||
created = data.get("records", [])
|
||||
all_results.append(
|
||||
{
|
||||
"batch": i // batch_size,
|
||||
"created": len(created),
|
||||
"success": True,
|
||||
}
|
||||
)
|
||||
else:
|
||||
all_results.append(
|
||||
{
|
||||
"batch": i // batch_size,
|
||||
"success": False,
|
||||
"error": f"Airtable error {resp.status_code}: {resp.text[:200]}",
|
||||
}
|
||||
)
|
||||
except Exception as e:
|
||||
all_results.append({"batch": i // batch_size, "success": False, "error": str(e)[:200]})
|
||||
|
||||
total_created = sum(r.get("created", 0) for r in all_results if r.get("success"))
|
||||
return {
|
||||
"success": all(r["success"] for r in all_results),
|
||||
"destination": "airtable",
|
||||
"total_records": len(records),
|
||||
"successfully_written": total_created,
|
||||
"batches": all_results,
|
||||
}
|
||||
|
||||
|
||||
# ── Email (SMTP) ──
|
||||
|
||||
|
||||
async def write_to_email(
|
||||
recipient: str,
|
||||
subject: str,
|
||||
body: str,
|
||||
smtp_host: str = "",
|
||||
smtp_port: int = 587,
|
||||
smtp_user: str = "",
|
||||
smtp_password: str = "",
|
||||
sender: str = "",
|
||||
) -> dict[str, Any]:
|
||||
"""Send data via email using SMTP.
|
||||
|
||||
Falls back to generating a mailto: link if SMTP not configured.
|
||||
"""
|
||||
if smtp_host and smtp_user and smtp_password:
|
||||
return await _send_smtp(
|
||||
recipient, subject, body, smtp_host, smtp_port, smtp_user, smtp_password, sender
|
||||
)
|
||||
|
||||
# Generate mailto link as fallback
|
||||
body_short = body[:500].replace("\n", "%0A").replace(" ", "%20")
|
||||
mailto = f"mailto:{recipient}?subject={quote(subject)}&body={body_short}"
|
||||
return {
|
||||
"success": True,
|
||||
"destination": "email",
|
||||
"note": "SMTP not configured — mailto link generated",
|
||||
"mailto_link": mailto,
|
||||
}
|
||||
|
||||
|
||||
async def _send_smtp(
|
||||
recipient: str,
|
||||
subject: str,
|
||||
body: str,
|
||||
host: str,
|
||||
port: int,
|
||||
user: str,
|
||||
password: str,
|
||||
sender: str,
|
||||
) -> dict[str, Any]:
|
||||
"""Send email via SMTP."""
|
||||
import smtplib
|
||||
from email.mime.text import MIMEText
|
||||
|
||||
try:
|
||||
msg = MIMEText(body, "plain" if not body.startswith("<") else "html")
|
||||
msg["Subject"] = subject
|
||||
msg["From"] = sender or user
|
||||
msg["To"] = recipient
|
||||
|
||||
with smtplib.SMTP(host, port) as server:
|
||||
server.starttls()
|
||||
server.login(user, password)
|
||||
server.send_message(msg)
|
||||
|
||||
return {"success": True, "destination": "email"}
|
||||
except Exception as e:
|
||||
return {"success": False, "error": f"SMTP error: {str(e)[:200]}"}
|
||||
|
||||
|
||||
# ── Generic dispatch ──
|
||||
|
||||
DESTINATIONS: dict[str, Any] = {
|
||||
"slack": write_to_slack,
|
||||
"googlesheets": write_to_googlesheets,
|
||||
"airtable": write_to_airtable,
|
||||
"email": write_to_email,
|
||||
}
|
||||
|
||||
SUPPORTED_DESTINATIONS = list(DESTINATIONS.keys())
|
||||
|
||||
|
||||
async def dispatch(
|
||||
destination: str,
|
||||
data: dict[str, Any],
|
||||
config: dict[str, Any],
|
||||
) -> dict[str, Any]:
|
||||
"""Dispatch data to a configured destination.
|
||||
|
||||
Args:
|
||||
destination: One of: slack, googlesheets, airtable, email
|
||||
data: Data to send (will be formatted for the destination)
|
||||
config: Destination-specific config (webhook_url, api_key, etc.)
|
||||
"""
|
||||
handler = DESTINATIONS.get(destination)
|
||||
if not handler:
|
||||
return {
|
||||
"success": False,
|
||||
"error": f"Unknown destination: {destination}. Supported: {SUPPORTED_DESTINATIONS}",
|
||||
}
|
||||
|
||||
# Format data for destination
|
||||
if destination == "slack":
|
||||
message = json.dumps(data, indent=2, default=str)
|
||||
return await handler( # type: ignore[no-any-return]
|
||||
webhook_url=config.get("webhook_url", ""),
|
||||
message=message,
|
||||
title=config.get("title", "Pry Data"),
|
||||
)
|
||||
|
||||
if destination == "googlesheets":
|
||||
rows = _data_to_rows(data)
|
||||
return await handler( # type: ignore[no-any-return]
|
||||
spreadsheet_id=config.get("spreadsheet_id", ""),
|
||||
range_name=config.get("range", "Sheet1!A1"),
|
||||
values=rows,
|
||||
credentials_json=config.get("credentials_json"),
|
||||
)
|
||||
|
||||
if destination == "airtable":
|
||||
records = data if isinstance(data, list) else [data]
|
||||
return await handler( # type: ignore[no-any-return]
|
||||
base_id=config.get("base_id", ""),
|
||||
table_name=config.get("table_name", "Table 1"),
|
||||
records=records,
|
||||
api_key=config.get("api_key", ""),
|
||||
)
|
||||
|
||||
if destination == "email":
|
||||
body = json.dumps(data, indent=2, default=str)
|
||||
return await handler( # type: ignore[no-any-return]
|
||||
recipient=config.get("recipient", ""),
|
||||
subject=config.get("subject", "Pry Data Export"),
|
||||
body=body,
|
||||
smtp_host=config.get("smtp_host", ""),
|
||||
smtp_port=config.get("smtp_port", 587),
|
||||
smtp_user=config.get("smtp_user", ""),
|
||||
smtp_password=config.get("smtp_password", ""),
|
||||
sender=config.get("sender", ""),
|
||||
)
|
||||
|
||||
return {"success": False, "error": f"Unhandled destination: {destination}"}
|
||||
|
||||
|
||||
def _data_to_rows(data: dict[str, Any] | list[Any]) -> list[list[Any]]:
|
||||
"""Convert extracted data to spreadsheet rows (header row + data rows)."""
|
||||
if isinstance(data, list):
|
||||
if not data:
|
||||
return [["No data"]]
|
||||
if isinstance(data[0], dict):
|
||||
headers = list(data[0].keys())
|
||||
rows = [[str(v) for v in r.values()] for r in data]
|
||||
return [headers, *rows]
|
||||
return [[str(item) for item in data]]
|
||||
|
||||
if isinstance(data, dict):
|
||||
headers = list(data.keys())
|
||||
values = [str(v) for v in data.values()]
|
||||
return [headers, values]
|
||||
|
||||
return [["value"], [str(data)]]
|
||||
Loading…
Add table
Add a link
Reference in a new issue