mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 20:21:09 +02:00
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:
parent
7a80a71496
commit
f29ef4aa3a
24 changed files with 1273 additions and 0 deletions
15
services/telegram-stats-bot/.env.example
Normal file
15
services/telegram-stats-bot/.env.example
Normal 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
|
||||
150
services/telegram-stats-bot/CLAUDE.md
Normal file
150
services/telegram-stats-bot/CLAUDE.md
Normal 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 |
|
||||
58
services/telegram-stats-bot/Dockerfile
Normal file
58
services/telegram-stats-bot/Dockerfile
Normal 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"]
|
||||
8
services/telegram-stats-bot/nest-cli.json
Normal file
8
services/telegram-stats-bot/nest-cli.json
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
{
|
||||
"$schema": "https://json.schemastore.org/nest-cli",
|
||||
"collection": "@nestjs/schematics",
|
||||
"sourceRoot": "src",
|
||||
"compilerOptions": {
|
||||
"deleteOutDir": true
|
||||
}
|
||||
}
|
||||
38
services/telegram-stats-bot/package.json
Normal file
38
services/telegram-stats-bot/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
|
|
@ -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 {}
|
||||
122
services/telegram-stats-bot/src/analytics/analytics.service.ts
Normal file
122
services/telegram-stats-bot/src/analytics/analytics.service.ts
Normal 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';
|
||||
}
|
||||
}
|
||||
}
|
||||
229
services/telegram-stats-bot/src/analytics/formatters.ts
Normal file
229
services/telegram-stats-bot/src/analytics/formatters.ts
Normal 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');
|
||||
}
|
||||
36
services/telegram-stats-bot/src/app.module.ts
Normal file
36
services/telegram-stats-bot/src/app.module.ts
Normal 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 {}
|
||||
12
services/telegram-stats-bot/src/bot/bot.module.ts
Normal file
12
services/telegram-stats-bot/src/bot/bot.module.ts
Normal 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 {}
|
||||
40
services/telegram-stats-bot/src/bot/bot.service.ts
Normal file
40
services/telegram-stats-bot/src/bot/bot.service.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
79
services/telegram-stats-bot/src/bot/bot.update.ts
Normal file
79
services/telegram-stats-bot/src/bot/bot.update.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
71
services/telegram-stats-bot/src/config/configuration.ts
Normal file
71
services/telegram-stats-bot/src/config/configuration.ts
Normal 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',
|
||||
};
|
||||
13
services/telegram-stats-bot/src/health.controller.ts
Normal file
13
services/telegram-stats-bot/src/health.controller.ts
Normal 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(),
|
||||
};
|
||||
}
|
||||
}
|
||||
18
services/telegram-stats-bot/src/main.ts
Normal file
18
services/telegram-stats-bot/src/main.ts
Normal 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();
|
||||
|
|
@ -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');
|
||||
}
|
||||
}
|
||||
|
|
@ -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 {}
|
||||
8
services/telegram-stats-bot/src/umami/umami.module.ts
Normal file
8
services/telegram-stats-bot/src/umami/umami.module.ts
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
import { Module } from '@nestjs/common';
|
||||
import { UmamiService } from './umami.service';
|
||||
|
||||
@Module({
|
||||
providers: [UmamiService],
|
||||
exports: [UmamiService],
|
||||
})
|
||||
export class UmamiModule {}
|
||||
135
services/telegram-stats-bot/src/umami/umami.service.ts
Normal file
135
services/telegram-stats-bot/src/umami/umami.service.ts
Normal 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];
|
||||
}
|
||||
}
|
||||
8
services/telegram-stats-bot/src/users/users.module.ts
Normal file
8
services/telegram-stats-bot/src/users/users.module.ts
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
import { Module } from '@nestjs/common';
|
||||
import { UsersService } from './users.service';
|
||||
|
||||
@Module({
|
||||
providers: [UsersService],
|
||||
exports: [UsersService],
|
||||
})
|
||||
export class UsersModule {}
|
||||
77
services/telegram-stats-bot/src/users/users.service.ts
Normal file
77
services/telegram-stats-bot/src/users/users.service.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
28
services/telegram-stats-bot/tsconfig.json
Normal file
28
services/telegram-stats-bot/tsconfig.json
Normal 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"]
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue