aboutsummaryrefslogtreecommitdiff
path: root/generate_config.py
diff options
context:
space:
mode:
Diffstat (limited to 'generate_config.py')
-rw-r--r--generate_config.py206
1 files changed, 206 insertions, 0 deletions
diff --git a/generate_config.py b/generate_config.py
new file mode 100644
index 0000000..bba424e
--- /dev/null
+++ b/generate_config.py
@@ -0,0 +1,206 @@
+#!/usr/bin/env python3
+"""Generate rclone combine configs for Microsoft Teams channel drives."""
+
+from __future__ import annotations
+
+import argparse
+import json
+import re
+import sys
+import time
+import urllib.error
+import urllib.parse
+import urllib.request
+from pathlib import Path
+
+GRAPH = "https://graph.microsoft.com/v1.0"
+HERE = Path(__file__).resolve().parent
+DEFAULT_CONFIG = HERE / "config.json"
+REMOTE_TMPL = (HERE / "remote-template.conf").read_text()
+COMBINE_TMPL = (HERE / "combine-template.conf").read_text()
+
+
+def sanitize_remote(name: str) -> str:
+ return re.sub(r"[^\w.-]+", "_", name).strip("_")
+
+
+def get_token(tenant: str, client_id: str, client_secret: str) -> str:
+ data = urllib.parse.urlencode(
+ {
+ "client_id": client_id,
+ "client_secret": client_secret,
+ "scope": "https://graph.microsoft.com/.default",
+ "grant_type": "client_credentials",
+ }
+ ).encode()
+ req = urllib.request.Request(
+ f"https://login.microsoftonline.com/{tenant}/oauth2/v2.0/token",
+ data=data,
+ method="POST",
+ )
+ with urllib.request.urlopen(req) as resp:
+ return json.load(resp)["access_token"]
+
+
+def graph_get(url: str, token: str) -> dict:
+ req = urllib.request.Request(url, headers={"Authorization": f"Bearer {token}"})
+ with urllib.request.urlopen(req) as resp:
+ return json.load(resp)
+
+
+def graph_paged(url: str, token: str) -> list:
+ items: list = []
+ while url:
+ payload = graph_get(url, token)
+ items.extend(payload.get("value", []))
+ url = payload.get("@odata.nextLink")
+ return items
+
+
+def find_team(teams: list, name: str, exact: bool) -> dict | None:
+ for team in teams:
+ dn = team.get("displayName") or ""
+ if exact and dn == name:
+ return team
+ if not exact and dn.lower().startswith(name.lower()):
+ return team
+ return None
+
+
+def collect_channels(team_id: str, token: str) -> list[dict]:
+ channels = graph_paged(f"{GRAPH}/teams/{team_id}/channels", token)
+ results = []
+ for ch in channels:
+ time.sleep(0.25)
+ folder = graph_get(
+ f"{GRAPH}/teams/{team_id}/channels/{ch['id']}/filesFolder", token
+ )
+ results.append(
+ {
+ "displayName": ch["displayName"],
+ "driveId": folder["parentReference"]["driveId"],
+ "membershipType": ch.get("membershipType", "standard"),
+ }
+ )
+ print(f" {ch['membershipType']}: {ch['displayName']} -> {folder['parentReference']['driveId']}")
+ return results
+
+
+def build_config(team_name: str, channels: list[dict], creds: dict) -> str:
+ team_remote = sanitize_remote(team_name)
+ standard = [c for c in channels if c["membershipType"] == "standard"]
+ other = [c for c in channels if c["membershipType"] != "standard"]
+
+ blocks: list[str] = []
+ upstreams: list[str] = []
+ written_remotes: set[str] = set()
+
+ def remote_block(name: str, drive_id: str) -> str:
+ return REMOTE_TMPL.format(
+ name=name,
+ drive_id=drive_id,
+ client_id=creds["client_id"],
+ client_secret=creds["client_secret"],
+ tenant=creds["tenant"],
+ )
+
+ if standard:
+ blocks.append(remote_block(team_remote, standard[0]["driveId"]))
+ written_remotes.add(team_remote)
+ for ch in standard:
+ name = ch["displayName"]
+ upstreams.append(f'"{name}={team_remote}:{name}"')
+
+ for ch in other:
+ remote = sanitize_remote(ch["displayName"])
+ if remote in written_remotes:
+ remote = f"{remote}_{ch['membershipType']}"
+ blocks.append(remote_block(remote, ch["driveId"]))
+ written_remotes.add(remote)
+ name = ch["displayName"]
+ # private drives nest files under a folder named after the channel
+ if ch["membershipType"] == "private":
+ upstreams.append(f'"{name}={remote}:{name}"')
+ else:
+ # shared / unknownFutureValue: files sit at drive root
+ upstreams.append(f'"{name}={remote}:"')
+
+ if upstreams:
+ blocks.append(COMBINE_TMPL.format(f"{team_remote}_combine", " ".join(upstreams)))
+
+ return "\n".join(blocks)
+
+
+def main() -> int:
+ parser = argparse.ArgumentParser(description=__doc__)
+ parser.add_argument("--team-name", nargs="+", help="Team display name prefix(es)")
+ parser.add_argument("--team-id", help="Team id (skips name lookup)")
+ parser.add_argument("--exact", action="store_true", help="Exact displayName match")
+ parser.add_argument(
+ "--config",
+ type=Path,
+ default=DEFAULT_CONFIG,
+ help=f"Path to config.json (default: {DEFAULT_CONFIG})",
+ )
+ parser.add_argument(
+ "-o",
+ "--outdir",
+ type=Path,
+ default=HERE,
+ help="Output directory for .conf files",
+ )
+ args = parser.parse_args()
+
+ if not args.team_id and not args.team_name:
+ parser.error("provide --team-name and/or --team-id")
+
+ cfg = json.loads(args.config.read_text())
+ creds = {
+ "tenant": cfg["tenantId"],
+ "client_id": cfg["clientID"],
+ "client_secret": cfg["clientSecret"],
+ }
+
+ print("Authenticating...")
+ token = get_token(creds["tenant"], creds["client_id"], creds["client_secret"])
+ args.outdir.mkdir(parents=True, exist_ok=True)
+
+ targets = []
+ if args.team_id:
+ team = graph_get(f"{GRAPH}/teams/{args.team_id}", token)
+ print(f"Team: {team['displayName']} ({args.team_id})")
+ targets.append(team)
+
+ if args.team_name:
+ print("Listing teams...")
+ all_teams = graph_paged(f"{GRAPH}/teams/", token)
+ for requested in args.team_name:
+ team = find_team(all_teams, requested, args.exact)
+ if not team:
+ print(f"ERROR: no team matched '{requested}'", file=sys.stderr)
+ continue
+ print(f"Found: {team['displayName']} ({team['id']})")
+ targets.append(team)
+
+ for team in targets:
+ print(f"\nChannels for {team['displayName']}:")
+ try:
+ channels = collect_channels(team["id"], token)
+ except urllib.error.HTTPError as e:
+ print(f"ERROR fetching channels: {e}", file=sys.stderr)
+ continue
+
+ if not channels:
+ print(f"ERROR: no channels for {team['displayName']}", file=sys.stderr)
+ continue
+
+ conf = build_config(team["displayName"], channels, creds)
+ out = args.outdir / f"{sanitize_remote(team['displayName'])}.conf"
+ out.write_text(conf)
+ print(f"Wrote {out}")
+
+ return 0
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())