wulf-pulse/lib/services/zoom-client.ts
lorentz c518eefdb2 feat: Morning NOC Summary adaptive card for Teams
- Add MorningSummaryService with Zabbix aggregation and adaptive card builder
- Add webhook delivery system with Teams incoming webhooks
- Add admin UI at /admin/morning-summary for webhook/config management
- Add API routes: /send, /test, /webhooks, /webhooks/[id], /config, /history
- Register morning-summary cron job in SyncScheduler (Mon-Fri 6:30 AM)
- Add outages_only filter (Unavailable triggers only)
- Fix host resolution: use getTriggerEnabledHosts to exclude disabled hosts
- Fix resolved events: event.get value:1 scoped to window with r_eventid filter
- Remove emojis from fact rows and section headers in card
- Remove Open Zabbix button (duplicate of View Problems)
- Add migrations: morning_summary_config + morning_summaries tables
- Add outages_only column to morning_summary_config
2026-03-11 09:34:51 -04:00

264 lines
7.2 KiB
TypeScript

/**
* Zoom Server-to-Server OAuth API Client
* Requires: ZOOM_ACCOUNT_ID, ZOOM_CLIENT_ID, ZOOM_CLIENT_SECRET
*/
export interface ZoomUser {
id: string;
email: string;
display_name: string;
status: string;
}
export interface ZoomCallLog {
id: string;
call_id: string;
caller_number: string;
caller_name: string;
callee_number: string;
callee_name: string;
direction: 'inbound' | 'outbound' | 'internal';
result: string; // 'Call connected', 'Missed', 'Voicemail', etc.
date_time: string; // ISO 8601 — Zoom Phone API uses date_time, not start_time
duration: number; // seconds
}
export interface ZoomMeeting {
id: string | number;
uuid: string;
host_id: string;
host_email?: string;
topic: string;
start_time: string;
end_time?: string;
duration: number; // minutes
participants_count?: number;
}
export interface ZoomMeetingParticipant {
id?: string;
user_id?: string;
name: string;
user_email: string;
duration: number; // seconds
join_time: string;
leave_time: string;
}
export interface ZoomClientConfig {
accountId: string;
clientId: string;
clientSecret: string;
}
export class ZoomClient {
private config: ZoomClientConfig;
private accessToken: string | null = null;
private tokenExpiry: number = 0;
private readonly baseUrl = 'https://api.zoom.us/v2';
constructor(config: ZoomClientConfig) {
this.config = config;
}
private async getToken(): Promise<string> {
// Refresh 5 minutes before expiry
if (this.accessToken && Date.now() < this.tokenExpiry - 300000) {
return this.accessToken;
}
const credentials = Buffer.from(
`${this.config.clientId}:${this.config.clientSecret}`
).toString('base64');
const url = `https://zoom.us/oauth/token?grant_type=account_credentials&account_id=${encodeURIComponent(this.config.accountId)}`;
const res = await fetch(url, {
method: 'POST',
headers: {
Authorization: `Basic ${credentials}`,
'Content-Type': 'application/x-www-form-urlencoded',
},
});
if (!res.ok) {
const text = await res.text();
throw new Error(`Zoom token request failed: ${res.status} ${text}`);
}
const data = await res.json();
this.accessToken = data.access_token;
this.tokenExpiry = Date.now() + data.expires_in * 1000;
return this.accessToken!;
}
private async fetchJson<T>(path: string, retries = 0): Promise<T> {
const token = await this.getToken();
const res = await fetch(`${this.baseUrl}${path}`, {
headers: {
Authorization: `Bearer ${token}`,
Accept: 'application/json',
},
});
if (res.status === 429) {
if (retries >= 5) {
throw new Error(`Zoom rate limit hit after ${retries} retries: ${path}`);
}
const delay = Math.pow(2, retries) * 1000;
console.warn(`[ZOOM] Rate limited on ${path}, retrying in ${delay}ms`);
await new Promise(resolve => setTimeout(resolve, delay));
return this.fetchJson<T>(path, retries + 1);
}
if (!res.ok) {
const text = await res.text();
throw new Error(`Zoom API error ${res.status} for ${path}: ${text}`);
}
return res.json();
}
/**
* Get all active Zoom users (paginated via next_page_token)
*/
async getUsers(): Promise<ZoomUser[]> {
const users: ZoomUser[] = [];
let nextPageToken = '';
do {
const params = new URLSearchParams({
status: 'active',
page_size: '300',
});
if (nextPageToken) params.set('next_page_token', nextPageToken);
const data = await this.fetchJson<{
users: ZoomUser[];
next_page_token?: string;
}>(`/users?${params}`);
users.push(...(data.users ?? []));
nextPageToken = data.next_page_token ?? '';
} while (nextPageToken);
return users;
}
/**
* Get phone call logs for a user in a date range (paginated)
* from/to: ISO date strings 'YYYY-MM-DD'
*/
async getUserCallLogs(
zoomUserId: string,
from: string,
to: string
): Promise<ZoomCallLog[]> {
const calls: ZoomCallLog[] = [];
let nextPageToken = '';
do {
const params = new URLSearchParams({
from,
to,
page_size: '300',
type: 'all',
});
if (nextPageToken) params.set('next_page_token', nextPageToken);
let data: { call_logs?: ZoomCallLog[]; next_page_token?: string };
try {
data = await this.fetchJson<{
call_logs?: ZoomCallLog[];
next_page_token?: string;
}>(`/phone/users/${encodeURIComponent(zoomUserId)}/call_logs?${params}`);
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
// 400 = user has no Zoom Phone license — skip silently
if (msg.includes('400') || msg.includes('404')) {
break;
}
throw err;
}
calls.push(...(data.call_logs ?? []));
nextPageToken = data.next_page_token ?? '';
} while (nextPageToken);
return calls;
}
/**
* Get past meetings for a user in a date range (paginated)
* Uses the Reports API — requires role or admin scope
*/
async getUserPastMeetings(
zoomUserId: string,
from: string,
to: string
): Promise<ZoomMeeting[]> {
const meetings: ZoomMeeting[] = [];
let nextPageToken = '';
do {
const params = new URLSearchParams({
type: 'past',
from,
to,
page_size: '300',
});
if (nextPageToken) params.set('next_page_token', nextPageToken);
let data: { meetings?: ZoomMeeting[]; next_page_token?: string };
try {
data = await this.fetchJson<{
meetings?: ZoomMeeting[];
next_page_token?: string;
}>(`/report/users/${encodeURIComponent(zoomUserId)}/meetings?${params}`);
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
if (msg.includes('400') || msg.includes('404')) {
break;
}
throw err;
}
meetings.push(...(data.meetings ?? []));
nextPageToken = data.next_page_token ?? '';
} while (nextPageToken);
return meetings;
}
/**
* Get participants for a past meeting (Zoom limitation: last 30 days only)
*/
async getMeetingParticipants(meetingId: string): Promise<ZoomMeetingParticipant[]> {
const participants: ZoomMeetingParticipant[] = [];
let nextPageToken = '';
do {
const params = new URLSearchParams({ page_size: '300' });
if (nextPageToken) params.set('next_page_token', nextPageToken);
let data: { participants?: ZoomMeetingParticipant[]; next_page_token?: string };
try {
data = await this.fetchJson<{
participants?: ZoomMeetingParticipant[];
next_page_token?: string;
}>(`/past_meetings/${encodeURIComponent(meetingId)}/participants?${params}`);
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
if (msg.includes('400') || msg.includes('404') || msg.includes('3001')) {
break;
}
throw err;
}
participants.push(...(data.participants ?? []));
nextPageToken = data.next_page_token ?? '';
} while (nextPageToken);
return participants;
}
}