feat(infra): add Telegram Stats Bot for ManaCore analytics

Adds a NestJS service that delivers Umami analytics via Telegram:

- Telegram commands: /start, /stats, /today, /week, /realtime, /users
- Scheduled reports: Daily at 9:00, Weekly on Monday at 9:00
- Umami API integration with token management
- User statistics from auth database
- Docker + CI/CD pipeline integration

Bot: @stats_mana_bot

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Till-JS 2026-01-23 20:39:04 +01:00
parent 7a80a71496
commit f29ef4aa3a
24 changed files with 1273 additions and 0 deletions

View file

@ -0,0 +1,15 @@
# Server
PORT=3300
TZ=Europe/Berlin
# Telegram Bot
TELEGRAM_BOT_TOKEN=your-telegram-bot-token
TELEGRAM_CHAT_ID=your-telegram-chat-id
# Umami Analytics
UMAMI_API_URL=http://localhost:3200
UMAMI_USERNAME=admin
UMAMI_PASSWORD=your-umami-password
# Database (for user counts - optional)
DATABASE_URL=postgresql://postgres:password@localhost:5432/manacore_auth

View file

@ -0,0 +1,150 @@
# Telegram Stats Bot - Claude Code Guidelines
## Overview
Telegram Stats Bot delivers analytics and statistics from Umami (stats.mana.how) via Telegram. It provides both automated scheduled reports and on-demand commands.
## Tech Stack
- **Framework**: NestJS 10
- **Telegram**: nestjs-telegraf + Telegraf
- **Scheduling**: @nestjs/schedule
- **Analytics**: Umami API
## Commands
```bash
# Development
pnpm start:dev # Start with hot reload
# Build
pnpm build # Production build
# Type check
pnpm type-check # Check TypeScript types
```
## Project Structure
```
services/telegram-stats-bot/
├── src/
│ ├── main.ts # Application entry point
│ ├── app.module.ts # Root module
│ ├── health.controller.ts # Health check endpoint
│ ├── config/
│ │ └── configuration.ts # Configuration & website IDs
│ ├── bot/
│ │ ├── bot.module.ts
│ │ ├── bot.service.ts # Send messages to Telegram
│ │ └── bot.update.ts # Command handlers
│ ├── umami/
│ │ ├── umami.module.ts
│ │ └── umami.service.ts # Umami API client
│ ├── analytics/
│ │ ├── analytics.module.ts
│ │ ├── analytics.service.ts # Data aggregation
│ │ └── formatters.ts # Message formatters
│ ├── users/
│ │ ├── users.module.ts
│ │ └── users.service.ts # User count from auth DB
│ └── scheduler/
│ ├── scheduler.module.ts
│ └── report.scheduler.ts # Cron jobs
└── Dockerfile
```
## Telegram Commands
| Command | Description |
|---------|-------------|
| `/start` | Show help |
| `/stats` | Overview of all apps (last 30 days) |
| `/today` | Today's statistics |
| `/week` | This week's statistics |
| `/realtime` | Active visitors right now |
| `/users` | Registered user statistics |
| `/help` | Show available commands |
## Scheduled Reports
| Report | Schedule | Timezone |
|--------|----------|----------|
| Daily | 09:00 | Europe/Berlin |
| Weekly | Monday 09:00 | Europe/Berlin |
## Environment Variables
```env
# Server
PORT=3300
TZ=Europe/Berlin
# Telegram
TELEGRAM_BOT_TOKEN=xxx
TELEGRAM_CHAT_ID=xxx
# Umami
UMAMI_API_URL=http://umami:3000
UMAMI_USERNAME=admin
UMAMI_PASSWORD=xxx
# Database (optional, for user counts)
DATABASE_URL=postgresql://...
```
## Adding New Website IDs
Edit `src/config/configuration.ts`:
```typescript
export const WEBSITE_IDS: Record<string, string> = {
'new-app-webapp': 'uuid-from-umami',
};
export const DISPLAY_NAMES: Record<string, string> = {
'new-app-webapp': 'New App',
};
```
## Docker
```bash
# Build locally
docker build -f services/telegram-stats-bot/Dockerfile -t telegram-stats-bot .
# Run
docker run -p 3300:3300 \
-e TELEGRAM_BOT_TOKEN=xxx \
-e TELEGRAM_CHAT_ID=xxx \
-e UMAMI_API_URL=http://umami:3000 \
-e UMAMI_USERNAME=admin \
-e UMAMI_PASSWORD=xxx \
telegram-stats-bot
```
## Health Check
```bash
curl http://localhost:3300/health
```
## Testing Bot Commands
In Telegram, send commands to your bot:
```
/start # Shows help message
/today # Gets today's stats
/week # Gets weekly stats
/realtime # Shows active visitors
```
## Key Files
| File | Purpose |
|------|---------|
| `src/config/configuration.ts` | All Umami website IDs |
| `src/analytics/formatters.ts` | Report formatting |
| `src/scheduler/report.scheduler.ts` | Cron job definitions |
| `src/umami/umami.service.ts` | Umami API authentication |

