mattrguerra/meridian-skillsmainPrivate on GitHub, published here in full

bitrix_tracker.py

Language Python6.2 KB184 lines

skills/bitrix-tracker/scripts/bitrix_tracker.py

  1. #!/usr/bin/env python3
  2. """Bitrix24 task tracker: a read-only status digest.
  3. Buckets open tasks into overdue / unassigned / stale / changed-since-yesterday.
  4. Never writes to Bitrix.
  5. Usage:
  6. python scripts/bitrix_tracker.py # digest from live Bitrix
  7. python scripts/bitrix_tracker.py --mock # digest from sample_tasks.json
  8. python scripts/bitrix_tracker.py --json # machine-readable output
  9. Config: config.json (copy from config.example.json).
  10. Secrets: .env with BITRIX_WEBHOOK_URL (copy from .env.example).
  11. Stdlib only, no pip installs.
  12. """
  13. import argparse
  14. import json
  15. import os
  16. import sys
  17. import urllib.parse
  18. import urllib.request
  19. from datetime import datetime, timedelta, timezone
  20. from pathlib import Path
  21. ROOT = Path(__file__).resolve().parent.parent
  22. def load_env(root: Path) -> dict:
  23. env = {}
  24. f = root / ".env"
  25. if f.exists():
  26. for line in f.read_text().splitlines():
  27. line = line.strip()
  28. if not line or line.startswith("#") or "=" not in line:
  29. continue
  30. k, v = line.split("=", 1)
  31. env[k.strip()] = v.strip()
  32. # the real environment overrides the file
  33. if os.environ.get("BITRIX_WEBHOOK_URL"):
  34. env["BITRIX_WEBHOOK_URL"] = os.environ["BITRIX_WEBHOOK_URL"]
  35. return env
  36. def load_config(root: Path, mock: bool) -> dict:
  37. cfg = root / "config.json"
  38. if not cfg.exists():
  39. if not mock:
  40. sys.exit("config.json not found. Copy config.example.json to config.json and fill it in.")
  41. cfg = root / "config.example.json" # fine for a dry mock run
  42. return json.loads(cfg.read_text())
  43. def _flatten(obj, parent=""):
  44. """Bitrix expects PHP-style nested params: filter[!STATUS]=5, select[0]=ID."""
  45. items = []
  46. if isinstance(obj, dict):
  47. for k, v in obj.items():
  48. key = f"{parent}[{k}]" if parent else str(k)
  49. items.extend(_flatten(v, key))
  50. elif isinstance(obj, (list, tuple)):
  51. for i, v in enumerate(obj):
  52. key = f"{parent}[{i}]" if parent else str(i)
  53. items.extend(_flatten(v, key))
  54. else:
  55. items.append((parent, obj))
  56. return items
  57. def bitrix_call(webhook: str, method: str, params: dict) -> dict:
  58. url = webhook.rstrip("/") + "/" + method + ".json"
  59. data = urllib.parse.urlencode(_flatten(params), doseq=True).encode()
  60. req = urllib.request.Request(url, data=data)
  61. with urllib.request.urlopen(req, timeout=30) as resp:
  62. payload = json.loads(resp.read().decode())
  63. if "error" in payload:
  64. raise RuntimeError(f"{payload.get('error')}: {payload.get('error_description')}")
  65. return payload
  66. def fetch_tasks_live(webhook: str, cfg: dict) -> list:
  67. tasks, start = [], 0
  68. while True:
  69. resp = bitrix_call(webhook, "tasks.task.list", {
  70. "filter": cfg.get("task_filter", {}),
  71. "select": cfg.get("task_select", ["ID", "TITLE", "RESPONSIBLE_ID"]),
  72. "start": start,
  73. })
  74. tasks.extend(resp.get("result", {}).get("tasks", []))
  75. nxt = resp.get("next")
  76. if not nxt:
  77. break
  78. start = nxt
  79. return tasks
  80. def parse_dt(val):
  81. if not val:
  82. return None
  83. val = str(val).strip()
  84. for fmt in ("%Y-%m-%dT%H:%M:%S%z", "%Y-%m-%dT%H:%M:%S", "%Y-%m-%d %H:%M:%S", "%Y-%m-%d"):
  85. try:
  86. dt = datetime.strptime(val, fmt)
  87. if dt.tzinfo is None:
  88. dt = dt.replace(tzinfo=timezone.utc)
  89. return dt
  90. except ValueError:
  91. continue
  92. return None
  93. def classify(tasks: list, cfg: dict, now: datetime) -> dict:
  94. stale_cut = now - timedelta(days=int(cfg.get("stale_days", 7)))
  95. day_cut = now - timedelta(hours=24)
  96. unassigned = {str(x) for x in cfg.get("unassigned_ids", [])}
  97. buckets = {"overdue": [], "unassigned": [], "stale": [], "changed_24h": []}
  98. for t in tasks:
  99. if str(t.get("CLOSED_DATE") or "").strip() or str(t.get("STATUS")) == "5":
  100. continue
  101. deadline = parse_dt(t.get("DEADLINE"))
  102. changed = parse_dt(t.get("CHANGED_DATE"))
  103. resp = str(t.get("RESPONSIBLE_ID", "")).strip()
  104. if deadline and deadline < now:
  105. buckets["overdue"].append(t)
  106. if resp in unassigned:
  107. buckets["unassigned"].append(t)
  108. if changed and changed < stale_cut:
  109. buckets["stale"].append(t)
  110. if changed and changed >= day_cut:
  111. buckets["changed_24h"].append(t)
  112. return buckets
  113. def render_text(buckets: dict, now: datetime) -> str:
  114. labels = [
  115. ("overdue", "OVERDUE", "DEADLINE"),
  116. ("unassigned", "UNASSIGNED", None),
  117. ("stale", "STALE (no movement)", "CHANGED_DATE"),
  118. ("changed_24h", "CHANGED IN LAST 24H", "CHANGED_DATE"),
  119. ]
  120. out = [f"Bitrix task digest ({now.strftime('%Y-%m-%d %H:%M %Z')})", "=" * 52, ""]
  121. for key, title, datefield in labels:
  122. rows = buckets[key]
  123. out.append(f"{title}: {len(rows)}")
  124. if not rows:
  125. out.append(" clear")
  126. else:
  127. for t in rows:
  128. extra = ""
  129. if datefield:
  130. extra = f" [{datefield.split('_')[0].lower()}: {str(t.get(datefield) or '')[:10]}]"
  131. out.append(f" #{t.get('ID','')} {(t.get('TITLE') or '')[:48]} (owner {t.get('RESPONSIBLE_ID','')}){extra}")
  132. out.append("")
  133. return "\n".join(out)
  134. def main():
  135. ap = argparse.ArgumentParser(description="Read-only Bitrix24 task digest.")
  136. ap.add_argument("--mock", action="store_true", help="use bundled sample_tasks.json")
  137. ap.add_argument("--json", action="store_true", help="emit JSON instead of text")
  138. args = ap.parse_args()
  139. cfg = load_config(ROOT, args.mock)
  140. if args.mock:
  141. tasks = json.loads((ROOT / "sample_tasks.json").read_text())
  142. else:
  143. env = load_env(ROOT)
  144. webhook = env.get("BITRIX_WEBHOOK_URL")
  145. if not webhook:
  146. sys.exit("BITRIX_WEBHOOK_URL not set. Copy .env.example to .env and fill it in.")
  147. tasks = fetch_tasks_live(webhook, cfg)
  148. now = datetime.now(timezone.utc)
  149. buckets = classify(tasks, cfg, now)
  150. if args.json:
  151. print(json.dumps({k: [t.get("ID") for t in v] for k, v in buckets.items()}, indent=2))
  152. else:
  153. print(render_text(buckets, now))
  154. if __name__ == "__main__":
  155. main()