986 lines
30 KiB
PHP
986 lines
30 KiB
PHP
<?php
|
|
namespace Domain\Tasks;
|
|
|
|
class MailToTaskImporter
|
|
{
|
|
private const ALLOWED_SENDER = 'jacek.pyziak@project-pro.pl';
|
|
private const TASK_USER_ID = 1;
|
|
private const TASK_STATUS_ID = 5;
|
|
private const TASK_PROJECT_ID = 72;
|
|
|
|
private $mdb;
|
|
private $attachments;
|
|
private $last_ai_error;
|
|
|
|
public function __construct( $mdb = null )
|
|
{
|
|
if ( $mdb )
|
|
$this -> mdb = $mdb;
|
|
else
|
|
{
|
|
global $mdb;
|
|
$this -> mdb = $mdb;
|
|
}
|
|
|
|
$this -> attachments = new TaskAttachmentRepository( $this -> mdb );
|
|
$this -> last_ai_error = '';
|
|
}
|
|
|
|
public function importFromImap( array $config )
|
|
{
|
|
if ( !extension_loaded( 'imap' ) )
|
|
return [ 'status' => 'error', 'msg' => 'Brak rozszerzenia IMAP w PHP.' ];
|
|
|
|
$mailbox = $this -> buildMailbox( $config );
|
|
$username = isset( $config['username'] ) ? (string)$config['username'] : '';
|
|
$password = isset( $config['password'] ) ? (string)$config['password'] : '';
|
|
|
|
if ( $mailbox === '' or $username === '' or $password === '' )
|
|
return [ 'status' => 'error', 'msg' => 'Brak konfiguracji skrzynki IMAP.' ];
|
|
|
|
$imap = @imap_open( $mailbox, $username, $password );
|
|
if ( !$imap )
|
|
return [ 'status' => 'error', 'msg' => 'Nie udalo sie polaczyc z IMAP: ' . imap_last_error() ];
|
|
|
|
$this -> ensureImportTable();
|
|
|
|
$message_numbers = imap_search( $imap, 'ALL' );
|
|
if ( !is_array( $message_numbers ) or !count( $message_numbers ) )
|
|
{
|
|
imap_close( $imap );
|
|
return [ 'status' => 'empty', 'msg' => 'Brak wiadomosci do importu.' ];
|
|
}
|
|
|
|
sort( $message_numbers, SORT_NUMERIC );
|
|
|
|
$imported = 0;
|
|
$skipped = 0;
|
|
$errors = 0;
|
|
$first_error_msg = null;
|
|
|
|
foreach ( $message_numbers as $message_no )
|
|
{
|
|
$header = @imap_headerinfo( $imap, $message_no );
|
|
if ( !$header )
|
|
{
|
|
$errors++;
|
|
continue;
|
|
}
|
|
|
|
$sender = $this -> extractSender( $header );
|
|
$subject = $this -> decodeHeaderValue( isset( $header -> subject ) ? $header -> subject : '' );
|
|
$received_at = $this -> parseReceivedDate( isset( $header -> date ) ? $header -> date : '' );
|
|
$message_key = $this -> messageKey( $header, $sender, $subject, $message_no );
|
|
|
|
if ( $this -> isMessageFinalized( $message_key ) )
|
|
{
|
|
$skipped++;
|
|
@imap_delete( $imap, $message_no );
|
|
continue;
|
|
}
|
|
|
|
if ( $sender !== self::ALLOWED_SENDER )
|
|
{
|
|
$this -> saveImportLog( $message_key, null, $sender, $subject, 'skipped_sender', null );
|
|
$skipped++;
|
|
@imap_delete( $imap, $message_no );
|
|
continue;
|
|
}
|
|
|
|
try
|
|
{
|
|
$content = $this -> extractMessageContent( $imap, $message_no );
|
|
$ai_used = false;
|
|
$ai_error_for_log = null;
|
|
|
|
// Sprawdź czy użyć AI do parsowania
|
|
global $settings;
|
|
$use_ai = isset( $settings['openai_parse_emails'] ) && $settings['openai_parse_emails'] === true;
|
|
$api_key = isset( $settings['openai_api_key'] ) ? trim( (string)$settings['openai_api_key'] ) : '';
|
|
if ( $use_ai && $api_key === '' )
|
|
{
|
|
$ai_error_for_log = 'Brak klucza API OpenAI.';
|
|
error_log( '[MailToTaskImporter] AI fallback: ' . $ai_error_for_log );
|
|
}
|
|
|
|
if ( $use_ai && $api_key !== '' )
|
|
{
|
|
$model = isset( $settings['openai_model'] ) ? trim( (string)$settings['openai_model'] ) : 'gpt-4o-mini';
|
|
$ai_result = $this -> parseWithAI( $api_key, $model, $subject, $content['text'] );
|
|
if ( is_array( $ai_result ) && isset( $ai_result['task_name'] ) && isset( $ai_result['task_text'] ) )
|
|
{
|
|
$task_name = $ai_result['task_name'];
|
|
$task_text = $ai_result['task_text'];
|
|
$ai_used = true;
|
|
}
|
|
else
|
|
{
|
|
// Fallback do normalnego parsowania jeśli AI nie zadziała
|
|
if ( $this -> last_ai_error !== '' )
|
|
{
|
|
error_log( '[MailToTaskImporter] AI fallback: ' . $this -> last_ai_error );
|
|
$ai_error_for_log = $this -> last_ai_error;
|
|
}
|
|
|
|
$task_name = trim( $subject ) !== '' ? trim( $subject ) : '(bez tematu)';
|
|
$task_text = $this -> prepareImportedTaskText( $content['text'] );
|
|
}
|
|
}
|
|
else
|
|
{
|
|
// Normalne parsowanie
|
|
$task_name = trim( $subject ) !== '' ? trim( $subject ) : '(bez tematu)';
|
|
$task_text = $this -> prepareImportedTaskText( $content['text'] );
|
|
}
|
|
$client_id = $this -> resolveClientIdBySenderDomain( $sender );
|
|
|
|
$task_id = \factory\Tasks::task_save(
|
|
null,
|
|
null,
|
|
self::TASK_USER_ID,
|
|
$task_name,
|
|
$task_text,
|
|
$received_at,
|
|
$received_at,
|
|
self::TASK_PROJECT_ID,
|
|
$client_id,
|
|
null,
|
|
null,
|
|
'off',
|
|
0,
|
|
0,
|
|
[ self::TASK_USER_ID ],
|
|
null,
|
|
null,
|
|
false,
|
|
'off',
|
|
false,
|
|
self::TASK_STATUS_ID,
|
|
'on',
|
|
0
|
|
);
|
|
|
|
if ( !$task_id )
|
|
{
|
|
$db_error = $this -> mdb -> error();
|
|
$db_error_text = is_array( $db_error ) ? implode( ' | ', array_filter( $db_error ) ) : '';
|
|
$error_msg = 'Nie udalo sie utworzyc zadania.' . ( $db_error_text !== '' ? ' DB: ' . $db_error_text : '' );
|
|
$this -> saveImportLog( $message_key, null, $sender, $subject, 'error', $error_msg );
|
|
if ( $first_error_msg === null )
|
|
$first_error_msg = $error_msg;
|
|
$errors++;
|
|
continue;
|
|
}
|
|
|
|
foreach ( $content['attachments'] as $attachment )
|
|
{
|
|
$this -> attachments -> uploadFromContent( $task_id, self::TASK_USER_ID, $attachment['name'], $attachment['content'] );
|
|
}
|
|
|
|
$import_status = 'imported';
|
|
$import_error = null;
|
|
if ( $use_ai && $ai_used )
|
|
$import_status = 'imported_ai';
|
|
elseif ( $use_ai && !$ai_used )
|
|
{
|
|
$import_status = 'imported_fallback';
|
|
$import_error = $ai_error_for_log;
|
|
}
|
|
|
|
$this -> saveImportLog( $message_key, $task_id, $sender, $subject, $import_status, $import_error );
|
|
$imported++;
|
|
}
|
|
catch ( \Throwable $e )
|
|
{
|
|
$this -> saveImportLog( $message_key, null, $sender, $subject, 'error', $e -> getMessage() );
|
|
if ( $first_error_msg === null )
|
|
$first_error_msg = $e -> getMessage();
|
|
$errors++;
|
|
}
|
|
|
|
$status_tmp = $this -> getImportStatus( $message_key );
|
|
if ( in_array( $status_tmp, [ 'imported', 'imported_ai', 'imported_fallback', 'skipped_sender' ], true ) )
|
|
@imap_delete( $imap, $message_no );
|
|
}
|
|
|
|
@imap_expunge( $imap );
|
|
@imap_close( $imap );
|
|
|
|
if ( $imported > 0 )
|
|
return [ 'status' => 'ok', 'msg' => 'Zaimportowano: ' . $imported . ', pominieto: ' . $skipped . ', bledy: ' . $errors . '.' ];
|
|
|
|
if ( $errors > 0 )
|
|
{
|
|
$msg = 'Brak nowych zadan. Bledy: ' . $errors . '.';
|
|
if ( $first_error_msg )
|
|
$msg .= ' Ostatni blad: ' . $first_error_msg;
|
|
return [ 'status' => 'error', 'msg' => $msg ];
|
|
}
|
|
|
|
return [ 'status' => 'empty', 'msg' => 'Brak nowych zadan. Pominieto: ' . $skipped . '.' ];
|
|
}
|
|
|
|
private function buildMailbox( array $config )
|
|
{
|
|
$mailbox = isset( $config['mailbox'] ) ? trim( (string)$config['mailbox'] ) : '';
|
|
if ( $mailbox !== '' )
|
|
return $mailbox;
|
|
|
|
$host = isset( $config['host'] ) ? trim( (string)$config['host'] ) : '';
|
|
if ( $host === '' )
|
|
return '';
|
|
|
|
$port = isset( $config['port'] ) ? (int)$config['port'] : 993;
|
|
$flags = isset( $config['flags'] ) ? trim( (string)$config['flags'] ) : '/imap/ssl';
|
|
$folder = isset( $config['folder'] ) ? trim( (string)$config['folder'] ) : 'INBOX';
|
|
|
|
return '{' . $host . ':' . $port . $flags . '}' . $folder;
|
|
}
|
|
|
|
private function resolveClientIdBySenderDomain( $sender )
|
|
{
|
|
$domain = $this -> extractDomainFromEmail( $sender );
|
|
if ( $domain === '' )
|
|
return null;
|
|
|
|
$rows = $this -> mdb -> select( 'crm_client', [ 'id', 'emails' ], [
|
|
'emails[!]' => null
|
|
] );
|
|
|
|
if ( !is_array( $rows ) or !count( $rows ) )
|
|
return null;
|
|
|
|
foreach ( $rows as $row )
|
|
{
|
|
$emails = $this -> parseEmailsField( isset( $row['emails'] ) ? $row['emails'] : '' );
|
|
foreach ( $emails as $email )
|
|
{
|
|
$email_domain = $this -> extractDomainFromEmail( $email );
|
|
if ( $email_domain !== '' and $email_domain === $domain )
|
|
return (int)$row['id'];
|
|
}
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
private function parseEmailsField( $emails_raw )
|
|
{
|
|
$emails_raw = (string)$emails_raw;
|
|
if ( trim( $emails_raw ) === '' )
|
|
return [];
|
|
|
|
preg_match_all( '/[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}/i', $emails_raw, $matches );
|
|
if ( isset( $matches[0] ) and is_array( $matches[0] ) and count( $matches[0] ) )
|
|
return array_values( array_unique( array_map( 'strtolower', $matches[0] ) ) );
|
|
|
|
return [];
|
|
}
|
|
|
|
private function extractDomainFromEmail( $email )
|
|
{
|
|
$email = strtolower( trim( (string)$email ) );
|
|
if ( $email === '' or strpos( $email, '@' ) === false )
|
|
return '';
|
|
|
|
$parts = explode( '@', $email );
|
|
$domain = trim( end( $parts ) );
|
|
return $domain !== '' ? $domain : '';
|
|
}
|
|
|
|
private function parseReceivedDate( $value )
|
|
{
|
|
$timestamp = strtotime( (string)$value );
|
|
if ( $timestamp === false )
|
|
return date( 'Y-m-d' );
|
|
|
|
return date( 'Y-m-d', $timestamp );
|
|
}
|
|
|
|
private function extractSender( $header )
|
|
{
|
|
if ( !isset( $header -> from ) or !is_array( $header -> from ) or !isset( $header -> from[0] ) )
|
|
return '';
|
|
|
|
$item = $header -> from[0];
|
|
$mailbox = isset( $item -> mailbox ) ? strtolower( trim( $item -> mailbox ) ) : '';
|
|
$host = isset( $item -> host ) ? strtolower( trim( $item -> host ) ) : '';
|
|
|
|
if ( $mailbox === '' or $host === '' )
|
|
return '';
|
|
|
|
return $mailbox . '@' . $host;
|
|
}
|
|
|
|
private function decodeHeaderValue( $value )
|
|
{
|
|
$value = (string)$value;
|
|
if ( $value === '' )
|
|
return '';
|
|
|
|
$chunks = @imap_mime_header_decode( $value );
|
|
if ( !is_array( $chunks ) or !count( $chunks ) )
|
|
return trim( $value );
|
|
|
|
$decoded = '';
|
|
foreach ( $chunks as $chunk )
|
|
$decoded .= isset( $chunk -> text ) ? $chunk -> text : '';
|
|
|
|
return trim( $decoded );
|
|
}
|
|
|
|
private function messageKey( $header, $sender, $subject, $message_no )
|
|
{
|
|
$message_id = '';
|
|
if ( isset( $header -> message_id ) and trim( (string)$header -> message_id ) !== '' )
|
|
$message_id = trim( (string)$header -> message_id );
|
|
|
|
if ( $message_id === '' )
|
|
$message_id = sha1( $sender . '|' . $subject . '|' . ( isset( $header -> date ) ? $header -> date : '' ) . '|' . $message_no );
|
|
|
|
return $message_id;
|
|
}
|
|
|
|
private function isMessageFinalized( $message_key )
|
|
{
|
|
$status = $this -> getImportStatus( $message_key );
|
|
return in_array( $status, [ 'imported', 'imported_ai', 'imported_fallback', 'skipped_sender' ], true );
|
|
}
|
|
|
|
private function getImportStatus( $message_key )
|
|
{
|
|
return $this -> mdb -> get( 'tasks_mail_import', 'status', [ 'message_key' => (string)$message_key ] );
|
|
}
|
|
|
|
private function saveImportLog( $message_key, $task_id, $sender, $subject, $status, $error )
|
|
{
|
|
$data = [
|
|
'task_id' => $task_id ? (int)$task_id : null,
|
|
'sender' => substr( (string)$sender, 0, 255 ),
|
|
'subject' => substr( (string)$subject, 0, 255 ),
|
|
'status' => substr( (string)$status, 0, 40 ),
|
|
'error_msg' => $error !== null ? (string)$error : null,
|
|
'date_add' => date( 'Y-m-d H:i:s' )
|
|
];
|
|
|
|
if ( $this -> mdb -> count( 'tasks_mail_import', [ 'message_key' => (string)$message_key ] ) )
|
|
$this -> mdb -> update( 'tasks_mail_import', $data, [ 'message_key' => (string)$message_key ] );
|
|
else
|
|
$this -> mdb -> insert( 'tasks_mail_import', array_merge( [ 'message_key' => (string)$message_key ], $data ) );
|
|
}
|
|
|
|
private function ensureImportTable()
|
|
{
|
|
$this -> mdb -> query(
|
|
'CREATE TABLE IF NOT EXISTS `tasks_mail_import` (
|
|
`id` INT NOT NULL AUTO_INCREMENT,
|
|
`message_key` VARCHAR(255) NOT NULL,
|
|
`task_id` INT NULL,
|
|
`sender` VARCHAR(255) NULL,
|
|
`subject` VARCHAR(255) NULL,
|
|
`status` VARCHAR(40) NOT NULL,
|
|
`error_msg` TEXT NULL,
|
|
`date_add` DATETIME NOT NULL,
|
|
PRIMARY KEY (`id`),
|
|
UNIQUE KEY `uk_tasks_mail_import_message_key` (`message_key`),
|
|
INDEX `idx_tasks_mail_import_task` (`task_id`)
|
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8'
|
|
);
|
|
}
|
|
|
|
private function extractMessageContent( $imap, $message_no )
|
|
{
|
|
$structure = @imap_fetchstructure( $imap, $message_no );
|
|
|
|
$parts = [];
|
|
$this -> flattenParts( $imap, $message_no, $structure, '', $parts );
|
|
|
|
$plain = '';
|
|
$html = '';
|
|
$attachment_candidates = [];
|
|
|
|
foreach ( $parts as $part )
|
|
{
|
|
if ( $part['mime'] === 'text/plain' and $plain === '' )
|
|
$plain = $part['body'];
|
|
|
|
if ( $part['mime'] === 'text/html' and $html === '' )
|
|
$html = $part['body'];
|
|
|
|
if ( $part['is_attachment'] )
|
|
$attachment_candidates[] = $part;
|
|
}
|
|
|
|
$cid_refs = $this -> extractReferencedCidValues( $html );
|
|
$attachments = [];
|
|
foreach ( $attachment_candidates as $part )
|
|
{
|
|
if ( !$this -> shouldImportAttachment( $part, $cid_refs ) )
|
|
continue;
|
|
|
|
$attachments[] = [
|
|
'name' => $part['name'],
|
|
'content' => $part['body']
|
|
];
|
|
}
|
|
|
|
$text = $plain !== '' ? $plain : $this -> htmlToText( $html );
|
|
$text = $this -> cleanBodyText( $text );
|
|
|
|
return [
|
|
'text' => $text,
|
|
'attachments' => $attachments
|
|
];
|
|
}
|
|
|
|
private function flattenParts( $imap, $message_no, $structure, $prefix, array &$parts )
|
|
{
|
|
if ( !$structure )
|
|
{
|
|
$parts[] = [
|
|
'mime' => 'text/plain',
|
|
'body' => '',
|
|
'name' => '',
|
|
'is_attachment' => false,
|
|
'is_inline' => false
|
|
];
|
|
return;
|
|
}
|
|
|
|
if ( !isset( $structure -> parts ) or !is_array( $structure -> parts ) or !count( $structure -> parts ) )
|
|
{
|
|
$part_number = $prefix !== '' ? $prefix : null;
|
|
$parts[] = $this -> parseSinglePart( $imap, $message_no, $structure, $part_number );
|
|
return;
|
|
}
|
|
|
|
foreach ( $structure -> parts as $index => $subpart )
|
|
{
|
|
$part_number = $prefix === '' ? (string)( $index + 1 ) : $prefix . '.' . ( $index + 1 );
|
|
if ( isset( $subpart -> parts ) and is_array( $subpart -> parts ) and count( $subpart -> parts ) )
|
|
$this -> flattenParts( $imap, $message_no, $subpart, $part_number, $parts );
|
|
else
|
|
$parts[] = $this -> parseSinglePart( $imap, $message_no, $subpart, $part_number );
|
|
}
|
|
}
|
|
|
|
private function parseSinglePart( $imap, $message_no, $part, $part_number )
|
|
{
|
|
$raw = '';
|
|
if ( $part_number === null )
|
|
$raw = (string)@imap_body( $imap, $message_no, FT_PEEK );
|
|
else
|
|
$raw = (string)@imap_fetchbody( $imap, $message_no, $part_number, FT_PEEK );
|
|
|
|
$mime = $this -> mimeType( $part );
|
|
$decoded = $this -> decodePartBody( $raw, isset( $part -> encoding ) ? (int)$part -> encoding : 0 );
|
|
if ( strpos( $mime, 'text/' ) === 0 )
|
|
$decoded = str_replace( [ "\r\n", "\r" ], "\n", (string)$decoded );
|
|
|
|
$params = $this -> partParams( $part );
|
|
|
|
$name = '';
|
|
if ( isset( $params['filename'] ) )
|
|
$name = $params['filename'];
|
|
elseif ( isset( $params['name'] ) )
|
|
$name = $params['name'];
|
|
|
|
$name = $this -> decodeHeaderValue( $name );
|
|
|
|
$disposition = isset( $part -> disposition ) ? strtoupper( trim( (string)$part -> disposition ) ) : '';
|
|
$content_id = '';
|
|
if ( isset( $part -> id ) and trim( (string)$part -> id ) !== '' )
|
|
$content_id = $this -> normalizeContentId( (string)$part -> id );
|
|
elseif ( isset( $params['content-id'] ) and trim( (string)$params['content-id'] ) !== '' )
|
|
$content_id = $this -> normalizeContentId( (string)$params['content-id'] );
|
|
|
|
$is_inline = $disposition === 'INLINE' or $content_id !== '';
|
|
$is_attachment = trim( $name ) !== '' or $disposition === 'ATTACHMENT';
|
|
|
|
return [
|
|
'mime' => $mime,
|
|
'body' => $decoded,
|
|
'name' => $name,
|
|
'is_attachment' => $is_attachment,
|
|
'is_inline' => $is_inline,
|
|
'disposition' => $disposition,
|
|
'content_id' => $content_id
|
|
];
|
|
}
|
|
|
|
private function partParams( $part )
|
|
{
|
|
$params = [];
|
|
|
|
if ( isset( $part -> parameters ) and is_array( $part -> parameters ) )
|
|
{
|
|
foreach ( $part -> parameters as $param )
|
|
{
|
|
if ( isset( $param -> attribute ) )
|
|
$params[ strtolower( (string)$param -> attribute ) ] = isset( $param -> value ) ? (string)$param -> value : '';
|
|
}
|
|
}
|
|
|
|
if ( isset( $part -> dparameters ) and is_array( $part -> dparameters ) )
|
|
{
|
|
foreach ( $part -> dparameters as $param )
|
|
{
|
|
if ( isset( $param -> attribute ) )
|
|
$params[ strtolower( (string)$param -> attribute ) ] = isset( $param -> value ) ? (string)$param -> value : '';
|
|
}
|
|
}
|
|
|
|
return $params;
|
|
}
|
|
|
|
private function decodePartBody( $raw, $encoding )
|
|
{
|
|
if ( $encoding === 3 )
|
|
return (string)base64_decode( $raw );
|
|
|
|
if ( $encoding === 4 )
|
|
return (string)quoted_printable_decode( $raw );
|
|
|
|
return (string)$raw;
|
|
}
|
|
|
|
private function mimeType( $part )
|
|
{
|
|
$primary = [
|
|
0 => 'text',
|
|
1 => 'multipart',
|
|
2 => 'message',
|
|
3 => 'application',
|
|
4 => 'audio',
|
|
5 => 'image',
|
|
6 => 'video',
|
|
7 => 'other'
|
|
];
|
|
|
|
$type = isset( $part -> type ) ? (int)$part -> type : 0;
|
|
$subtype = isset( $part -> subtype ) ? strtolower( (string)$part -> subtype ) : 'plain';
|
|
|
|
return ( isset( $primary[$type] ) ? $primary[$type] : 'text' ) . '/' . $subtype;
|
|
}
|
|
|
|
private function htmlToText( $html )
|
|
{
|
|
$html = (string)$html;
|
|
if ( trim( $html ) === '' )
|
|
return '';
|
|
|
|
$html = preg_replace( '/<style\b[^>]*>.*?<\/style>/is', ' ', $html );
|
|
$html = preg_replace( '/<script\b[^>]*>.*?<\/script>/is', ' ', $html );
|
|
$html = preg_replace( '/<blockquote\b[^>]*>.*?<\/blockquote>/is', ' ', $html );
|
|
$html = preg_replace( '/<div[^>]*class="[^"]*(gmail_quote|gmail_signature)[^"]*"[^>]*>.*?<\/div>/is', ' ', $html );
|
|
$html = preg_replace( '/<br\s*\/?>/i', "\n", $html );
|
|
$html = preg_replace( '/<\/(p|div|li|tr|h[1-6])>/i', "\n", $html );
|
|
$text = html_entity_decode( strip_tags( $html ), ENT_QUOTES | ENT_HTML5, 'UTF-8' );
|
|
|
|
return $text;
|
|
}
|
|
|
|
private function cleanBodyText( $text )
|
|
{
|
|
$text = str_replace( [ "\r\n", "\r" ], "\n", (string)$text );
|
|
$text = preg_replace( '/\x{00a0}/u', ' ', $text );
|
|
|
|
$lines = explode( "\n", $text );
|
|
$clean = [];
|
|
|
|
foreach ( $lines as $line )
|
|
{
|
|
$line_trim = rtrim( $line );
|
|
|
|
if ( preg_match( '/^\s*>+/', $line_trim ) )
|
|
break;
|
|
|
|
if ( preg_match( '/^\s*On\s.+wrote:\s*$/i', $line_trim ) )
|
|
break;
|
|
|
|
if ( preg_match( '/^\s*W\s+dniu\s+.+napisal\(a\):\s*$/iu', $line_trim ) )
|
|
break;
|
|
|
|
if ( preg_match( '/^\s*-----Original Message-----\s*$/i', $line_trim ) )
|
|
break;
|
|
|
|
if ( preg_match( '/^\s*Od:\s.+$/iu', $line_trim ) )
|
|
break;
|
|
|
|
if ( preg_match( '/^\s*From:\s.+$/i', $line_trim ) )
|
|
break;
|
|
|
|
if ( preg_match( '/^\s*Sent:\s.+$/i', $line_trim ) )
|
|
break;
|
|
|
|
if ( preg_match( '/^\s*--\s*$/', $line_trim ) )
|
|
break;
|
|
|
|
$clean[] = $line_trim;
|
|
}
|
|
|
|
$text = trim( implode( "\n", $clean ) );
|
|
|
|
return trim( $text );
|
|
}
|
|
|
|
private function prepareImportedTaskText( $text )
|
|
{
|
|
$text = trim( (string)$text );
|
|
if ( $text === '' )
|
|
return '(brak tresci)';
|
|
|
|
if ( preg_match( '/<\s*[a-z][^>]*>/i', $text ) )
|
|
{
|
|
$text = $this -> htmlToText( $text );
|
|
$text = $this -> cleanBodyText( $text );
|
|
}
|
|
|
|
$text = str_replace( [ "\r\n", "\r" ], "\n", $text );
|
|
$text = trim( $text );
|
|
|
|
if ( $text === '' )
|
|
return '(brak tresci)';
|
|
|
|
return nl2br( $text );
|
|
}
|
|
|
|
private function shouldImportAttachment( array $part, array $cid_refs )
|
|
{
|
|
$name = trim( isset( $part['name'] ) ? (string)$part['name'] : '' );
|
|
if ( $name === '' )
|
|
return false;
|
|
|
|
$mime = strtolower( isset( $part['mime'] ) ? (string)$part['mime'] : '' );
|
|
$disposition = strtoupper( isset( $part['disposition'] ) ? (string)$part['disposition'] : '' );
|
|
$content_id = $this -> normalizeContentId( isset( $part['content_id'] ) ? (string)$part['content_id'] : '' );
|
|
|
|
if ( !empty( $part['is_inline'] ) )
|
|
return false;
|
|
|
|
if ( $mime === 'text/plain' or $mime === 'text/html' )
|
|
return false;
|
|
|
|
if ( $content_id !== '' and isset( $cid_refs[$content_id] ) )
|
|
return false;
|
|
|
|
if ( strpos( $mime, 'image/' ) === 0 and $disposition !== 'ATTACHMENT' )
|
|
return false;
|
|
|
|
return true;
|
|
}
|
|
|
|
private function extractReferencedCidValues( $html )
|
|
{
|
|
$html = (string)$html;
|
|
if ( trim( $html ) === '' )
|
|
return [];
|
|
|
|
preg_match_all( '/cid:([^"\'>\s]+)/i', $html, $matches );
|
|
if ( !isset( $matches[1] ) or !is_array( $matches[1] ) or !count( $matches[1] ) )
|
|
return [];
|
|
|
|
$refs = [];
|
|
foreach ( $matches[1] as $raw )
|
|
{
|
|
$cid = $this -> normalizeContentId( $raw );
|
|
if ( $cid !== '' )
|
|
$refs[$cid] = true;
|
|
}
|
|
|
|
return $refs;
|
|
}
|
|
|
|
private function normalizeContentId( $value )
|
|
{
|
|
$value = trim( (string)$value );
|
|
if ( $value === '' )
|
|
return '';
|
|
|
|
$value = trim( $value, '<>' );
|
|
return strtolower( $value );
|
|
}
|
|
|
|
private function parseWithAI( $api_key, $model, $subject, $raw_content, $attempt = 1 )
|
|
{
|
|
$this -> last_ai_error = '';
|
|
$api_key = trim( (string)$api_key );
|
|
$subject = trim( (string)$subject );
|
|
$raw_content = trim( (string)$raw_content );
|
|
$model = trim( (string)$model );
|
|
|
|
if ( $api_key === '' )
|
|
{
|
|
$this -> last_ai_error = 'Brak klucza API OpenAI.';
|
|
return null;
|
|
}
|
|
|
|
if ( $model === '' )
|
|
$model = 'gpt-4o-mini';
|
|
|
|
if ( $raw_content === '' )
|
|
{
|
|
$this -> last_ai_error = 'Pusta tresc emaila do analizy AI.';
|
|
return null;
|
|
}
|
|
|
|
if ( !function_exists( 'curl_init' ) )
|
|
{
|
|
$this -> last_ai_error = 'Brak rozszerzenia cURL.';
|
|
return null;
|
|
}
|
|
|
|
// Przygotuj treść do analizy (usuń HTML jesli jest)
|
|
if ( preg_match( '/<\s*[a-z][^>]*>/i', $raw_content ) )
|
|
{
|
|
$raw_content = $this -> htmlToText( $raw_content );
|
|
$raw_content = $this -> cleanBodyText( $raw_content );
|
|
}
|
|
|
|
$content_limit = $attempt > 1 ? 1200 : 1800;
|
|
$prompt = "Jesteś asystentem do przetwarzania emaili na zadania w systemie CRM. " .
|
|
"Na podstawie poniższego tematu i treści emaila wygeneruj zadanie wdrożeniowe, NIE podsumowanie. " .
|
|
"Opis ma być szczegółową listą rzeczy do wykonania dla zespołu.\n\n" .
|
|
"Wymagania:\n" .
|
|
"- Nie pomijaj żadnych wymagań, liczb, zakresów cen, CTA, pól formularza, kroków i terminów.\n" .
|
|
"- Zamień treść emaila na checklistę implementacyjną.\n" .
|
|
"- Zachowaj strukturę sekcji i podsekcji.\n" .
|
|
"- Pisz konkretnie co zmienić, gdzie zmienić i jakie treści dodać.\n" .
|
|
"- Nie używaj ogólników typu: \"zaktualizować stronę\".\n\n" .
|
|
"Format pola task_text:\n" .
|
|
"1) Cel zmiany\n" .
|
|
"2) Zakres zmian (lista punktów)\n" .
|
|
"3) Treści do wstawienia (dokładne teksty/liczby)\n" .
|
|
"4) Zmiany formularza\n" .
|
|
"5) Wersja sezonowa / warunki wyświetlania\n" .
|
|
"6) CTA i bonusy\n" .
|
|
"7) Kryteria akceptacji\n\n" .
|
|
"Temat emaila: " . $subject . "\n\n" .
|
|
"Treść emaila:\n" . mb_substr( $raw_content, 0, $content_limit ) . "\n\n" .
|
|
"Zwróć TYLKO poprawny JSON:\n" .
|
|
'{"task_name": "krótki temat zadania", "task_text": "szczegółowa checklista wdrożeniowa"}';
|
|
|
|
$payload = [
|
|
'model' => $model,
|
|
'messages' => [
|
|
[
|
|
'role' => 'system',
|
|
'content' => 'Jesteś asystentem do przetwarzania emaili. Odpowiadasz TYLKO w formacie JSON i tworzysz szczegółowe checklisty wdrożeniowe, nie skróty.'
|
|
],
|
|
[
|
|
'role' => 'user',
|
|
'content' => $prompt
|
|
]
|
|
]
|
|
];
|
|
|
|
if ( stripos( $model, 'gpt-5' ) === 0 )
|
|
$payload['response_format'] = [ 'type' => 'json_object' ];
|
|
|
|
if ( stripos( $model, 'gpt-5' ) === 0 )
|
|
$payload['max_completion_tokens'] = $attempt > 1 ? 1600 : 1000;
|
|
else
|
|
{
|
|
$payload['temperature'] = 0.3;
|
|
$payload['max_tokens'] = 1200;
|
|
}
|
|
|
|
$ch = curl_init( 'https://api.openai.com/v1/chat/completions' );
|
|
curl_setopt_array( $ch, [
|
|
CURLOPT_RETURNTRANSFER => true,
|
|
CURLOPT_POST => true,
|
|
CURLOPT_POSTFIELDS => json_encode( $payload ),
|
|
CURLOPT_HTTPHEADER => [
|
|
'Content-Type: application/json',
|
|
'Authorization: Bearer ' . $api_key
|
|
],
|
|
CURLOPT_TIMEOUT => 30
|
|
] );
|
|
|
|
$response = curl_exec( $ch );
|
|
$curl_error = curl_error( $ch );
|
|
$http_code = curl_getinfo( $ch, CURLINFO_HTTP_CODE );
|
|
curl_close( $ch );
|
|
|
|
if ( $response === false )
|
|
{
|
|
$this -> last_ai_error = 'Blad cURL: ' . $curl_error;
|
|
return null;
|
|
}
|
|
|
|
if ( $http_code !== 200 )
|
|
{
|
|
$this -> last_ai_error = 'HTTP ' . $http_code . ' z OpenAI: ' . $this -> extractApiErrorMessage( $response );
|
|
return null;
|
|
}
|
|
|
|
if ( !$response )
|
|
{
|
|
$this -> last_ai_error = 'Pusta odpowiedz z OpenAI.';
|
|
return null;
|
|
}
|
|
|
|
$data = json_decode( $response, true );
|
|
if ( !is_array( $data ) )
|
|
{
|
|
$this -> last_ai_error = 'Niepoprawny JSON z OpenAI: ' . json_last_error_msg();
|
|
return null;
|
|
}
|
|
|
|
if ( isset( $data['error']['message'] ) )
|
|
{
|
|
$this -> last_ai_error = 'OpenAI error: ' . (string)$data['error']['message'];
|
|
return null;
|
|
}
|
|
|
|
if ( !isset( $data['choices'][0] ) || !is_array( $data['choices'][0] ) )
|
|
{
|
|
$this -> last_ai_error = 'Brak choices[0] w odpowiedzi OpenAI. RAW: ' . $this -> clipForLog( $response );
|
|
return null;
|
|
}
|
|
|
|
$choice = $data['choices'][0];
|
|
$content = $this -> extractModelMessageContent( $choice );
|
|
if ( $content === '' )
|
|
{
|
|
$finish_reason = isset( $choice['finish_reason'] ) ? (string)$choice['finish_reason'] : '(brak)';
|
|
$refusal = isset( $choice['message']['refusal'] ) ? (string)$choice['message']['refusal'] : '';
|
|
|
|
if ( $finish_reason === 'length' && (int)$attempt === 1 )
|
|
{
|
|
$retry_content = mb_substr( $raw_content, 0, 1200 );
|
|
return $this -> parseWithAI( $api_key, $model, $subject, $retry_content, 2 );
|
|
}
|
|
|
|
$error = 'Pusta tresc odpowiedzi modelu. finish_reason=' . $finish_reason;
|
|
if ( $refusal !== '' )
|
|
$error .= ', refusal=' . $this -> clipForLog( $refusal );
|
|
|
|
$this -> last_ai_error = $error . '. RAW: ' . $this -> clipForLog( $response );
|
|
return null;
|
|
}
|
|
|
|
// Usuń markdown code block jeśli jest
|
|
$content = preg_replace( '/```json\s*|\s*```/', '', $content );
|
|
$content = trim( $content );
|
|
|
|
// Jeśli model zwrócił JSON z komentarzem wokół, wyciągnij tylko obiekt.
|
|
$json_start = strpos( $content, '{' );
|
|
$json_end = strrpos( $content, '}' );
|
|
if ( $json_start !== false && $json_end !== false && $json_end > $json_start )
|
|
$content = substr( $content, $json_start, $json_end - $json_start + 1 );
|
|
|
|
$parsed = json_decode( $content, true );
|
|
if ( !is_array( $parsed ) || !isset( $parsed['task_name'] ) || !isset( $parsed['task_text'] ) )
|
|
{
|
|
$this -> last_ai_error = 'Niepoprawny JSON z modelu. Odpowiedz: ' . $this -> clipForLog( $content );
|
|
return null;
|
|
}
|
|
|
|
$task_name = $this -> normalizeAiTextValue( $parsed['task_name'] );
|
|
$task_text = $this -> normalizeAiTextValue( $parsed['task_text'] );
|
|
|
|
if ( $task_name === '' )
|
|
$task_name = '(bez tematu)';
|
|
|
|
if ( $task_text === '' )
|
|
$task_text = '(brak treści)';
|
|
|
|
// Formatuj treść zadania z nl2br
|
|
$task_text = nl2br( $task_text );
|
|
|
|
return [
|
|
'task_name' => $task_name,
|
|
'task_text' => $task_text
|
|
];
|
|
}
|
|
|
|
private function extractApiErrorMessage( $response )
|
|
{
|
|
$data = json_decode( (string)$response, true );
|
|
if ( is_array( $data ) && isset( $data['error']['message'] ) )
|
|
return (string)$data['error']['message'];
|
|
|
|
return $this -> clipForLog( $response );
|
|
}
|
|
|
|
private function extractModelMessageContent( array $choice )
|
|
{
|
|
if ( isset( $choice['message']['content'] ) && is_string( $choice['message']['content'] ) )
|
|
return trim( $choice['message']['content'] );
|
|
|
|
if ( isset( $choice['message']['content'] ) && is_array( $choice['message']['content'] ) )
|
|
{
|
|
$chunks = [];
|
|
foreach ( $choice['message']['content'] as $part )
|
|
{
|
|
if ( is_string( $part ) )
|
|
$chunks[] = $part;
|
|
elseif ( is_array( $part ) )
|
|
{
|
|
if ( isset( $part['text'] ) && is_string( $part['text'] ) )
|
|
$chunks[] = $part['text'];
|
|
elseif ( isset( $part['text']['value'] ) && is_string( $part['text']['value'] ) )
|
|
$chunks[] = $part['text']['value'];
|
|
}
|
|
}
|
|
|
|
return trim( implode( "\n", $chunks ) );
|
|
}
|
|
|
|
if ( isset( $choice['message']['tool_calls'][0]['function']['arguments'] ) && is_string( $choice['message']['tool_calls'][0]['function']['arguments'] ) )
|
|
return trim( $choice['message']['tool_calls'][0]['function']['arguments'] );
|
|
|
|
if ( isset( $choice['message']['function_call']['arguments'] ) && is_string( $choice['message']['function_call']['arguments'] ) )
|
|
return trim( $choice['message']['function_call']['arguments'] );
|
|
|
|
return '';
|
|
}
|
|
|
|
private function clipForLog( $value, $limit = 600 )
|
|
{
|
|
$text = trim( (string)$value );
|
|
if ( $text === '' )
|
|
return '';
|
|
|
|
return mb_substr( $text, 0, (int)$limit );
|
|
}
|
|
|
|
private function normalizeAiTextValue( $value )
|
|
{
|
|
if ( is_string( $value ) )
|
|
return trim( $value );
|
|
|
|
if ( is_numeric( $value ) || is_bool( $value ) )
|
|
return trim( (string)$value );
|
|
|
|
if ( is_array( $value ) )
|
|
{
|
|
$lines = [];
|
|
foreach ( $value as $item )
|
|
{
|
|
$item_text = $this -> normalizeAiTextValue( $item );
|
|
if ( $item_text !== '' )
|
|
$lines[] = $item_text;
|
|
}
|
|
|
|
return trim( implode( "\n", $lines ) );
|
|
}
|
|
|
|
if ( is_object( $value ) )
|
|
{
|
|
$array_value = json_decode( json_encode( $value ), true );
|
|
if ( is_array( $array_value ) )
|
|
{
|
|
$normalized = $this -> normalizeAiTextValue( $array_value );
|
|
if ( $normalized !== '' )
|
|
return $normalized;
|
|
}
|
|
|
|
return trim( json_encode( $value ) );
|
|
}
|
|
|
|
return '';
|
|
}
|
|
}
|