View file

@ -0,0 +1,58 @@
# Build stage
FROM node:20-alpine AS builder
# Install pnpm
RUN npm install -g pnpm@9.15.0
WORKDIR /app
# Copy package files for telegram-stats-bot only (standalone build)
COPY services/telegram-stats-bot/package.json ./
# Install all dependencies (including devDependencies for build)
RUN pnpm install
# Copy source code
COPY services/telegram-stats-bot/src ./src
COPY services/telegram-stats-bot/tsconfig*.json ./
COPY services/telegram-stats-bot/nest-cli.json ./
# Build the application
RUN pnpm build
# Production stage
FROM node:20-alpine AS production
# Install pnpm
RUN npm install -g pnpm@9.15.0
WORKDIR /app
# Copy package files
COPY --from=builder /app/package.json ./
# Install production dependencies only
RUN pnpm install --prod
# Copy built application
COPY --from=builder /app/dist ./dist
# Create non-root user
RUN addgroup -g 1001 -S nodejs && \
adduser -S nestjs -u 1001
# Change ownership
RUN chown -R nestjs:nodejs /app
# Switch to non-root user
USER nestjs
# Expose port
EXPOSE 3300
# Health check
HEALTHCHECK --interval=30s --timeout=3s --start-period=40s --retries=3 \
CMD node -e "require('http').get('http://localhost:3300/health', (r) => {process.exit(r.statusCode === 200 ? 0 : 1)})"
# Start the application
CMD ["node", "dist/main"]

View file

@ -0,0 +1,8 @@
{
"$schema": "https://json.schemastore.org/nest-cli",
"collection": "@nestjs/schematics",
"sourceRoot": "src",
"compilerOptions": {
"deleteOutDir": true
}
}

View file

@ -0,0 +1,38 @@
{
"name": "@manacore/telegram-stats-bot",
"version": "1.0.0",
"description": "Telegram bot for ManaCore analytics and statistics from Umami",
"private": true,
"license": "MIT",
"scripts": {
"prebuild": "rimraf dist",
"build": "nest build",
"format": "prettier --write \"src/**/*.ts\"",
"start": "nest start",
"start:dev": "nest start --watch",
"start:debug": "nest start --debug --watch",
"start:prod": "node dist/main",
"lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix",
"type-check": "tsc --noEmit"
},
"dependencies": {
"@nestjs/common": "^10.4.15",
"@nestjs/config": "^3.3.0",
"@nestjs/core": "^10.4.15",
"@nestjs/platform-express": "^10.4.15",
"@nestjs/schedule": "^4.1.2",
"nestjs-telegraf": "^2.8.0",
"telegraf": "^4.16.3",
"drizzle-orm": "^0.38.3",
"postgres": "^3.4.5",
"reflect-metadata": "^0.2.2",
"rxjs": "^7.8.1"
},
"devDependencies": {
"@nestjs/cli": "^10.4.9",
"@nestjs/schematics": "^10.2.3",
"@types/node": "^22.10.5",
"rimraf": "^6.0.1",
"typescript": "^5.7.3"
}
}

View file

@ -0,0 +1,10 @@
import { Module } from '@nestjs/common';
import { UmamiModule } from '../umami/umami.module';
import { AnalyticsService } from './analytics.service';
@Module({
imports: [UmamiModule],
providers: [AnalyticsService],
exports: [AnalyticsService],
})
export class AnalyticsModule {}

View file

