feat(mana-media): add centralized media storage with NutriPhi integration

- Implement mana-media service with PostgreSQL/Drizzle ORM persistence
- Add content-addressable storage (SHA-256) for automatic deduplication
- Add Matrix MXC URL import endpoint to copy images from Matrix
- Create @manacore/media-client package for service consumption
- Integrate mana-media into NutriPhi bot for persistent image storage
- Update pnpm-workspace.yaml to include nested service packages
- Add mana-media to docker-compose with port 3015

Images sent to NutriPhi bot are now stored in mana-media after analysis,
providing persistent storage with deduplication across all apps.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Till-JS 2026-02-02 17:30:14 +01:00
parent 171cf7a854
commit d4663b5643
31 changed files with 2114 additions and 4419 deletions

View file

@ -99,6 +99,7 @@ services:
BASE_URL: https://auth.mana.how
# Cross-domain SSO: share session cookies across all *.mana.how subdomains
COOKIE_DOMAIN: .mana.how
MANA_CORE_SERVICE_KEY: ${MANA_CORE_SERVICE_KEY}
SMTP_HOST: smtp-relay.brevo.com
SMTP_PORT: 587
SMTP_USER: ${SMTP_USER:-94cde5002@smtp-brevo.com}
@ -746,15 +747,21 @@ services:
depends_on:
synapse:
condition: service_healthy
mana-media:
condition: service_healthy
environment:
NODE_ENV: production
PORT: 4016
TZ: Europe/Berlin
REDIS_HOST: redis
REDIS_PASSWORD: ${REDIS_PASSWORD:-redis123}
MANA_CORE_SERVICE_KEY: ${MANA_CORE_SERVICE_KEY}
MANA_CORE_AUTH_URL: http://mana-auth:3001
MATRIX_HOMESERVER_URL: http://synapse:8008
MATRIX_ACCESS_TOKEN: ${MATRIX_NUTRIPHI_BOT_TOKEN}
MATRIX_ALLOWED_ROOMS: ${MATRIX_NUTRIPHI_BOT_ROOMS:-}
NUTRIPHI_BACKEND_URL: http://nutriphi-backend:3037
MANA_CORE_AUTH_URL: http://mana-auth:3001
MANA_MEDIA_URL: http://mana-media:3015
volumes:
- matrix_bots_data:/app/data
ports:

1670
pnpm-lock.yaml generated

File diff suppressed because it is too large Load diff

View file

@ -20,6 +20,12 @@ packages:
# Standalone microservices
- 'services/*'
# Sub-apps within services (for services with nested structure like mana-media)
- 'services/*/apps/*'
# Service-specific packages
- 'services/*/packages/*'
# Monorepo-wide shared packages
- 'packages/*'

View file

