P0 fixes: - agent register: upsert by agent_id (no duplicate rows) - daemon poll-once: extract _gmail_poll_cycle, run synchronously - garc_core.py: suppress urllib3/googleapiclient DeprecationWarnings P1 fixes: - OAuth: detect RefreshError → delete stale token → re-auth flow - OAuth: scope coverage check before returning valid creds - ingress: add stale-reset subcommand (reset in_progress > N min) - sheets: trim-sheet / clean-all — deleteDimension for empty rows - approval gate: send Gmail notification to GARC_APPROVAL_EMAIL P2 additions: - Google Chat: garc-chat-helper.py + garc send chat subcommands - Service Account: garc auth service-account verify + DWD support - Audit log: Sheets audit tab + garc audit list + bin/garc async hook - garc auth revoke: POST /revoke + delete token file - kg: pagination fix, shell injection fix, garc-kg-query.py - docs: _doc_insert_text / append_doc / garc drive append-doc P3 additions: - Multi-tenant: lib/profile.sh (list/use/add/show/remove/current) bin/garc: auto-load profile config.env and token.json - Google Forms pipeline: garc-forms-helper.py + lib/forms.sh garc forms list/responses/watch - systemd: _daemon_install_service OS-detect → launchd or systemd units - Python version gate (>=3.10) in bin/garc + pyproject.toml - garc doctor command for environment diagnostics Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
179 lines
6 KiB
Python
179 lines
6 KiB
Python
#!/usr/bin/env python3
|
|
"""
|
|
GARC Forms Helper — Google Forms response ingestion
|
|
list-forms / list-responses / watch (polling loop)
|
|
"""
|
|
|
|
import argparse
|
|
import json
|
|
import sys
|
|
import time
|
|
from pathlib import Path
|
|
from datetime import datetime, timezone
|
|
|
|
sys.path.insert(0, str(Path(__file__).parent))
|
|
from garc_core import build_service, utc_now
|
|
|
|
|
|
def get_svc():
|
|
return build_service("forms", "v1")
|
|
|
|
|
|
def list_forms() -> list:
|
|
"""List Google Forms accessible to this user (via Drive)."""
|
|
svc_drive = build_service("drive", "v3")
|
|
results = svc_drive.files().list(
|
|
q="mimeType='application/vnd.google-apps.form' and trashed=false",
|
|
pageSize=50,
|
|
fields="files(id,name,webViewLink,modifiedTime)"
|
|
).execute()
|
|
forms = results.get("files", [])
|
|
if not forms:
|
|
print("No Forms found.")
|
|
return []
|
|
print(f"Forms ({len(forms)}):")
|
|
for f in forms:
|
|
modified = f.get("modifiedTime", "")[:10]
|
|
print(f" {f['id']:<44} {f['name']:<40} {modified}")
|
|
return forms
|
|
|
|
|
|
def list_responses(form_id: str, max_results: int = 50,
|
|
since: str = "", output_format: str = "table") -> list:
|
|
"""List responses to a specific Form."""
|
|
svc = get_svc()
|
|
kwargs: dict = {"formId": form_id, "pageSize": max_results}
|
|
if since:
|
|
kwargs["filter"] = f"timestamp > {since}"
|
|
|
|
result = svc.forms().responses().list(**kwargs).execute()
|
|
responses = result.get("responses", [])
|
|
|
|
if not responses:
|
|
print(f"No responses for form: {form_id}")
|
|
return []
|
|
|
|
if output_format == "json":
|
|
print(json.dumps(responses, ensure_ascii=False, indent=2))
|
|
else:
|
|
print(f"Responses ({len(responses)}):")
|
|
for r in responses:
|
|
resp_id = r.get("responseId", "?")[:16]
|
|
create_time = r.get("createTime", "")[:19]
|
|
answers = r.get("answers", {})
|
|
answer_count = len(answers)
|
|
print(f" [{resp_id}] {create_time} ({answer_count} answers)")
|
|
|
|
return responses
|
|
|
|
|
|
def watch_form(form_id: str, agent_id: str, interval: int = 60,
|
|
max_msgs: int = 10, seen_file_path: str = ""):
|
|
"""Poll a Form for new responses and enqueue them via garc ingress."""
|
|
import subprocess
|
|
import os
|
|
|
|
garc_dir = os.environ.get("GARC_DIR", "")
|
|
garc_bin = Path(garc_dir) / "bin" / "garc"
|
|
|
|
seen_path = Path(seen_file_path) if seen_file_path else \
|
|
Path.home() / ".garc" / "cache" / "seen" / f"forms-{form_id[:16]}.txt"
|
|
seen_path.parent.mkdir(parents=True, exist_ok=True)
|
|
seen_path.touch()
|
|
|
|
try:
|
|
seen = set(seen_path.read_text().splitlines())
|
|
except Exception:
|
|
seen = set()
|
|
|
|
svc = get_svc()
|
|
|
|
print(f"[forms-poller] Watching form {form_id} (agent={agent_id}, interval={interval}s)")
|
|
|
|
while True:
|
|
try:
|
|
result = svc.forms().responses().list(
|
|
formId=form_id, pageSize=max_msgs
|
|
).execute()
|
|
responses = result.get("responses", [])
|
|
except Exception as e:
|
|
print(f"[forms-poller] fetch error: {e}", flush=True)
|
|
time.sleep(interval)
|
|
continue
|
|
|
|
new_seen = []
|
|
for resp in responses:
|
|
resp_id = resp.get("responseId", "")
|
|
if not resp_id or resp_id in seen:
|
|
new_seen.append(resp_id)
|
|
continue
|
|
|
|
# Build a summary of the answers
|
|
answers = resp.get("answers", {})
|
|
answer_lines = []
|
|
for q_id, ans in list(answers.items())[:5]: # first 5 questions
|
|
text_answers = ans.get("textAnswers", {}).get("answers", [])
|
|
for ta in text_answers:
|
|
answer_lines.append(ta.get("value", ""))
|
|
|
|
create_time = resp.get("createTime", "")[:10]
|
|
summary = "; ".join(answer_lines[:3]) if answer_lines else "(no answers)"
|
|
text = f"New Google Form response ({create_time}): {summary[:120]}"
|
|
|
|
cmd = [str(garc_bin), "ingress", "enqueue",
|
|
"--text", text,
|
|
"--source", "google_forms",
|
|
"--sender", form_id,
|
|
"--agent", agent_id]
|
|
|
|
env = dict(os.environ, GARC_DIR=garc_dir)
|
|
r = subprocess.run(cmd, capture_output=True, text=True, env=env)
|
|
if r.returncode == 0:
|
|
print(f"[forms-poller] Enqueued response: {resp_id[:16]}", flush=True)
|
|
else:
|
|
print(f"[forms-poller] Enqueue failed: {r.stderr.strip()}", flush=True)
|
|
|
|
new_seen.append(resp_id)
|
|
|
|
if new_seen:
|
|
with open(seen_path, "a") as f:
|
|
f.write("\n".join(new_seen) + "\n")
|
|
|
|
if interval == 0:
|
|
break # single-shot mode
|
|
time.sleep(interval)
|
|
|
|
|
|
def main():
|
|
parser = argparse.ArgumentParser(description="GARC Forms Helper")
|
|
sub = parser.add_subparsers(dest="command")
|
|
|
|
sub.add_parser("list-forms", help="List accessible Google Forms")
|
|
|
|
lrp = sub.add_parser("list-responses", help="List responses for a form")
|
|
lrp.add_argument("form_id", help="Form ID")
|
|
lrp.add_argument("--max", type=int, default=50)
|
|
lrp.add_argument("--since", default="", help="ISO timestamp filter")
|
|
lrp.add_argument("--format", default="table", choices=["table", "json"])
|
|
|
|
wp = sub.add_parser("watch", help="Poll form for new responses and enqueue")
|
|
wp.add_argument("form_id", help="Form ID to watch")
|
|
wp.add_argument("--agent", required=True, help="GARC agent ID")
|
|
wp.add_argument("--interval", type=int, default=60, help="Poll interval in seconds")
|
|
wp.add_argument("--max", type=int, default=10)
|
|
wp.add_argument("--seen-file", default="", help="Path to seen-IDs file")
|
|
|
|
args = parser.parse_args()
|
|
|
|
if args.command == "list-forms":
|
|
list_forms()
|
|
elif args.command == "list-responses":
|
|
list_responses(args.form_id, args.max, args.since, args.format)
|
|
elif args.command == "watch":
|
|
watch_form(args.form_id, args.agent, args.interval, args.max, args.seen_file)
|
|
else:
|
|
parser.print_help()
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|