Skip to content

Prune inactive teams — report, archive, or delete teams idle for too long.

A team is considered inactive when it has no messages for at least --days (no messages at all counts as inactive). Destructive actions (archive/delete) run as a dry run unless --apply is passed.

python prune_inactive_teams.py                      # report candidates (>=180d)
python prune_inactive_teams.py --days 90 --action archive --apply
python prune_inactive_teams.py --days 365 --action delete --apply --team "Old team"

Permissions

Requires application permissions Team.ReadBasic.All, TeamMember.Read.All, Directory.Read.All (+ ChannelSettings.ReadWrite.All to archive).

View source

from __future__ import annotations

import argparse
from datetime import datetime, timedelta, timezone

from office365.graph_client import GraphClient
from tests.settings import client_id, client_secret, tenant


def _last_activity(team) -> datetime | None:
    """Newest message date across the team's channels (None when no messages)."""
    latest = None
    for channel in team.channels.get().execute_query():
        try:
            msgs = channel.messages.top(1).order_by("createdDateTime desc").get().execute_query()
        except Exception:
            continue
        if not msgs:
            continue
        created = msgs[0].created_datetime
        if created and (latest is None or created > latest):
            latest = created
    return latest


def main():
    parser = argparse.ArgumentParser(description="Report, archive, or delete inactive teams")
    parser.add_argument("--days", type=int, default=90, help="inactivity threshold in days")
    parser.add_argument("--action", choices=["report", "archive", "delete"], default="delete")
    parser.add_argument("--apply", action="store_true", help="actually run archive/delete (dry run by default)")
    parser.add_argument("--team", help="restrict to one team by display name")
    args = parser.parse_args()

    client = GraphClient(tenant=tenant).with_client_secret(client_id, client_secret)
    cutoff = datetime.now(timezone.utc) - timedelta(days=args.days)

    candidates = []
    for team in client.teams.get_all().execute_query():
        if team.is_archived or (args.team and team.display_name != args.team):
            continue
        last = _last_activity(team)
        if last is None or last < cutoff:
            days = (datetime.now(timezone.utc) - last).days if last else None
            candidates.append((team, days))

    print(f"{len(candidates)} inactive team(s) (no activity in {args.days} days)")
    for team, days in sorted(candidates, key=lambda item: item[1] or -1, reverse=True):
        inactive = "no messages" if days is None else f"{days}d"
        print(f"  {team.display_name}  ({team.visibility})  inactive: {inactive}")

    if args.action == "report":
        return

    if not args.apply:
        print(f"\nDry run — pass --apply to {args.action} {len(candidates)} team(s).")
        return

    for team, _days in candidates:
        if args.action == "archive":
            team.archive().execute_query()
        else:
            team.delete_object().execute_query_retry()
        print(f"  {args.action}d: {team.display_name}")


if __name__ == "__main__":
    main()

← Back to Microsoft Teams