@ -0,0 +1,122 @@
import { Injectable, Logger } from '@nestjs/common';
import { UmamiService, UmamiStats } from '../umami/umami.service';
import {
formatDailyReport,
formatWeeklyReport,
formatRealtimeReport,
formatStatsOverview,
} from './formatters';
@Injectable()
export class AnalyticsService {
private readonly logger = new Logger(AnalyticsService.name);
constructor(private readonly umamiService: UmamiService) {}
private getStartOfDay(date: Date = new Date()): Date {
const start = new Date(date);
start.setHours(0, 0, 0, 0);
return start;
}
private getEndOfDay(date: Date = new Date()): Date {
const end = new Date(date);
end.setHours(23, 59, 59, 999);
return end;
}
private getStartOfWeek(date: Date = new Date()): Date {
const start = new Date(date);
const day = start.getDay();
const diff = start.getDate() - day + (day === 0 ? -6 : 1); // Adjust for Monday start
start.setDate(diff);
start.setHours(0, 0, 0, 0);
return start;
}
private getEndOfWeek(date: Date = new Date()): Date {
const end = this.getStartOfWeek(date);
end.setDate(end.getDate() + 6);
end.setHours(23, 59, 59, 999);
return end;
}
async getTodayStats(): Promise<Map<string, UmamiStats>> {
const startAt = this.getStartOfDay().getTime();
const endAt = this.getEndOfDay().getTime();
return this.umamiService.getAllWebsiteStats(startAt, endAt);
}
async getYesterdayStats(): Promise<Map<string, UmamiStats>> {
const yesterday = new Date();
yesterday.setDate(yesterday.getDate() - 1);
const startAt = this.getStartOfDay(yesterday).getTime();
const endAt = this.getEndOfDay(yesterday).getTime();
return this.umamiService.getAllWebsiteStats(startAt, endAt);
}
async getWeekStats(): Promise<Map<string, UmamiStats>> {
const startAt = this.getStartOfWeek().getTime();
const endAt = this.getEndOfWeek().getTime();
return this.umamiService.getAllWebsiteStats(startAt, endAt);
}
async getPreviousWeekStats(): Promise<Map<string, UmamiStats>> {
const prevWeekStart = this.getStartOfWeek();
prevWeekStart.setDate(prevWeekStart.getDate() - 7);
const prevWeekEnd = this.getEndOfWeek(prevWeekStart);
return this.umamiService.getAllWebsiteStats(prevWeekStart.getTime(), prevWeekEnd.getTime());
}
async getRealtimeStats(): Promise<Map<string, number>> {
return this.umamiService.getAllActiveVisitors();
}
async generateDailyReport(): Promise<string> {
try {
const stats = await this.getTodayStats();
return formatDailyReport(stats, new Date());
} catch (error) {
this.logger.error('Failed to generate daily report:', error);
return '❌ Fehler beim Erstellen des Daily Reports';
}
}
async generateWeeklyReport(): Promise<string> {
try {
const stats = await this.getWeekStats();
const prevStats = await this.getPreviousWeekStats();
const weekStart = this.getStartOfWeek();
const weekEnd = this.getEndOfWeek();
return formatWeeklyReport(stats, weekStart, weekEnd, prevStats);
} catch (error) {
this.logger.error('Failed to generate weekly report:', error);
return '❌ Fehler beim Erstellen des Weekly Reports';
}
}
async generateRealtimeReport(): Promise<string> {
try {
const activeVisitors = await this.getRealtimeStats();
return formatRealtimeReport(activeVisitors);
} catch (error) {
this.logger.error('Failed to generate realtime report:', error);
return '❌ Fehler beim Abrufen der Realtime-Daten';
}
}
async generateStatsOverview(): Promise<string> {
try {
// Get last 30 days stats for overview
const thirtyDaysAgo = new Date();
thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - 30);
const startAt = this.getStartOfDay(thirtyDaysAgo).getTime();
const endAt = this.getEndOfDay().getTime();
const stats = await this.umamiService.getAllWebsiteStats(startAt, endAt);
return formatStatsOverview(stats);
} catch (error) {
this.logger.error('Failed to generate stats overview:', error);
return '❌ Fehler beim Abrufen der Statistiken';
}
}
}

View file

