feat(telegram-contacts-bot): implement new Telegram bot for contacts

- Add NestJS service with Telegraf for Telegram bot functionality
- Implement commands: /search, /favorites, /recent, /birthdays, /tags, /stats, /add
- Create database schema for user linking and bot settings
- Add Contacts API client for fetching contacts data
- Include birthday detection and upcoming birthdays list
- Add message formatters for German locale
- Include Dockerfile and CLAUDE.md documentation
- Update TELEGRAM_BOTS.md with new bot

Commands implemented:
- /search [name] - Search contacts
- /favorites - Show favorite contacts
- /recent - Recently added contacts
- /birthdays - Upcoming birthdays
- /tags, /tag [name] - Tag management
- /stats - Contact statistics
- /add [name] - Quick-add contact
- /link, /unlink - Account management

https://claude.ai/code/session_01LwmhvhKpEsvVtY1ZKhYu6f
This commit is contained in:
Claude 2026-01-28 00:46:28 +00:00
parent 4737d75b7f
commit 3753724e82
No known key found for this signature in database
22 changed files with 5617 additions and 0 deletions

View file

@ -10,6 +10,7 @@ Dokumentation aller Telegram-Bots im ManaCore Monorepo.
| [telegram-ollama-bot](#telegram-ollama-bot) | 3301 | Lokale LLM-Inferenz via Ollama | ✅ Aktiv |
| [telegram-project-doc-bot](#telegram-project-doc-bot) | 3302 | Projektdokumentation & Blogpost-Generierung | ✅ Aktiv |
| [telegram-calendar-bot](#telegram-calendar-bot) | 3303 | Kalender-Termine & Erinnerungen | 🚧 In Entwicklung |
| [telegram-contacts-bot](#telegram-contacts-bot) | 3304 | Kontaktsuche & Geburtstags-Erinnerungen | 🚧 In Entwicklung |
## Gemeinsame Architektur

View file

@ -0,0 +1,21 @@
# Server
PORT=3304
NODE_ENV=development
TZ=Europe/Berlin
# Telegram
TELEGRAM_BOT_TOKEN=xxx # Bot Token from @BotFather
TELEGRAM_ALLOWED_USERS= # Optional: Comma-separated user IDs
# Contacts Backend API
CONTACTS_API_URL=http://localhost:3015
MANA_CORE_AUTH_URL=http://localhost:3001
# Database (for telegram user links)
DATABASE_URL=postgresql://manacore:devpassword@localhost:5432/contacts_bot
# Birthday Reminder Settings
BIRTHDAY_CHECK_ENABLED=true
BIRTHDAY_CHECK_TIME=08:00
BIRTHDAY_CHECK_TIMEZONE=Europe/Berlin
BIRTHDAY_DAYS_AHEAD=7 # Remind N days before birthday

View file

@ -0,0 +1,207 @@
# Telegram Contacts Bot
Telegram Bot für die Contacts-App mit Kontaktsuche, Quick-Add und Geburtstags-Erinnerungen.
## Tech Stack
- **Framework**: NestJS 10
- **Telegram**: nestjs-telegraf + Telegraf
- **Database**: PostgreSQL + Drizzle ORM
- **Scheduling**: @nestjs/schedule
- **Date Handling**: date-fns, date-fns-tz
## Commands
```bash
# Development
pnpm start:dev # Start with hot reload
# Build
pnpm build # Production build
# Type check
pnpm type-check # Check TypeScript types
# Database
pnpm db:generate # Generate migrations
pnpm db:push # Push schema to database
pnpm db:studio # Open Drizzle Studio
```
## Telegram Commands
| Command | Beschreibung |
|---------|--------------|
| `/start` | Hilfe & Account verknüpfen |
| `/help` | Verfügbare Befehle anzeigen |
| `/search [Name]` | Kontakt suchen |
| `/favorites` | Favoriten-Kontakte |
| `/recent` | Zuletzt hinzugefügt |
| `/birthdays` | Anstehende Geburtstage |
| `/tags` | Alle Tags anzeigen |
| `/tag [Name]` | Kontakte mit Tag |
| `/stats` | Kontakt-Statistiken |
| `/add [Name]` | Neuen Kontakt hinzufügen |
| `/status` | Verbindungsstatus |
| `/link` | ManaCore Account verknüpfen |
| `/unlink` | Verknüpfung trennen |
## Environment Variables
```env
# Server
PORT=3304
NODE_ENV=development
TZ=Europe/Berlin
# Telegram
TELEGRAM_BOT_TOKEN=xxx # Bot Token from @BotFather
TELEGRAM_ALLOWED_USERS=123,456 # Optional: Comma-separated user IDs
# Contacts Backend API
CONTACTS_API_URL=http://localhost:3015
MANA_CORE_AUTH_URL=http://localhost:3001
# Database
DATABASE_URL=postgresql://manacore:devpassword@localhost:5432/contacts_bot
# Birthday Reminder Settings
BIRTHDAY_CHECK_ENABLED=true
BIRTHDAY_CHECK_TIME=08:00
BIRTHDAY_CHECK_TIMEZONE=Europe/Berlin
BIRTHDAY_DAYS_AHEAD=7
```
## Projekt-Struktur
```
services/telegram-contacts-bot/
├── src/
│ ├── main.ts # Entry point
│ ├── app.module.ts # Root module
│ ├── health.controller.ts # Health endpoint
│ ├── config/
│ │ └── configuration.ts # Config & commands
│ ├── database/
│ │ ├── database.module.ts # Drizzle connection
│ │ └── schema.ts # DB schema
│ ├── bot/
│ │ ├── bot.module.ts
│ │ ├── bot.update.ts # Command handlers
│ │ └── formatters.ts # Message formatters
│ ├── contacts/
│ │ ├── contacts.module.ts
│ │ └── contacts.client.ts # Contacts API client
│ └── user/
│ ├── user.module.ts
│ └── user.service.ts # User management
├── drizzle/ # Migrations
├── drizzle.config.ts
├── package.json
└── Dockerfile
```
## Features
### Kontakt-Suche
- Suche nach Name, Email, Telefon, Firma
- Einfach Text senden = automatische Suche
- Detailansicht bei einzelnem Treffer
### Favoriten & Tags
- Favoriten-Kontakte schnell abrufen
- Nach Tags filtern
- Alle Tags auflisten
### Geburtstage
- Anstehende Geburtstage anzeigen
- Konfigurierbare Vorlaufzeit
- Alter wird berechnet
### Quick-Add
- Schnell neuen Kontakt erstellen
- Automatische Erkennung von Name/Telefon/Email
- Beispiel: `/add Max Mustermann 0171-1234567`
### Statistiken
- Gesamtzahl Kontakte
- Favoriten-Anzahl
- Kontakte mit Geburtstag
- Anzahl Tags
## API Endpoints (Contacts Backend)
Der Bot kommuniziert mit dem Contacts Backend:
| Endpoint | Verwendung |
|----------|------------|
| `GET /api/v1/contacts?search=` | Kontaktsuche |
| `GET /api/v1/contacts?favorite=true` | Favoriten |
| `GET /api/v1/contacts` | Alle Kontakte |
| `GET /api/v1/contacts/:id` | Kontakt-Details |
| `POST /api/v1/contacts` | Kontakt erstellen |
| `GET /api/v1/tags` | Alle Tags |
## Health Check
```bash
curl http://localhost:3304/health
```
## Lokale Entwicklung
```bash
# Docker starten (PostgreSQL)
pnpm docker:up
# Datenbank erstellen
psql -h localhost -U manacore -c "CREATE DATABASE contacts_bot;"
# In Bot-Verzeichnis
cd services/telegram-contacts-bot
# .env erstellen
cp .env.example .env
# Token eintragen
# Schema pushen
pnpm db:push
# Bot starten
pnpm start:dev
```
## Deployment
### Docker
```yaml
telegram-contacts-bot:
build:
context: .
dockerfile: services/telegram-contacts-bot/Dockerfile
restart: always
environment:
PORT: 3304
TELEGRAM_BOT_TOKEN: ${TELEGRAM_CONTACTS_BOT_TOKEN}
CONTACTS_API_URL: http://contacts-backend:3015
DATABASE_URL: ${CONTACTS_BOT_DATABASE_URL}
ports:
- "3304:3304"
```
### macOS (launchd)
```bash
# Service-Datei unter ~/Library/LaunchAgents/com.manacore.telegram-contacts-bot.plist
launchctl load ~/Library/LaunchAgents/com.manacore.telegram-contacts-bot.plist
```
## Roadmap
- [ ] Geburtstags-Erinnerungen via Scheduler
- [ ] Kontakt bearbeiten via Telegram
- [ ] Notizen zu Kontakt hinzufügen
- [ ] Aktivitäten loggen (Anruf, Meeting, etc.)
- [ ] vCard Export
- [ ] Inline Keyboards für Interaktionen

View file

@ -0,0 +1,45 @@
FROM node:20-alpine AS builder
WORKDIR /app
# Install pnpm
RUN corepack enable && corepack prepare pnpm@9.15.0 --activate
# Copy package files
COPY package.json pnpm-lock.yaml* ./
COPY services/telegram-contacts-bot/package.json ./services/telegram-contacts-bot/
# Install dependencies
RUN pnpm install --frozen-lockfile --filter @manacore/telegram-contacts-bot
# Copy source
COPY services/telegram-contacts-bot ./services/telegram-contacts-bot
# Build
WORKDIR /app/services/telegram-contacts-bot
RUN pnpm build
# Production image
FROM node:20-alpine AS runner
WORKDIR /app
# Install pnpm
RUN corepack enable && corepack prepare pnpm@9.15.0 --activate
# Copy built application
COPY --from=builder /app/services/telegram-contacts-bot/dist ./dist
COPY --from=builder /app/services/telegram-contacts-bot/package.json ./
COPY --from=builder /app/services/telegram-contacts-bot/node_modules ./node_modules
# Set environment
ENV NODE_ENV=production
ENV PORT=3304
EXPOSE 3304
# Health check
HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
CMD wget --no-verbose --tries=1 --spider http://localhost:3304/health || exit 1
CMD ["node", "dist/src/main.js"]

View file

@ -0,0 +1,10 @@
import { defineConfig } from 'drizzle-kit';
export default defineConfig({
schema: './src/database/schema.ts',
out: './drizzle',
dialect: 'postgresql',
dbCredentials: {
url: process.env.DATABASE_URL || 'postgresql://manacore:devpassword@localhost:5432/contacts_bot',
},
});

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,44 @@
{
"name": "@manacore/telegram-contacts-bot",
"version": "1.0.0",
"description": "Telegram bot for contacts lookup, quick-add, and birthday reminders",
"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/src/main",
"lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix",
"type-check": "tsc --noEmit",
"db:generate": "drizzle-kit generate",
"db:push": "drizzle-kit push",
"db:studio": "drizzle-kit studio"
},
"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",
"date-fns": "^4.1.0",
"date-fns-tz": "^3.2.0",
"drizzle-orm": "^0.38.3",
"nestjs-telegraf": "^2.8.0",
"postgres": "^3.4.5",
"reflect-metadata": "^0.2.2",
"rxjs": "^7.8.1",
"telegraf": "^4.16.3"
},
"devDependencies": {
"@nestjs/cli": "^10.4.9",
"@nestjs/schematics": "^10.2.3",
"@types/node": "^22.10.5",
"drizzle-kit": "^0.30.1",
"rimraf": "^6.0.1",
"typescript": "^5.7.3"
}
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,33 @@
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 { DatabaseModule } from './database/database.module';
import { BotModule } from './bot/bot.module';
import { ContactsModule } from './contacts/contacts.module';
import { UserModule } from './user/user.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.token') || '',
}),
inject: [ConfigService],
}),
DatabaseModule,
BotModule,
ContactsModule,
UserModule,
],
controllers: [HealthController],
})
export class AppModule {}

View file

@ -0,0 +1,10 @@
import { Module } from '@nestjs/common';
import { BotUpdate } from './bot.update';
import { ContactsModule } from '../contacts/contacts.module';
import { UserModule } from '../user/user.module';
@Module({
imports: [ContactsModule, UserModule],
providers: [BotUpdate],
})
export class BotModule {}

View file

@ -0,0 +1,380 @@
import { Logger } from '@nestjs/common';
import { Update, Ctx, Start, Help, Command, On, Message } from 'nestjs-telegraf';
import { Context } from 'telegraf';
import { ConfigService } from '@nestjs/config';
import { ContactsClient } from '../contacts/contacts.client';
import { UserService } from '../user/user.service';
import {
formatHelpMessage,
formatSearchResults,
formatFavorites,
formatRecentContacts,
formatUpcomingBirthdays,
formatTags,
formatContactsByTag,
formatStats,
formatContact,
formatContactCreated,
formatStatusMessage,
} from './formatters';
@Update()
export class BotUpdate {
private readonly logger = new Logger(BotUpdate.name);
private readonly allowedUsers: number[];
constructor(
private readonly configService: ConfigService,
private readonly contactsClient: ContactsClient,
private readonly userService: UserService
) {
this.allowedUsers = this.configService.get<number[]>('telegram.allowedUsers') || [];
}
private isAllowed(userId: number): boolean {
if (this.allowedUsers.length === 0) return true;
return this.allowedUsers.includes(userId);
}
private async getAccessToken(telegramUserId: number): Promise<string | null> {
const user = await this.userService.getUserByTelegramId(telegramUserId);
if (!user || !user.isActive || !user.accessToken) {
return null;
}
return user.accessToken;
}
@Start()
async start(@Ctx() ctx: Context) {
const userId = ctx.from?.id;
this.logger.log(`/start from user ${userId}`);
if (!userId || !this.isAllowed(userId)) {
await ctx.reply('❌ Du bist nicht berechtigt, diesen Bot zu nutzen.');
return;
}
await ctx.replyWithHTML(formatHelpMessage());
}
@Help()
async help(@Ctx() ctx: Context) {
const userId = ctx.from?.id;
if (!userId || !this.isAllowed(userId)) return;
await ctx.replyWithHTML(formatHelpMessage());
}
@Command('search')
async search(@Ctx() ctx: Context, @Message('text') text: string) {
const userId = ctx.from?.id;
this.logger.log(`/search from user ${userId}`);
if (!userId || !this.isAllowed(userId)) return;
const accessToken = await this.getAccessToken(userId);
if (!accessToken) {
await ctx.reply('❌ Bitte verknüpfe zuerst deinen Account mit /link');
return;
}
const query = text.replace(/^\/search\s*/i, '').trim();
if (!query) {
await ctx.reply('❓ Bitte gib einen Suchbegriff ein.\n\nBeispiel: /search Max');
return;
}
await ctx.reply(`🔍 Suche nach "${query}"...`);
await this.userService.updateLastActive(userId);
const contacts = await this.contactsClient.searchContacts(accessToken, query);
await ctx.replyWithHTML(formatSearchResults(contacts, query));
}
@Command('favorites')
async favorites(@Ctx() ctx: Context) {
const userId = ctx.from?.id;
this.logger.log(`/favorites from user ${userId}`);
if (!userId || !this.isAllowed(userId)) return;
const accessToken = await this.getAccessToken(userId);
if (!accessToken) {
await ctx.reply('❌ Bitte verknüpfe zuerst deinen Account mit /link');
return;
}
await ctx.reply('⭐ Lade Favoriten...');
await this.userService.updateLastActive(userId);
const contacts = await this.contactsClient.getFavorites(accessToken);
await ctx.replyWithHTML(formatFavorites(contacts));
}
@Command('recent')
async recent(@Ctx() ctx: Context) {
const userId = ctx.from?.id;
this.logger.log(`/recent from user ${userId}`);
if (!userId || !this.isAllowed(userId)) return;
const accessToken = await this.getAccessToken(userId);
if (!accessToken) {
await ctx.reply('❌ Bitte verknüpfe zuerst deinen Account mit /link');
return;
}
await ctx.reply('🕐 Lade kürzlich hinzugefügte Kontakte...');
await this.userService.updateLastActive(userId);
const contacts = await this.contactsClient.getRecentContacts(accessToken, 10);
await ctx.replyWithHTML(formatRecentContacts(contacts));
}
@Command('birthdays')
async birthdays(@Ctx() ctx: Context) {
const userId = ctx.from?.id;
this.logger.log(`/birthdays from user ${userId}`);
if (!userId || !this.isAllowed(userId)) return;
const accessToken = await this.getAccessToken(userId);
if (!accessToken) {
await ctx.reply('❌ Bitte verknüpfe zuerst deinen Account mit /link');
return;
}
await ctx.reply('🎂 Lade anstehende Geburtstage...');
await this.userService.updateLastActive(userId);
const settings = await this.userService.getBotSettings(userId);
const daysAhead = settings?.birthdayReminderDays || 30;
const contacts = await this.contactsClient.getUpcomingBirthdays(accessToken, daysAhead);
await ctx.replyWithHTML(formatUpcomingBirthdays(contacts, daysAhead));
}
@Command('tags')
async tags(@Ctx() ctx: Context) {
const userId = ctx.from?.id;
this.logger.log(`/tags from user ${userId}`);
if (!userId || !this.isAllowed(userId)) return;
const accessToken = await this.getAccessToken(userId);
if (!accessToken) {
await ctx.reply('❌ Bitte verknüpfe zuerst deinen Account mit /link');
return;
}
await this.userService.updateLastActive(userId);
const tags = await this.contactsClient.getTags(accessToken);
await ctx.replyWithHTML(formatTags(tags));
}
@Command('tag')
async tag(@Ctx() ctx: Context, @Message('text') text: string) {
const userId = ctx.from?.id;
this.logger.log(`/tag from user ${userId}`);
if (!userId || !this.isAllowed(userId)) return;
const accessToken = await this.getAccessToken(userId);
if (!accessToken) {
await ctx.reply('❌ Bitte verknüpfe zuerst deinen Account mit /link');
return;
}
const tagName = text.replace(/^\/tag\s*/i, '').trim().replace(/^#/, '');
if (!tagName) {
await ctx.reply('❓ Bitte gib einen Tag-Namen ein.\n\nBeispiel: /tag Familie');
return;
}
await ctx.reply(`🏷️ Lade Kontakte mit Tag #${tagName}...`);
await this.userService.updateLastActive(userId);
// Find tag ID by name
const tags = await this.contactsClient.getTags(accessToken);
const tag = tags.find((t) => t.name.toLowerCase() === tagName.toLowerCase());
if (!tag) {
await ctx.reply(`❌ Tag "#${tagName}" nicht gefunden.\n\nNutze /tags um alle Tags zu sehen.`);
return;
}
const contacts = await this.contactsClient.getContactsByTag(accessToken, tag.id);
await ctx.replyWithHTML(formatContactsByTag(contacts, tagName));
}
@Command('stats')
async stats(@Ctx() ctx: Context) {
const userId = ctx.from?.id;
this.logger.log(`/stats from user ${userId}`);
if (!userId || !this.isAllowed(userId)) return;
const accessToken = await this.getAccessToken(userId);
if (!accessToken) {
await ctx.reply('❌ Bitte verknüpfe zuerst deinen Account mit /link');
return;
}
await ctx.reply('📊 Lade Statistiken...');
await this.userService.updateLastActive(userId);
const stats = await this.contactsClient.getStats(accessToken);
await ctx.replyWithHTML(formatStats(stats));
}
@Command('add')
async add(@Ctx() ctx: Context, @Message('text') text: string) {
const userId = ctx.from?.id;
this.logger.log(`/add from user ${userId}`);
if (!userId || !this.isAllowed(userId)) return;
const accessToken = await this.getAccessToken(userId);
if (!accessToken) {
await ctx.reply('❌ Bitte verknüpfe zuerst deinen Account mit /link');
return;
}
const input = text.replace(/^\/add\s*/i, '').trim();
if (!input) {
await ctx.replyWithHTML(`📝 <b>Kontakt erstellen</b>
Beispiele:
/add Max Mustermann
/add Anna Schmidt 0171-1234567
/add info@firma.de
Format: /add [Name] [Telefon/Email]`);
return;
}
// Parse input: try to extract name, phone, email
const parts = input.split(/\s+/);
const data: {
firstName?: string;
lastName?: string;
phone?: string;
email?: string;
} = {};
for (const part of parts) {
if (part.includes('@')) {
data.email = part;
} else if (/^[\d+\-()]+$/.test(part.replace(/\s/g, ''))) {
data.phone = part;
} else if (!data.firstName) {
data.firstName = part;
} else if (!data.lastName) {
data.lastName = part;
}
}
if (!data.firstName && !data.email) {
await ctx.reply('❌ Bitte gib mindestens einen Namen oder eine E-Mail an.');
return;
}
await ctx.reply('📝 Erstelle Kontakt...');
await this.userService.updateLastActive(userId);
const contact = await this.contactsClient.createContact(accessToken, data);
if (contact) {
await ctx.replyWithHTML(formatContactCreated(contact));
} else {
await ctx.reply('❌ Fehler beim Erstellen des Kontakts.');
}
}
@Command('status')
async status(@Ctx() ctx: Context) {
const userId = ctx.from?.id;
this.logger.log(`/status from user ${userId}`);
if (!userId || !this.isAllowed(userId)) return;
const user = await this.userService.getUserByTelegramId(userId);
await ctx.replyWithHTML(
formatStatusMessage(
!!user?.isActive && !!user?.accessToken,
user?.telegramUsername || user?.telegramFirstName,
user?.lastActiveAt || undefined
)
);
}
@Command('link')
async link(@Ctx() ctx: Context) {
const userId = ctx.from?.id;
this.logger.log(`/link from user ${userId}`);
if (!userId || !this.isAllowed(userId)) return;
await ctx.replyWithHTML(`🔗 <b>Account verknüpfen</b>
Um deinen ManaCore Account zu verknüpfen:
1. Öffne die Contacts Web-App
2. Gehe zu <b>Einstellungen Integrationen Telegram</b>
3. Klicke auf "Mit Telegram verknüpfen"
4. Gib deine Telegram User-ID ein: <code>${userId}</code>
Nach der Verknüpfung kannst du alle Bot-Funktionen nutzen.`);
}
@Command('unlink')
async unlink(@Ctx() ctx: Context) {
const userId = ctx.from?.id;
this.logger.log(`/unlink from user ${userId}`);
if (!userId || !this.isAllowed(userId)) return;
const success = await this.userService.unlinkUser(userId);
if (success) {
await ctx.reply('✅ Account-Verknüpfung wurde aufgehoben.');
} else {
await ctx.reply('❌ Fehler beim Aufheben der Verknüpfung.');
}
}
@On('text')
async onText(@Ctx() ctx: Context, @Message('text') text: string) {
const userId = ctx.from?.id;
// Ignore commands
if (text.startsWith('/')) return;
if (!userId || !this.isAllowed(userId)) return;
// Check if user is linked
const accessToken = await this.getAccessToken(userId);
if (!accessToken) return;
// Treat any text as a search query
if (text.length >= 2) {
this.logger.log(`Search query from ${userId}: ${text.substring(0, 30)}...`);
await this.userService.updateLastActive(userId);
const contacts = await this.contactsClient.searchContacts(accessToken, text);
if (contacts.length === 1) {
// Show detailed view for single result
await ctx.replyWithHTML(formatContact(contacts[0], true));
} else {
await ctx.replyWithHTML(formatSearchResults(contacts, text));
}
}
}
}

View file

@ -0,0 +1,357 @@
import { format, differenceInYears, parseISO } from 'date-fns';
import { de } from 'date-fns/locale';
import { Contact, ContactTag, ContactStats } from '../contacts/contacts.client';
/**
* Escape HTML special characters
*/
function escapeHtml(text: string): string {
return text
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
/**
* Get display name for contact
*/
function getDisplayName(contact: Contact): string {
if (contact.displayName) return contact.displayName;
if (contact.firstName && contact.lastName) return `${contact.firstName} ${contact.lastName}`;
if (contact.firstName) return contact.firstName;
if (contact.lastName) return contact.lastName;
if (contact.nickname) return contact.nickname;
if (contact.email) return contact.email;
return 'Unbekannt';
}
/**
* Format a single contact for display
*/
export function formatContact(contact: Contact, detailed = false): string {
const name = getDisplayName(contact);
const star = contact.isFavorite ? ' ⭐' : '';
let text = `👤 <b>${escapeHtml(name)}</b>${star}\n`;
if (contact.company) {
text += `🏢 ${escapeHtml(contact.company)}`;
if (contact.jobTitle) {
text += ` - ${escapeHtml(contact.jobTitle)}`;
}
text += '\n';
}
if (contact.phone) {
text += `📞 ${escapeHtml(contact.phone)}\n`;
}
if (contact.mobile && contact.mobile !== contact.phone) {
text += `📱 ${escapeHtml(contact.mobile)}\n`;
}
if (contact.email) {
text += `📧 ${escapeHtml(contact.email)}\n`;
}
if (detailed) {
if (contact.street || contact.city) {
let address = '';
if (contact.street) address += contact.street;
if (contact.postalCode) address += `, ${contact.postalCode}`;
if (contact.city) address += ` ${contact.city}`;
if (contact.country) address += `, ${contact.country}`;
text += `📍 ${escapeHtml(address.trim())}\n`;
}
if (contact.website) {
text += `🌐 ${escapeHtml(contact.website)}\n`;
}
if (contact.birthday) {
const birthday = parseISO(contact.birthday);
const age = differenceInYears(new Date(), birthday);
text += `🎂 ${format(birthday, 'd. MMMM yyyy', { locale: de })} (${age} Jahre)\n`;
}
if (contact.tags && contact.tags.length > 0) {
const tagNames = contact.tags.map((t) => `#${t.name}`).join(' ');
text += `🏷️ ${escapeHtml(tagNames)}\n`;
}
if (contact.notes) {
const notes =
contact.notes.length > 100 ? contact.notes.substring(0, 100) + '...' : contact.notes;
text += `📝 ${escapeHtml(notes)}\n`;
}
}
return text.trim();
}
/**
* Format contact list
*/
export function formatContactList(contacts: Contact[], title: string): string {
if (contacts.length === 0) {
return `${title}\n\n✨ Keine Kontakte gefunden.`;
}
let text = `${title}\n\n`;
for (const contact of contacts.slice(0, 15)) {
const name = getDisplayName(contact);
const star = contact.isFavorite ? '⭐ ' : '';
const company = contact.company ? ` (${contact.company})` : '';
text += `${star}👤 ${escapeHtml(name)}${escapeHtml(company)}\n`;
}
if (contacts.length > 15) {
text += `\n... und ${contacts.length - 15} weitere`;
}
text += `\n\n───────────────\n${contacts.length} Kontakt${contacts.length === 1 ? '' : 'e'}`;
return text;
}
/**
* Format search results
*/
export function formatSearchResults(contacts: Contact[], query: string): string {
const title = `🔍 <b>Suche nach "${escapeHtml(query)}"</b>`;
if (contacts.length === 0) {
return `${title}\n\n❌ Keine Kontakte gefunden.\n\nTipp: Versuche es mit einem anderen Suchbegriff.`;
}
let text = `${title}\n\n`;
for (const contact of contacts.slice(0, 10)) {
text += formatContact(contact, false) + '\n\n';
}
if (contacts.length > 10) {
text += `... und ${contacts.length - 10} weitere Treffer`;
}
return text;
}
/**
* Format favorites list
*/
export function formatFavorites(contacts: Contact[]): string {
return formatContactList(contacts, '⭐ <b>Deine Favoriten</b>');
}
/**
* Format recent contacts
*/
export function formatRecentContacts(contacts: Contact[]): string {
return formatContactList(contacts, '🕐 <b>Zuletzt hinzugefügt</b>');
}
/**
* Format birthday contact
*/
function formatBirthdayContact(contact: Contact): string {
const name = getDisplayName(contact);
const birthday = parseISO(contact.birthday!);
const today = new Date();
// Calculate days until birthday
const thisYearBirthday = new Date(today.getFullYear(), birthday.getMonth(), birthday.getDate());
if (thisYearBirthday < today) {
thisYearBirthday.setFullYear(today.getFullYear() + 1);
}
const daysUntil = Math.ceil(
(thisYearBirthday.getTime() - today.getTime()) / (1000 * 60 * 60 * 24)
);
const age = differenceInYears(thisYearBirthday, birthday);
let dayText = '';
if (daysUntil === 0) {
dayText = '🎉 <b>Heute!</b>';
} else if (daysUntil === 1) {
dayText = '⏰ Morgen';
} else {
dayText = `in ${daysUntil} Tagen`;
}
return `🎂 <b>${escapeHtml(name)}</b> wird ${age}\n ${format(birthday, 'd. MMMM', { locale: de })} (${dayText})`;
}
/**
* Format upcoming birthdays
*/
export function formatUpcomingBirthdays(contacts: Contact[], daysAhead: number): string {
const title = `🎂 <b>Anstehende Geburtstage</b> (nächste ${daysAhead} Tage)`;
if (contacts.length === 0) {
return `${title}\n\n✨ Keine Geburtstage in den nächsten ${daysAhead} Tagen.`;
}
let text = `${title}\n\n`;
for (const contact of contacts) {
text += formatBirthdayContact(contact) + '\n\n';
}
return text.trim();
}
/**
* Format birthday reminder notification
*/
export function formatBirthdayReminder(contact: Contact): string {
const name = getDisplayName(contact);
const birthday = parseISO(contact.birthday!);
const today = new Date();
const thisYearBirthday = new Date(today.getFullYear(), birthday.getMonth(), birthday.getDate());
if (thisYearBirthday < today) {
thisYearBirthday.setFullYear(today.getFullYear() + 1);
}
const daysUntil = Math.ceil(
(thisYearBirthday.getTime() - today.getTime()) / (1000 * 60 * 60 * 24)
);
const age = differenceInYears(thisYearBirthday, birthday);
if (daysUntil === 0) {
return `🎉 <b>Heute hat ${escapeHtml(name)} Geburtstag!</b>\n\nWird ${age} Jahre alt. Zeit zum Gratulieren! 🎈`;
}
return `🎂 <b>Geburtstags-Erinnerung</b>\n\n${escapeHtml(name)} hat in ${daysUntil} Tag${daysUntil === 1 ? '' : 'en'} Geburtstag!\n📅 ${format(birthday, 'd. MMMM', { locale: de })} (wird ${age})`;
}
/**
* Format tags list
*/
export function formatTags(tags: ContactTag[]): string {
if (tags.length === 0) {
return '🏷️ <b>Deine Tags</b>\n\nKeine Tags vorhanden.';
}
let text = '🏷️ <b>Deine Tags</b>\n\n';
for (const tag of tags) {
text += `• #${escapeHtml(tag.name)}\n`;
}
text += `\n───────────────\n${tags.length} Tag${tags.length === 1 ? '' : 's'}`;
text += '\n\nNutze /tag [name] um Kontakte mit einem Tag anzuzeigen.';
return text;
}
/**
* Format contacts by tag
*/
export function formatContactsByTag(contacts: Contact[], tagName: string): string {
return formatContactList(contacts, `🏷️ <b>Kontakte mit Tag #${escapeHtml(tagName)}</b>`);
}
/**
* Format contact statistics
*/
export function formatStats(stats: ContactStats): string {
return `📊 <b>Deine Kontakt-Statistiken</b>
👥 Gesamt: ${stats.totalContacts} Kontakte
Favoriten: ${stats.favorites}
🎂 Mit Geburtstag: ${stats.withBirthday}
🆕 Diese Woche hinzugefügt: ${stats.recentlyAdded}
🏷 Tags: ${stats.tagCount}`;
}
/**
* Format contact created confirmation
*/
export function formatContactCreated(contact: Contact): string {
return `✅ <b>Kontakt erstellt!</b>\n\n${formatContact(contact, true)}`;
}
/**
* Format note added confirmation
*/
export function formatNoteAdded(contactName: string): string {
return `📝 Notiz zu <b>${escapeHtml(contactName)}</b> hinzugefügt!`;
}
/**
* Format activity logged confirmation
*/
export function formatActivityLogged(contactName: string, activityType: string): string {
const activityEmoji: Record<string, string> = {
called: '📞',
emailed: '📧',
met: '🤝',
messaged: '💬',
};
const emoji = activityEmoji[activityType] || '📋';
return `${emoji} Aktivität für <b>${escapeHtml(contactName)}</b> geloggt!`;
}
/**
* Format help message
*/
export function formatHelpMessage(): string {
return `📇 <b>Contacts Bot - Hilfe</b>
<b>Kontakte finden:</b>
/search [Name] - Kontakt suchen
/favorites - Favoriten anzeigen
/recent - Zuletzt hinzugefügt
<b>Geburtstage:</b>
/birthdays - Anstehende Geburtstage
<b>Tags:</b>
/tags - Alle Tags anzeigen
/tag [Name] - Kontakte mit Tag
<b>Statistiken:</b>
/stats - Kontakt-Statistiken
<b>Neuer Kontakt:</b>
/add Vorname Nachname
<b>Account:</b>
/link - ManaCore Account verknüpfen
/unlink - Verknüpfung trennen
/status - Verbindungsstatus
💡 Du kannst auch einfach einen Namen senden, um danach zu suchen!`;
}
/**
* Format status message
*/
export function formatStatusMessage(
isLinked: boolean,
username?: string | null,
lastActive?: Date
): string {
if (!isLinked) {
return `📊 <b>Status</b>
Nicht mit ManaCore verknüpft
Nutze /link um deinen Account zu verknüpfen.`;
}
const lastActiveText = lastActive ? format(lastActive, 'd. MMM HH:mm', { locale: de }) : 'Nie';
return `📊 <b>Status</b>
Verknüpft mit ManaCore
👤 ${username || 'Unbekannt'}
🕐 Letzte Aktivität: ${lastActiveText}
Nutze /unlink um die Verknüpfung zu trennen.`;
}

View file

@ -0,0 +1,53 @@
export default () => ({
port: parseInt(process.env.PORT || '3304', 10),
nodeEnv: process.env.NODE_ENV || 'development',
telegram: {
token: process.env.TELEGRAM_BOT_TOKEN || '',
allowedUsers: process.env.TELEGRAM_ALLOWED_USERS
? process.env.TELEGRAM_ALLOWED_USERS.split(',').map((id) => parseInt(id.trim(), 10))
: [],
},
contacts: {
apiUrl: process.env.CONTACTS_API_URL || 'http://localhost:3015',
authUrl: process.env.MANA_CORE_AUTH_URL || 'http://localhost:3001',
},
database: {
url: process.env.DATABASE_URL,
},
birthday: {
checkEnabled: process.env.BIRTHDAY_CHECK_ENABLED === 'true',
checkTime: process.env.BIRTHDAY_CHECK_TIME || '08:00',
timezone: process.env.BIRTHDAY_CHECK_TIMEZONE || 'Europe/Berlin',
daysAhead: parseInt(process.env.BIRTHDAY_DAYS_AHEAD || '7', 10),
},
});
// Command descriptions for BotFather
export const COMMANDS = [
{ command: 'start', description: 'Hilfe & Account verknüpfen' },
{ command: 'help', description: 'Verfügbare Befehle anzeigen' },
{ command: 'search', description: 'Kontakt suchen (z.B. /search Max)' },
{ command: 'favorites', description: 'Favoriten-Kontakte anzeigen' },
{ command: 'recent', description: 'Zuletzt hinzugefügte Kontakte' },
{ command: 'birthdays', description: 'Anstehende Geburtstage' },
{ command: 'add', description: 'Neuen Kontakt hinzufügen' },
{ command: 'tags', description: 'Alle Tags anzeigen' },
{ command: 'tag', description: 'Kontakte mit Tag anzeigen' },
{ command: 'stats', description: 'Kontakt-Statistiken' },
{ command: 'link', description: 'ManaCore Account verknüpfen' },
{ command: 'unlink', description: 'Account-Verknüpfung trennen' },
{ command: 'status', description: 'Verbindungsstatus prüfen' },
];
// Activity types for logging
export const ACTIVITY_TYPES = {
called: '📞 Angerufen',
emailed: '📧 E-Mail gesendet',
met: '🤝 Getroffen',
messaged: '💬 Nachricht gesendet',
note_added: '📝 Notiz hinzugefügt',
};

View file

@ -0,0 +1,293 @@
import { Injectable, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
export interface Contact {
id: string;
firstName: string | null;
lastName: string | null;
displayName: string | null;
nickname: string | null;
email: string | null;
phone: string | null;
mobile: string | null;
company: string | null;
jobTitle: string | null;
street: string | null;
city: string | null;
postalCode: string | null;
country: string | null;
website: string | null;
birthday: string | null;
notes: string | null;
photoUrl: string | null;
isFavorite: boolean;
isArchived: boolean;
createdAt: string;
updatedAt: string;
tags?: ContactTag[];
}
export interface ContactTag {
id: string;
name: string;
color: string | null;
}
export interface ContactNote {
id: string;
content: string;
isPinned: boolean;
createdAt: string;
}
export interface ContactStats {
totalContacts: number;
favorites: number;
withBirthday: number;
recentlyAdded: number;
tagCount: number;
}
@Injectable()
export class ContactsClient {
private readonly logger = new Logger(ContactsClient.name);
private readonly apiUrl: string;
constructor(private configService: ConfigService) {
this.apiUrl = this.configService.get<string>('contacts.apiUrl') || 'http://localhost:3015';
}
/**
* Make authenticated API request
*/
private async request<T>(
endpoint: string,
accessToken: string,
options: RequestInit = {}
): Promise<T | null> {
try {
const response = await fetch(`${this.apiUrl}${endpoint}`, {
...options,
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${accessToken}`,
...options.headers,
},
});
if (!response.ok) {
this.logger.error(`API error: ${response.status} ${response.statusText}`);
return null;
}
return await response.json();
} catch (error) {
this.logger.error(`Request failed: ${error}`);
return null;
}
}
/**
* Search contacts by name, email, phone, or company
*/
async searchContacts(accessToken: string, query: string): Promise<Contact[]> {
const result = await this.request<{ contacts: Contact[] }>(
`/api/v1/contacts?search=${encodeURIComponent(query)}&limit=10`,
accessToken
);
return result?.contacts || [];
}
/**
* Get all contacts
*/
async getAllContacts(accessToken: string, limit = 100): Promise<Contact[]> {
const result = await this.request<{ contacts: Contact[] }>(
`/api/v1/contacts?limit=${limit}`,
accessToken
);
return result?.contacts || [];
}
/**
* Get favorite contacts
*/
async getFavorites(accessToken: string): Promise<Contact[]> {
const result = await this.request<{ contacts: Contact[] }>(
'/api/v1/contacts?favorite=true&limit=20',
accessToken
);
return result?.contacts || [];
}
/**
* Get recently added contacts
*/
async getRecentContacts(accessToken: string, limit = 10): Promise<Contact[]> {
const result = await this.request<{ contacts: Contact[] }>(
`/api/v1/contacts?sort=created_at&order=desc&limit=${limit}`,
accessToken
);
return result?.contacts || [];
}
/**
* Get contact by ID
*/
async getContact(accessToken: string, contactId: string): Promise<Contact | null> {
return await this.request<Contact>(`/api/v1/contacts/${contactId}`, accessToken);
}
/**
* Get contacts with upcoming birthdays
*/
async getUpcomingBirthdays(accessToken: string, daysAhead = 30): Promise<Contact[]> {
// Get all contacts and filter by birthday
const result = await this.request<{ contacts: Contact[] }>(
'/api/v1/contacts?limit=500',
accessToken
);
if (!result?.contacts) return [];
const today = new Date();
const upcoming: Array<{ contact: Contact; daysUntil: number }> = [];
for (const contact of result.contacts) {
if (!contact.birthday) continue;
const birthday = new Date(contact.birthday);
const thisYearBirthday = new Date(
today.getFullYear(),
birthday.getMonth(),
birthday.getDate()
);
// If birthday already passed this year, check next year
if (thisYearBirthday < today) {
thisYearBirthday.setFullYear(today.getFullYear() + 1);
}
const daysUntil = Math.ceil(
(thisYearBirthday.getTime() - today.getTime()) / (1000 * 60 * 60 * 24)
);
if (daysUntil <= daysAhead) {
upcoming.push({ contact, daysUntil });
}
}
// Sort by days until birthday
upcoming.sort((a, b) => a.daysUntil - b.daysUntil);
return upcoming.map((u) => u.contact);
}
/**
* Get all tags
*/
async getTags(accessToken: string): Promise<ContactTag[]> {
const result = await this.request<ContactTag[]>('/api/v1/tags', accessToken);
return result || [];
}
/**
* Get contacts by tag
*/
async getContactsByTag(accessToken: string, tagId: string): Promise<Contact[]> {
const result = await this.request<{ contacts: Contact[] }>(
`/api/v1/contacts?tag=${tagId}&limit=50`,
accessToken
);
return result?.contacts || [];
}
/**
* Create a new contact
*/
async createContact(
accessToken: string,
data: {
firstName?: string;
lastName?: string;
email?: string;
phone?: string;
mobile?: string;
company?: string;
notes?: string;
}
): Promise<Contact | null> {
return await this.request<Contact>('/api/v1/contacts', accessToken, {
method: 'POST',
body: JSON.stringify(data),
});
}
/**
* Add note to contact
*/
async addNote(
accessToken: string,
contactId: string,
content: string
): Promise<ContactNote | null> {
return await this.request<ContactNote>(`/api/v1/contacts/${contactId}/notes`, accessToken, {
method: 'POST',
body: JSON.stringify({ content }),
});
}
/**
* Log activity for contact
*/
async logActivity(
accessToken: string,
contactId: string,
activityType: string,
description?: string
): Promise<boolean> {
const result = await this.request<{ id: string }>(
`/api/v1/contacts/${contactId}/activities`,
accessToken,
{
method: 'POST',
body: JSON.stringify({ activityType, description }),
}
);
return result !== null;
}
/**
* Toggle favorite status
*/
async toggleFavorite(accessToken: string, contactId: string): Promise<boolean> {
const result = await this.request<Contact>(
`/api/v1/contacts/${contactId}/favorite`,
accessToken,
{ method: 'POST' }
);
return result !== null;
}
/**
* Get contact statistics
*/
async getStats(accessToken: string): Promise<ContactStats> {
const [contacts, favorites, tags] = await Promise.all([
this.getAllContacts(accessToken, 500),
this.getFavorites(accessToken),
this.getTags(accessToken),
]);
const now = new Date();
const weekAgo = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000);
return {
totalContacts: contacts.length,
favorites: favorites.length,
withBirthday: contacts.filter((c) => c.birthday).length,
recentlyAdded: contacts.filter((c) => new Date(c.createdAt) > weekAgo).length,
tagCount: tags.length,
};
}
}

View file

@ -0,0 +1,8 @@
import { Module } from '@nestjs/common';
import { ContactsClient } from './contacts.client';
@Module({
providers: [ContactsClient],
exports: [ContactsClient],
})
export class ContactsModule {}

View file

@ -0,0 +1,39 @@
import { Module, Global, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { drizzle, PostgresJsDatabase } from 'drizzle-orm/postgres-js';
import postgres from 'postgres';
import * as schema from './schema';
export const DATABASE_CONNECTION = 'DATABASE_CONNECTION';
export type Database = PostgresJsDatabase<typeof schema>;
@Global()
@Module({
providers: [
{
provide: DATABASE_CONNECTION,
useFactory: async (configService: ConfigService): Promise<Database | null> => {
const logger = new Logger('Database');
const databaseUrl = configService.get<string>('database.url');
if (!databaseUrl) {
logger.warn('DATABASE_URL not set, database features will be disabled');
return null;
}
try {
const client = postgres(databaseUrl);
const db = drizzle(client, { schema });
logger.log('Database connection established');
return db;
} catch (error) {
logger.error(`Failed to connect to database: ${error}`);
return null;
}
},
inject: [ConfigService],
},
],
exports: [DATABASE_CONNECTION],
})
export class DatabaseModule {}

View file

@ -0,0 +1,69 @@
import {
pgTable,
uuid,
bigint,
text,
timestamp,
boolean,
time,
varchar,
integer,
} from 'drizzle-orm/pg-core';
/**
* Telegram users linked to ManaCore accounts
*/
export const telegramUsers = pgTable('telegram_users', {
id: uuid('id').primaryKey().defaultRandom(),
telegramUserId: bigint('telegram_user_id', { mode: 'number' }).notNull().unique(),
telegramUsername: text('telegram_username'),
telegramFirstName: text('telegram_first_name'),
manaUserId: text('mana_user_id').notNull(),
accessToken: text('access_token'),
refreshToken: text('refresh_token'),
tokenExpiresAt: timestamp('token_expires_at', { withTimezone: true }),
isActive: boolean('is_active').default(true),
linkedAt: timestamp('linked_at', { withTimezone: true }).defaultNow(),
lastActiveAt: timestamp('last_active_at', { withTimezone: true }),
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(),
});
/**
* User-specific bot settings
*/
export const botSettings = pgTable('bot_settings', {
id: uuid('id').primaryKey().defaultRandom(),
telegramUserId: bigint('telegram_user_id', { mode: 'number' })
.notNull()
.unique()
.references(() => telegramUsers.telegramUserId, { onDelete: 'cascade' }),
// Birthday reminders
birthdayRemindersEnabled: boolean('birthday_reminders_enabled').default(true),
birthdayReminderTime: time('birthday_reminder_time').default('08:00'),
birthdayReminderDays: integer('birthday_reminder_days').default(7),
timezone: varchar('timezone', { length: 100 }).default('Europe/Berlin'),
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(),
});
/**
* Sent birthday reminders to avoid duplicates
*/
export const sentBirthdayReminders = pgTable('sent_birthday_reminders', {
id: uuid('id').primaryKey().defaultRandom(),
telegramUserId: bigint('telegram_user_id', { mode: 'number' })
.notNull()
.references(() => telegramUsers.telegramUserId, { onDelete: 'cascade' }),
contactId: text('contact_id').notNull(),
birthdayYear: integer('birthday_year').notNull(),
sentAt: timestamp('sent_at', { withTimezone: true }).defaultNow().notNull(),
messageId: integer('message_id'),
});
export type TelegramUser = typeof telegramUsers.$inferSelect;
export type NewTelegramUser = typeof telegramUsers.$inferInsert;
export type BotSetting = typeof botSettings.$inferSelect;
export type SentBirthdayReminder = typeof sentBirthdayReminders.$inferSelect;

View file

@ -0,0 +1,14 @@
import { Controller, Get } from '@nestjs/common';
@Controller('health')
export class HealthController {
@Get()
check() {
return {
status: 'ok',
service: 'telegram-contacts-bot',
timestamp: new Date().toISOString(),
environment: process.env.NODE_ENV || 'development',
};
}
}

View file

@ -0,0 +1,17 @@
import { NestFactory } from '@nestjs/core';
import { Logger } from '@nestjs/common';
import { AppModule } from './app.module';
async function bootstrap() {
const app = await NestFactory.create(AppModule);
const logger = new Logger('Bootstrap');
const port = process.env.PORT || 3304;
await app.listen(port);
logger.log(`Telegram Contacts Bot running on port ${port}`);
logger.log(`Contacts API: ${process.env.CONTACTS_API_URL || 'http://localhost:3015'}`);
}
bootstrap();

View file

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

View file

@ -0,0 +1,171 @@
import { Injectable, Inject, Logger } from '@nestjs/common';
import { eq } from 'drizzle-orm';
import { DATABASE_CONNECTION, Database } from '../database/database.module';
import { telegramUsers, botSettings, TelegramUser, NewTelegramUser, BotSetting } from '../database/schema';
@Injectable()
export class UserService {
private readonly logger = new Logger(UserService.name);
constructor(@Inject(DATABASE_CONNECTION) private db: Database | null) {}
async getUserByTelegramId(telegramUserId: number): Promise<TelegramUser | null> {
if (!this.db) return null;
try {
const result = await this.db
.select()
.from(telegramUsers)
.where(eq(telegramUsers.telegramUserId, telegramUserId))
.limit(1);
return result[0] || null;
} catch (error) {
this.logger.error(`Error getting user: ${error}`);
return null;
}
}
async linkUser(data: {
telegramUserId: number;
telegramUsername?: string;
telegramFirstName?: string;
manaUserId: string;
accessToken: string;
refreshToken?: string;
tokenExpiresAt?: Date;
}): Promise<TelegramUser | null> {
if (!this.db) return null;
try {
const existing = await this.getUserByTelegramId(data.telegramUserId);
if (existing) {
const result = await this.db
.update(telegramUsers)
.set({
telegramUsername: data.telegramUsername,
telegramFirstName: data.telegramFirstName,
manaUserId: data.manaUserId,
accessToken: data.accessToken,
refreshToken: data.refreshToken,
tokenExpiresAt: data.tokenExpiresAt,
isActive: true,
updatedAt: new Date(),
})
.where(eq(telegramUsers.telegramUserId, data.telegramUserId))
.returning();
return result[0] || null;
} else {
const newUser: NewTelegramUser = {
telegramUserId: data.telegramUserId,
telegramUsername: data.telegramUsername,
telegramFirstName: data.telegramFirstName,
manaUserId: data.manaUserId,
accessToken: data.accessToken,
refreshToken: data.refreshToken,
tokenExpiresAt: data.tokenExpiresAt,
};
const result = await this.db.insert(telegramUsers).values(newUser).returning();
// Create default bot settings
if (result[0]) {
await this.db.insert(botSettings).values({
telegramUserId: data.telegramUserId,
});
}
return result[0] || null;
}
} catch (error) {
this.logger.error(`Error linking user: ${error}`);
return null;
}
}
async unlinkUser(telegramUserId: number): Promise<boolean> {
if (!this.db) return false;
try {
await this.db
.update(telegramUsers)
.set({
isActive: false,
accessToken: null,
refreshToken: null,
updatedAt: new Date(),
})
.where(eq(telegramUsers.telegramUserId, telegramUserId));
return true;
} catch (error) {
this.logger.error(`Error unlinking user: ${error}`);
return false;
}
}
async updateLastActive(telegramUserId: number): Promise<void> {
if (!this.db) return;
try {
await this.db
.update(telegramUsers)
.set({ lastActiveAt: new Date() })
.where(eq(telegramUsers.telegramUserId, telegramUserId));
} catch (error) {
this.logger.error(`Error updating last active: ${error}`);
}
}
async getBotSettings(telegramUserId: number): Promise<BotSetting | null> {
if (!this.db) return null;
try {
const result = await this.db
.select()
.from(botSettings)
.where(eq(botSettings.telegramUserId, telegramUserId))
.limit(1);
return result[0] || null;
} catch (error) {
this.logger.error(`Error getting bot settings: ${error}`);
return null;
}
}
async getAllActiveUsers(): Promise<TelegramUser[]> {
if (!this.db) return [];
try {
return await this.db.select().from(telegramUsers).where(eq(telegramUsers.isActive, true));
} catch (error) {
this.logger.error(`Error getting active users: ${error}`);
return [];
}
}
async getUsersWithBirthdayReminders(): Promise<
Array<{ user: TelegramUser; settings: BotSetting }>
> {
if (!this.db) return [];
try {
const result = await this.db
.select({
user: telegramUsers,
settings: botSettings,
})
.from(telegramUsers)
.innerJoin(botSettings, eq(telegramUsers.telegramUserId, botSettings.telegramUserId))
.where(eq(telegramUsers.isActive, true));
return result.filter((r) => r.settings.birthdayRemindersEnabled);
} catch (error) {
this.logger.error(`Error getting users with birthday reminders: ${error}`);
return [];
}
}
}

View file

@ -0,0 +1,22 @@
{
"compilerOptions": {
"module": "commonjs",
"declaration": true,
"removeComments": true,
"emitDecoratorMetadata": true,
"experimentalDecorators": true,
"allowSyntheticDefaultImports": true,
"target": "ES2021",
"sourceMap": true,
"outDir": "./dist",
"baseUrl": "./",
"incremental": true,
"skipLibCheck": true,
"strictNullChecks": true,
"noImplicitAny": false,
"strictBindCallApply": false,
"forceConsistentCasingInFileNames": false,
"noFallthroughCasesInSwitch": false,
"esModuleInterop": true
}
}