feat(services): add telegram-project-doc-bot service

Add new NestJS-based Telegram bot for project documentation with:
- Drizzle ORM for database access
- OpenAI integration for AI features
- S3 storage support via AWS SDK
- Monorepo integration (dev scripts, database setup, MinIO bucket)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Till-JS 2026-01-27 03:29:08 +01:00
parent bff80b552a
commit 7c20d88649
28 changed files with 2365 additions and 180 deletions

View file

@ -0,0 +1,24 @@
# Server
PORT=3302
# Telegram
TELEGRAM_BOT_TOKEN=your-bot-token-from-botfather
TELEGRAM_ALLOWED_USERS= # Optional: comma-separated user IDs
# Database
DATABASE_URL=postgresql://postgres:postgres@localhost:5432/projectdoc
# Storage (MinIO)
S3_ENDPOINT=http://localhost:9000
S3_REGION=us-east-1
S3_ACCESS_KEY=minioadmin
S3_SECRET_KEY=minioadmin
S3_BUCKET=projectdoc-storage
# AI - Transcription (OpenAI Whisper)
OPENAI_API_KEY=sk-your-openai-key
# AI - Generation
LLM_PROVIDER=ollama # ollama | openai
OLLAMA_URL=http://localhost:11434
OLLAMA_MODEL=gemma3:4b

View file

@ -0,0 +1,245 @@
# Telegram Project Doc Bot
Telegram Bot zum Sammeln von Projektdokumentation (Fotos, Sprachnotizen, Text) und automatischer Blogbeitrag-Generierung.
## Tech Stack
- **Framework**: NestJS 10
- **Telegram**: nestjs-telegraf + Telegraf
- **Database**: PostgreSQL + Drizzle ORM
- **Storage**: S3 (MinIO lokal, Hetzner in Produktion)
- **AI - Transcription**: OpenAI Whisper
- **AI - Generation**: Ollama (lokal) oder OpenAI GPT
## 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 anzeigen |
| `/help` | Hilfe anzeigen |
| `/new [Name]` | Neues Projekt erstellen |
| `/projects` | Alle Projekte auflisten |
| `/switch [ID]` | Projekt wechseln |
| `/status` | Status des aktiven Projekts |
| `/archive` | Projekt archivieren |
| `/generate` | Blogbeitrag generieren |
| `/generate [Stil]` | Mit bestimmtem Stil generieren |
| `/styles` | Verfügbare Stile anzeigen |
| `/export` | Letzte Generierung als Datei |
## Blog-Stile
| Stil | Beschreibung |
|------|--------------|
| `casual` | Locker & persönlich |
| `formal` | Professionell & sachlich |
| `tutorial` | Anleitung mit Schritten |
| `diary` | Tagebuch-Stil |
## User Flow
```
1. /new Gartenhaus-Renovierung → Projekt erstellen
2. 📷 Foto senden → Wird gespeichert
3. 🎤 Sprachnotiz senden → Transkribiert + gespeichert
4. "Heute das Fundament gegossen" → Text-Notiz
5. /status → Übersicht
6. /generate tutorial → Blogbeitrag erstellen
7. /export → Als .md Datei
```
## Environment Variables
```env
# Server
PORT=3302
# Telegram
TELEGRAM_BOT_TOKEN=xxx # Bot Token von @BotFather
TELEGRAM_ALLOWED_USERS=123,456 # Optional: Nur diese User IDs erlauben
# Database
DATABASE_URL=postgresql://postgres:postgres@localhost:5432/projectdoc
# Storage (MinIO)
S3_ENDPOINT=http://localhost:9000
S3_REGION=us-east-1
S3_ACCESS_KEY=minioadmin
S3_SECRET_KEY=minioadmin
S3_BUCKET=projectdoc-storage
# AI - Transcription (optional, aber empfohlen)
OPENAI_API_KEY=sk-xxx
# AI - Generation
LLM_PROVIDER=ollama # ollama oder openai
OLLAMA_URL=http://localhost:11434
OLLAMA_MODEL=gemma3:4b
```
## Projekt-Struktur
```
services/telegram-project-doc-bot/
├── src/
│ ├── main.ts # Entry point
│ ├── app.module.ts # Root module
│ ├── health.controller.ts # Health endpoint
│ ├── config/
│ │ └── configuration.ts # Config + Blog-Stile
│ ├── database/
│ │ ├── database.module.ts # Drizzle connection
│ │ └── schema.ts # DB schema
│ ├── bot/
│ │ ├── bot.module.ts
│ │ └── bot.update.ts # Command handlers
│ ├── project/
│ │ ├── project.module.ts
│ │ └── project.service.ts # Projekt CRUD
│ ├── media/
│ │ ├── media.module.ts
│ │ ├── media.service.ts # Foto/Voice/Text verarbeiten
│ │ └── storage.service.ts # S3 Upload/Download
│ ├── transcription/
│ │ ├── transcription.module.ts
│ │ └── transcription.service.ts # Whisper API
│ └── generation/
│ ├── generation.module.ts
│ └── generation.service.ts # Blogpost AI
├── drizzle/ # Migrations
├── drizzle.config.ts
├── package.json
└── Dockerfile
```
## Lokale Entwicklung
### 1. Bot bei Telegram erstellen
1. Öffne @BotFather in Telegram
2. Sende `/newbot`
3. Wähle einen Namen (z.B. "Project Doc Bot")
4. Wähle einen Username (z.B. "my_projectdoc_bot")
5. Kopiere den Token
### 2. Umgebung vorbereiten
```bash
# Docker Services starten (PostgreSQL, MinIO, Ollama)
pnpm docker:up
# Datenbank erstellen
psql -h localhost -U postgres -c "CREATE DATABASE projectdoc;"
# Schema pushen
cd services/telegram-project-doc-bot
cp .env.example .env
# Token und Keys eintragen
pnpm db:push
```
### 3. Bot starten
```bash
pnpm start:dev
```
## Features
- **Multi-Projekt**: Mehrere Projekte pro User
- **Foto-Speicherung**: Fotos in S3 mit Metadaten
- **Voice-Transkription**: Automatisch via Whisper
- **Text-Notizen**: Einfache Nachrichten werden gespeichert
- **Chronologisch**: Alle Einträge behalten ihre Reihenfolge
- **Mehrere Stile**: casual, formal, tutorial, diary
- **Export**: Markdown-Datei zum Download
## Datenbank-Schema
```
projects
├── id (UUID)
├── telegram_user_id (INT)
├── name (TEXT)
├── description (TEXT)
├── status (TEXT: active, archived, completed)
├── created_at, updated_at
media_items
├── id (UUID)
├── project_id (UUID FK)
├── type (TEXT: photo, voice, text)
├── storage_key (TEXT)
├── caption (TEXT)
├── transcription (TEXT)
├── ai_description (TEXT)
├── metadata (JSONB)
├── telegram_file_id (TEXT)
├── order_index (INT)
├── created_at
generations
├── id (UUID)
├── project_id (UUID FK)
├── style (TEXT)
├── content (TEXT - Markdown)
├── pdf_key (TEXT)
├── is_latest (BOOL)
├── created_at
```
## Health Check
```bash
curl http://localhost:3302/health
```
## Deployment
### Docker (empfohlen)
```yaml
# docker-compose.yml
telegram-project-doc-bot:
build: ./services/telegram-project-doc-bot
restart: always
environment:
PORT: 3302
TELEGRAM_BOT_TOKEN: ${TELEGRAM_BOT_TOKEN}
DATABASE_URL: ${DATABASE_URL}
S3_ENDPOINT: ${S3_ENDPOINT}
S3_ACCESS_KEY: ${S3_ACCESS_KEY}
S3_SECRET_KEY: ${S3_SECRET_KEY}
S3_BUCKET: projectdoc-storage
LLM_PROVIDER: ollama
OLLAMA_URL: http://ollama:11434
ports:
- "3302:3302"
```
## Roadmap
- [ ] Foto-Vision-Analyse (was ist auf dem Bild?)
- [ ] PDF-Export
- [ ] Bilder im Blogpost einbetten
- [ ] Projekt-Templates
- [ ] Web-Dashboard zur Ansicht
- [ ] Telegram Mini App für bessere UX