@ -0,0 +1,229 @@
import { DISPLAY_NAMES } from '../config/configuration';
import { UmamiStats } from '../umami/umami.service';
export function formatNumber(num: number): string {
return num.toLocaleString('de-DE');
}
export function formatChange(change: number): string {
if (change === 0) return '→';
const sign = change > 0 ? '+' : '';
return `${sign}${Math.round(change)}%`;
}
export function formatChangeEmoji(change: number): string {
if (change > 10) return '📈';
if (change > 0) return '↗';
if (change < -10) return '📉';
if (change < 0) return '↘';
return '→';
}
export function getDisplayName(websiteKey: string): string {
return DISPLAY_NAMES[websiteKey] || websiteKey;
}
export function formatDate(date: Date, format: 'short' | 'long' = 'short'): string {
const options: Intl.DateTimeFormatOptions =
format === 'short'
? { day: 'numeric', month: 'numeric', year: 'numeric' }
: { day: 'numeric', month: 'long', year: 'numeric' };
return date.toLocaleDateString('de-DE', options);
}
export function formatWeekNumber(date: Date): string {
const startOfYear = new Date(date.getFullYear(), 0, 1);
const days = Math.floor((date.getTime() - startOfYear.getTime()) / (24 * 60 * 60 * 1000));
const weekNumber = Math.ceil((days + startOfYear.getDay() + 1) / 7);
return `KW ${weekNumber}`;
}
export function formatDailyReport(stats: Map<string, UmamiStats>, date: Date): string {
const lines: string[] = [
'📊 <b>ManaCore Daily Report</b>',
'━━━━━━━━━━━━━━━━━━━━',
'',
`📅 ${formatDate(date, 'long')}`,
'',
'<b>📈 Besucher heute:</b>',
];
// Sort by visitors (descending)
const sortedStats = Array.from(stats.entries())
.filter(([key]) => key.endsWith('-webapp'))
.sort((a, b) => b[1].visitors.value - a[1].visitors.value);
let totalVisitors = 0;
let totalPageviews = 0;
for (const [key, stat] of sortedStats) {
const name = getDisplayName(key).padEnd(12);
const visitors = stat.visitors.value;
const change = formatChange(stat.visitors.change);
const emoji = formatChangeEmoji(stat.visitors.change);
totalVisitors += visitors;
totalPageviews += stat.pageviews.value;
lines.push(` ${name}: ${formatNumber(visitors)} (${change}) ${emoji}`);
}
lines.push('');
lines.push(`📄 <b>Pageviews:</b> ${formatNumber(totalPageviews)}`);
lines.push(`👥 <b>Besucher gesamt:</b> ${formatNumber(totalVisitors)}`);
return lines.join('\n');
}
export function formatWeeklyReport(
stats: Map<string, UmamiStats>,
weekStart: Date,
weekEnd: Date,
prevStats?: Map<string, UmamiStats>
): string {
const lines: string[] = [
'📊 <b>ManaCore Weekly Report</b>',
'━━━━━━━━━━━━━━━━━━━━',
'',
`📅 ${formatWeekNumber(weekStart)} (${formatDate(weekStart)} - ${formatDate(weekEnd)})`,
'',
' Besucher Pageviews',
];
// Sort by visitors (descending)
const sortedStats = Array.from(stats.entries())
.filter(([key]) => key.endsWith('-webapp'))
.sort((a, b) => b[1].visitors.value - a[1].visitors.value);
let totalVisitors = 0;
let totalPageviews = 0;
for (const [key, stat] of sortedStats) {
const name = getDisplayName(key).padEnd(12);
const visitors = formatNumber(stat.visitors.value).padStart(6);
const pageviews = formatNumber(stat.pageviews.value).padStart(9);
totalVisitors += stat.visitors.value;
totalPageviews += stat.pageviews.value;
lines.push(`${name}: ${visitors} ${pageviews}`);
}
lines.push('────────────────────────────');
lines.push(
`<b>Total:</b> ${formatNumber(totalVisitors).padStart(6)} ${formatNumber(totalPageviews).padStart(9)}`
);
// Calculate week-over-week change if previous stats available
if (prevStats) {
let prevTotal = 0;
for (const [key, stat] of prevStats.entries()) {
if (key.endsWith('-webapp')) {
prevTotal += stat.visitors.value;
}
}
if (prevTotal > 0) {
const change = ((totalVisitors - prevTotal) / prevTotal) * 100;
lines.push('');
lines.push(`📊 <b>vs. Vorwoche:</b> ${formatChange(change)} ${formatChangeEmoji(change)}`);
}
}
return lines.join('\n');
}
export function formatRealtimeReport(activeVisitors: Map<string, number>): string {
const lines: string[] = ['🔴 <b>Realtime - Aktive Besucher</b>', '━━━━━━━━━━━━━━━━━━━━', ''];
// Sort by active visitors (descending)
const sortedVisitors = Array.from(activeVisitors.entries())
.filter(([key]) => key.endsWith('-webapp'))
.sort((a, b) => b[1] - a[1]);
let total = 0;
for (const [key, count] of sortedVisitors) {
const name = getDisplayName(key).padEnd(12);
total += count;
const indicator = count > 0 ? '🟢' : '⚪';
lines.push(`${indicator} ${name}: ${count}`);
}
lines.push('');
lines.push(`👥 <b>Gesamt aktiv:</b> ${total}`);
return lines.join('\n');
}
export function formatStatsOverview(stats: Map<string, UmamiStats>): string {
const lines: string[] = ['📊 <b>ManaCore Stats Übersicht</b>', '━━━━━━━━━━━━━━━━━━━━', ''];
// Group by type
const webapps = Array.from(stats.entries())
.filter(([key]) => key.endsWith('-webapp'))
.sort((a, b) => b[1].visitors.value - a[1].visitors.value);
const landings = Array.from(stats.entries())
.filter(([key]) => key.endsWith('-landing'))
.sort((a, b) => b[1].visitors.value - a[1].visitors.value);
lines.push('<b>🌐 Web Apps:</b>');
for (const [key, stat] of webapps) {
const name = getDisplayName(key).padEnd(12);
lines.push(` ${name}: ${formatNumber(stat.visitors.value)} visitors`);
}
if (landings.length > 0) {
lines.push('');
lines.push('<b>🏠 Landing Pages:</b>');
for (const [key, stat] of landings) {
const name = getDisplayName(key).padEnd(12);
lines.push(` ${name}: ${formatNumber(stat.visitors.value)} visitors`);
}
}
return lines.join('\n');
}
export function formatHelpMessage(): string {
return `🤖 <b>ManaCore Stats Bot</b>
Verfügbare Befehle:
/stats - Übersicht aller Apps
/today - Heutige Statistiken
/week - Wochenstatistiken
/realtime - Aktive Besucher jetzt
/users - Registrierte User
/help - Diese Hilfe anzeigen
📅 Automatische Reports:
Daily: Jeden Tag um 9:00
Weekly: Jeden Montag um 9:00`;
}
export interface UserStats {
totalUsers: number;
verifiedUsers: number;
todayNewUsers: number;
weekNewUsers: number;
monthNewUsers: number;
}
export function formatUsersReport(stats: UserStats): string {
const lines: string[] = [
'👥 <b>ManaCore User Statistics</b>',
'━━━━━━━━━━━━━━━━━━━━',
'',
`👤 <b>Gesamt:</b> ${formatNumber(stats.totalUsers)}`,
`✅ <b>Verifiziert:</b> ${formatNumber(stats.verifiedUsers)}`,
'',
'<b>📊 Neue Registrierungen:</b>',
` Heute: +${formatNumber(stats.todayNewUsers)}`,
` Diese Woche: +${formatNumber(stats.weekNewUsers)}`,
` Dieser Monat: +${formatNumber(stats.monthNewUsers)}`,
];
return lines.join('\n');
}

