platform-codebase/features/conversation-assistant/server/src/modules/processing/processing.controller.ts
Quinn Ftw 913663922e feat(conversation-assistant): server-side text extraction from attributedBody
Move iMessage text extraction from macOS client to server for better
maintainability. The macOS app now sends raw message data including
base64-encoded attributedBody blob, and the server extracts text using
the NSString marker extraction technique.

Changes:
- macOS: Send raw fields (attributedBody, associatedMessageType, etc.)
- Server: Add ProcessingModule for text extraction
- Server: Add migration for raw data columns
- Server: Use proven NSString marker extraction algorithm

Fixes messages showing as "[Attachment]" by properly parsing the
typedstream binary format used by modern iMessage.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-29 19:00:26 -08:00

176 lines
4.3 KiB
TypeScript

import {
Controller,
Post,
Get,
Param,
Query,
HttpCode,
HttpStatus,
} from '@nestjs/common';
import { ApiTags, ApiOperation, ApiResponse, ApiQuery, ApiParam } from '@nestjs/swagger';
import { Not, IsNull } from 'typeorm';
import { ProcessingService } from './processing.service';
@ApiTags('processing')
@Controller('api/processing')
export class ProcessingController {
constructor(private readonly processingService: ProcessingService) {}
@Post('process')
@HttpCode(HttpStatus.OK)
@ApiOperation({
summary: 'Process unprocessed messages',
description: 'Extract text and determine message types for messages that have not been processed yet',
})
@ApiQuery({
name: 'limit',
required: false,
type: Number,
description: 'Maximum number of messages to process (default: 1000)',
})
@ApiResponse({
status: 200,
description: 'Processing complete',
schema: {
example: {
success: true,
data: {
processed: 150,
errors: 2,
messages: [
{ id: 'uuid-1', messageType: 'text', textExtracted: true },
{ id: 'uuid-2', messageType: 'attachment', textExtracted: false },
],
},
},
},
})
async processMessages(@Query('limit') limit?: string) {
const result = await this.processingService.processUnprocessedMessages(
limit ? parseInt(limit, 10) : 1000,
);
return {
success: true,
data: result,
};
}
@Post('reprocess')
@HttpCode(HttpStatus.OK)
@ApiOperation({
summary: 'Reprocess all messages',
description: 'Clear processing state and reprocess all messages. Use after schema changes or algorithm updates.',
})
@ApiQuery({
name: 'limit',
required: false,
type: Number,
description: 'Maximum number of messages to reprocess (default: 10000)',
})
@ApiResponse({
status: 200,
description: 'Reprocessing complete',
schema: {
example: {
success: true,
data: {
processed: 5000,
errors: 10,
},
},
},
})
async reprocessMessages(@Query('limit') limit?: string) {
const result = await this.processingService.reprocessAllMessages(
limit ? parseInt(limit, 10) : 10000,
);
return {
success: true,
data: result,
};
}
@Post('reprocess/:id')
@HttpCode(HttpStatus.OK)
@ApiOperation({
summary: 'Reprocess a single message',
description: 'Reprocess a specific message by ID',
})
@ApiParam({
name: 'id',
description: 'Message UUID',
format: 'uuid',
})
@ApiResponse({
status: 200,
description: 'Message reprocessed',
schema: {
example: {
success: true,
data: {
id: 'uuid-1',
messageType: 'text',
text: 'Hello world',
processedAt: '2024-01-01T12:00:00Z',
},
},
},
})
@ApiResponse({ status: 404, description: 'Message not found' })
async reprocessMessage(@Param('id') id: string) {
const message = await this.processingService.reprocessMessage(id);
if (!message) {
return {
success: false,
error: { message: 'Message not found' },
};
}
return {
success: true,
data: {
id: message.id,
messageType: message.messageType,
text: message.text,
processedAt: message.processedAt?.toISOString(),
},
};
}
@Get('stats')
@ApiOperation({
summary: 'Get processing statistics',
description: 'Returns counts of processed and unprocessed messages',
})
@ApiResponse({
status: 200,
description: 'Processing statistics',
schema: {
example: {
success: true,
data: {
total: 5000,
processed: 4950,
unprocessed: 50,
},
},
},
})
async getStats() {
// This would need a separate method in the service, but for now we'll inline it
const { processingService } = this;
const total = await processingService['messageRepository'].count();
const processed = await processingService['messageRepository'].count({
where: { processedAt: Not(IsNull()) },
});
const unprocessed = total - processed;
return {
success: true,
data: {
total,
processed,
unprocessed,
},
};
}
}