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:
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
157
lib/notification-settings-db.ts
Normal file
157
lib/notification-settings-db.ts
Normal 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
170
lib/telegram-service.ts
Normal 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();
|
||||
Reference in New Issue
Block a user