#!/usr/bin/env node // scripts/webex-rooms-report.ts // // READ-ONLY diagnostic. Lists every Webex room the bot token can see, buckets // them by last-activity age, and cross-references against legacy/spaces.json // so we can tell sendi-managed rooms apart from any others. // // Nothing is written. No DELETE calls are issued. Safe to run at any time. // // Usage: // npm run webex-rooms-report # full report // npm run webex-rooms-report -- --months=24 # highlight rooms stale > N months // npm run webex-rooms-report -- --stale-only # print details only for stale rooms import fs from 'fs'; import path from 'path'; import env from '../src/config/env.ts'; interface WebexRoom { id: string; title: string; type: 'group' | 'direct'; isLocked?: boolean; lastActivity?: string; created?: string; } interface Args { months: number; staleOnly: boolean; sampleSize: number; } function parseArgs(argv: string[]): Args { const a: Args = { months: 24, staleOnly: false, sampleSize: 20 }; for (const raw of argv) { if (raw === '--stale-only') { a.staleOnly = true; continue; } const m = /^--months=(\d+)$/.exec(raw); if (m) { a.months = parseInt(m[1]!, 10); continue; } const s = /^--sample=(\d+)$/.exec(raw); if (s) { a.sampleSize = parseInt(s[1]!, 10); continue; } } return a; } function parseLinkHeader(link: string | null): string | undefined { if (!link) return undefined; // Format: ; rel="next" const match = /<([^>]+)>;\s*rel="next"/i.exec(link); return match?.[1]; } async function fetchAllRooms(): Promise { const rooms: WebexRoom[] = []; let next: string | undefined = `https://webexapis.com/v1/rooms?max=1000&sortBy=lastactivity`; let pageCount = 0; while (next) { pageCount++; process.stdout.write(` fetching page ${pageCount}...\r`); const res = await fetch(next, { headers: { Authorization: `Bearer ${env.WEBEX_TOKEN}` }, }); if (!res.ok) { const body = await res.text().catch(() => ''); throw new Error(`Webex list rooms failed (${res.status}): ${body.slice(0, 300)}`); } const json = (await res.json()) as { items: WebexRoom[] }; rooms.push(...(json.items ?? [])); next = parseLinkHeader(res.headers.get('link')); } process.stdout.write('\n'); return rooms; } function loadLegacySpaceIds(): Set { const jsonPath = path.resolve(process.cwd(), 'legacy', 'spaces.json'); if (!fs.existsSync(jsonPath)) return new Set(); const data = JSON.parse(fs.readFileSync(jsonPath, 'utf8')) as Record; return new Set(Object.keys(data)); } function ageInDays(d: Date, now: Date = new Date()): number { return Math.floor((now.getTime() - d.getTime()) / (24 * 60 * 60 * 1000)); } async function main(): Promise { const args = parseArgs(process.argv.slice(2)); console.log('Fetching all Webex rooms the bot can see (paginated)...'); const rooms = await fetchAllRooms(); console.log(` → ${rooms.length} rooms total\n`); const legacyIds = loadLegacySpaceIds(); console.log(`Legacy spaces.json known IDs: ${legacyIds.size}\n`); // ---- Bucket rooms by lastActivity age ---- const now = new Date(); const buckets: Record = { 'no lastActivity': [], '≤ 7 days': [], '≤ 30 days': [], '≤ 3 months': [], '≤ 6 months': [], '≤ 12 months': [], '≤ 18 months': [], '≤ 24 months': [], '> 24 months': [], }; for (const r of rooms) { if (!r.lastActivity) { buckets['no lastActivity']!.push(r); continue; } const days = ageInDays(new Date(r.lastActivity), now); if (days <= 7) buckets['≤ 7 days']!.push(r); else if (days <= 30) buckets['≤ 30 days']!.push(r); else if (days <= 90) buckets['≤ 3 months']!.push(r); else if (days <= 180) buckets['≤ 6 months']!.push(r); else if (days <= 365) buckets['≤ 12 months']!.push(r); else if (days <= 547) buckets['≤ 18 months']!.push(r); else if (days <= 730) buckets['≤ 24 months']!.push(r); else buckets['> 24 months']!.push(r); } console.log('Activity buckets (all rooms bot can see):'); for (const [name, arr] of Object.entries(buckets)) { const inLegacy = arr.filter((r) => legacyIds.has(r.id)).length; console.log(` ${name.padEnd(18)} count=${String(arr.length).padStart(5)} in legacy=${inLegacy}`); } // ---- Rooms stale beyond the threshold ---- const cutoffMs = now.getTime() - args.months * 30.4375 * 24 * 60 * 60 * 1000; const stale = rooms.filter((r) => { if (!r.lastActivity) return true; return new Date(r.lastActivity).getTime() < cutoffMs; }); const staleInLegacy = stale.filter((r) => legacyIds.has(r.id)); const staleNotInLegacy = stale.filter((r) => !legacyIds.has(r.id)); console.log(`\nStale > ${args.months} months: ${stale.length}`); console.log(` - in legacy spaces.json: ${staleInLegacy.length}`); console.log(` - NOT in legacy spaces.json: ${staleNotInLegacy.length}`); // ---- Sanity: how many legacy rooms did we NOT see? (bot no longer a member) ---- const seenIds = new Set(rooms.map((r) => r.id)); let legacyMissing = 0; for (const id of legacyIds) if (!seenIds.has(id)) legacyMissing++; console.log(`\nLegacy spaces already gone from Webex (or bot removed): ${legacyMissing}`); // ---- Optional sample listing ---- if (args.staleOnly || args.sampleSize > 0) { const sampleList = args.staleOnly ? staleInLegacy : rooms.slice(0, args.sampleSize); const label = args.staleOnly ? `\nSample of stale + legacy rooms (up to ${args.sampleSize}):` : `\nSample of first ${args.sampleSize} rooms (all):`; console.log(label); const header = 'lastActivity'.padEnd(22) + 'age(d) '.padStart(8) + ' ' + 'legacy? '.padEnd(9) + 'title'; console.log(header); console.log('-'.repeat(header.length)); for (const r of sampleList.slice(0, args.sampleSize)) { const la = r.lastActivity ?? '(none)'; const days = r.lastActivity ? String(ageInDays(new Date(r.lastActivity), now)) : ' n/a'; const isLegacy = legacyIds.has(r.id) ? 'yes' : 'no'; console.log(`${la.padEnd(22)}${days.padStart(6)} ${isLegacy.padEnd(9)}${r.title}`); } } console.log('\nDone. No changes made.'); } main().catch((err) => { console.error(`\nReport failed: ${err instanceof Error ? err.message : String(err)}`); process.exit(1); });