View file

@ -0,0 +1,41 @@
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* ./
# Install dependencies
RUN pnpm install --frozen-lockfile
# Copy source
COPY . .
# Build
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 package files and install prod dependencies only
COPY package.json pnpm-lock.yaml* ./
RUN pnpm install --prod --frozen-lockfile
# Copy built app
COPY --from=builder /app/dist ./dist
# Set environment
ENV NODE_ENV=production
ENV PORT=3302
EXPOSE 3302
CMD ["node", "dist/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://postgres:postgres@localhost:5432/projectdoc',
},
});

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-project-doc-bot",
"version": "1.0.0",
"description": "Telegram bot for project documentation - collect photos and voice notes, generate blog posts",
"private": true,
"license": "MIT",
"scripts": {
"prebuild": "rimraf dist",
"build": "nest build",
"format": "prettier --write \"src/**/*.ts\"",
"start": "nest start",
"start:dev": "nest start --watch",
"start:debug": "nest start --debug --watch",
"start:prod": "node dist/main",
"lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix",
"type-check": "tsc --noEmit",
"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",
"@aws-sdk/client-s3": "^3.721.0",
"@aws-sdk/s3-request-presigner": "^3.721.0",
"drizzle-orm": "^0.38.3",
"nestjs-telegraf": "^2.8.0",
"openai": "^4.77.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"
}
}

View file

@ -0,0 +1,27 @@
import { Module } from '@nestjs/common';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { TelegrafModule } from 'nestjs-telegraf';
import configuration from './config/configuration';
import { DatabaseModule } from './database/database.module';
import { BotModule } from './bot/bot.module';
import { HealthController } from './health.controller';
@Module({
imports: [
ConfigModule.forRoot({
isGlobal: true,
load: [configuration],
}),
TelegrafModule.forRootAsync({
imports: [ConfigModule],
useFactory: (configService: ConfigService) => ({
token: configService.get<string>('telegram.token') || '',
}),
inject: [ConfigService],
}),
DatabaseModule,
BotModule,
],
controllers: [HealthController],
})
export class AppModule {}

View file

@ -0,0 +1,11 @@
import { Module } from '@nestjs/common';
import { BotUpdate } from './bot.update';
import { ProjectModule } from '../project/project.module';
import { MediaModule } from '../media/media.module';
import { GenerationModule } from '../generation/generation.module';
@Module({
imports: [ProjectModule, MediaModule, GenerationModule],
providers: [BotUpdate],
})
export class BotModule {}

View file

