Files
SDS-CRM/server/teamviewer.js
2026-03-23 02:09:14 +01:00

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: [],
});
}
});
}