#!/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())