@ -0,0 +1,490 @@
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 { ProjectService } from '../project/project.service';
import { MediaService } from '../media/media.service';
import { GenerationService } from '../generation/generation.service';
import { BLOG_STYLES } from '../config/configuration';
interface PhotoSize {
file_id: string;
file_unique_id: string;
width: number;
height: number;
file_size?: number;
}
interface Voice {
file_id: string;
file_unique_id: string;
duration: number;
mime_type?: string;
file_size?: number;
}
@Update()
export class BotUpdate {
private readonly logger = new Logger(BotUpdate.name);
private readonly allowedUsers: number[];
// Active project per user (userId -> projectId)
private activeProjects: Map<number, string> = new Map();
constructor(
private readonly projectService: ProjectService,
private readonly mediaService: MediaService,
private readonly generationService: GenerationService,
private configService: ConfigService
) {
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 formatHelp(): string {
const styles = Object.entries(BLOG_STYLES)
.map(([key, value]) => `• <code>${key}</code> - ${value.name}`)
.join('\n');
return `<b>📸 Project Doc Bot</b>
Sammle Fotos, Sprachnotizen und Text für deine Projekte und erstelle daraus Blogbeiträge.
<b>Projekt-Commands:</b>
/new [Name] - Neues Projekt starten
/projects - Alle Projekte anzeigen
/switch [ID] - Projekt wechseln
/status - Status des aktiven Projekts
/archive - Aktives Projekt archivieren
<b>Content:</b>
📷 Foto senden - Wird gespeichert
🎤 Sprachnotiz - Wird transkribiert
💬 Text-Nachricht - Als Notiz gespeichert
<b>Generierung:</b>
/generate - Blogbeitrag erstellen
/generate [Stil] - Mit bestimmtem Stil
/styles - Verfügbare Stile anzeigen
/export - Letzte Generierung exportieren
<b>Verfügbare Stile:</b>
${styles}
<b>Tipp:</b> Starte mit /new Projektname`;
}
@Start()
async start(@Ctx() ctx: Context) {
const userId = ctx.from?.id;
if (!userId || !this.isAllowed(userId)) {
await ctx.reply('Zugriff verweigert.');
return;
}
this.logger.log(`/start from user ${userId}`);
await ctx.replyWithHTML(this.formatHelp());
}
@Help()
async help(@Ctx() ctx: Context) {
const userId = ctx.from?.id;
if (!userId || !this.isAllowed(userId)) {
await ctx.reply('Zugriff verweigert.');
return;
}
await ctx.replyWithHTML(this.formatHelp());
}
@Command('new')
async newProject(@Ctx() ctx: Context, @Message('text') text: string) {
const userId = ctx.from?.id;
if (!userId || !this.isAllowed(userId)) {
await ctx.reply('Zugriff verweigert.');
return;
}
const name = text.replace('/new', '').trim();
if (!name) {
await ctx.reply('Verwendung: /new Projektname\n\nBeispiel: /new Gartenhaus-Renovierung');
return;
}
try {
this.logger.log(`Creating project "${name}" for user ${userId}`);
const project = await this.projectService.create({
telegramUserId: userId,
name,
});
this.activeProjects.set(userId, project.id);
this.logger.log(`User ${userId} created project "${name}" with id ${project.id}`);
await ctx.replyWithHTML(
`✅ <b>Projekt erstellt!</b>\n\n` +
`<b>Name:</b> ${project.name}\n` +
`<b>ID:</b> <code>${project.id.slice(0, 8)}</code>\n\n` +
`Sende jetzt:\n` +
`📷 Fotos\n` +
`🎤 Sprachnotizen\n` +
`💬 Text-Nachrichten\n\n` +
`Mit /generate erstellst du den Blogbeitrag.`
);
} catch (error) {
this.logger.error('Failed to create project:', error);
const errorMsg = error instanceof Error ? error.message : 'Unbekannter Fehler';
await ctx.reply(`Fehler beim Erstellen des Projekts: ${errorMsg}`);
}
}
@Command('projects')
async listProjects(@Ctx() ctx: Context) {
const userId = ctx.from?.id;
if (!userId || !this.isAllowed(userId)) {
await ctx.reply('Zugriff verweigert.');
return;
}
const projects = await this.projectService.findByUser(userId);
if (projects.length === 0) {
await ctx.reply('Keine Projekte gefunden.\n\nStarte mit: /new Projektname');
return;
}
const activeId = this.activeProjects.get(userId);
const projectList = await Promise.all(
projects.map(async (p) => {
const stats = await this.projectService.getStats(p.id);
const active = p.id === activeId ? ' ✓' : '';
const status = p.status === 'archived' ? ' 📦' : '';
return `• <b>${p.name}</b>${active}${status}\n ID: <code>${p.id.slice(0, 8)}</code> | ${stats.total} Einträge`;
})
);
await ctx.replyWithHTML(
`<b>📂 Deine Projekte:</b>\n\n${projectList.join('\n\n')}\n\n` + `Wechseln mit: /switch [ID]`
);
}
@Command('switch')
async switchProject(@Ctx() ctx: Context, @Message('text') text: string) {
const userId = ctx.from?.id;
if (!userId || !this.isAllowed(userId)) {
await ctx.reply('Zugriff verweigert.');
return;
}
const idPrefix = text.replace('/switch', '').trim();
if (!idPrefix) {
await ctx.reply('Verwendung: /switch [ID]\n\nZeige Projekte mit /projects');
return;
}
// Find project by ID prefix
const projects = await this.projectService.findByUser(userId);
const project = projects.find((p) => p.id.startsWith(idPrefix));
if (!project) {
await ctx.reply(`Projekt mit ID "${idPrefix}" nicht gefunden.`);
return;
}
this.activeProjects.set(userId, project.id);
const stats = await this.projectService.getStats(project.id);
await ctx.replyWithHTML(
`✅ Gewechselt zu: <b>${project.name}</b>\n\n` +
`📷 ${stats.photos} Fotos\n` +
`🎤 ${stats.voices} Sprachnotizen\n` +
`📝 ${stats.texts} Textnotizen`
);
}
@Command('status')
async status(@Ctx() ctx: Context) {
const userId = ctx.from?.id;
if (!userId || !this.isAllowed(userId)) {
await ctx.reply('Zugriff verweigert.');
return;
}
const projectId = this.activeProjects.get(userId);
if (!projectId) {
await ctx.reply('Kein aktives Projekt.\n\nStarte mit: /new Projektname');
return;
}
const project = await this.projectService.findById(projectId);
if (!project) {
this.activeProjects.delete(userId);
await ctx.reply('Projekt nicht gefunden. Starte ein neues mit /new');
return;
}
const stats = await this.projectService.getStats(projectId);
const latest = await this.generationService.getLatestGeneration(projectId);
let statusText =
`<b>📊 Projekt-Status</b>\n\n` +
`<b>Name:</b> ${project.name}\n` +
`<b>Status:</b> ${project.status}\n` +
`<b>Erstellt:</b> ${project.createdAt.toLocaleDateString('de-DE')}\n\n` +
`<b>Inhalte:</b>\n` +
`📷 ${stats.photos} Fotos\n` +
`🎤 ${stats.voices} Sprachnotizen\n` +
`📝 ${stats.texts} Textnotizen\n` +
`<b>Gesamt:</b> ${stats.total} Einträge`;
if (latest) {
statusText += `\n\n<b>Letzte Generierung:</b>\n${latest.createdAt.toLocaleString('de-DE')} (${latest.style})`;
}
await ctx.replyWithHTML(statusText);
}
@Command('archive')
async archiveProject(@Ctx() ctx: Context) {
const userId = ctx.from?.id;
if (!userId || !this.isAllowed(userId)) {
await ctx.reply('Zugriff verweigert.');
return;
}
const projectId = this.activeProjects.get(userId);
if (!projectId) {
await ctx.reply('Kein aktives Projekt.');
return;
}
await this.projectService.update(projectId, { status: 'archived' });
this.activeProjects.delete(userId);
await ctx.reply('📦 Projekt archiviert.\n\nStarte ein neues mit /new');
}
@Command('styles')
async showStyles(@Ctx() ctx: Context) {
const userId = ctx.from?.id;
if (!userId || !this.isAllowed(userId)) {
await ctx.reply('Zugriff verweigert.');
return;
}
const styles = Object.entries(BLOG_STYLES)
.map(
([key, value]) => `<b>${key}</b> - ${value.name}\n<i>${value.prompt.slice(0, 80)}...</i>`
)
.join('\n\n');
await ctx.replyWithHTML(
`<b>📝 Verfügbare Blog-Stile:</b>\n\n${styles}\n\nVerwendung: /generate [stil]`
);
}
@Command('generate')
async generate(@Ctx() ctx: Context, @Message('text') text: string) {
const userId = ctx.from?.id;
if (!userId || !this.isAllowed(userId)) {
await ctx.reply('Zugriff verweigert.');
return;
}
const projectId = this.activeProjects.get(userId);
if (!projectId) {
await ctx.reply('Kein aktives Projekt.\n\nStarte mit: /new Projektname');
return;
}
const style = text.replace('/generate', '').trim().toLowerCase() || 'casual';
const validStyles = Object.keys(BLOG_STYLES);
if (!validStyles.includes(style)) {
await ctx.reply(
`Unbekannter Stil: "${style}"\n\nVerfügbar: ${validStyles.join(', ')}\n\nZeige Details mit /styles`
);
return;
}
await ctx.reply('🚀 Generiere Blogbeitrag...\n\nDas kann einen Moment dauern.');
await ctx.sendChatAction('typing');
try {
const content = await this.generationService.generateBlogpost(
projectId,
style as keyof typeof BLOG_STYLES
);
// Split if too long for Telegram
if (content.length <= 4000) {
await ctx.reply(content);
} else {
// Send as document
const buffer = Buffer.from(content, 'utf-8');
await ctx.replyWithDocument(
{
source: buffer,
filename: 'blogpost.md',
},
{
caption: '📄 Blogbeitrag (zu lang für Telegram-Nachricht)',
}
);
// Also send a preview
const preview = content.slice(0, 1000) + '\n\n[...gekürzt, siehe Datei]';
await ctx.reply(preview);
}
await ctx.reply('✅ Blogbeitrag erstellt!\n\nExportieren mit /export');
} catch (error) {
this.logger.error('Generation failed:', error);
const message = error instanceof Error ? error.message : 'Unbekannter Fehler';
await ctx.reply(`❌ Fehler: ${message}`);
}
}
@Command('export')
async exportGeneration(@Ctx() ctx: Context) {
const userId = ctx.from?.id;
if (!userId || !this.isAllowed(userId)) {
await ctx.reply('Zugriff verweigert.');
return;
}
const projectId = this.activeProjects.get(userId);
if (!projectId) {
await ctx.reply('Kein aktives Projekt.');
return;
}
const latest = await this.generationService.getLatestGeneration(projectId);
if (!latest) {
await ctx.reply('Noch kein Blogbeitrag generiert.\n\nErstelle einen mit /generate');
return;
}
const project = await this.projectService.findById(projectId);
const filename = `${project?.name.replace(/[^a-zA-Z0-9]/g, '_') || 'blogpost'}.md`;
const buffer = Buffer.from(latest.content, 'utf-8');
await ctx.replyWithDocument(
{
source: buffer,
filename,
},
{
caption: `📄 ${filename}\nGeneriert: ${latest.createdAt.toLocaleString('de-DE')}`,
}
);
}
@On('photo')
async onPhoto(@Ctx() ctx: Context) {
const userId = ctx.from?.id;
if (!userId || !this.isAllowed(userId)) {
await ctx.reply('Zugriff verweigert.');
return;
}
const projectId = this.activeProjects.get(userId);
if (!projectId) {
await ctx.reply('Kein aktives Projekt.\n\nStarte mit: /new Projektname');
return;
}
const message = ctx.message as { photo?: PhotoSize[]; caption?: string };
const photos = message.photo;
if (!photos || photos.length === 0) return;
// Get largest photo
const photo = photos[photos.length - 1];
const caption = message.caption;
await ctx.sendChatAction('upload_photo');
try {
await this.mediaService.processPhoto(projectId, photo.file_id, caption);
const stats = await this.projectService.getStats(projectId);
await ctx.reply(`📷 Foto gespeichert! (${stats.photos} Fotos gesamt)`);
} catch (error) {
this.logger.error('Failed to process photo:', error);
await ctx.reply('❌ Fehler beim Speichern des Fotos.');
}
}
@On('voice')
async onVoice(@Ctx() ctx: Context) {
const userId = ctx.from?.id;
if (!userId || !this.isAllowed(userId)) {
await ctx.reply('Zugriff verweigert.');
return;
}
const projectId = this.activeProjects.get(userId);
if (!projectId) {
await ctx.reply('Kein aktives Projekt.\n\nStarte mit: /new Projektname');
return;
}
const message = ctx.message as { voice?: Voice };
const voice = message.voice;
if (!voice) return;
await ctx.reply('🎤 Verarbeite Sprachnotiz...');
await ctx.sendChatAction('typing');
try {
const item = await this.mediaService.processVoice(projectId, voice.file_id, voice.duration);
const stats = await this.projectService.getStats(projectId);
let reply = `✅ Sprachnotiz gespeichert! (${stats.voices} gesamt)`;
if (item.transcription) {
reply += `\n\n📝 Transkription:\n"${item.transcription}"`;
}
await ctx.reply(reply);
} catch (error) {
this.logger.error('Failed to process voice:', error);
await ctx.reply('❌ Fehler beim Verarbeiten der Sprachnotiz.');
}
}
@On('text')
async onText(@Ctx() ctx: Context, @Message('text') text: string) {
const userId = ctx.from?.id;
if (!userId || !this.isAllowed(userId)) {
await ctx.reply('Zugriff verweigert.');
return;
}
// Ignore commands
if (text.startsWith('/')) return;
const projectId = this.activeProjects.get(userId);
if (!projectId) {
// No active project - show hint
await ctx.reply('💡 Tipp: Starte ein Projekt mit /new Projektname');
return;
}
try {
await this.mediaService.addTextNote(projectId, text);
const stats = await this.projectService.getStats(projectId);
await ctx.reply(`📝 Notiz gespeichert! (${stats.texts} Notizen gesamt)`);
} catch (error) {
this.logger.error('Failed to add text note:', error);
await ctx.reply('❌ Fehler beim Speichern der Notiz.');
}
}
}

View file

@ -0,0 +1,53 @@
export default () => ({
port: parseInt(process.env.PORT || '3302', 10),
telegram: {
token: process.env.TELEGRAM_BOT_TOKEN,
allowedUsers:
process.env.TELEGRAM_ALLOWED_USERS?.split(',')
.map((id) => parseInt(id.trim(), 10))
.filter((id) => !isNaN(id)) || [],
},
database: {
url: process.env.DATABASE_URL || 'postgresql://postgres:postgres@localhost:5432/projectdoc',
},
s3: {
endpoint: process.env.S3_ENDPOINT || 'http://localhost:9000',
region: process.env.S3_REGION || 'us-east-1',
accessKey: process.env.S3_ACCESS_KEY || 'minioadmin',
secretKey: process.env.S3_SECRET_KEY || 'minioadmin',
bucket: process.env.S3_BUCKET || 'projectdoc-storage',
},
openai: {
apiKey: process.env.OPENAI_API_KEY,
},
llm: {
provider: process.env.LLM_PROVIDER || 'ollama',
ollama: {
url: process.env.OLLAMA_URL || 'http://localhost:11434',
model: process.env.OLLAMA_MODEL || 'gemma3:4b',
},
},
});
export const BLOG_STYLES = {
casual: {
name: 'Locker & Persönlich',
prompt:
'Schreibe einen lockeren, persönlichen Blogbeitrag. Verwende "ich" und erzähle die Geschichte authentisch.',
},
formal: {
name: 'Professionell',
prompt:
'Schreibe einen professionellen, sachlichen Blogbeitrag. Verwende eine neutrale Sprache.',
},
tutorial: {
name: 'Anleitung/Tutorial',
prompt:
'Schreibe einen anleitenden Blogbeitrag mit klaren Schritten. Nummeriere die Schritte und gib praktische Tipps.',
},
diary: {
name: 'Tagebuch',
prompt:
'Schreibe einen Tagebuch-Eintrag mit persönlichen Eindrücken und Gefühlen. Sehr authentisch und emotional.',
},
};

View file

@ -0,0 +1,24 @@
import { Module, Global } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { drizzle } from 'drizzle-orm/postgres-js';
import postgres from 'postgres';
import * as schema from './schema';
export const DATABASE_CONNECTION = 'DATABASE_CONNECTION';
@Global()
@Module({
providers: [
{
provide: DATABASE_CONNECTION,
useFactory: (configService: ConfigService) => {
const connectionString = configService.get<string>('database.url');
const client = postgres(connectionString!);
return drizzle(client, { schema });
},
inject: [ConfigService],
},
],
exports: [DATABASE_CONNECTION],
})
export class DatabaseModule {}

View file

@ -0,0 +1,85 @@
import { pgTable, uuid, text, timestamp, integer, jsonb, boolean } from 'drizzle-orm/pg-core';
import { relations } from 'drizzle-orm';
// Projects table
export const projects = pgTable('projects', {
id: uuid('id').primaryKey().defaultRandom(),
telegramUserId: integer('telegram_user_id').notNull(),
name: text('name').notNull(),
description: text('description'),
status: text('status').default('active').notNull(), // active, archived, completed
createdAt: timestamp('created_at').defaultNow().notNull(),
updatedAt: timestamp('updated_at').defaultNow().notNull(),
});
// Media items (photos, voice notes, text)
export const mediaItems = pgTable('media_items', {
id: uuid('id').primaryKey().defaultRandom(),
projectId: uuid('project_id')
.references(() => projects.id, { onDelete: 'cascade' })
.notNull(),
type: text('type').notNull(), // photo, voice, text
// Storage
storageKey: text('storage_key'), // S3 key for photo/voice
thumbnailKey: text('thumbnail_key'), // Thumbnail for photos
// Content
caption: text('caption'), // Original caption/text
transcription: text('transcription'), // Voice → Text
aiDescription: text('ai_description'), // Vision → Description
// Metadata
metadata: jsonb('metadata').$type<{
width?: number;
height?: number;
duration?: number;
mimeType?: string;
fileSize?: number;
}>(),
telegramFileId: text('telegram_file_id'),
orderIndex: integer('order_index').default(0).notNull(),
createdAt: timestamp('created_at').defaultNow().notNull(),
});
// Generated blog posts
export const generations = pgTable('generations', {
id: uuid('id').primaryKey().defaultRandom(),
projectId: uuid('project_id')
.references(() => projects.id, { onDelete: 'cascade' })
.notNull(),
style: text('style').default('casual').notNull(),
content: text('content').notNull(), // Generated markdown
pdfKey: text('pdf_key'), // S3 key for PDF export
isLatest: boolean('is_latest').default(true).notNull(),
createdAt: timestamp('created_at').defaultNow().notNull(),
});
// Relations
export const projectsRelations = relations(projects, ({ many }) => ({
mediaItems: many(mediaItems),
generations: many(generations),
}));
export const mediaItemsRelations = relations(mediaItems, ({ one }) => ({
project: one(projects, {
fields: [mediaItems.projectId],
references: [projects.id],
}),
}));
export const generationsRelations = relations(generations, ({ one }) => ({
project: one(projects, {
fields: [generations.projectId],
references: [projects.id],
}),
}));
// Types
export type Project = typeof projects.$inferSelect;
export type NewProject = typeof projects.$inferInsert;
export type MediaItem = typeof mediaItems.$inferSelect;
export type NewMediaItem = typeof mediaItems.$inferInsert;
export type Generation = typeof generations.$inferSelect;
export type NewGeneration = typeof generations.$inferInsert;

View file

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

View file

@ -0,0 +1,202 @@
import { Injectable, Inject, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { eq, desc } from 'drizzle-orm';
import { PostgresJsDatabase } from 'drizzle-orm/postgres-js';
import OpenAI from 'openai';
import { DATABASE_CONNECTION } from '../database/database.module';
import * as schema from '../database/schema';
import { Generation, Project, MediaItem } from '../database/schema';
import { BLOG_STYLES } from '../config/configuration';
type BlogStyle = keyof typeof BLOG_STYLES;
@Injectable()
export class GenerationService {
private readonly logger = new Logger(GenerationService.name);
private readonly llmProvider: string;
private readonly ollamaUrl: string;
private readonly ollamaModel: string;
private readonly openai: OpenAI | null;
constructor(
@Inject(DATABASE_CONNECTION)
private db: PostgresJsDatabase<typeof schema>,
private configService: ConfigService
) {
this.llmProvider = this.configService.get<string>('llm.provider') || 'ollama';
this.ollamaUrl = this.configService.get<string>('llm.ollama.url') || 'http://localhost:11434';
this.ollamaModel = this.configService.get<string>('llm.ollama.model') || 'gemma3:4b';
const apiKey = this.configService.get<string>('openai.apiKey');
this.openai = apiKey ? new OpenAI({ apiKey }) : null;
this.logger.log(`LLM Provider: ${this.llmProvider}`);
}
async generateBlogpost(projectId: string, style: BlogStyle = 'casual'): Promise<string> {
// 1. Load project
const project = await this.db.query.projects.findFirst({
where: eq(schema.projects.id, projectId),
});
if (!project) {
throw new Error('Projekt nicht gefunden');
}
// 2. Load all media items
const items = await this.db.query.mediaItems.findMany({
where: eq(schema.mediaItems.projectId, projectId),
orderBy: [schema.mediaItems.orderIndex, schema.mediaItems.createdAt],
});
if (items.length === 0) {
throw new Error(
'Keine Inhalte im Projekt. Füge zuerst Fotos, Sprachnotizen oder Text hinzu.'
);
}
// 3. Build context from media items
const context = this.buildContext(items);
// 4. Build prompt
const styleConfig = BLOG_STYLES[style] || BLOG_STYLES.casual;
const prompt = this.buildPrompt(project, context, styleConfig.prompt);
// 5. Generate with LLM
this.logger.log(`Generating blogpost for "${project.name}" with style "${style}"`);
const content = await this.callLlm(prompt);
// 6. Mark previous generations as not latest
await this.db
.update(schema.generations)
.set({ isLatest: false })
.where(eq(schema.generations.projectId, projectId));
// 7. Save generation
const [generation] = await this.db
.insert(schema.generations)
.values({
projectId,
style,
content,
isLatest: true,
})
.returning();
this.logger.log(`Generated blogpost: ${generation.id} (${content.length} chars)`);
return content;
}
private buildContext(items: MediaItem[]): string {
return items
.map((item, index) => {
const num = index + 1;
const timestamp = item.createdAt.toLocaleString('de-DE', {
day: '2-digit',
month: '2-digit',
hour: '2-digit',
minute: '2-digit',
});
if (item.type === 'photo') {
const desc = item.aiDescription || item.caption || 'Keine Beschreibung';
return `[Foto ${num}] (${timestamp})\n${desc}`;
}
if (item.type === 'voice') {
const text = item.transcription || '(Keine Transkription verfügbar)';
return `[Sprachnotiz ${num}] (${timestamp})\n"${text}"`;
}
// text
return `[Notiz ${num}] (${timestamp})\n${item.caption}`;
})
.join('\n\n---\n\n');
}
private buildPrompt(project: Project, context: string, stylePrompt: string): string {
return `Du bist ein erfahrener Blogger und Content Creator.
${stylePrompt}
## Projekt-Informationen
**Name:** ${project.name}
${project.description ? `**Beschreibung:** ${project.description}` : ''}
## Gesammelte Inhalte (chronologisch)
${context}
## Aufgabe
Erstelle einen gut strukturierten Blogbeitrag in Markdown basierend auf den obigen Inhalten.
**Anforderungen:**
- Verwende eine passende, ansprechende Überschrift (# Titel)
- Strukturiere den Beitrag mit Zwischenüberschriften (## Abschnitte)
- Verweise im Text auf die Fotos mit [Foto X], damit sie später eingebettet werden können
- Integriere die Sprachnotizen und Textnotizen natürlich in den Fließtext
- Füge am Ende eine kurze Zusammenfassung oder "Lessons Learned" hinzu
- Schreibe auf Deutsch
- Der Beitrag sollte authentisch und persönlich klingen
Beginne direkt mit dem Blogbeitrag (ohne Einleitung wie "Hier ist der Blogbeitrag"):`;
}
private async callLlm(prompt: string): Promise<string> {
if (this.llmProvider === 'openai' && this.openai) {
return this.callOpenAI(prompt);
}
return this.callOllama(prompt);
}
private async callOpenAI(prompt: string): Promise<string> {
if (!this.openai) {
throw new Error('OpenAI not configured');
}
const response = await this.openai.chat.completions.create({
model: 'gpt-4o-mini',
messages: [{ role: 'user', content: prompt }],
temperature: 0.7,
max_tokens: 4000,
});
return response.choices[0]?.message?.content || '';
}
private async callOllama(prompt: string): Promise<string> {
const response = await fetch(`${this.ollamaUrl}/api/generate`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
model: this.ollamaModel,
prompt,
stream: false,
}),
signal: AbortSignal.timeout(180000), // 3 minutes timeout
});
if (!response.ok) {
throw new Error(`Ollama API error: ${response.status}`);
}
const data = await response.json();
return data.response || '';
}
async getLatestGeneration(projectId: string): Promise<Generation | undefined> {
return this.db.query.generations.findFirst({
where: eq(schema.generations.projectId, projectId),
orderBy: [desc(schema.generations.createdAt)],
});
}
getAvailableStyles(): { key: string; name: string }[] {
return Object.entries(BLOG_STYLES).map(([key, value]) => ({
key,
name: value.name,
}));
}
}