@ -1,36 +1,46 @@
# mana-media - Unified Media Platform
Central media handling service for all ManaCore applications.
Central media handling service for all ManaCore applications with content-addressable storage (CAS) and automatic deduplication.
## Overview
mana-media provides:
- **Upload API** - Chunked uploads, deduplication
- **Processing** - Thumbnails, WebP conversion, resizing
- **Content-Addressable Storage** - SHA-256 based deduplication across all apps
- **Upload API** - File uploads with automatic deduplication
- **Matrix Import** - Copy images from Matrix MXC URLs to persistent storage
- **Processing** - Thumbnails, WebP conversion, resizing (via BullMQ)
- **Delivery** - Optimized file serving, on-the-fly transforms
- **Search** (planned) - Vector-based semantic search
**Port:** 3015 (production)
## Quick Start
```bash
# Start dependencies (Redis + MinIO)
docker compose up redis minio -d
# Start dependencies (Redis + MinIO + PostgreSQL)
pnpm docker:up
# Create database
PGPASSWORD=devpassword psql -h localhost -U manacore -d postgres -c "CREATE DATABASE mana_media;"
# Install dependencies
cd services/mana-media/apps/api
pnpm install
# Push schema
pnpm db:push
# Start development server
pnpm dev
```
Service runs on `http://localhost:3050`
Service runs on `http://localhost:3015`
## API Endpoints
### Upload
```bash
# Upload file
curl -X POST http://localhost:3050/api/v1/media/upload \
curl -X POST http://localhost:3015/api/v1/media/upload \
-F "file=@image.jpg" \
-F "app=chat" \
-F "userId=user123"
@ -39,18 +49,34 @@ curl -X POST http://localhost:3050/api/v1/media/upload \
{
"id": "abc123",
"status": "processing",
"hash": "sha256...",
"urls": {
"original": "http://localhost:3050/api/v1/media/abc123/file",
"thumbnail": "http://localhost:3050/api/v1/media/abc123/file/thumb"
"original": "http://localhost:3015/api/v1/media/abc123/file",
"thumbnail": "http://localhost:3015/api/v1/media/abc123/file/thumb"
}
}
```
### Import from Matrix
```bash
# Import media from Matrix MXC URL
curl -X POST http://localhost:3015/api/v1/media/import/matrix \
-H "Content-Type: application/json" \
-d '{
"mxcUrl": "mxc://matrix.mana.how/abc123",
"app": "nutriphi",
"userId": "user-uuid"
}'
```
### Get Media
```bash
# Get metadata
GET /api/v1/media/:id
# Get by content hash (check if file exists)
GET /api/v1/media/hash/:sha256hash
# Get original file
GET /api/v1/media/:id/file
@ -66,8 +92,8 @@ GET /api/v1/media/:id/transform?w=400&h=300&fit=cover&format=webp
### List & Delete
```bash
# List media
GET /api/v1/media?app=chat&limit=50
# List media (filter by app/user)
GET /api/v1/media?app=chat&userId=user123&limit=50
# Delete
DELETE /api/v1/media/:id
@ -97,22 +123,48 @@ const customUrl = media.getTransformUrl(result.id, {
## Architecture
```
┌─────────────────────────────────────────────────┐
│ mana-media │
├─────────────────────────────────────────────────┤
│ Upload Module │ Handles file uploads │
│ Process Module │ Sharp/FFmpeg processing │
│ Storage Module │ MinIO abstraction │
│ Delivery Module │ File serving + transforms │
└─────────────────────────────────────────────────┘
│ │
▼ ▼
┌─────────┐ ┌─────────┐
│ Redis │ │ MinIO │
│ Queue │ │ Storage │
└─────────┘ └─────────┘
┌─────────────────────────────────────────────────────────────┐
│ mana-media (Port 3015) │
├─────────────────────────────────────────────────────────────┤
│ Upload Module │ File uploads, Matrix import, dedup │
│ Matrix Module │ Download from Matrix MXC URLs │
│ Process Module │ Sharp thumbnail generation (BullMQ) │
│ Storage Module │ MinIO S3 abstraction │
│ Delivery Module │ File serving + on-the-fly transforms │
│ Database Module │ PostgreSQL + Drizzle ORM │
└─────────────────────────────────────────────────────────────┘
│ │ │
▼ ▼ ▼
┌─────────┐ ┌─────────┐ ┌────────────┐
│ Redis │ │ MinIO │ │ PostgreSQL │
│ BullMQ │ │ Storage │ │ mana_media │
└─────────┘ └─────────┘ └────────────┘
```
## Database Schema
### media (Content-Addressable Storage)
| Column | Type | Description |
|--------|------|-------------|
| id | UUID | Primary key |
| content_hash | TEXT | SHA-256 hash (unique) |
| original_name | TEXT | Original filename |
| mime_type | TEXT | MIME type |
| size | BIGINT | File size in bytes |
| original_key | TEXT | S3 storage key |
| status | TEXT | uploading/processing/ready/failed |
| thumbnail_key | TEXT | Thumbnail S3 key |
| width/height | INT | Image dimensions |
### media_references (User ownership)
| Column | Type | Description |
|--------|------|-------------|
| id | UUID | Primary key |
| media_id | UUID | FK to media |
| user_id | UUID | Owner user ID |
| app | TEXT | Source app (nutriphi, contacts, etc.) |
| source_url | TEXT | Original source (e.g., mxc:// URL) |
## Processing Pipeline
| File Type | Generated Variants |
@ -125,14 +177,20 @@ const customUrl = media.getTransformUrl(result.id, {
| Variable | Default | Description |
|----------|---------|-------------|
| PORT | 3050 | API port |
| PORT | 3015 | API port |
| DATABASE_URL | - | PostgreSQL connection string |
| REDIS_HOST | localhost | Redis host |
| REDIS_PORT | 6379 | Redis port |
| REDIS_PASSWORD | - | Redis password (optional) |
| S3_ENDPOINT | localhost | MinIO/S3 endpoint |
| S3_PORT | 9000 | MinIO/S3 port |
| S3_USE_SSL | false | Use HTTPS for S3 |
| S3_ACCESS_KEY | minioadmin | S3 access key |
| S3_SECRET_KEY | minioadmin | S3 secret key |
| S3_BUCKET | mana-media | Storage bucket |
| S3_PUBLIC_URL | - | Public URL for media |
| MATRIX_HOMESERVER_URL | https://matrix.mana.how | Matrix homeserver |
| PUBLIC_URL | http://localhost:3015/api/v1 | Public API URL |
## Development
@ -145,6 +203,10 @@ pnpm type-check
# Build
pnpm build
# Database commands
pnpm db:push # Push schema to database
pnpm db:studio # Open Drizzle Studio
```
## Storage Layout
@ -166,8 +228,29 @@ mana-media bucket/
## Roadmap
- [x] v0.1: Basic upload + thumbnails
- [ ] v0.2: Video thumbnails (FFmpeg)
- [ ] v0.3: Chunked upload for large files
- [ ] v0.4: OCR for documents
- [ ] v0.5: Vector search (Qdrant)
- [x] v0.2: PostgreSQL persistence with Drizzle ORM
- [x] v0.3: Content-addressable storage with SHA-256 deduplication
- [x] v0.4: Matrix MXC URL import
- [ ] v0.5: Video thumbnails (FFmpeg)
- [ ] v0.6: Chunked upload for large files
- [ ] v0.7: OCR for documents
- [ ] v0.8: Vector search (Qdrant)
- [ ] v1.0: Full production ready
## Integration Example (NutriPhi Bot)
```typescript
// In matrix-nutriphi-bot
const response = await fetch('http://mana-media:3015/api/v1/media/import/matrix', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
mxcUrl: 'mxc://matrix.mana.how/abc123',
app: 'nutriphi',
userId: userUuid,
}),
});
const { id, hash, urls } = await response.json();
// Store id or hash in meal record for reference
```

View file

@ -1,42 +1,64 @@
FROM node:22-alpine AS builder
# ================================
# Build Stage (Monorepo-aware)
# ================================
FROM node:20-alpine AS base
RUN corepack enable && corepack prepare pnpm@9.15.0 --activate
WORKDIR /app
# Install pnpm
RUN corepack enable && corepack prepare pnpm@latest --activate
# Copy package files
COPY package.json pnpm-lock.yaml* ./
# Install dependencies
FROM base AS deps
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./
COPY packages/shared-drizzle-config/package.json ./packages/shared-drizzle-config/
COPY services/mana-media/package.json ./services/mana-media/
COPY services/mana-media/apps/api/package.json ./services/mana-media/apps/api/
COPY services/mana-media/packages/client/package.json ./services/mana-media/packages/client/
RUN pnpm install --frozen-lockfile
# Copy source
COPY . .
# Build
# Build shared packages
FROM base AS shared-builder
COPY --from=deps /app/node_modules ./node_modules
COPY --from=deps /app/packages/shared-drizzle-config/node_modules ./packages/shared-drizzle-config/node_modules
COPY packages/shared-drizzle-config ./packages/shared-drizzle-config
WORKDIR /app/packages/shared-drizzle-config
RUN pnpm build
# Production stage
FROM node:22-alpine AS runner
# Build the application
FROM base AS builder
COPY --from=deps /app/node_modules ./node_modules
COPY --from=deps /app/services/mana-media/node_modules ./services/mana-media/node_modules
COPY --from=deps /app/services/mana-media/apps/api/node_modules ./services/mana-media/apps/api/node_modules
COPY --from=shared-builder /app/packages/shared-drizzle-config ./packages/shared-drizzle-config
COPY services/mana-media ./services/mana-media
WORKDIR /app/services/mana-media/apps/api
RUN pnpm build
# ================================
# Production Stage
# ================================
FROM node:20-alpine AS runner
RUN corepack enable && corepack prepare pnpm@9.15.0 --activate \
&& apk add --no-cache postgresql-client wget
ENV NODE_ENV=production
# Create non-root user
RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 nestjs
USER nestjs
WORKDIR /app
# Install pnpm
RUN corepack enable && corepack prepare pnpm@latest --activate
# Copy built application
COPY --from=builder --chown=nestjs:nodejs /app/services/mana-media/apps/api/dist ./dist
COPY --from=builder --chown=nestjs:nodejs /app/services/mana-media/apps/api/node_modules ./node_modules
COPY --from=builder --chown=nestjs:nodejs /app/services/mana-media/apps/api/package.json ./
COPY --from=builder --chown=nestjs:nodejs /app/packages/shared-drizzle-config ./packages/shared-drizzle-config
# Copy package files and install production deps only
COPY package.json pnpm-lock.yaml* ./
RUN pnpm install --frozen-lockfile --prod
# Expose port
EXPOSE 3015
# Copy built files
COPY --from=builder /app/dist ./dist
# Create non-root user
RUN addgroup --system --gid 1001 nodejs && \
adduser --system --uid 1001 nestjs
USER nestjs
EXPOSE 3050
# Health check
HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
CMD wget --no-verbose --tries=1 --spider http://localhost:3015/health || exit 1
# Start the application
CMD ["node", "dist/main"]

View file

@ -0,0 +1,6 @@
import { createDrizzleConfig } from '@manacore/shared-drizzle-config';
export default createDrizzleConfig({
dbName: 'mana_media',
schemaPath: './src/db/schema/index.ts',
});

View file

@ -8,7 +8,9 @@
"start": "nest start",
"start:prod": "node dist/main",
"type-check": "tsc --noEmit",
"lint": "eslint 'src/**/*.ts'"
"lint": "eslint 'src/**/*.ts'",
"db:push": "drizzle-kit push",
"db:studio": "drizzle-kit studio"
},
"dependencies": {
"@nestjs/bullmq": "^11.0.0",
@ -17,20 +19,25 @@
"@nestjs/core": "^11.0.0",
"@nestjs/platform-express": "^11.0.0",
"bullmq": "^5.34.0",
"drizzle-orm": "^0.38.3",
"express": "^4.21.0",
"mime-types": "^2.1.35",
"minio": "^8.0.0",
"postgres": "^3.4.5",
"reflect-metadata": "^0.2.0",
"rxjs": "^7.8.0",
"sharp": "^0.33.0",
"uuid": "^11.0.0"
},
"devDependencies": {
"@manacore/shared-drizzle-config": "workspace:*",
"@nestjs/cli": "^11.0.0",
"@types/express": "^5.0.0",
"@types/mime-types": "^2.1.4",
"@types/multer": "^2.0.0",
"@types/node": "^22.0.0",
"@types/uuid": "^10.0.0",
"drizzle-kit": "^0.30.1",
"typescript": "^5.7.0"
}
}

File diff suppressed because it is too large Load diff

View file

@ -1,10 +1,12 @@
import { Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
import { BullModule } from '@nestjs/bullmq';
import { DatabaseModule } from './db/database.module';
import { UploadModule } from './modules/upload/upload.module';
import { StorageModule } from './modules/storage/storage.module';
import { ProcessModule } from './modules/process/process.module';
import { DeliveryModule } from './modules/delivery/delivery.module';
import { MatrixModule } from './modules/matrix/matrix.module';
import { HealthController } from './health.controller';
@Module({
@ -16,12 +18,15 @@ import { HealthController } from './health.controller';
connection: {
host: process.env.REDIS_HOST || 'localhost',
port: parseInt(process.env.REDIS_PORT || '6379'),
password: process.env.REDIS_PASSWORD || undefined,
},
}),
DatabaseModule,
StorageModule,
UploadModule,
ProcessModule,
DeliveryModule,
MatrixModule,
],
controllers: [HealthController],
})

View file

@ -0,0 +1,37 @@
import { drizzle } from 'drizzle-orm/postgres-js';
import * as schema from './schema';
// Use require for postgres to avoid ESM/CommonJS interop issues
const postgres = require('postgres');
let connection: ReturnType<typeof postgres> | null = null;
let db: ReturnType<typeof drizzle> | null = null;
export function getConnection(databaseUrl: string) {
if (!connection) {
connection = postgres(databaseUrl, {
max: 10,
idle_timeout: 20,
connect_timeout: 10,
});
}
return connection;
}
export function getDb(databaseUrl: string) {
if (!db) {
const conn = getConnection(databaseUrl);
db = drizzle(conn, { schema });
}
return db;
}
export async function closeConnection() {
if (connection) {
await connection.end();
connection = null;
db = null;
}
}
export type Database = ReturnType<typeof getDb>;

View file

@ -0,0 +1,30 @@
import { Module, Global } from '@nestjs/common';
import type { OnModuleDestroy } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { getDb, closeConnection } from './connection';
import type { Database } from './connection';
export const DATABASE_CONNECTION = 'DATABASE_CONNECTION';
@Global()
@Module({
providers: [
{
provide: DATABASE_CONNECTION,
useFactory: (configService: ConfigService): Database => {
const databaseUrl = configService.get<string>('DATABASE_URL');
if (!databaseUrl) {
throw new Error('DATABASE_URL environment variable is not set');
}
return getDb(databaseUrl);
},
inject: [ConfigService],
},
],
exports: [DATABASE_CONNECTION],
})
export class DatabaseModule implements OnModuleDestroy {
async onModuleDestroy() {
await closeConnection();
}
}

View file

@ -0,0 +1 @@
export * from './media.schema';

View file

@ -0,0 +1,149 @@
import {
pgTable,
uuid,
text,
timestamp,
integer,
boolean,
index,
jsonb,
bigint,
} from 'drizzle-orm/pg-core';
import { relations } from 'drizzle-orm';
/**
* Core media table - stores unique files by content hash (SHA-256)
* This is the Content-Addressable Storage (CAS) approach
*/
export const media = pgTable(
'media',
{
id: uuid('id').primaryKey().defaultRandom(),
// Content-addressable: SHA-256 hash of the file content
contentHash: text('content_hash').notNull().unique(),
// Original filename (for display purposes)
originalName: text('original_name'),
// MIME type
mimeType: text('mime_type').notNull(),
// File size in bytes
size: bigint('size', { mode: 'number' }).notNull(),
// Storage keys
originalKey: text('original_key').notNull(),
// Processing status
status: text('status', { enum: ['uploading', 'processing', 'ready', 'failed'] })
.default('uploading')
.notNull(),
// Image metadata
width: integer('width'),
height: integer('height'),
format: text('format'),
hasAlpha: boolean('has_alpha'),
// Generated variants
thumbnailKey: text('thumbnail_key'),
mediumKey: text('medium_key'),
largeKey: text('large_key'),
// Timestamps
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(),
},
(table) => [
index('media_content_hash_idx').on(table.contentHash),
index('media_status_idx').on(table.status),
index('media_created_at_idx').on(table.createdAt),
]
);
/**
* Media references - tracks which user/app owns a reference to a media item
* Multiple users can reference the same media (deduplication)
*/
export const mediaReferences = pgTable(
'media_references',
{
id: uuid('id').primaryKey().defaultRandom(),
// The media being referenced
mediaId: uuid('media_id')
.references(() => media.id, { onDelete: 'cascade' })
.notNull(),
// Owner info
userId: uuid('user_id').notNull(),
// Source app (nutriphi, contacts, chat, etc.)
app: text('app').notNull(),
// Optional: reference to the source (e.g., mxc:// URL from Matrix)
sourceUrl: text('source_url'),
// Custom metadata per reference
metadata: jsonb('metadata'),
// Timestamps
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
},
(table) => [
index('media_ref_media_id_idx').on(table.mediaId),
index('media_ref_user_id_idx').on(table.userId),
index('media_ref_app_idx').on(table.app),
index('media_ref_user_app_idx').on(table.userId, table.app),
]
);
/**
* Lazy-generated thumbnails cache
* Stores on-the-fly generated thumbnails with specific parameters
*/
export const mediaThumbnails = pgTable(
'media_thumbnails',
{
id: uuid('id').primaryKey().defaultRandom(),
mediaId: uuid('media_id')
.references(() => media.id, { onDelete: 'cascade' })
.notNull(),
// Parameters that define this thumbnail
width: integer('width').notNull(),
height: integer('height').notNull(),
fit: text('fit').default('cover').notNull(),
format: text('format').default('webp').notNull(),
quality: integer('quality').default(80).notNull(),
// Storage key for this specific thumbnail
storageKey: text('storage_key').notNull(),
// Size of the generated thumbnail
size: integer('size').notNull(),
// Timestamps
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
},
(table) => [
index('media_thumb_media_id_idx').on(table.mediaId),
index('media_thumb_params_idx').on(
table.mediaId,
table.width,
table.height,
table.fit,
table.format
),
]
);
// Relations
export const mediaRelations = relations(media, ({ many }) => ({
references: many(mediaReferences),
thumbnails: many(mediaThumbnails),
}));
export const mediaReferencesRelations = relations(mediaReferences, ({ one }) => ({
media: one(media, {
fields: [mediaReferences.mediaId],
references: [media.id],
}),
}));
export const mediaThumbnailsRelations = relations(mediaThumbnails, ({ one }) => ({
media: one(media, {
fields: [mediaThumbnails.mediaId],
references: [media.id],
}),
}));
// Types
export type Media = typeof media.$inferSelect;
export type NewMedia = typeof media.$inferInsert;
export type MediaReference = typeof mediaReferences.$inferSelect;
export type NewMediaReference = typeof mediaReferences.$inferInsert;
export type MediaThumbnail = typeof mediaThumbnails.$inferSelect;
export type NewMediaThumbnail = typeof mediaThumbnails.$inferInsert;

View file

@ -1,16 +1,23 @@
import { NestFactory } from '@nestjs/core';
import { ValidationPipe } from '@nestjs/common';
import { ValidationPipe, Logger } from '@nestjs/common';
import { json, urlencoded } from 'express';
import { AppModule } from './app.module';
async function bootstrap() {
const logger = new Logger('Bootstrap');
const app = await NestFactory.create(AppModule);
app.setGlobalPrefix('api/v1');
// Increase body size limit for large file uploads
app.use(json({ limit: '100mb' }));
app.use(urlencoded({ extended: true, limit: '100mb' }));
app.useGlobalPipes(
new ValidationPipe({
whitelist: true,
transform: true,
forbidNonWhitelisted: true,
})
);
@ -19,10 +26,11 @@ async function bootstrap() {
credentials: true,
});
const port = process.env.PORT || 3050;
const port = process.env.PORT || 3015;
await app.listen(port);
console.log(`mana-media service running on port ${port}`);
logger.log(`Mana Media service running on port ${port}`);
logger.log(`Health check: http://localhost:${port}/health`);
}
bootstrap();

View file

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

View file

@ -0,0 +1,160 @@
import { Injectable, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
export interface MatrixMediaInfo {
buffer: Buffer;
mimeType: string;
size: number;
filename?: string;
}
/**
* Service for downloading media from Matrix homeservers
* Handles MXC URLs like mxc://matrix.mana.how/abc123
*/
@Injectable()
export class MatrixService {
private readonly logger = new Logger(MatrixService.name);
private readonly homeserverUrl: string;
constructor(private config: ConfigService) {
this.homeserverUrl = this.config.get('MATRIX_HOMESERVER_URL', 'https://matrix.mana.how');
}
/**
* Parse an MXC URL into server and media ID
* @param mxcUrl - URL in format mxc://server/media_id
*/
parseMxcUrl(mxcUrl: string): { server: string; mediaId: string } | null {
const match = mxcUrl.match(/^mxc:\/\/([^/]+)\/(.+)$/);
if (!match) {
return null;
}
return { server: match[1], mediaId: match[2] };
}
/**
* Convert MXC URL to HTTP download URL
*/
getDownloadUrl(mxcUrl: string): string | null {
const parsed = this.parseMxcUrl(mxcUrl);
if (!parsed) {
return null;
}
// Use the Matrix Content Repository API
// Format: /_matrix/media/v3/download/{serverName}/{mediaId}
return `${this.homeserverUrl}/_matrix/media/v3/download/${parsed.server}/${parsed.mediaId}`;
}
/**
* Download media from a Matrix MXC URL
*/
async downloadFromMxc(mxcUrl: string): Promise<MatrixMediaInfo | null> {
const downloadUrl = this.getDownloadUrl(mxcUrl);
if (!downloadUrl) {
this.logger.error(`Invalid MXC URL: ${mxcUrl}`);
return null;
}
try {
this.logger.debug(`Downloading from Matrix: ${downloadUrl}`);
const response = await fetch(downloadUrl);
if (!response.ok) {
this.logger.error(
`Failed to download from Matrix: ${response.status} ${response.statusText}`
);
return null;
}
const contentType = response.headers.get('content-type') || 'application/octet-stream';
const contentDisposition = response.headers.get('content-disposition');
// Extract filename from Content-Disposition if available
let filename: string | undefined;
if (contentDisposition) {
const match = contentDisposition.match(
/filename[*]?=['"]?(?:UTF-\d['"]*)?([^;\r\n"']*)['"]?/i
);
if (match) {
filename = decodeURIComponent(match[1]);
}
}
const arrayBuffer = await response.arrayBuffer();
const buffer = Buffer.from(arrayBuffer);
return {
buffer,
mimeType: contentType,
size: buffer.length,
filename,
};
} catch (error) {
this.logger.error(`Error downloading from Matrix: ${error}`);
return null;
}
}
/**
* Download a thumbnail from Matrix
* Matrix can generate thumbnails on-the-fly with specified dimensions
*/
async downloadThumbnailFromMxc(
mxcUrl: string,
options?: {
width?: number;
height?: number;
method?: 'crop' | 'scale';
}
): Promise<MatrixMediaInfo | null> {
const parsed = this.parseMxcUrl(mxcUrl);
if (!parsed) {
this.logger.error(`Invalid MXC URL: ${mxcUrl}`);
return null;
}
const width = options?.width || 320;
const height = options?.height || 240;
const method = options?.method || 'scale';
// Use the Matrix thumbnail API
// Format: /_matrix/media/v3/thumbnail/{serverName}/{mediaId}?width=X&height=Y&method=crop|scale
const thumbnailUrl = `${this.homeserverUrl}/_matrix/media/v3/thumbnail/${parsed.server}/${parsed.mediaId}?width=${width}&height=${height}&method=${method}`;
try {
this.logger.debug(`Downloading thumbnail from Matrix: ${thumbnailUrl}`);
const response = await fetch(thumbnailUrl);
if (!response.ok) {
this.logger.warn(
`Failed to get thumbnail from Matrix: ${response.status}, falling back to full download`
);
return this.downloadFromMxc(mxcUrl);
}
const contentType = response.headers.get('content-type') || 'image/png';
const arrayBuffer = await response.arrayBuffer();
const buffer = Buffer.from(arrayBuffer);
return {
buffer,
mimeType: contentType,
size: buffer.length,
};
} catch (error) {
this.logger.error(`Error downloading thumbnail from Matrix: ${error}`);
return null;
}
}
/**
* Check if a URL is a valid MXC URL
*/
isValidMxcUrl(url: string): boolean {
return this.parseMxcUrl(url) !== null;
}
}

View file

@ -1,4 +1,5 @@
import { Processor, WorkerHost } from '@nestjs/bullmq';
import { Logger } from '@nestjs/common';
import { Job } from 'bullmq';
import { ProcessService } from './process.service';
import { UploadService } from '../upload/upload.service';
@ -12,6 +13,8 @@ interface ProcessJobData {
@Processor(PROCESS_QUEUE)
export class ProcessWorker extends WorkerHost {
private readonly logger = new Logger(ProcessWorker.name);
constructor(
private processService: ProcessService,
private uploadService: UploadService
@ -22,7 +25,7 @@ export class ProcessWorker extends WorkerHost {
async process(job: Job<ProcessJobData>): Promise<void> {
const { mediaId, mimeType, originalKey } = job.data;
console.log(`Processing media ${mediaId} (${mimeType})`);
this.logger.log(`Processing media ${mediaId} (${mimeType})`);
try {
if (SUPPORTED_IMAGE_TYPES.includes(mimeType)) {
@ -32,7 +35,7 @@ export class ProcessWorker extends WorkerHost {
await this.uploadService.update(mediaId, { status: 'ready' });
}
} catch (error) {
console.error(`Failed to process media ${mediaId}:`, error);
this.logger.error(`Failed to process media ${mediaId}:`, error);
await this.uploadService.update(mediaId, { status: 'failed' });
throw error;
}
@ -47,16 +50,16 @@ export class ProcessWorker extends WorkerHost {
await this.uploadService.update(mediaId, {
status: 'ready',
keys: {
original: originalKey,
thumbnail: result.thumbnail,
medium: result.medium,
large: result.large,
},
metadata: result.metadata,
thumbnailKey: result.thumbnail,
mediumKey: result.medium,
largeKey: result.large,
width: result.metadata?.width,
height: result.metadata?.height,
format: result.metadata?.format,
hasAlpha: result.metadata?.hasAlpha,
});
console.log(
this.logger.log(
`Processed image ${mediaId}: thumbnail=${!!result.thumbnail}, medium=${!!result.medium}, large=${!!result.large}`
);
}

View file

@ -17,9 +17,10 @@ import { UploadService, MediaRecord } from './upload.service';
interface UploadResponse {
id: string;
status: MediaRecord['status'];
originalName: string;
originalName: string | null;
mimeType: string;
size: number;
hash: string;
urls: {
original: string;
thumbnail?: string;
@ -29,6 +30,13 @@ interface UploadResponse {
createdAt: Date;
}
interface ImportFromMatrixDto {
mxcUrl: string;
app: string;
userId: string;
skipProcessing?: boolean;
}
@Controller('media')
export class UploadController {
constructor(private uploadService: UploadService) {}
@ -60,6 +68,37 @@ export class UploadController {
return this.toResponse(record);
}
/**
* Import media from a Matrix MXC URL
* Copies the file from Matrix to our storage with deduplication
*/
@Post('import/matrix')
async importFromMatrix(@Body() dto: ImportFromMatrixDto): Promise<UploadResponse> {
if (!dto.mxcUrl) {
throw new BadRequestException('mxcUrl is required');
}
if (!dto.app) {
throw new BadRequestException('app is required');
}
if (!dto.userId) {
throw new BadRequestException('userId is required');
}
const record = await this.uploadService.importFromMatrix(dto.mxcUrl, {
app: dto.app,
userId: dto.userId,
skipProcessing: dto.skipProcessing,
});
if (!record) {
throw new BadRequestException(
'Failed to import from Matrix. Invalid MXC URL or download failed.'
);
}
return this.toResponse(record);
}
@Get(':id')
async get(@Param('id') id: string): Promise<UploadResponse> {
const record = await this.uploadService.get(id);
@ -69,6 +108,19 @@ export class UploadController {
return this.toResponse(record);
}
/**
* Get media by content hash (SHA-256)
* Useful for checking if a file already exists before uploading
*/
@Get('hash/:hash')
async getByHash(@Param('hash') hash: string): Promise<UploadResponse> {
const record = await this.uploadService.getByHash(hash);
if (!record) {
throw new NotFoundException('Media not found');
}
return this.toResponse(record);
}
@Get()
async list(
@Query('app') app?: string,
@ -93,7 +145,7 @@ export class UploadController {
}
private toResponse(record: MediaRecord): UploadResponse {
const baseUrl = process.env.PUBLIC_URL || 'http://localhost:3050/api/v1';
const baseUrl = process.env.PUBLIC_URL || 'http://localhost:3015/api/v1';
return {
id: record.id,
@ -101,6 +153,7 @@ export class UploadController {
originalName: record.originalName,
mimeType: record.mimeType,
size: record.size,
hash: record.hash,
urls: {
original: `${baseUrl}/media/${record.id}/file`,
thumbnail: record.keys.thumbnail ? `${baseUrl}/media/${record.id}/file/thumb` : undefined,

View file

@ -2,6 +2,7 @@ import { Module } from '@nestjs/common';
import { BullModule } from '@nestjs/bullmq';
import { UploadController } from './upload.controller';
import { UploadService } from './upload.service';
import { MatrixModule } from '../matrix/matrix.module';
import { PROCESS_QUEUE } from '../process/process.constants';
@Module({
@ -9,6 +10,7 @@ import { PROCESS_QUEUE } from '../process/process.constants';
BullModule.registerQueue({
name: PROCESS_QUEUE,
}),
MatrixModule,
],
controllers: [UploadController],
providers: [UploadService],

View file

@ -1,15 +1,25 @@
import { Injectable } from '@nestjs/common';
import { Injectable, Inject } from '@nestjs/common';
import { InjectQueue } from '@nestjs/bullmq';
import { Queue } from 'bullmq';
import { v4 as uuid } from 'uuid';
import * as mime from 'mime-types';
import * as crypto from 'crypto';
import { eq } from 'drizzle-orm';
import { StorageService } from '../storage/storage.service';
import { MatrixService } from '../matrix/matrix.service';
import { PROCESS_QUEUE } from '../process/process.constants';
import { DATABASE_CONNECTION } from '../../db/database.module';
import type { Database } from '../../db/connection';
import {
media,
mediaReferences,
type Media,
type NewMedia,
type NewMediaReference,
} from '../../db/schema';
export interface MediaRecord {
id: string;
originalName: string;
originalName: string | null;
mimeType: string;
size: number;
hash: string;
@ -22,19 +32,23 @@ export interface MediaRecord {
medium?: string;
large?: string;
};
metadata?: Record<string, unknown>;
metadata?: {
width?: number;
height?: number;
format?: string;
hasAlpha?: boolean;
};
createdAt: Date;
updatedAt: Date;
}
// In-memory store for MVP (replace with DB later)
const mediaStore = new Map<string, MediaRecord>();
@Injectable()
export class UploadService {
constructor(
private storage: StorageService,
@InjectQueue(PROCESS_QUEUE) private processQueue: Queue
private matrixService: MatrixService,
@InjectQueue(PROCESS_QUEUE) private processQueue: Queue,
@Inject(DATABASE_CONNECTION) private db: Database
) {}
async upload(
@ -45,113 +59,271 @@ export class UploadService {
skipProcessing?: boolean;
}
): Promise<MediaRecord> {
const id = uuid();
const ext = mime.extension(file.mimetype) || 'bin';
const hash = this.computeHash(file.buffer);
// Check for duplicate
const existing = this.findByHash(hash);
// Check for existing media with same content hash
const existing = await this.findByHash(hash);
if (existing) {
return existing;
// If userId and app provided, create a reference
if (options?.userId && options?.app) {
await this.createReference(existing.id, options.userId, options.app);
}
return this.toMediaRecord(existing);
}
// Generate storage keys
// Generate storage key
const ext = mime.extension(file.mimetype) || 'bin';
const date = new Date();
const datePath = `${date.getFullYear()}/${String(date.getMonth() + 1).padStart(2, '0')}/${String(date.getDate()).padStart(2, '0')}`;
const id = crypto.randomUUID();
const originalKey = `originals/${datePath}/${id}.${ext}`;
// Upload original
// Upload to storage
await this.storage.upload(originalKey, file.buffer, file.mimetype, {
'x-amz-meta-original-name': file.originalname,
'x-amz-meta-media-id': id,
});
// Create record
const record: MediaRecord = {
id,
originalName: file.originalname,
mimeType: file.mimetype,
size: file.size,
hash,
status: options?.skipProcessing ? 'ready' : 'processing',
app: options?.app,
userId: options?.userId,
keys: {
original: originalKey,
},
createdAt: date,
updatedAt: date,
};
// Insert into database
const [inserted] = await this.db
.insert(media)
.values({
id,
contentHash: hash,
originalName: file.originalname,
mimeType: file.mimetype,
size: file.size,
originalKey,
status: options?.skipProcessing ? 'ready' : 'processing',
} satisfies NewMedia)
.returning();
mediaStore.set(id, record);
// Create reference if user provided
if (options?.userId && options?.app) {
await this.createReference(inserted.id, options.userId, options.app);
}
// Queue processing job
if (!options?.skipProcessing) {
await this.processQueue.add('process-media', {
mediaId: id,
mediaId: inserted.id,
mimeType: file.mimetype,
originalKey,
});
}
return record;
return this.toMediaRecord(inserted);
}
/**
* Import media from a Matrix MXC URL
*/
async importFromMatrix(
mxcUrl: string,
options: {
app: string;
userId: string;
skipProcessing?: boolean;
}
): Promise<MediaRecord | null> {
// Download from Matrix
const matrixMedia = await this.matrixService.downloadFromMxc(mxcUrl);
if (!matrixMedia) {
return null;
}
const hash = this.computeHash(matrixMedia.buffer);
// Check for existing media
const existing = await this.findByHash(hash);
if (existing) {
// Create reference with source URL
await this.createReference(existing.id, options.userId, options.app, mxcUrl);
return this.toMediaRecord(existing);
}
// Generate storage key
const ext = mime.extension(matrixMedia.mimeType) || 'bin';
const date = new Date();
const datePath = `${date.getFullYear()}/${String(date.getMonth() + 1).padStart(2, '0')}/${String(date.getDate()).padStart(2, '0')}`;
const id = crypto.randomUUID();
const originalKey = `originals/${datePath}/${id}.${ext}`;
// Upload to storage
await this.storage.upload(originalKey, matrixMedia.buffer, matrixMedia.mimeType, {
'x-amz-meta-source': 'matrix',
'x-amz-meta-source-url': mxcUrl,
'x-amz-meta-media-id': id,
});
// Insert into database
const [inserted] = await this.db
.insert(media)
.values({
id,
contentHash: hash,
originalName: matrixMedia.filename || null,
mimeType: matrixMedia.mimeType,
size: matrixMedia.size,
originalKey,
status: options?.skipProcessing ? 'ready' : 'processing',
} satisfies NewMedia)
.returning();
// Create reference with source URL
await this.createReference(inserted.id, options.userId, options.app, mxcUrl);
// Queue processing job
if (!options?.skipProcessing) {
await this.processQueue.add('process-media', {
mediaId: inserted.id,
mimeType: matrixMedia.mimeType,
originalKey,
});
}
return this.toMediaRecord(inserted);
}
async get(id: string): Promise<MediaRecord | null> {
return mediaStore.get(id) || null;
const [result] = await this.db.select().from(media).where(eq(media.id, id)).limit(1);
return result ? this.toMediaRecord(result) : null;
}
async update(id: string, updates: Partial<MediaRecord>): Promise<MediaRecord | null> {
const record = mediaStore.get(id);
if (!record) return null;
async getByHash(hash: string): Promise<MediaRecord | null> {
const result = await this.findByHash(hash);
return result ? this.toMediaRecord(result) : null;
}
const updated = {
...record,
...updates,
updatedAt: new Date(),
};
mediaStore.set(id, updated);
return updated;
async update(
id: string,
updates: Partial<
Pick<
Media,
| 'status'
| 'thumbnailKey'
| 'mediumKey'
| 'largeKey'
| 'width'
| 'height'
| 'format'
| 'hasAlpha'
>
>
): Promise<MediaRecord | null> {
const [updated] = await this.db
.update(media)
.set({
...updates,
updatedAt: new Date(),
})
.where(eq(media.id, id))
.returning();
return updated ? this.toMediaRecord(updated) : null;
}
async delete(id: string): Promise<boolean> {
const record = mediaStore.get(id);
const [record] = await this.db.select().from(media).where(eq(media.id, id)).limit(1);
if (!record) return false;
// Delete all associated files
const keys = Object.values(record.keys).filter(Boolean) as string[];
// Delete all associated storage files
const keys = [
record.originalKey,
record.thumbnailKey,
record.mediumKey,
record.largeKey,
].filter(Boolean) as string[];
for (const key of keys) {
await this.storage.delete(key).catch(() => {});
}
mediaStore.delete(id);
// Delete from database (references will cascade)
await this.db.delete(media).where(eq(media.id, id));
return true;
}
async list(options?: { app?: string; userId?: string; limit?: number }): Promise<MediaRecord[]> {
let records = Array.from(mediaStore.values());
// If filtering by user/app, we need to join with references
if (options?.userId || options?.app) {
const query = this.db
.select({ media: media })
.from(media)
.innerJoin(mediaReferences, eq(media.id, mediaReferences.mediaId));
if (options?.app) {
records = records.filter((r) => r.app === options.app);
}
if (options?.userId) {
records = records.filter((r) => r.userId === options.userId);
// Build conditions
const conditions = [];
if (options.userId) {
conditions.push(eq(mediaReferences.userId, options.userId));
}
if (options.app) {
conditions.push(eq(mediaReferences.app, options.app));
}
const results = await query
.where(conditions.length === 1 ? conditions[0] : undefined)
.limit(options.limit || 50);
return results.map((r) => this.toMediaRecord(r.media));
}
records.sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime());
// Simple list without filtering
const results = await this.db
.select()
.from(media)
.orderBy(media.createdAt)
.limit(options?.limit || 50);
if (options?.limit) {
records = records.slice(0, options.limit);
}
return results.map((r) => this.toMediaRecord(r));
}
return records;
private async findByHash(hash: string): Promise<Media | null> {
const [result] = await this.db.select().from(media).where(eq(media.contentHash, hash)).limit(1);
return result || null;
}
private async createReference(
mediaId: string,
userId: string,
app: string,
sourceUrl?: string
): Promise<void> {
await this.db.insert(mediaReferences).values({
mediaId,
userId,
app,
sourceUrl: sourceUrl || null,
} satisfies NewMediaReference);
}
private computeHash(buffer: Buffer): string {
return crypto.createHash('sha256').update(buffer).digest('hex');
}
private findByHash(hash: string): MediaRecord | undefined {
return Array.from(mediaStore.values()).find((r) => r.hash === hash);
private toMediaRecord(m: Media): MediaRecord {
return {
id: m.id,
originalName: m.originalName,
mimeType: m.mimeType,
size: Number(m.size),
hash: m.contentHash,
status: m.status as MediaRecord['status'],
keys: {
original: m.originalKey,
thumbnail: m.thumbnailKey || undefined,
medium: m.mediumKey || undefined,
large: m.largeKey || undefined,
},
metadata: m.width
? {
width: m.width || undefined,
height: m.height || undefined,
format: m.format || undefined,
hasAlpha: m.hasAlpha || undefined,
}
: undefined,
createdAt: m.createdAt,
updatedAt: m.updatedAt,
};
}
}

View file

@ -0,0 +1,3 @@
import { createDrizzleConfig } from '@manacore/shared-drizzle-config';
export default createDrizzleConfig('./src/db/schema');

View file

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

View file

@ -1,9 +1,10 @@
export interface MediaResult {
id: string;
status: 'uploading' | 'processing' | 'ready' | 'failed';
originalName: string;
originalName: string | null;
mimeType: string;
size: number;
hash: string;
urls: {
original: string;
thumbnail?: string;
@ -13,6 +14,13 @@ export interface MediaResult {
createdAt: Date;
}
export interface ImportFromMatrixOptions {
mxcUrl: string;
app: string;
userId: string;
skipProcessing?: boolean;
}
export interface UploadOptions {
app?: string;
userId?: string;
@ -77,6 +85,53 @@ export class MediaClient {
return response.json();
}
/**
* Import media from a Matrix MXC URL
* Copies the file from Matrix to mana-media storage with deduplication
*/
async importFromMatrix(options: ImportFromMatrixOptions): Promise<MediaResult> {
const response = await fetch(`${this.baseUrl}/api/v1/media/import/matrix`, {
method: 'POST',
headers: {
...this.getHeaders(),
'Content-Type': 'application/json',
},
body: JSON.stringify({
mxcUrl: options.mxcUrl,
app: options.app,
userId: options.userId,
skipProcessing: options.skipProcessing,
}),
});
if (!response.ok) {
const error = await response.text();
throw new Error(`Import from Matrix failed: ${response.statusText} - ${error}`);
}
return response.json();
}
/**
* Get media by content hash (SHA-256)
* Useful for checking if a file already exists before uploading
*/
async getByHash(hash: string): Promise<MediaResult | null> {
const response = await fetch(`${this.baseUrl}/api/v1/media/hash/${hash}`, {
headers: this.getHeaders(),
});
if (response.status === 404) {
return null;
}
if (!response.ok) {
throw new Error(`Get by hash failed: ${response.statusText}`);
}
return response.json();
}
/**
* Get media by ID
*/

View file

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

View file

@ -10,6 +10,7 @@ Matrix NutriPhi Bot is a Matrix chat bot for AI-powered nutrition tracking. It i
- **Matrix**: matrix-bot-sdk
- **Backend**: NutriPhi API (port 3023)
- **Auth**: Mana Core Auth (JWT)
- **Media Storage**: mana-media (port 3015)
## Commands
@ -41,9 +42,9 @@ services/matrix-nutriphi-bot/
│ ├── nutriphi/
│ │ ├── nutriphi.module.ts
│ │ └── nutriphi.service.ts # NutriPhi API client
│ └── session/
│ ├── session.module.ts
│ └── session.service.ts # User session & auth management
│ └── media/
│ ├── media.module.ts
│ └── media.service.ts # mana-media client for persistent storage
├── Dockerfile
└── package.json
```
@ -67,10 +68,10 @@ services/matrix-nutriphi-bot/
## Image Analysis Flow
1. User sends image to room
2. Bot stores image URL: "Bild empfangen!"
3. User sends `!analyze` or `!analyze Beschreibung`
4. Bot downloads image, sends to NutriPhi API
5. Bot displays nutrition results
2. Bot acknowledges: "Bild empfangen! Analysiere..."
3. Bot downloads image, sends to NutriPhi API for analysis
4. Bot displays nutrition results
5. (Background) Image is stored in mana-media for persistent storage with deduplication
## Environment Variables
@ -91,6 +92,9 @@ NUTRIPHI_API_PREFIX=/api/v1
# Mana Core Auth
MANA_CORE_AUTH_URL=http://localhost:3001
# Mana Media (optional - for persistent image storage)
MANA_MEDIA_URL=http://localhost:3015
# Development bypass (optional)
DEV_BYPASS_AUTH=false
DEV_USER_ID=
@ -108,6 +112,7 @@ docker run -p 3315:3315 \
-e MATRIX_ACCESS_TOKEN=syt_xxx \
-e NUTRIPHI_BACKEND_URL=http://nutriphi-backend:3023 \
-e MANA_CORE_AUTH_URL=http://mana-core-auth:3001 \
-e MANA_MEDIA_URL=http://mana-media:3015 \
-v matrix-nutriphi-bot-data:/app/data \
matrix-nutriphi-bot
```

View file

@ -29,6 +29,7 @@
"dependencies": {
"@manacore/bot-services": "workspace:*",
"@manacore/matrix-bot-common": "workspace:*",
"@manacore/media-client": "workspace:*",
"@nestjs/common": "^10.4.15",
"@nestjs/config": "^3.3.0",
"@nestjs/core": "^10.4.15",

View file

@ -2,6 +2,7 @@ import { Module } from '@nestjs/common';
import { MatrixService } from './matrix.service';
import { NutriPhiModule } from '../nutriphi/nutriphi.module';
import { SessionModule, TranscriptionModule, CreditModule } from '@manacore/bot-services';
import { MediaModule } from '../media/media.module';
@Module({
imports: [
@ -9,6 +10,7 @@ import { SessionModule, TranscriptionModule, CreditModule } from '@manacore/bot-
SessionModule.forRoot({ storageMode: 'redis' }),
TranscriptionModule.forRoot(),
CreditModule.forRoot(),
MediaModule,
],
providers: [MatrixService],
exports: [MatrixService],

View file

@ -14,6 +14,7 @@ import {
WeeklyStats,
} from '../nutriphi/nutriphi.service';
import { SessionService, TranscriptionService, CreditService } from '@manacore/bot-services';
import { MediaService } from '../media/media.service';
import { HELP_MESSAGE, MEAL_TYPE_LABELS } from '../config/configuration';
const PHOTO_ANALYSIS_CREDITS = 3;
@ -36,7 +37,8 @@ export class MatrixService extends BaseMatrixService {
private nutriphiService: NutriPhiService,
private sessionService: SessionService,
private transcriptionService: TranscriptionService,
private creditService: CreditService
private creditService: CreditService,
private mediaService: MediaService
) {
super(configService);
}
@ -114,6 +116,19 @@ Sag "hilfe" fur alle Befehle!`;
const response = this.formatAnalysisResult(result);
await this.sendMessage(roomId, response);
// Store image in mana-media for persistent storage (non-blocking)
// Use Matrix sender ID as user identifier
this.mediaService
.storeFromMatrix(mxcUrl, sender)
.then((mediaResult) => {
if (mediaResult) {
this.logger.log(`Image stored in mana-media: ${mediaResult.id}`);
}
})
.catch((error) => {
this.logger.warn(`Failed to store image in mana-media: ${error}`);
});
} catch (error) {
await this.client.setTyping(roomId, false);
const errorMsg = error instanceof Error ? error.message : 'Unbekannter Fehler';

View file

@ -18,6 +18,9 @@ export default () => ({
stt: {
url: process.env.STT_URL || 'http://localhost:3020',
},
media: {
url: process.env.MANA_MEDIA_URL || 'http://localhost:3015',
},
});
export const HELP_MESSAGE = `**NutriPhi Bot - KI-Ernahrungsassistent**

View file

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

View file

@ -0,0 +1,83 @@
import { Injectable, Logger, OnModuleInit } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { MediaClient, MediaResult } from '@manacore/media-client';
@Injectable()
export class MediaService implements OnModuleInit {
private readonly logger = new Logger(MediaService.name);
private client: MediaClient | null = null;
constructor(private configService: ConfigService) {}
onModuleInit() {
const mediaUrl = this.configService.get<string>('media.url');
if (mediaUrl) {
this.client = new MediaClient(mediaUrl);
this.logger.log(`MediaClient initialized with URL: ${mediaUrl}`);
} else {
this.logger.warn('MANA_MEDIA_URL not configured, media storage disabled');
}
}
/**
* Store an image from a Matrix MXC URL in mana-media.
* Returns the media record if successful, null if disabled or failed.
*/
async storeFromMatrix(mxcUrl: string, userId: string): Promise<MediaResult | null> {
if (!this.client) {
this.logger.debug('Media storage disabled, skipping storage');
return null;
}
try {
const result = await this.client.importFromMatrix({
mxcUrl,
app: 'nutriphi',
userId,
});
this.logger.log(`Stored media from Matrix: ${result.id} (hash: ${result.hash})`);
return result;
} catch (error) {
this.logger.error(`Failed to store media from Matrix: ${error}`);
return null;
}
}
/**
* Check if a file already exists by hash
*/
async checkExists(hash: string): Promise<MediaResult | null> {
if (!this.client) {
return null;
}
try {
return await this.client.getByHash(hash);
} catch {
return null;
}
}
/**
* Get media by ID
*/
async get(id: string): Promise<MediaResult | null> {
if (!this.client) {
return null;
}
try {
return await this.client.get(id);
} catch {
return null;
}
}
/**
* Check if media service is available
*/
isEnabled(): boolean {
return this.client !== null;
}
}