257 lines
14 KiB
JavaScript
257 lines
14 KiB
JavaScript
const CALLBACK_PREFIX = 'security_';
|
|
|
|
export class SecurityOnboarding {
|
|
constructor({ store, alerter, chatId } = {}) {
|
|
this.store = store;
|
|
this.alerter = alerter;
|
|
this.chatId = String(chatId || '');
|
|
this.sessions = new Map();
|
|
}
|
|
|
|
isActive(chatId) {
|
|
return this.sessions.has(String(chatId));
|
|
}
|
|
|
|
async ensureStarted() {
|
|
if (!this.alerter?.isConfigured || this.store?.exists) return false;
|
|
if (!this.store?.available) {
|
|
await this.alerter.sendMessage('Security Manager setup is unavailable until SECURITY_PROFILE_ENCRYPTION_KEY is configured.', { parseMode: null });
|
|
return false;
|
|
}
|
|
await this.start(this.chatId);
|
|
return true;
|
|
}
|
|
|
|
async start(chatId, { languageOnly = false } = {}) {
|
|
if (!this.store?.available) {
|
|
return this.alerter.sendMessage('Security Manager setup is unavailable until SECURITY_PROFILE_ENCRYPTION_KEY is configured correctly.', { chatId, parseMode: null });
|
|
}
|
|
const key = String(chatId);
|
|
const existing = this.store?.getProfile();
|
|
this.sessions.set(key, { step: 'language', mode: languageOnly ? 'language_only' : 'full', draft: existing || emptyDraft() });
|
|
return this.alerter.sendMessage('Choose your language / Sprache auswählen', {
|
|
chatId,
|
|
parseMode: null,
|
|
replyMarkup: languageKeyboard(),
|
|
});
|
|
}
|
|
|
|
async handleCallback(data, query) {
|
|
const value = String(data || '');
|
|
if (!value.startsWith(CALLBACK_PREFIX)) return { handled: false };
|
|
const chatId = String(query.message?.chat?.id || this.chatId);
|
|
const session = this.sessions.get(chatId);
|
|
|
|
if (value.startsWith('security_language:')) {
|
|
const language = value.split(':')[1];
|
|
if (!['de', 'en'].includes(language)) return { handled: true, response: plain('Unsupported language.') };
|
|
const active = session || { step: 'language', mode: 'full', draft: this.store?.getProfile() || emptyDraft() };
|
|
active.draft.language = language;
|
|
if (active.mode === 'language_only' && this.store?.exists) {
|
|
this.store.save(active.draft);
|
|
this.sessions.delete(chatId);
|
|
return { handled: true, response: plain(t(language, 'languageSaved')) };
|
|
}
|
|
active.step = 'consent';
|
|
this.sessions.set(chatId, active);
|
|
return { handled: true, response: { text: t(language, 'consent'), parseMode: null, replyMarkup: consentKeyboard(language) } };
|
|
}
|
|
|
|
const storedLanguage = this.store?.getProfile()?.language || 'en';
|
|
if (value === 'security_delete:confirm') {
|
|
this.store.delete();
|
|
this.sessions.delete(chatId);
|
|
return { handled: true, response: plain(t(storedLanguage, 'deleted')) };
|
|
}
|
|
if (value === 'security_delete:cancel') return { handled: true, response: plain(t(storedLanguage, 'deleteCancelled')) };
|
|
|
|
if (!session) return { handled: true, response: plain('Setup session expired. Use /onboarding to restart.') };
|
|
const language = session.draft.language || 'en';
|
|
|
|
if (value.startsWith('security_consent:')) {
|
|
const choice = value.split(':')[1];
|
|
if (choice === 'cancel') {
|
|
this.sessions.delete(chatId);
|
|
return { handled: true, response: plain(t(language, 'cancelled')) };
|
|
}
|
|
session.mode = choice === 'minimal' ? 'minimal' : 'full';
|
|
session.draft.consentedAt = new Date().toISOString();
|
|
session.step = session.mode === 'minimal' ? 'country' : 'preferredName';
|
|
return { handled: true, response: plain(promptFor(session.step, language)) };
|
|
}
|
|
|
|
if (value === 'security_review:save') {
|
|
this.store.save(session.draft);
|
|
this.sessions.delete(chatId);
|
|
return { handled: true, response: plain(t(language, 'saved')) };
|
|
}
|
|
if (value === 'security_review:restart') {
|
|
this.sessions.delete(chatId);
|
|
await this.start(chatId);
|
|
return { handled: true, response: null };
|
|
}
|
|
return { handled: true, response: plain(t(language, 'unknownAction')) };
|
|
}
|
|
|
|
handleMessage(text, msg) {
|
|
const chatId = String(msg.chat?.id || this.chatId);
|
|
const session = this.sessions.get(chatId);
|
|
if (!session) return { handled: false };
|
|
if (session.step === 'language' || session.step === 'consent' || session.step === 'review') {
|
|
const language = session.draft.language || 'en';
|
|
return {
|
|
handled: true,
|
|
response: plain(language === 'de'
|
|
? 'Bitte benutze die Schaltflaechen der letzten Nachricht oder starte mit /onboarding neu.'
|
|
: 'Use the buttons on the previous message, or restart with /onboarding.'),
|
|
};
|
|
}
|
|
const value = String(text || '').trim();
|
|
const skipped = /^\/?skip$/i.test(value);
|
|
applyAnswer(session, skipped ? '' : value);
|
|
session.step = nextStep(session.step, session.mode);
|
|
if (session.step === 'review') {
|
|
return {
|
|
handled: true,
|
|
response: {
|
|
text: `${t(session.draft.language, 'review')}\n\n${formatProfile(session.draft, session.draft.language)}`,
|
|
parseMode: null,
|
|
replyMarkup: reviewKeyboard(session.draft.language),
|
|
},
|
|
};
|
|
}
|
|
return { handled: true, response: plain(promptFor(session.step, session.draft.language)) };
|
|
}
|
|
|
|
profileText() {
|
|
const profile = this.store?.getProfile();
|
|
return profile ? formatProfile(profile, profile.language) : 'No Security Manager profile exists. Use /onboarding to begin.';
|
|
}
|
|
|
|
deletePrompt() {
|
|
const language = this.store?.getProfile()?.language || 'en';
|
|
return { text: t(language, 'deletePrompt'), parseMode: null, replyMarkup: deleteKeyboard(language) };
|
|
}
|
|
}
|
|
|
|
function applyAnswer(session, value) {
|
|
const draft = session.draft;
|
|
switch (session.step) {
|
|
case 'preferredName': draft.preferredName = clean(value, 80); break;
|
|
case 'country': draft.location.country = clean(value, 80); break;
|
|
case 'region': draft.location.region = clean(value, 100); break;
|
|
case 'city': draft.location.city = clean(value, 100); break;
|
|
case 'timezone': draft.timezone = clean(value, 80); break;
|
|
case 'household': {
|
|
const [adults, children, pets] = value.split(/[;,\s]+/).map(Number);
|
|
draft.household = { adults: bounded(adults, 0, 20, 1), children: bounded(children, 0, 20, 0), pets: bounded(pets, 0, 20, 0) };
|
|
break;
|
|
}
|
|
case 'mobility': draft.mobility = list(value, 8); break;
|
|
case 'travelPattern': draft.travelPattern = clean(value, 120); break;
|
|
case 'riskPriorities': draft.riskPriorities = list(value, 8); break;
|
|
case 'criticalDependencies': draft.criticalDependencies = list(value, 8); break;
|
|
case 'alertPreference': draft.alertPreference = normalizeAlert(value); break;
|
|
case 'quietHours': draft.quietHours = clean(value, 40); break;
|
|
}
|
|
}
|
|
|
|
function nextStep(step, mode) {
|
|
const full = ['preferredName', 'country', 'region', 'city', 'timezone', 'household', 'mobility', 'travelPattern', 'riskPriorities', 'criticalDependencies', 'alertPreference', 'quietHours', 'review'];
|
|
const minimal = ['country', 'region', 'city', 'timezone', 'riskPriorities', 'alertPreference', 'quietHours', 'review'];
|
|
const steps = mode === 'minimal' ? minimal : full;
|
|
return steps[steps.indexOf(step) + 1] || 'review';
|
|
}
|
|
|
|
function promptFor(step, language) {
|
|
const prompts = {
|
|
de: {
|
|
preferredName: 'Wie soll ich dich ansprechen? Optional: /skip',
|
|
country: 'In welchem Land soll ich Gefahren priorisieren? Bitte keine genaue Adresse.',
|
|
region: 'Welche Region oder welches Bundesland? Optional: /skip',
|
|
city: 'Welche Stadt oder nächstgelegene größere Stadt? Optional: /skip',
|
|
timezone: 'Welche Zeitzone nutzt du? Beispiel: Europe/Berlin. Optional: /skip',
|
|
household: 'Haushalt als Erwachsene,Kinder,Haustiere. Beispiel: 2,1,1. Optional: /skip',
|
|
mobility: 'Verkehrsmittel, kommagetrennt: auto,bahn,fahrrad,zu_fuss. Optional: /skip',
|
|
travelPattern: 'Wie häufig und wohin reist du typischerweise? Keine Buchungsdaten. Optional: /skip',
|
|
riskPriorities: 'Prioritäten, kommagetrennt: weather,conflict,infrastructure,cyber,travel,health,crime,economic',
|
|
criticalDependencies: 'Kritische Abhängigkeiten, kommagetrennt: electricity,internet,mobile_network,public_transport,private_vehicle,medical_power,mobility_support. Optional: /skip',
|
|
alertPreference: 'Alarmstufe: critical_only, important oder all',
|
|
quietHours: 'Ruhezeit im Format 22:00-07:00 oder /skip. Kritische Warnungen dürfen sie übersteuern.',
|
|
},
|
|
en: {
|
|
preferredName: 'How should I address you? Optional: /skip',
|
|
country: 'Which country should I prioritize for threats? Do not provide an exact address.',
|
|
region: 'Which region or state? Optional: /skip',
|
|
city: 'Which city or nearest major city? Optional: /skip',
|
|
timezone: 'Which timezone do you use? Example: Europe/Berlin. Optional: /skip',
|
|
household: 'Household as adults,children,pets. Example: 2,1,1. Optional: /skip',
|
|
mobility: 'Transport modes, comma-separated: car,rail,bicycle,walking. Optional: /skip',
|
|
travelPattern: 'How often and where do you typically travel? No booking details. Optional: /skip',
|
|
riskPriorities: 'Priorities, comma-separated: weather,conflict,infrastructure,cyber,travel,health,crime,economic',
|
|
criticalDependencies: 'Critical dependencies, comma-separated: electricity,internet,mobile_network,public_transport,private_vehicle,medical_power,mobility_support. Optional: /skip',
|
|
alertPreference: 'Alert level: critical_only, important, or all',
|
|
quietHours: 'Quiet hours as 22:00-07:00 or /skip. Critical warnings may override them.',
|
|
},
|
|
};
|
|
return prompts[language]?.[step] || prompts.en[step] || 'Continue.';
|
|
}
|
|
|
|
function formatProfile(profile, language) {
|
|
const labels = language === 'de'
|
|
? ['Sprache', 'Name', 'Standort', 'Zeitzone', 'Haushalt', 'Mobilität', 'Reisen', 'Risiken', 'Abhängigkeiten', 'Alarme', 'Ruhezeit']
|
|
: ['Language', 'Name', 'Location', 'Timezone', 'Household', 'Mobility', 'Travel', 'Risks', 'Dependencies', 'Alerts', 'Quiet hours'];
|
|
const location = [profile.location?.city, profile.location?.region, profile.location?.country].filter(Boolean).join(', ') || '-';
|
|
const household = `${profile.household?.adults ?? 1}/${profile.household?.children ?? 0}/${profile.household?.pets ?? 0}`;
|
|
return [
|
|
`${labels[0]}: ${profile.language}`,
|
|
`${labels[1]}: ${profile.preferredName || '-'}`,
|
|
`${labels[2]}: ${location}`,
|
|
`${labels[3]}: ${profile.timezone || '-'}`,
|
|
`${labels[4]}: ${household}`,
|
|
`${labels[5]}: ${(profile.mobility || []).join(', ') || '-'}`,
|
|
`${labels[6]}: ${profile.travelPattern || '-'}`,
|
|
`${labels[7]}: ${(profile.riskPriorities || []).join(', ') || '-'}`,
|
|
`${labels[8]}: ${(profile.criticalDependencies || []).join(', ') || '-'}`,
|
|
`${labels[9]}: ${profile.alertPreference || 'important'}`,
|
|
`${labels[10]}: ${profile.quietHours || '-'}`,
|
|
].join('\n');
|
|
}
|
|
|
|
function emptyDraft() {
|
|
return { language: 'en', preferredName: null, location: {}, timezone: null, household: { adults: 1, children: 0, pets: 0 }, mobility: [], travelPattern: null, riskPriorities: [], criticalDependencies: [], alertPreference: 'important', quietHours: null, consentedAt: null };
|
|
}
|
|
|
|
function languageKeyboard() {
|
|
return { inline_keyboard: [[{ text: 'Deutsch', callback_data: 'security_language:de' }, { text: 'English', callback_data: 'security_language:en' }]] };
|
|
}
|
|
function consentKeyboard(language) {
|
|
return { inline_keyboard: [[{ text: language === 'de' ? 'Vollständig' : 'Full setup', callback_data: 'security_consent:full' }, { text: language === 'de' ? 'Minimal' : 'Minimal', callback_data: 'security_consent:minimal' }], [{ text: language === 'de' ? 'Abbrechen' : 'Cancel', callback_data: 'security_consent:cancel' }]] };
|
|
}
|
|
function reviewKeyboard(language) {
|
|
return { inline_keyboard: [[{ text: language === 'de' ? 'Speichern' : 'Save', callback_data: 'security_review:save' }, { text: language === 'de' ? 'Neu starten' : 'Restart', callback_data: 'security_review:restart' }]] };
|
|
}
|
|
function deleteKeyboard(language) {
|
|
return { inline_keyboard: [[{ text: language === 'de' ? 'Profil löschen' : 'Delete profile', callback_data: 'security_delete:confirm' }, { text: language === 'de' ? 'Abbrechen' : 'Cancel', callback_data: 'security_delete:cancel' }]] };
|
|
}
|
|
|
|
function t(language, key) {
|
|
const text = {
|
|
de: {
|
|
consent: 'Bevor wir beginnen: Das Profil wird lokal verschlüsselt gespeichert und nur deinem konfigurierten LLM für Sicherheitsbewertungen bereitgestellt. Keine genaue Adresse, Ausweisdaten, Passwörter, Konten oder Diagnosen eingeben. Alle Felder außer Land sind optional; /skip überspringt. Du kannst das Profil jederzeit anzeigen oder löschen. Wähle den Umfang.',
|
|
languageSaved: 'Sprache wurde gespeichert.', cancelled: 'Einrichtung abgebrochen. Mit /onboarding kannst du später starten.', review: 'Bitte prüfe das Profil. Erst Speichern schreibt den verschlüsselten Datensatz.', saved: 'Security-Manager-Profil verschlüsselt gespeichert.', deleted: 'Security-Manager-Profil wurde vollständig gelöscht.', deletePrompt: 'Soll das verschlüsselte Security-Manager-Profil wirklich gelöscht werden?', deleteCancelled: 'Löschen abgebrochen.', unknownAction: 'Unbekannte Aktion.',
|
|
},
|
|
en: {
|
|
consent: 'Before we begin: the profile is stored locally with encryption and shared only with your configured LLM for security assessments. Do not enter an exact address, identity documents, passwords, accounts, or diagnoses. Every field except country is optional; /skip skips it. You can view or delete the profile at any time. Choose setup scope.',
|
|
languageSaved: 'Language saved.', cancelled: 'Setup cancelled. Use /onboarding to begin later.', review: 'Review the profile. Nothing is persisted until you select Save.', saved: 'Security Manager profile saved with encryption.', deleted: 'Security Manager profile was deleted completely.', deletePrompt: 'Delete the encrypted Security Manager profile?', deleteCancelled: 'Deletion cancelled.', unknownAction: 'Unknown action.',
|
|
},
|
|
};
|
|
return text[language]?.[key] || text.en[key] || key;
|
|
}
|
|
|
|
function plain(text) { return { text, parseMode: null }; }
|
|
function clean(value, maxLength) { return String(value || '').replace(/[\u0000-\u001f]/g, ' ').replace(/\s+/g, ' ').trim().slice(0, maxLength); }
|
|
function list(value, max) { return [...new Set(String(value || '').split(',').map(item => clean(item, 40).toLowerCase()).filter(Boolean))].slice(0, max); }
|
|
function bounded(value, min, max, fallback) { return Number.isFinite(value) ? Math.max(min, Math.min(max, Math.trunc(value))) : fallback; }
|
|
function normalizeAlert(value) { const normalized = clean(value, 40).toLowerCase(); return ['critical_only', 'important', 'all'].includes(normalized) ? normalized : 'important'; }
|