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

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