View file

@ -0,0 +1,36 @@
import { Module } from '@nestjs/common';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { ScheduleModule } from '@nestjs/schedule';
import { TelegrafModule } from 'nestjs-telegraf';
import configuration from './config/configuration';
import { BotModule } from './bot/bot.module';
import { UmamiModule } from './umami/umami.module';
import { AnalyticsModule } from './analytics/analytics.module';
import { SchedulerModule } from './scheduler/scheduler.module';
import { HealthController } from './health.controller';
@Module({
imports: [
ConfigModule.forRoot({
isGlobal: true,
load: [configuration],
}),
ScheduleModule.forRoot(),
TelegrafModule.forRootAsync({
imports: [ConfigModule],
useFactory: (configService: ConfigService) => ({
token: configService.get<string>('telegram.botToken') || '',
launchOptions: {
dropPendingUpdates: true,
},
}),
inject: [ConfigService],
}),
BotModule,
UmamiModule,
AnalyticsModule,
SchedulerModule,
],
controllers: [HealthController],
})
export class AppModule {}

View file

@ -0,0 +1,12 @@
import { Module } from '@nestjs/common';
import { AnalyticsModule } from '../analytics/analytics.module';
import { UsersModule } from '../users/users.module';
import { BotService } from './bot.service';
import { BotUpdate } from './bot.update';
@Module({
imports: [AnalyticsModule, UsersModule],
providers: [BotService, BotUpdate],
exports: [BotService],
})
export class BotModule {}

View file