View file

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

View file

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

View file

@ -0,0 +1,11 @@
import { Module } from '@nestjs/common';
import { MediaService } from './media.service';
import { StorageService } from './storage.service';
import { TranscriptionModule } from '../transcription/transcription.module';
@Module({
imports: [TranscriptionModule],
providers: [MediaService, StorageService],
exports: [MediaService, StorageService],
})
export class MediaModule {}

View file

@ -0,0 +1,164 @@
import { Injectable, Inject, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { eq, asc } from 'drizzle-orm';
import { PostgresJsDatabase } from 'drizzle-orm/postgres-js';
import { DATABASE_CONNECTION } from '../database/database.module';
import * as schema from '../database/schema';
import { MediaItem, NewMediaItem } from '../database/schema';
import { StorageService } from './storage.service';
import { TranscriptionService } from '../transcription/transcription.service';
@Injectable()
export class MediaService {
private readonly logger = new Logger(MediaService.name);
private readonly telegramApiUrl: string;
constructor(
@Inject(DATABASE_CONNECTION)
private db: PostgresJsDatabase<typeof schema>,
private storageService: StorageService,
private transcriptionService: TranscriptionService,
private configService: ConfigService
) {
const token = this.configService.get<string>('telegram.token');
this.telegramApiUrl = `https://api.telegram.org/bot${token}`;
}
// Get file URL from Telegram
private async getTelegramFileUrl(fileId: string): Promise<string> {
const response = await fetch(`${this.telegramApiUrl}/getFile?file_id=${fileId}`);
const data = await response.json();
if (!data.ok) {
throw new Error(`Telegram API error: ${data.description}`);
}
const token = this.configService.get<string>('telegram.token');
return `https://api.telegram.org/file/bot${token}/${data.result.file_path}`;
}
// Download file from URL
private async downloadFile(url: string): Promise<Buffer> {
const response = await fetch(url);
const arrayBuffer = await response.arrayBuffer();
return Buffer.from(arrayBuffer);
}
// Process a photo from Telegram
async processPhoto(projectId: string, fileId: string, caption?: string): Promise<MediaItem> {
this.logger.log(`Processing photo for project ${projectId}`);
// 1. Download from Telegram
const fileUrl = await this.getTelegramFileUrl(fileId);
const buffer = await this.downloadFile(fileUrl);
// 2. Generate storage key and upload
const filename = `photo_${Date.now()}.jpg`;
const storageKey = this.storageService.generateKey(projectId, 'photo', filename);
await this.storageService.upload(storageKey, buffer, 'image/jpeg');
// 3. Get next order index
const orderIndex = await this.getNextOrderIndex(projectId);
// 4. Save to database
const [item] = await this.db
.insert(schema.mediaItems)
.values({
projectId,
type: 'photo',
storageKey,
caption,
telegramFileId: fileId,
orderIndex,
metadata: { fileSize: buffer.length },
})
.returning();
this.logger.log(`Photo saved: ${item.id}`);
return item;
}
// Process a voice note from Telegram
async processVoice(projectId: string, fileId: string, duration?: number): Promise<MediaItem> {
this.logger.log(`Processing voice for project ${projectId}`);
// 1. Download from Telegram
const fileUrl = await this.getTelegramFileUrl(fileId);
const buffer = await this.downloadFile(fileUrl);
// 2. Transcribe with Whisper
let transcription: string | undefined;
if (this.transcriptionService.isAvailable()) {
try {
transcription = await this.transcriptionService.transcribe(buffer);
} catch (error) {
this.logger.warn('Transcription failed, saving without:', error);
}
}
// 3. Generate storage key and upload
const filename = `voice_${Date.now()}.ogg`;
const storageKey = this.storageService.generateKey(projectId, 'voice', filename);
await this.storageService.upload(storageKey, buffer, 'audio/ogg');
// 4. Get next order index
const orderIndex = await this.getNextOrderIndex(projectId);
// 5. Save to database
const [item] = await this.db
.insert(schema.mediaItems)
.values({
projectId,
type: 'voice',
storageKey,
transcription,
telegramFileId: fileId,
orderIndex,
metadata: { duration, fileSize: buffer.length },
})
.returning();
this.logger.log(`Voice saved: ${item.id}, transcription: ${transcription ? 'yes' : 'no'}`);
return item;
}
// Add a text note
async addTextNote(projectId: string, text: string): Promise<MediaItem> {
const orderIndex = await this.getNextOrderIndex(projectId);
const [item] = await this.db
.insert(schema.mediaItems)
.values({
projectId,
type: 'text',
caption: text,
orderIndex,
})
.returning();
this.logger.log(`Text note saved: ${item.id}`);
return item;
}
// Get all media items for a project
async getByProject(projectId: string): Promise<MediaItem[]> {
return this.db.query.mediaItems.findMany({
where: eq(schema.mediaItems.projectId, projectId),
orderBy: [asc(schema.mediaItems.orderIndex), asc(schema.mediaItems.createdAt)],
});
}
// Get next order index for a project
private async getNextOrderIndex(projectId: string): Promise<number> {
const items = await this.db.query.mediaItems.findMany({
where: eq(schema.mediaItems.projectId, projectId),
});
return items.length;
}
// Delete a media item
async delete(id: string): Promise<boolean> {
const result = await this.db.delete(schema.mediaItems).where(eq(schema.mediaItems.id, id));
return (result as unknown as { rowCount: number }).rowCount > 0;
}
}

View file

@ -0,0 +1,77 @@
import { Injectable, Logger, OnModuleInit } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import {
S3Client,
PutObjectCommand,
GetObjectCommand,
HeadBucketCommand,
CreateBucketCommand,
} from '@aws-sdk/client-s3';
import { getSignedUrl } from '@aws-sdk/s3-request-presigner';
@Injectable()
export class StorageService implements OnModuleInit {
private readonly logger = new Logger(StorageService.name);
private readonly s3: S3Client;
private readonly bucket: string;
constructor(private configService: ConfigService) {
this.bucket = this.configService.get<string>('s3.bucket')!;
this.s3 = new S3Client({
endpoint: this.configService.get<string>('s3.endpoint'),
region: this.configService.get<string>('s3.region'),
credentials: {
accessKeyId: this.configService.get<string>('s3.accessKey')!,
secretAccessKey: this.configService.get<string>('s3.secretKey')!,
},
forcePathStyle: true, // Required for MinIO
});
}
async onModuleInit() {
await this.ensureBucket();
}
private async ensureBucket(): Promise<void> {
try {
await this.s3.send(new HeadBucketCommand({ Bucket: this.bucket }));
this.logger.log(`Bucket "${this.bucket}" exists`);
} catch (error: unknown) {
if (error && typeof error === 'object' && 'name' in error && error.name === 'NotFound') {
this.logger.log(`Creating bucket "${this.bucket}"...`);
await this.s3.send(new CreateBucketCommand({ Bucket: this.bucket }));
this.logger.log(`Bucket "${this.bucket}" created`);
} else {
this.logger.warn(`Could not check bucket: ${error}`);
}
}
}
async upload(key: string, buffer: Buffer, contentType: string): Promise<string> {
await this.s3.send(
new PutObjectCommand({
Bucket: this.bucket,
Key: key,
Body: buffer,
ContentType: contentType,
})
);
this.logger.debug(`Uploaded ${key} (${buffer.length} bytes)`);
return key;
}
async getSignedUrl(key: string, expiresIn = 3600): Promise<string> {
const command = new GetObjectCommand({
Bucket: this.bucket,
Key: key,
});
return getSignedUrl(this.s3, command, { expiresIn });
}
generateKey(projectId: string, type: 'photo' | 'voice' | 'pdf', filename: string): string {
return `${projectId}/${type}/${filename}`;
}
}

View file

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

View file

@ -0,0 +1,90 @@
import { Injectable, Inject, Logger } from '@nestjs/common';
import { eq, and, desc } from 'drizzle-orm';
import { PostgresJsDatabase } from 'drizzle-orm/postgres-js';
import { DATABASE_CONNECTION } from '../database/database.module';
import * as schema from '../database/schema';
import { Project, NewProject } from '../database/schema';
@Injectable()
export class ProjectService {
private readonly logger = new Logger(ProjectService.name);
constructor(
@Inject(DATABASE_CONNECTION)
private db: PostgresJsDatabase<typeof schema>
) {}
async create(data: {
telegramUserId: number;
name: string;
description?: string;
}): Promise<Project> {
const [project] = await this.db
.insert(schema.projects)
.values({
telegramUserId: data.telegramUserId,
name: data.name,
description: data.description,
})
.returning();
this.logger.log(`Created project "${project.name}" for user ${data.telegramUserId}`);
return project;
}
async findById(id: string): Promise<Project | undefined> {
return this.db.query.projects.findFirst({
where: eq(schema.projects.id, id),
});
}
async findByUser(telegramUserId: number): Promise<Project[]> {
return this.db.query.projects.findMany({
where: eq(schema.projects.telegramUserId, telegramUserId),
orderBy: [desc(schema.projects.updatedAt)],
});
}
async findActiveByUser(telegramUserId: number): Promise<Project[]> {
return this.db.query.projects.findMany({
where: and(
eq(schema.projects.telegramUserId, telegramUserId),
eq(schema.projects.status, 'active')
),
orderBy: [desc(schema.projects.updatedAt)],
});
}
async update(id: string, data: Partial<NewProject>): Promise<Project | undefined> {
const [project] = await this.db
.update(schema.projects)
.set({ ...data, updatedAt: new Date() })
.where(eq(schema.projects.id, id))
.returning();
return project;
}
async delete(id: string): Promise<boolean> {
const result = await this.db.delete(schema.projects).where(eq(schema.projects.id, id));
return (result as unknown as { rowCount: number }).rowCount > 0;
}
async getStats(projectId: string): Promise<{
photos: number;
voices: number;
texts: number;
total: number;
}> {
const items = await this.db.query.mediaItems.findMany({
where: eq(schema.mediaItems.projectId, projectId),
});
return {
photos: items.filter((i) => i.type === 'photo').length,
voices: items.filter((i) => i.type === 'voice').length,
texts: items.filter((i) => i.type === 'text').length,
total: items.length,
};
}
}

View file

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

View file

@ -0,0 +1,49 @@
import { Injectable, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import OpenAI from 'openai';
@Injectable()
export class TranscriptionService {
private readonly logger = new Logger(TranscriptionService.name);
private readonly openai: OpenAI | null;
constructor(private configService: ConfigService) {
const apiKey = this.configService.get<string>('openai.apiKey');
if (apiKey) {
this.openai = new OpenAI({ apiKey });
this.logger.log('OpenAI Whisper initialized');
} else {
this.openai = null;
this.logger.warn('OpenAI API key not configured - transcription disabled');
}
}
async transcribe(audioBuffer: Buffer, filename = 'audio.ogg'): Promise<string> {
if (!this.openai) {
throw new Error('Transcription not available - OpenAI API key not configured');
}
try {
// Create a File object from the buffer using Uint8Array
const uint8Array = new Uint8Array(audioBuffer);
const file = new File([uint8Array], filename, { type: 'audio/ogg' });
const response = await this.openai.audio.transcriptions.create({
file,
model: 'whisper-1',
language: 'de', // Default to German, could be made configurable
});
this.logger.debug(`Transcribed ${audioBuffer.length} bytes -> ${response.text.length} chars`);
return response.text;
} catch (error) {
this.logger.error('Transcription failed:', error);
throw new Error('Transkription fehlgeschlagen');
}
}
isAvailable(): boolean {
return this.openai !== null;
}
}

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
}
}