mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-15 13:21:08 +02:00
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:
parent
4737d75b7f
commit
3753724e82
22 changed files with 5617 additions and 0 deletions
|
|
@ -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
|
||||
|
||||
|
|
|
|||
21
services/telegram-contacts-bot/.env.example
Normal file
21
services/telegram-contacts-bot/.env.example
Normal 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
|
||||
207
services/telegram-contacts-bot/CLAUDE.md
Normal file
207
services/telegram-contacts-bot/CLAUDE.md
Normal 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
|
||||
45
services/telegram-contacts-bot/Dockerfile
Normal file
45
services/telegram-contacts-bot/Dockerfile
Normal 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"]
|
||||
10
services/telegram-contacts-bot/drizzle.config.ts
Normal file
10
services/telegram-contacts-bot/drizzle.config.ts
Normal 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',
|
||||
},
|
||||
});
|
||||
8
services/telegram-contacts-bot/nest-cli.json
Normal file
8
services/telegram-contacts-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
|
||||
}
|
||||
}
|
||||
44
services/telegram-contacts-bot/package.json
Normal file
44
services/telegram-contacts-bot/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
3807
services/telegram-contacts-bot/pnpm-lock.yaml
generated
Normal file
3807
services/telegram-contacts-bot/pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load diff
33
services/telegram-contacts-bot/src/app.module.ts
Normal file
33
services/telegram-contacts-bot/src/app.module.ts
Normal 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 {}
|
||||
10
services/telegram-contacts-bot/src/bot/bot.module.ts
Normal file
10
services/telegram-contacts-bot/src/bot/bot.module.ts
Normal 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 {}
|
||||
380
services/telegram-contacts-bot/src/bot/bot.update.ts
Normal file
380
services/telegram-contacts-bot/src/bot/bot.update.ts
Normal 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));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
357
services/telegram-contacts-bot/src/bot/formatters.ts
Normal file
357
services/telegram-contacts-bot/src/bot/formatters.ts
Normal 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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"');
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.`;
|
||||
}
|
||||
53
services/telegram-contacts-bot/src/config/configuration.ts
Normal file
53
services/telegram-contacts-bot/src/config/configuration.ts
Normal 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',
|
||||
};
|
||||
293
services/telegram-contacts-bot/src/contacts/contacts.client.ts
Normal file
293
services/telegram-contacts-bot/src/contacts/contacts.client.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
import { Module } from '@nestjs/common';
|
||||
import { ContactsClient } from './contacts.client';
|
||||
|
||||
@Module({
|
||||
providers: [ContactsClient],
|
||||
exports: [ContactsClient],
|
||||
})
|
||||
export class ContactsModule {}
|
||||
|
|
@ -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 {}
|
||||
69
services/telegram-contacts-bot/src/database/schema.ts
Normal file
69
services/telegram-contacts-bot/src/database/schema.ts
Normal 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;
|
||||
14
services/telegram-contacts-bot/src/health.controller.ts
Normal file
14
services/telegram-contacts-bot/src/health.controller.ts
Normal 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',
|
||||
};
|
||||
}
|
||||
}
|
||||
17
services/telegram-contacts-bot/src/main.ts
Normal file
17
services/telegram-contacts-bot/src/main.ts
Normal 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();
|
||||
8
services/telegram-contacts-bot/src/user/user.module.ts
Normal file
8
services/telegram-contacts-bot/src/user/user.module.ts
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
import { Module } from '@nestjs/common';
|
||||
import { UserService } from './user.service';
|
||||
|
||||
@Module({
|
||||
providers: [UserService],
|
||||
exports: [UserService],
|
||||
})
|
||||
export class UserModule {}
|
||||
171
services/telegram-contacts-bot/src/user/user.service.ts
Normal file
171
services/telegram-contacts-bot/src/user/user.service.ts
Normal 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 [];
|
||||
}
|
||||
}
|
||||
}
|
||||
22
services/telegram-contacts-bot/tsconfig.json
Normal file
22
services/telegram-contacts-bot/tsconfig.json
Normal 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
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue