Add Telegram notification integration for geofencing

Features:
- Multi-channel notifications (Email + Telegram)
- User-configurable notification settings per channel
- Telegram bot integration with rich messages, location pins, and inline buttons
- QR code generation for easy bot access (@myidbot support)
- Admin UI for notification settings management
- Test functionality for Telegram connection
- Comprehensive documentation

Implementation:
- lib/telegram-service.ts: Telegram API integration
- lib/notification-settings-db.ts: Database layer for user notification preferences
- lib/geofence-notifications.ts: Extended for parallel multi-channel delivery
- API routes for settings management and testing
- Admin UI with QR code display and step-by-step instructions
- Database table: UserNotificationSettings

Documentation:
- docs/telegram.md: Technical implementation guide
- docs/telegram-anleitung.md: User guide with @myidbot instructions
- docs/telegram-setup.md: Admin setup guide
- README.md: Updated NPM scripts section

Docker:
- Updated Dockerfile to copy public directory
- Added TELEGRAM_BOT_TOKEN environment variable
- Integrated notification settings initialization in db:init

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2025-12-04 14:54:19 +00:00
parent 17aaf130a8
commit 0d1dbeafda
18 changed files with 3200 additions and 21 deletions

View File

@@ -1,15 +1,19 @@
/**
* Geofence Notification Service
* Handles sending email notifications for geofence events
* Handles sending email and Telegram notifications for geofence events
*/
import { emailService } from './email-service';
import { telegramService } from './telegram-service';
import {
renderGeofenceEnterEmail,
renderGeofenceExitEmail,
GeofenceEmailData,
} from './email-renderer';
import { geofenceDb } from './geofence-db';
import {
getUserNotificationSettings,
} from './notification-settings-db';
import type { GeofenceEvent } from './types';
import Database from 'better-sqlite3';
import path from 'path';
@@ -62,6 +66,7 @@ function getUser(userId: string): UserInfo | null {
/**
* Send notification for a single geofence event
* Supports both Email and Telegram based on user preferences
*/
async function sendEventNotification(event: GeofenceEvent): Promise<void> {
try {
@@ -79,13 +84,87 @@ async function sendEventNotification(event: GeofenceEvent): Promise<void> {
// Get owner details
const owner = getUser(geofence.owner_id);
if (!owner || !owner.email) {
console.log(`[GeofenceNotification] No email for owner ${geofence.owner_id}, skipping notification`);
geofenceDb.markNotificationSent(event.id!, true); // Mark as "sent" (no email needed)
if (!owner) {
throw new Error(`Owner not found: ${geofence.owner_id}`);
}
// Get notification preferences
const settings = getUserNotificationSettings(owner.id);
// Prepare notification tasks
const promises: Promise<void>[] = [];
// Send Email if enabled
if (settings.email_enabled && owner.email) {
promises.push(
sendEmailNotification(event, owner, device, geofence)
);
}
// Send Telegram if enabled
if (settings.telegram_enabled && settings.telegram_chat_id) {
promises.push(
sendTelegramNotification(
event,
owner,
device,
geofence,
settings.telegram_chat_id
)
);
}
// If no notification channel enabled, skip
if (promises.length === 0) {
console.log(
`[GeofenceNotification] No notification channels enabled for user ${owner.id}`
);
geofenceDb.markNotificationSent(event.id!, true);
return;
}
// Prepare email data
// Send notifications in parallel
const results = await Promise.allSettled(promises);
// Check if at least one succeeded
const anySuccess = results.some((r) => r.status === 'fulfilled');
if (anySuccess) {
geofenceDb.markNotificationSent(event.id!, true);
console.log(
`[GeofenceNotification] Sent notification for geofence ${geofence.name}`
);
} else {
// All failed
const errors = results
.filter((r) => r.status === 'rejected')
.map((r: any) => r.reason.message)
.join('; ');
geofenceDb.markNotificationSent(event.id!, false, errors);
throw new Error(`All notification channels failed: ${errors}`);
}
} catch (error) {
console.error('[GeofenceNotification] Failed to send notification:', error);
const errorMessage =
error instanceof Error ? error.message : 'Unknown error';
geofenceDb.markNotificationSent(event.id!, false, errorMessage);
throw error;
}
}
/**
* Send email notification (extracted from main function)
*/
async function sendEmailNotification(
event: GeofenceEvent,
owner: UserInfo,
device: DeviceInfo,
geofence: GeofenceInfo
): Promise<void> {
try {
const emailData: GeofenceEmailData = {
username: owner.username,
deviceName: device.name,
@@ -94,11 +173,8 @@ async function sendEventNotification(event: GeofenceEvent): Promise<void> {
latitude: event.latitude,
longitude: event.longitude,
distanceFromCenter: event.distance_from_center || 0,
// Optional: Add map URL later
// mapUrl: `${process.env.NEXT_PUBLIC_URL}/map?lat=${event.latitude}&lon=${event.longitude}`
};
// Render and send email
let html: string;
let subject: string;
@@ -110,20 +186,51 @@ async function sendEventNotification(event: GeofenceEvent): Promise<void> {
subject = `${device.name} hat ${geofence.name} verlassen`;
}
// Send via existing email service
await emailService['sendEmail'](owner.email, subject, html);
await emailService['sendEmail'](owner.email!, subject, html);
// Mark notification as sent
geofenceDb.markNotificationSent(event.id!, true);
console.log(`[GeofenceNotification] Sent ${event.event_type} notification for geofence ${geofence.name} to ${owner.email}`);
console.log(
`[GeofenceNotification] Sent email to ${owner.email}`
);
} catch (error) {
console.error('[GeofenceNotification] Failed to send notification:', error);
console.error('[GeofenceNotification] Email error:', error);
throw error;
}
}
// Mark notification as failed
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
geofenceDb.markNotificationSent(event.id!, false, errorMessage);
/**
* Send Telegram notification (new function)
*/
async function sendTelegramNotification(
event: GeofenceEvent,
owner: UserInfo,
device: DeviceInfo,
geofence: GeofenceInfo,
chatId: string
): Promise<void> {
try {
const baseUrl = process.env.NEXTAUTH_URL || 'http://localhost:3000';
// Ensure latitude and longitude are numbers
const lat = typeof event.latitude === 'number' ? event.latitude : parseFloat(event.latitude);
const lon = typeof event.longitude === 'number' ? event.longitude : parseFloat(event.longitude);
await telegramService.sendGeofenceNotification({
chatId,
deviceName: device.name,
geofenceName: geofence.name,
eventType: event.event_type,
latitude: lat,
longitude: lon,
timestamp: event.timestamp,
mapUrl: `${baseUrl}/map?lat=${lat}&lon=${lon}`,
dashboardUrl: `${baseUrl}/admin/geofences/events`,
});
console.log(
`[GeofenceNotification] Sent Telegram notification to ${chatId}`
);
} catch (error) {
console.error('[GeofenceNotification] Telegram error:', error);
throw error;
}
}

View File

@@ -0,0 +1,157 @@
import Database from 'better-sqlite3';
import path from 'path';
const dbPath = path.join(process.cwd(), 'data', 'database.sqlite');
export interface UserNotificationSettings {
user_id: string;
email_enabled: boolean;
telegram_enabled: boolean;
telegram_chat_id: string | null;
created_at?: string;
updated_at?: string;
}
/**
* Get notification settings for a user
* Returns default settings if not found
*/
export function getUserNotificationSettings(
userId: string
): UserNotificationSettings {
const db = new Database(dbPath);
try {
const stmt = db.prepare(`
SELECT
user_id,
email_enabled,
telegram_enabled,
telegram_chat_id,
created_at,
updated_at
FROM UserNotificationSettings
WHERE user_id = ?
`);
const result = stmt.get(userId) as any;
// Return defaults if no settings found
if (!result) {
return {
user_id: userId,
email_enabled: true,
telegram_enabled: false,
telegram_chat_id: null,
};
}
// Convert SQLite integers to booleans
return {
user_id: result.user_id,
email_enabled: result.email_enabled === 1,
telegram_enabled: result.telegram_enabled === 1,
telegram_chat_id: result.telegram_chat_id,
created_at: result.created_at,
updated_at: result.updated_at,
};
} finally {
db.close();
}
}
/**
* Update notification settings for a user
*/
export function updateUserNotificationSettings(
userId: string,
settings: Partial<UserNotificationSettings>
): UserNotificationSettings {
const db = new Database(dbPath);
try {
// Check if settings exist
const existing = db
.prepare('SELECT user_id FROM UserNotificationSettings WHERE user_id = ?')
.get(userId);
if (existing) {
// Update existing
const updates: string[] = [];
const values: any[] = [];
if (settings.email_enabled !== undefined) {
updates.push('email_enabled = ?');
values.push(settings.email_enabled ? 1 : 0);
}
if (settings.telegram_enabled !== undefined) {
updates.push('telegram_enabled = ?');
values.push(settings.telegram_enabled ? 1 : 0);
}
if (settings.telegram_chat_id !== undefined) {
updates.push('telegram_chat_id = ?');
values.push(settings.telegram_chat_id);
}
updates.push('updated_at = datetime(\'now\')');
values.push(userId);
const stmt = db.prepare(`
UPDATE UserNotificationSettings
SET ${updates.join(', ')}
WHERE user_id = ?
`);
stmt.run(...values);
} else {
// Insert new
const stmt = db.prepare(`
INSERT INTO UserNotificationSettings (
user_id,
email_enabled,
telegram_enabled,
telegram_chat_id
) VALUES (?, ?, ?, ?)
`);
stmt.run(
userId,
settings.email_enabled !== undefined ? (settings.email_enabled ? 1 : 0) : 1,
settings.telegram_enabled !== undefined ? (settings.telegram_enabled ? 1 : 0) : 0,
settings.telegram_chat_id ?? null
);
}
return getUserNotificationSettings(userId);
} finally {
db.close();
}
}
/**
* Initialize notification settings table
*/
export function initNotificationSettingsTable(): void {
const db = new Database(dbPath);
try {
db.exec(`
CREATE TABLE IF NOT EXISTS UserNotificationSettings (
user_id TEXT PRIMARY KEY,
email_enabled INTEGER DEFAULT 1,
telegram_enabled INTEGER DEFAULT 0,
telegram_chat_id TEXT,
created_at TEXT DEFAULT (datetime('now')),
updated_at TEXT DEFAULT (datetime('now')),
FOREIGN KEY (user_id) REFERENCES User(id) ON DELETE CASCADE,
CHECK (email_enabled IN (0, 1)),
CHECK (telegram_enabled IN (0, 1))
);
CREATE INDEX IF NOT EXISTS idx_user_notification_settings_user
ON UserNotificationSettings(user_id);
`);
console.log('✓ UserNotificationSettings table initialized');
} finally {
db.close();
}
}

170
lib/telegram-service.ts Normal file
View File

@@ -0,0 +1,170 @@
import axios from 'axios';
interface TelegramMessage {
chatId: string;
text: string;
latitude?: number;
longitude?: number;
buttons?: Array<{ text: string; url: string }>;
}
interface GeofenceNotificationParams {
chatId: string;
deviceName: string;
geofenceName: string;
eventType: 'enter' | 'exit';
latitude: number;
longitude: number;
timestamp: string;
mapUrl?: string;
dashboardUrl?: string;
}
class TelegramService {
private botToken: string;
private baseUrl: string;
constructor() {
this.botToken = process.env.TELEGRAM_BOT_TOKEN || '';
if (!this.botToken) {
console.warn('[TelegramService] No TELEGRAM_BOT_TOKEN configured');
}
this.baseUrl = `https://api.telegram.org/bot${this.botToken}`;
}
/**
* Check if Telegram is configured
*/
isConfigured(): boolean {
return !!this.botToken;
}
/**
* Send text message with optional inline buttons
*/
async sendMessage(params: TelegramMessage): Promise<void> {
if (!this.isConfigured()) {
throw new Error('Telegram bot token not configured');
}
const { chatId, text, buttons } = params;
const payload: any = {
chat_id: chatId,
text: text,
parse_mode: 'HTML',
};
// Add inline buttons if provided
if (buttons && buttons.length > 0) {
payload.reply_markup = {
inline_keyboard: [
buttons.map(btn => ({
text: btn.text,
url: btn.url,
})),
],
};
}
const response = await axios.post(`${this.baseUrl}/sendMessage`, payload);
if (!response.data.ok) {
throw new Error(`Telegram API error: ${response.data.description}`);
}
}
/**
* Send location pin on map
*/
async sendLocation(
chatId: string,
latitude: number,
longitude: number
): Promise<void> {
if (!this.isConfigured()) {
throw new Error('Telegram bot token not configured');
}
const response = await axios.post(`${this.baseUrl}/sendLocation`, {
chat_id: chatId,
latitude,
longitude,
});
if (!response.data.ok) {
throw new Error(`Telegram API error: ${response.data.description}`);
}
}
/**
* Send geofence notification (complete with text + location + buttons)
*/
async sendGeofenceNotification(
params: GeofenceNotificationParams
): Promise<void> {
const {
chatId,
deviceName,
geofenceName,
eventType,
latitude,
longitude,
timestamp,
mapUrl,
dashboardUrl,
} = params;
// Format message
const emoji = eventType === 'enter' ? '🟢' : '🔴';
const action = eventType === 'enter' ? 'BETRETEN' : 'VERLASSEN';
const verb = eventType === 'enter' ? 'betreten' : 'verlassen';
const formattedDate = new Date(timestamp).toLocaleString('de-DE', {
dateStyle: 'short',
timeStyle: 'short',
});
const text = `
${emoji} <b>Geofence ${action}</b>
📱 <b>Device:</b> ${deviceName}
📍 <b>Geofence:</b> ${geofenceName}
🕐 <b>Zeit:</b> ${formattedDate}
📊 <b>Ereignis:</b> Hat ${geofenceName} ${verb}
`.trim();
// Prepare inline buttons
const buttons = [];
if (mapUrl) {
buttons.push({ text: '🗺️ Auf Karte zeigen', url: mapUrl });
}
if (dashboardUrl) {
buttons.push({ text: '📊 Dashboard öffnen', url: dashboardUrl });
}
// Send text message with buttons
await this.sendMessage({ chatId, text, buttons });
// Send location pin
await this.sendLocation(chatId, latitude, longitude);
}
/**
* Test connection by sending a simple message
*/
async testConnection(chatId: string): Promise<boolean> {
try {
await this.sendMessage({
chatId,
text: '✅ <b>Telegram Connection Test</b>\n\nDie Verbindung funktioniert!',
});
return true;
} catch (error) {
console.error('[TelegramService] Test failed:', error);
return false;
}
}
}
export const telegramService = new TelegramService();