@ -0,0 +1,40 @@
import { Injectable, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { InjectBot } from 'nestjs-telegraf';
import { Telegraf, Context } from 'telegraf';
@Injectable()
export class BotService {
private readonly logger = new Logger(BotService.name);
private readonly chatId: string;
constructor(
@InjectBot() private readonly bot: Telegraf<Context>,
private readonly configService: ConfigService
) {
this.chatId = this.configService.get<string>('telegram.chatId') || '';
}
async sendMessage(message: string, chatId?: string): Promise<void> {
const targetChatId = chatId || this.chatId;
if (!targetChatId) {
this.logger.warn('No chat ID configured, skipping message');
return;
}
try {
await this.bot.telegram.sendMessage(targetChatId, message, {
parse_mode: 'HTML',
});
this.logger.log(`Message sent to chat ${targetChatId}`);
} catch (error) {
this.logger.error(`Failed to send message: ${error}`);
throw error;
}
}
async sendReport(report: string): Promise<void> {
return this.sendMessage(report);
}
}

View file

@ -0,0 +1,79 @@
import { Logger } from '@nestjs/common';
import { Update, Ctx, Start, Help, Command } from 'nestjs-telegraf';
import { Context } from 'telegraf';
import { AnalyticsService } from '../analytics/analytics.service';
import { UsersService } from '../users/users.service';
import { formatHelpMessage, formatUsersReport } from '../analytics/formatters';
@Update()
export class BotUpdate {
private readonly logger = new Logger(BotUpdate.name);
constructor(
private readonly analyticsService: AnalyticsService,
private readonly usersService: UsersService
) {}
@Start()
async start(@Ctx() ctx: Context) {
this.logger.log(`/start command from ${ctx.from?.id}`);
await ctx.replyWithHTML(formatHelpMessage());
}
@Help()
async help(@Ctx() ctx: Context) {
this.logger.log(`/help command from ${ctx.from?.id}`);
await ctx.replyWithHTML(formatHelpMessage());
}
@Command('stats')
async stats(@Ctx() ctx: Context) {
this.logger.log(`/stats command from ${ctx.from?.id}`);
await ctx.reply('📊 Lade Statistiken...');
const report = await this.analyticsService.generateStatsOverview();
await ctx.replyWithHTML(report);
}
@Command('today')
async today(@Ctx() ctx: Context) {
this.logger.log(`/today command from ${ctx.from?.id}`);
await ctx.reply('📊 Lade heutige Statistiken...');
const report = await this.analyticsService.generateDailyReport();
await ctx.replyWithHTML(report);
}
@Command('week')
async week(@Ctx() ctx: Context) {
this.logger.log(`/week command from ${ctx.from?.id}`);
await ctx.reply('📊 Lade Wochenstatistiken...');
const report = await this.analyticsService.generateWeeklyReport();
await ctx.replyWithHTML(report);
}
@Command('realtime')
async realtime(@Ctx() ctx: Context) {
this.logger.log(`/realtime command from ${ctx.from?.id}`);
await ctx.reply('🔴 Lade Realtime-Daten...');
const report = await this.analyticsService.generateRealtimeReport();
await ctx.replyWithHTML(report);
}
@Command('users')
async users(@Ctx() ctx: Context) {
this.logger.log(`/users command from ${ctx.from?.id}`);
await ctx.reply('👥 Lade User-Statistiken...');
const stats = await this.usersService.getUserStats();
if (!stats) {
await ctx.reply('❌ Datenbank nicht verfügbar');
return;
}
const report = formatUsersReport(stats);
await ctx.replyWithHTML(report);
}
}

View file

@ -0,0 +1,71 @@
export default () => ({
port: parseInt(process.env.PORT || '3300', 10),
timezone: process.env.TZ || 'Europe/Berlin',
telegram: {
botToken: process.env.TELEGRAM_BOT_TOKEN,
chatId: process.env.TELEGRAM_CHAT_ID,
},
umami: {
apiUrl: process.env.UMAMI_API_URL || 'http://localhost:3200',
username: process.env.UMAMI_USERNAME || 'admin',
password: process.env.UMAMI_PASSWORD,
},
database: {
url: process.env.DATABASE_URL,
},
});
export const WEBSITE_IDS: Record<string, string> = {
// Landing Pages
'chat-landing': 'a264b165-80d2-47ab-91f4-2efc01de0b66',
'manacore-landing': 'cef3798d-85ae-47df-a44a-e9bee09dbcf9',
'clock-landing': '0332b471-a022-46af-a726-0f45932bfd58',
// Web Apps
'chat-webapp': '5cf9d569-3266-4a57-80dd-3a652dc32786',
'manacore-webapp': '4a14016d-394a-44e0-8ecc-67271f63ffb0',
'todo-webapp': 'ac021d98-778e-46cf-b6b2-2f650ea78f07',
'calendar-webapp': '884fc0a8-3b67-43bd-903b-2be531c66792',
'clock-webapp': '1e7b5006-87a5-4547-8a3d-ab30eac15dd4',
'contacts-webapp': 'ab89a839-be15-4949-99b4-e72492cee4ff',
'picture-webapp': 'bc552bd2-667d-44b4-a717-0dce6a8db98f',
'manadeck-webapp': '314fc57a-c63d-4008-b19e-5e272c0329d6',
'planta-webapp': '876f30bd-43e3-405a-9697-6157db67ca6b',
'zitare-landing': '17e7f92d-8f85-4e78-a4f5-10f0b47e8fb8',
'zitare-webapp': '8ad3c21f-6e9b-4d1e-b3a2-5c8f7d6e9a4b',
};
// Grouped websites for reporting
export const WEBSITE_GROUPS = {
landings: ['chat-landing', 'manacore-landing', 'clock-landing', 'zitare-landing'],
webapps: [
'manacore-webapp',
'chat-webapp',
'todo-webapp',
'calendar-webapp',
'clock-webapp',
'contacts-webapp',
'picture-webapp',
'manadeck-webapp',
'planta-webapp',
'zitare-webapp',
],
};
// Display names for reports
export const DISPLAY_NAMES: Record<string, string> = {
'chat-landing': 'Chat Landing',
'chat-webapp': 'Chat',
'manacore-landing': 'ManaCore Landing',
'manacore-webapp': 'ManaCore',
'todo-webapp': 'Todo',
'calendar-webapp': 'Calendar',
'clock-landing': 'Clock Landing',
'clock-webapp': 'Clock',
'contacts-webapp': 'Contacts',
'picture-webapp': 'Picture',
'manadeck-webapp': 'ManaDeck',
'planta-webapp': 'Planta',
'zitare-landing': 'Zitare Landing',
'zitare-webapp': 'Zitare',
};

View file

@ -0,0 +1,13 @@
import { Controller, Get } from '@nestjs/common';
@Controller()
export class HealthController {
@Get('health')
health() {
return {
status: 'ok',
service: 'telegram-stats-bot',
timestamp: new Date().toISOString(),
};
}
}

View file

@ -0,0 +1,18 @@
import { NestFactory } from '@nestjs/core';
import { Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { AppModule } from './app.module';
async function bootstrap() {
const logger = new Logger('Bootstrap');
const app = await NestFactory.create(AppModule);
const configService = app.get(ConfigService);
const port = configService.get<number>('port') || 3300;
await app.listen(port);
logger.log(`Telegram Stats Bot running on port ${port}`);
logger.log(`Timezone: ${configService.get<string>('timezone')}`);
}
bootstrap();

View file

@ -0,0 +1,66 @@
import { Injectable, Logger } from '@nestjs/common';
import { Cron } from '@nestjs/schedule';
import { AnalyticsService } from '../analytics/analytics.service';
import { BotService } from '../bot/bot.service';
@Injectable()
export class ReportScheduler {
private readonly logger = new Logger(ReportScheduler.name);
constructor(
private readonly analyticsService: AnalyticsService,
private readonly botService: BotService
) {}
/**
* Daily Report - Every day at 9:00 AM Europe/Berlin
* Cron: minute hour day month weekday
*/
@Cron('0 9 * * *', {
name: 'daily-report',
timeZone: 'Europe/Berlin',
})
async sendDailyReport(): Promise<void> {
this.logger.log('Starting daily report...');
try {
const report = await this.analyticsService.generateDailyReport();
await this.botService.sendReport(report);
this.logger.log('Daily report sent successfully');
} catch (error) {
this.logger.error('Failed to send daily report:', error);
}
}
/**
* Weekly Report - Every Monday at 9:00 AM Europe/Berlin
* Cron: minute hour day month weekday (1 = Monday)
*/
@Cron('0 9 * * 1', {
name: 'weekly-report',
timeZone: 'Europe/Berlin',
})
async sendWeeklyReport(): Promise<void> {
this.logger.log('Starting weekly report...');
try {
const report = await this.analyticsService.generateWeeklyReport();
await this.botService.sendReport(report);
this.logger.log('Weekly report sent successfully');
} catch (error) {
this.logger.error('Failed to send weekly report:', error);
}
}
/**
* Health check log - Every hour
* Useful for debugging and ensuring the scheduler is running
*/
@Cron('0 * * * *', {
name: 'scheduler-health',
timeZone: 'Europe/Berlin',
})
healthCheck(): void {
this.logger.debug('Scheduler health check - running');
}
}

View file

@ -0,0 +1,10 @@
import { Module } from '@nestjs/common';
import { AnalyticsModule } from '../analytics/analytics.module';
import { BotModule } from '../bot/bot.module';
import { ReportScheduler } from './report.scheduler';
@Module({
imports: [AnalyticsModule, BotModule],
providers: [ReportScheduler],
})
export class SchedulerModule {}

View file

@ -0,0 +1,8 @@
import { Module } from '@nestjs/common';
import { UmamiService } from './umami.service';
@Module({
providers: [UmamiService],
exports: [UmamiService],
})
export class UmamiModule {}

View file

@ -0,0 +1,135 @@
import { Injectable, Logger, OnModuleInit } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { WEBSITE_IDS } from '../config/configuration';
export interface UmamiStats {
pageviews: { value: number; change: number };
visitors: { value: number; change: number };
visits: { value: number; change: number };
bounces: { value: number; change: number };
totalTime: { value: number; change: number };
}
export interface ActiveVisitors {
websiteId: string;
visitors: number;
}
@Injectable()
export class UmamiService implements OnModuleInit {
private readonly logger = new Logger(UmamiService.name);
private apiUrl: string;
private username: string;
private password: string;
private authToken: string | null = null;
private tokenExpiry: Date | null = null;
constructor(private configService: ConfigService) {
this.apiUrl = this.configService.get<string>('umami.apiUrl') || 'http://localhost:3200';
this.username = this.configService.get<string>('umami.username') || 'admin';
this.password = this.configService.get<string>('umami.password') || '';
}
async onModuleInit() {
try {
await this.authenticate();
this.logger.log('Successfully authenticated with Umami');
} catch (error) {
this.logger.warn(
'Failed to authenticate with Umami on startup. Will retry on first request.'
);
}
}
private async authenticate(): Promise<void> {
const response = await fetch(`${this.apiUrl}/api/auth/login`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
username: this.username,
password: this.password,
}),
});
if (!response.ok) {
throw new Error(`Umami auth failed: ${response.status}`);
}
const data = await response.json();
this.authToken = data.token;
// Token is valid for 24 hours, refresh after 23 hours
this.tokenExpiry = new Date(Date.now() + 23 * 60 * 60 * 1000);
}
private async getAuthToken(): Promise<string> {
if (!this.authToken || !this.tokenExpiry || this.tokenExpiry < new Date()) {
await this.authenticate();
}
return this.authToken!;
}
private async apiRequest<T>(endpoint: string): Promise<T> {
const token = await this.getAuthToken();
const response = await fetch(`${this.apiUrl}${endpoint}`, {
headers: {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json',
},
});
if (!response.ok) {
throw new Error(`Umami API error: ${response.status} ${await response.text()}`);
}
return response.json();
}
async getWebsiteStats(websiteId: string, startAt: number, endAt: number): Promise<UmamiStats> {
return this.apiRequest<UmamiStats>(
`/api/websites/${websiteId}/stats?startAt=${startAt}&endAt=${endAt}`
);
}
async getActiveVisitors(websiteId: string): Promise<number> {
try {
const result = await this.apiRequest<ActiveVisitors[]>(`/api/websites/${websiteId}/active`);
return result?.[0]?.visitors || 0;
} catch {
return 0;
}
}
async getAllWebsiteStats(startAt: number, endAt: number): Promise<Map<string, UmamiStats>> {
const results = new Map<string, UmamiStats>();
for (const [name, id] of Object.entries(WEBSITE_IDS)) {
try {
const stats = await this.getWebsiteStats(id, startAt, endAt);
results.set(name, stats);
} catch (error) {
this.logger.warn(`Failed to get stats for ${name}: ${error}`);
}
}
return results;
}
async getAllActiveVisitors(): Promise<Map<string, number>> {
const results = new Map<string, number>();
for (const [name, id] of Object.entries(WEBSITE_IDS)) {
try {
const visitors = await this.getActiveVisitors(id);
results.set(name, visitors);
} catch (error) {
this.logger.warn(`Failed to get active visitors for ${name}: ${error}`);
}
}
return results;
}
getWebsiteId(name: string): string | undefined {
return WEBSITE_IDS[name];
}
}

