bitrix_tracker.py
Language Python6.2 KB184 lines
#!/usr/bin/env python3"""Bitrix24 task tracker: a read-only status digest.Buckets open tasks into overdue / unassigned / stale / changed-since-yesterday.Never writes to Bitrix.Usage:python scripts/bitrix_tracker.py # digest from live Bitrixpython scripts/bitrix_tracker.py --mock # digest from sample_tasks.jsonpython scripts/bitrix_tracker.py --json # machine-readable outputConfig: config.json (copy from config.example.json).Secrets: .env with BITRIX_WEBHOOK_URL (copy from .env.example).Stdlib only, no pip installs."""import argparseimport jsonimport osimport sysimport urllib.parseimport urllib.requestfrom datetime import datetime, timedelta, timezonefrom pathlib import PathROOT = Path(__file__).resolve().parent.parentdef load_env(root: Path) -> dict:env = {}f = root / ".env"if f.exists():for line in f.read_text().splitlines():line = line.strip()if not line or line.startswith("#") or "=" not in line:continuek, v = line.split("=", 1)env[k.strip()] = v.strip()# the real environment overrides the fileif os.environ.get("BITRIX_WEBHOOK_URL"):env["BITRIX_WEBHOOK_URL"] = os.environ["BITRIX_WEBHOOK_URL"]return envdef load_config(root: Path, mock: bool) -> dict:cfg = root / "config.json"if not cfg.exists():if not mock:sys.exit("config.json not found. Copy config.example.json to config.json and fill it in.")cfg = root / "config.example.json" # fine for a dry mock runreturn json.loads(cfg.read_text())def _flatten(obj, parent=""):"""Bitrix expects PHP-style nested params: filter[!STATUS]=5, select[0]=ID."""items = []if isinstance(obj, dict):for k, v in obj.items():key = f"{parent}[{k}]" if parent else str(k)items.extend(_flatten(v, key))elif isinstance(obj, (list, tuple)):for i, v in enumerate(obj):key = f"{parent}[{i}]" if parent else str(i)items.extend(_flatten(v, key))else:items.append((parent, obj))return itemsdef bitrix_call(webhook: str, method: str, params: dict) -> dict:url = webhook.rstrip("/") + "/" + method + ".json"data = urllib.parse.urlencode(_flatten(params), doseq=True).encode()req = urllib.request.Request(url, data=data)with urllib.request.urlopen(req, timeout=30) as resp:payload = json.loads(resp.read().decode())if "error" in payload:raise RuntimeError(f"{payload.get('error')}: {payload.get('error_description')}")return payloaddef fetch_tasks_live(webhook: str, cfg: dict) -> list:tasks, start = [], 0while True:resp = bitrix_call(webhook, "tasks.task.list", {"filter": cfg.get("task_filter", {}),"select": cfg.get("task_select", ["ID", "TITLE", "RESPONSIBLE_ID"]),"start": start,})tasks.extend(resp.get("result", {}).get("tasks", []))nxt = resp.get("next")if not nxt:breakstart = nxtreturn tasksdef parse_dt(val):if not val:return Noneval = str(val).strip()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"):try:dt = datetime.strptime(val, fmt)if dt.tzinfo is None:dt = dt.replace(tzinfo=timezone.utc)return dtexcept ValueError:continuereturn Nonedef classify(tasks: list, cfg: dict, now: datetime) -> dict:stale_cut = now - timedelta(days=int(cfg.get("stale_days", 7)))day_cut = now - timedelta(hours=24)unassigned = {str(x) for x in cfg.get("unassigned_ids", [])}buckets = {"overdue": [], "unassigned": [], "stale": [], "changed_24h": []}for t in tasks:if str(t.get("CLOSED_DATE") or "").strip() or str(t.get("STATUS")) == "5":continuedeadline = parse_dt(t.get("DEADLINE"))changed = parse_dt(t.get("CHANGED_DATE"))resp = str(t.get("RESPONSIBLE_ID", "")).strip()if deadline and deadline < now:buckets["overdue"].append(t)if resp in unassigned:buckets["unassigned"].append(t)if changed and changed < stale_cut:buckets["stale"].append(t)if changed and changed >= day_cut:buckets["changed_24h"].append(t)return bucketsdef render_text(buckets: dict, now: datetime) -> str:labels = [("overdue", "OVERDUE", "DEADLINE"),("unassigned", "UNASSIGNED", None),("stale", "STALE (no movement)", "CHANGED_DATE"),("changed_24h", "CHANGED IN LAST 24H", "CHANGED_DATE"),]out = [f"Bitrix task digest ({now.strftime('%Y-%m-%d %H:%M %Z')})", "=" * 52, ""]for key, title, datefield in labels:rows = buckets[key]out.append(f"{title}: {len(rows)}")if not rows:out.append(" clear")else:for t in rows:extra = ""if datefield:extra = f" [{datefield.split('_')[0].lower()}: {str(t.get(datefield) or '')[:10]}]"out.append(f" #{t.get('ID','')} {(t.get('TITLE') or '')[:48]} (owner {t.get('RESPONSIBLE_ID','')}){extra}")out.append("")return "\n".join(out)def main():ap = argparse.ArgumentParser(description="Read-only Bitrix24 task digest.")ap.add_argument("--mock", action="store_true", help="use bundled sample_tasks.json")ap.add_argument("--json", action="store_true", help="emit JSON instead of text")args = ap.parse_args()cfg = load_config(ROOT, args.mock)if args.mock:tasks = json.loads((ROOT / "sample_tasks.json").read_text())else:env = load_env(ROOT)webhook = env.get("BITRIX_WEBHOOK_URL")if not webhook:sys.exit("BITRIX_WEBHOOK_URL not set. Copy .env.example to .env and fill it in.")tasks = fetch_tasks_live(webhook, cfg)now = datetime.now(timezone.utc)buckets = classify(tasks, cfg, now)if args.json:print(json.dumps({k: [t.get("ID") for t in v] for k, v in buckets.items()}, indent=2))else:print(render_text(buckets, now))if __name__ == "__main__":main()