308 lines
9.5 KiB
JavaScript
308 lines
9.5 KiB
JavaScript
/**
|
|
* TeamViewer Web API: Connection Reports (/api/v1/reports/connections)
|
|
*/
|
|
|
|
const CONNECTIONS_URL =
|
|
'https://webapi.teamviewer.com/api/v1/reports/connections';
|
|
|
|
const FETCH_HEADERS_BASE = {
|
|
Accept: 'application/json',
|
|
'User-Agent': 'SDS-CRM/1.0 (Node.js; TeamViewer reports/connections)',
|
|
};
|
|
|
|
/**
|
|
* TeamViewer akzeptiert ISO-8601-UTC ohne Millisekunden, z. B. 2019-01-31T19:20:30Z.
|
|
* JavaScripts toISOString() liefert .sssZ — das führt zu HTTP 400 „from_date is not valid“.
|
|
*/
|
|
export function formatTeamViewerDateParam(date) {
|
|
return new Date(date).toISOString().replace(/\.\d{3}Z$/, 'Z');
|
|
}
|
|
|
|
/** Dauer aus TeamViewer-Feldern `start_date` / `end_date` (ISO-Strings, ggf. leer). */
|
|
export function computeRemoteDurationSeconds(startIso, endIso) {
|
|
const ts = startIso != null ? String(startIso).trim() : '';
|
|
const te = endIso != null ? String(endIso).trim() : '';
|
|
if (!ts || !te) return null;
|
|
const s = new Date(ts);
|
|
const e = new Date(te);
|
|
if (Number.isNaN(s.getTime()) || Number.isNaN(e.getTime()) || e < s) {
|
|
return null;
|
|
}
|
|
return Math.max(0, Math.round((e - s) / 1000));
|
|
}
|
|
|
|
/**
|
|
* Gruppiert nach Benutzer (userid, sonst username), pro Benutzer Geräte mit
|
|
* jüngster Session je Gerät; Anzeigename Gerät = devicename oder sonst deviceid.
|
|
*/
|
|
/** TeamViewer liefert Feldnamen je nach Version leicht unterschiedlich (userid / user_id / …). */
|
|
/** TeamViewer Connection Report: optionales Freitextfeld */
|
|
function pickNotes(rec) {
|
|
const raw =
|
|
rec.notes ??
|
|
rec.Notes ??
|
|
rec.connection_notes ??
|
|
rec.session_notes ??
|
|
'';
|
|
const s = String(raw ?? '').trim();
|
|
return s || null;
|
|
}
|
|
|
|
function pickUserIdentity(rec) {
|
|
const uid = String(
|
|
rec.userid ?? rec.user_id ?? rec.userId ?? rec.UserID ?? '',
|
|
).trim();
|
|
const un = String(
|
|
rec.username ??
|
|
rec.user_name ??
|
|
rec.userName ??
|
|
rec.UserName ??
|
|
'',
|
|
).trim();
|
|
return { uid, un };
|
|
}
|
|
|
|
function buildSessionsByUser(allRecords) {
|
|
const byUser = new Map();
|
|
for (const rec of allRecords) {
|
|
const { uid, un } = pickUserIdentity(rec);
|
|
const userKey = uid || un || '_unbekannt';
|
|
if (!byUser.has(userKey)) {
|
|
byUser.set(userKey, {
|
|
userid: uid || null,
|
|
username: un || userKey,
|
|
records: [],
|
|
});
|
|
}
|
|
byUser.get(userKey).records.push(rec);
|
|
}
|
|
|
|
const users = [];
|
|
for (const [userKey, { userid, username, records }] of byUser) {
|
|
const byDev = new Map();
|
|
for (const rec of records) {
|
|
const did = String(
|
|
rec.deviceid ?? rec.device_id ?? rec.deviceId ?? '',
|
|
).trim();
|
|
const dn = String(
|
|
rec.devicename ?? rec.device_name ?? rec.deviceName ?? '',
|
|
).trim();
|
|
const dkey = did || dn;
|
|
if (!dkey) continue;
|
|
if (!byDev.has(dkey)) byDev.set(dkey, []);
|
|
byDev.get(dkey).push(rec);
|
|
}
|
|
const devices = [];
|
|
for (const [, recs] of byDev) {
|
|
recs.sort((a, b) => {
|
|
const eb =
|
|
(b.end_date ?? b.endDate ?? b.EndDate)
|
|
? new Date(b.end_date ?? b.endDate ?? b.EndDate).getTime()
|
|
: 0;
|
|
const ea =
|
|
(a.end_date ?? a.endDate ?? a.EndDate)
|
|
? new Date(a.end_date ?? a.endDate ?? a.EndDate).getTime()
|
|
: 0;
|
|
return eb - ea;
|
|
});
|
|
const rec = recs[0];
|
|
const did = String(
|
|
rec.deviceid ?? rec.device_id ?? rec.deviceId ?? '',
|
|
).trim();
|
|
const dn = String(
|
|
rec.devicename ?? rec.device_name ?? rec.deviceName ?? '',
|
|
).trim();
|
|
const label = dn || did;
|
|
const idForCrm = did || dn;
|
|
const startRaw = rec.start_date ?? rec.startDate ?? rec.StartDate;
|
|
const endRaw = rec.end_date ?? rec.endDate ?? rec.EndDate;
|
|
const startDate = startRaw ? String(startRaw).trim() : '';
|
|
const endDate = endRaw ? String(endRaw).trim() : '';
|
|
devices.push({
|
|
deviceid: idForCrm,
|
|
label,
|
|
startDate: startDate || null,
|
|
endDate: endDate || null,
|
|
durationSeconds: computeRemoteDurationSeconds(startDate, endDate),
|
|
notes: pickNotes(rec),
|
|
});
|
|
}
|
|
devices.sort((a, b) =>
|
|
a.label.localeCompare(b.label, 'de', { sensitivity: 'base' }),
|
|
);
|
|
users.push({
|
|
userKey,
|
|
userid,
|
|
username,
|
|
devices,
|
|
});
|
|
}
|
|
users.sort((a, b) =>
|
|
a.username.localeCompare(b.username, 'de', { sensitivity: 'base' }),
|
|
);
|
|
return users;
|
|
}
|
|
|
|
function upstreamError(res, httpStatus, raw) {
|
|
const text = String(raw ?? '').trim();
|
|
if (text.startsWith('<')) {
|
|
return res.status(502).json({
|
|
message:
|
|
'TeamViewer-Web-API lieferte HTML statt JSON. Typisch: Script-Token ungültig/abgelaufen, falsche Berechtigungen (Verbindungsberichte), oder Dienst kurzzeitig nicht erreichbar.',
|
|
hint: 'TeamViewer Management Console → Apps → Script-Token: Zugriff auf Reporting / Connection Reports prüfen.',
|
|
devices: [],
|
|
users: [],
|
|
});
|
|
}
|
|
let detail = text.slice(0, 600);
|
|
try {
|
|
const j = JSON.parse(text);
|
|
const msg =
|
|
(typeof j.error_description === 'string' && j.error_description) ||
|
|
(typeof j.error === 'string' && j.error) ||
|
|
(typeof j.message === 'string' && j.message);
|
|
if (msg) detail = String(msg);
|
|
} catch {
|
|
/* Rohtext */
|
|
}
|
|
return res.status(502).json({
|
|
message: `TeamViewer-API meldet HTTP ${httpStatus}.`,
|
|
detail,
|
|
devices: [],
|
|
users: [],
|
|
});
|
|
}
|
|
|
|
function bodyNotJson(res, raw) {
|
|
const text = String(raw ?? '').trim();
|
|
if (text.startsWith('<')) {
|
|
return upstreamError(res, 200, raw);
|
|
}
|
|
return res.status(502).json({
|
|
message: 'TeamViewer: Antwort ist kein gültiges JSON.',
|
|
detail: text.slice(0, 400),
|
|
devices: [],
|
|
users: [],
|
|
});
|
|
}
|
|
|
|
/** TeamViewer-Antworten: `records` (Reports) oder andere Schlüssel / verschachtelt. */
|
|
function extractReportRecords(data) {
|
|
if (!data || typeof data !== 'object') return [];
|
|
if (Array.isArray(data)) return data;
|
|
const candidates = [
|
|
data.records,
|
|
data.Records,
|
|
data.connections,
|
|
data.Connections,
|
|
data.items,
|
|
data.Items,
|
|
data.values,
|
|
data.data?.records,
|
|
data.data?.connections,
|
|
data.report?.records,
|
|
];
|
|
let fallbackEmpty = null;
|
|
for (const c of candidates) {
|
|
if (Array.isArray(c)) {
|
|
if (c.length > 0) return c;
|
|
if (fallbackEmpty === null) fallbackEmpty = c;
|
|
}
|
|
}
|
|
return fallbackEmpty ?? [];
|
|
}
|
|
|
|
function hasNextPage(data) {
|
|
const next = data?.next_offset ?? data?.nextOffset ?? data?.NextOffset;
|
|
if (next == null) return false;
|
|
const s = String(next).trim();
|
|
return s.length > 0;
|
|
}
|
|
|
|
/** @param {import('express').Router} api @param {() => object} loadIntegrations */
|
|
export function registerTeamViewerRoutes(api, loadIntegrations) {
|
|
api.get('/integrations/teamviewer/connections', async (_req, res) => {
|
|
const integ = loadIntegrations();
|
|
const token = String(
|
|
integ.teamviewer?.bearerToken || integ.teamviewer?.apiToken || '',
|
|
).trim();
|
|
if (!token) {
|
|
return res.status(400).json({
|
|
message:
|
|
'TeamViewer Bearer-Token in den Optionen hinterlegen (Abschnitt TeamViewer).',
|
|
devices: [],
|
|
users: [],
|
|
});
|
|
}
|
|
const now = new Date();
|
|
const from = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000);
|
|
const fromStr = formatTeamViewerDateParam(from);
|
|
const toStr = formatTeamViewerDateParam(now);
|
|
|
|
try {
|
|
const allRecords = [];
|
|
let offset;
|
|
let pagesFetched = 0;
|
|
for (let page = 0; page < 25; page++) {
|
|
pagesFetched += 1;
|
|
const u = new URL(CONNECTIONS_URL);
|
|
u.searchParams.set('from_date', fromStr);
|
|
u.searchParams.set('to_date', toStr);
|
|
if (offset != null && offset !== '') {
|
|
u.searchParams.set('offset', String(offset));
|
|
}
|
|
const r = await fetch(u, {
|
|
headers: {
|
|
...FETCH_HEADERS_BASE,
|
|
Authorization: `Bearer ${token}`,
|
|
},
|
|
});
|
|
const raw = await r.text();
|
|
if (!r.ok) {
|
|
console.error(
|
|
'[TeamViewer API] upstream HTTP',
|
|
r.status,
|
|
String(raw ?? '').slice(0, 500),
|
|
);
|
|
return upstreamError(res, r.status, raw);
|
|
}
|
|
let data;
|
|
try {
|
|
data = JSON.parse(raw);
|
|
} catch {
|
|
return bodyNotJson(res, raw);
|
|
}
|
|
const recs = extractReportRecords(data);
|
|
for (const rec of recs) {
|
|
allRecords.push(rec);
|
|
}
|
|
/*
|
|
* Wichtig: nicht abbrechen, nur weil eine Seite 0 Zeilen hat — TeamViewer liefert
|
|
* manchmal leere erste Seiten, setzt aber weiter next_offset (vgl. records_remaining).
|
|
* Früher: if (!next || recs.length === 0) break → alle Daten verloren.
|
|
*/
|
|
if (!hasNextPage(data)) break;
|
|
offset = data.next_offset ?? data.nextOffset ?? data.NextOffset;
|
|
}
|
|
|
|
const users = buildSessionsByUser(allRecords);
|
|
res.json({
|
|
users,
|
|
devices: [],
|
|
meta: {
|
|
recordCount: allRecords.length,
|
|
pagesFetched,
|
|
source: 'reports/connections',
|
|
},
|
|
});
|
|
} catch (e) {
|
|
console.error('[TeamViewer API] interner Fehler', e);
|
|
res.status(500).json({
|
|
message: 'TeamViewer-Verbindungen konnten nicht geladen werden.',
|
|
devices: [],
|
|
users: [],
|
|
});
|
|
}
|
|
});
|
|
}
|