- 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
264 lines
7.2 KiB
TypeScript
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;
|
|
}
|
|
}
|