View file

@ -0,0 +1,8 @@
import { Module } from '@nestjs/common';
import { UsersService } from './users.service';
@Module({
providers: [UsersService],
exports: [UsersService],
})
export class UsersModule {}

View file

@ -0,0 +1,77 @@
import { Injectable, Logger, OnModuleInit } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import postgres from 'postgres';
export interface UserStats {
totalUsers: number;
verifiedUsers: number;
todayNewUsers: number;
weekNewUsers: number;
monthNewUsers: number;
}
@Injectable()
export class UsersService implements OnModuleInit {
private readonly logger = new Logger(UsersService.name);
private sql: postgres.Sql | null = null;
private databaseUrl: string | undefined;
constructor(private configService: ConfigService) {
this.databaseUrl = this.configService.get<string>('database.url');
}
async onModuleInit() {
if (this.databaseUrl) {
try {
this.sql = postgres(this.databaseUrl);
this.logger.log('Database connection initialized');
} catch (error) {
this.logger.warn('Failed to initialize database connection:', error);
}
} else {
this.logger.warn('DATABASE_URL not configured, user stats will be unavailable');
}
}
async getUserStats(): Promise<UserStats | null> {
if (!this.sql) {
return null;
}
try {
const now = new Date();
const startOfToday = new Date(now);
startOfToday.setHours(0, 0, 0, 0);
const startOfWeek = new Date(now);
const day = startOfWeek.getDay();
const diff = startOfWeek.getDate() - day + (day === 0 ? -6 : 1);
startOfWeek.setDate(diff);
startOfWeek.setHours(0, 0, 0, 0);
const startOfMonth = new Date(now.getFullYear(), now.getMonth(), 1);
const [result] = await this.sql`
SELECT
COUNT(*) as total_users,
COUNT(*) FILTER (WHERE email_verified = true) as verified_users,
COUNT(*) FILTER (WHERE created_at >= ${startOfToday.toISOString()}) as today_new_users,
COUNT(*) FILTER (WHERE created_at >= ${startOfWeek.toISOString()}) as week_new_users,
COUNT(*) FILTER (WHERE created_at >= ${startOfMonth.toISOString()}) as month_new_users
FROM auth.users
WHERE deleted_at IS NULL
`;
return {
totalUsers: Number(result.total_users),
verifiedUsers: Number(result.verified_users),
todayNewUsers: Number(result.today_new_users),
weekNewUsers: Number(result.week_new_users),
monthNewUsers: Number(result.month_new_users),
};
} catch (error) {
this.logger.error('Failed to fetch user stats:', error);
return null;
}
}
}

View file

@ -0,0 +1,28 @@
{
"compilerOptions": {
"module": "commonjs",
"declaration": true,
"removeComments": true,
"emitDecoratorMetadata": true,
"experimentalDecorators": true,
"allowSyntheticDefaultImports": true,
"target": "ES2022",
"sourceMap": true,
"outDir": "./dist",
"baseUrl": "./",
"incremental": true,
"skipLibCheck": true,
"strictNullChecks": true,
"noImplicitAny": true,
"strictBindCallApply": true,
"forceConsistentCasingInFileNames": true,
"noFallthroughCasesInSwitch": true,
"esModuleInterop": true,
"resolveJsonModule": true,
"paths": {
"@/*": ["src/*"]
}
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
}