Inital Commit
This commit is contained in:
294
server/teamviewer.js
Normal file
294
server/teamviewer.js
Normal file
@@ -0,0 +1,294 @@
|
||||
/**
|
||||
* 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 / …). */
|
||||
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),
|
||||
});
|
||||
}
|
||||
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: [],
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user