From e8547bd3413a5cbc1d10513c8a65e3fbbc169c84 Mon Sep 17 00:00:00 2001 From: Tim Lappe Date: Fri, 14 Mar 2025 08:13:27 +0100 Subject: [PATCH] Added chatgpt, calendar and home assistant integration --- .cursorrules | 3 +- .env | 12 ++ composer.json | 1 + composer.lock | 175 +++++++++++++++++- .../dev/dev.HOME_ASSISTANT_TOKEN.ad10d3.php | 3 + .../secrets/dev/dev.OPENAI_API_KEY.66949e.php | 3 + config/secrets/dev/dev.decrypt.private.php | 4 + config/secrets/dev/dev.encrypt.public.php | 3 + config/secrets/dev/dev.list.php | 6 + .../prod/prod.HOME_ASSISTANT_TOKEN.ad10d3.php | 3 + .../prod/prod.OPENAI_API_KEY.66949e.php | 3 + config/secrets/prod/prod.decrypt.private.php | 4 + config/secrets/prod/prod.encrypt.public.php | 3 + config/secrets/prod/prod.list.php | 6 + config/services.yaml | 15 ++ src/Command/ChatGPTCommand.php | 63 +++++++ src/Command/HomeAssistantCommand.php | 122 ++++++++++++ src/Command/ReadCalendarCommand.php | 79 ++++++++ src/Command/RunAgentCommand.php | 37 ++++ src/Controller/ChatController.php | 52 ++++++ src/Controller/WebController.php | 16 ++ src/Core/Agent/Agent.php | 54 ++++++ src/Core/Agent/PromptProvider.php | 29 +++ src/Core/Home/Calendar/CalendarConfig.php | 34 ++++ .../Home/Calendar/CalendarConfigFactory.php | 25 +++ src/Core/Home/Calendar/CalendarEvent.php | 64 +++++++ src/Core/Home/Calendar/CalendarFactory.php | 25 +++ src/Core/Home/Calendar/CalendarInterface.php | 19 ++ src/Core/Home/Calendar/CalendarService.php | 69 +++++++ .../Home/Calendar/IcsCalendarProvider.php | 136 ++++++++++++++ src/Core/HomeAssistant/EntityState.php | 51 +++++ .../HomeAssistant/HomeAssistantClient.php | 90 +++++++++ .../HomeAssistant/HomeAssistantException.php | 11 ++ .../HomeAssistant/HomeAssistantService.php | 75 ++++++++ src/Core/OpenAI/ChatGPTService.php | 56 ++++++ src/Core/OpenAI/OpenAIClient.php | 44 +++++ templates/base.html.twig | 9 +- templates/calendar/add.html.twig | 56 ++++++ templates/calendar/list.html.twig | 50 +++++ templates/chat/index.html.twig | 120 ++++++++++++ .../Home/Calendar/CalendarServiceTest.php | 80 ++++++++ 41 files changed, 1704 insertions(+), 6 deletions(-) create mode 100644 config/secrets/dev/dev.HOME_ASSISTANT_TOKEN.ad10d3.php create mode 100644 config/secrets/dev/dev.OPENAI_API_KEY.66949e.php create mode 100644 config/secrets/dev/dev.decrypt.private.php create mode 100644 config/secrets/dev/dev.encrypt.public.php create mode 100644 config/secrets/dev/dev.list.php create mode 100644 config/secrets/prod/prod.HOME_ASSISTANT_TOKEN.ad10d3.php create mode 100644 config/secrets/prod/prod.OPENAI_API_KEY.66949e.php create mode 100644 config/secrets/prod/prod.decrypt.private.php create mode 100644 config/secrets/prod/prod.encrypt.public.php create mode 100644 config/secrets/prod/prod.list.php create mode 100644 src/Command/ChatGPTCommand.php create mode 100644 src/Command/HomeAssistantCommand.php create mode 100644 src/Command/ReadCalendarCommand.php create mode 100644 src/Command/RunAgentCommand.php create mode 100644 src/Controller/ChatController.php create mode 100644 src/Controller/WebController.php create mode 100644 src/Core/Agent/Agent.php create mode 100644 src/Core/Agent/PromptProvider.php create mode 100644 src/Core/Home/Calendar/CalendarConfig.php create mode 100644 src/Core/Home/Calendar/CalendarConfigFactory.php create mode 100644 src/Core/Home/Calendar/CalendarEvent.php create mode 100644 src/Core/Home/Calendar/CalendarFactory.php create mode 100644 src/Core/Home/Calendar/CalendarInterface.php create mode 100644 src/Core/Home/Calendar/CalendarService.php create mode 100644 src/Core/Home/Calendar/IcsCalendarProvider.php create mode 100644 src/Core/HomeAssistant/EntityState.php create mode 100644 src/Core/HomeAssistant/HomeAssistantClient.php create mode 100644 src/Core/HomeAssistant/HomeAssistantException.php create mode 100644 src/Core/HomeAssistant/HomeAssistantService.php create mode 100644 src/Core/OpenAI/ChatGPTService.php create mode 100644 src/Core/OpenAI/OpenAIClient.php create mode 100644 templates/calendar/add.html.twig create mode 100644 templates/calendar/list.html.twig create mode 100644 templates/chat/index.html.twig create mode 100644 tests/Core/Home/Calendar/CalendarServiceTest.php diff --git a/.cursorrules b/.cursorrules index 4cb25b0..6bce2eb 100644 --- a/.cursorrules +++ b/.cursorrules @@ -1,4 +1,5 @@ -You are an export AI programming assistant that primarily focuses on producing clean and readable code. +You are an expert AI programming assistant that primarily focuses on producing clean and readable code. +You are also an expert in Software architect and you provide very decoupled code with come abstractions. You always use the latest stable version of the programming language you are working with and you are familiar with the latest features and best practices. You are a full stack developer with expert knowledge Symfony and Docker. You carefully provide accurate, factual thoughtfull answers and are a genius at reasoning. \ No newline at end of file diff --git a/.env b/.env index f07c5f4..bc233da 100644 --- a/.env +++ b/.env @@ -28,3 +28,15 @@ APP_SECRET= # DATABASE_URL="mysql://app:!ChangeMe!@127.0.0.1:3306/app?serverVersion=10.11.2-MariaDB&charset=utf8mb4" DATABASE_URL="postgresql://app:!ChangeMe!@127.0.0.1:5432/app?serverVersion=16&charset=utf8" ###< doctrine/doctrine-bundle ### + +###> OpenAI API ### +OPENAI_MODEL=gpt-4o +OPENAI_API_URL=https://api.openai.com/v1 +###< OpenAI API ### + + +###> Home Assistant Integration ### +HOME_ASSISTANT_URL=https://ha.strolap.com +HOME_ASSISTANT_TOKEN= +HOME_ASSISTANT_VERIFY_SSL=true +###< Home Assistant Integration ### diff --git a/composer.json b/composer.json index 03a4ece..748ffd7 100644 --- a/composer.json +++ b/composer.json @@ -15,6 +15,7 @@ "symfony/dotenv": "7.2.*", "symfony/flex": "^2", "symfony/framework-bundle": "7.2.*", + "symfony/http-client": "7.2.*", "symfony/runtime": "7.2.*", "symfony/twig-bundle": "7.2.*", "symfony/yaml": "7.2.*" diff --git a/composer.lock b/composer.lock index 5755171..79d9caf 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "b6035c82f9404fcb793ed5458974888b", + "content-hash": "7d2859d190dfcf0c235a4cf526f044af", "packages": [ { "name": "doctrine/cache", @@ -2673,6 +2673,179 @@ ], "time": "2025-02-26T08:19:39+00:00" }, + { + "name": "symfony/http-client", + "version": "v7.2.4", + "source": { + "type": "git", + "url": "https://github.com/symfony/http-client.git", + "reference": "78981a2ffef6437ed92d4d7e2a86a82f256c6dc6" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/http-client/zipball/78981a2ffef6437ed92d4d7e2a86a82f256c6dc6", + "reference": "78981a2ffef6437ed92d4d7e2a86a82f256c6dc6", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "psr/log": "^1|^2|^3", + "symfony/deprecation-contracts": "^2.5|^3", + "symfony/http-client-contracts": "~3.4.4|^3.5.2", + "symfony/service-contracts": "^2.5|^3" + }, + "conflict": { + "amphp/amp": "<2.5", + "php-http/discovery": "<1.15", + "symfony/http-foundation": "<6.4" + }, + "provide": { + "php-http/async-client-implementation": "*", + "php-http/client-implementation": "*", + "psr/http-client-implementation": "1.0", + "symfony/http-client-implementation": "3.0" + }, + "require-dev": { + "amphp/http-client": "^4.2.1|^5.0", + "amphp/http-tunnel": "^1.0|^2.0", + "amphp/socket": "^1.1", + "guzzlehttp/promises": "^1.4|^2.0", + "nyholm/psr7": "^1.0", + "php-http/httplug": "^1.0|^2.0", + "psr/http-client": "^1.0", + "symfony/amphp-http-client-meta": "^1.0|^2.0", + "symfony/dependency-injection": "^6.4|^7.0", + "symfony/http-kernel": "^6.4|^7.0", + "symfony/messenger": "^6.4|^7.0", + "symfony/process": "^6.4|^7.0", + "symfony/rate-limiter": "^6.4|^7.0", + "symfony/stopwatch": "^6.4|^7.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\HttpClient\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides powerful methods to fetch HTTP resources synchronously or asynchronously", + "homepage": "https://symfony.com", + "keywords": [ + "http" + ], + "support": { + "source": "https://github.com/symfony/http-client/tree/v7.2.4" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2025-02-13T10:27:23+00:00" + }, + { + "name": "symfony/http-client-contracts", + "version": "v3.5.2", + "source": { + "type": "git", + "url": "https://github.com/symfony/http-client-contracts.git", + "reference": "ee8d807ab20fcb51267fdace50fbe3494c31e645" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/http-client-contracts/zipball/ee8d807ab20fcb51267fdace50fbe3494c31e645", + "reference": "ee8d807ab20fcb51267fdace50fbe3494c31e645", + "shasum": "" + }, + "require": { + "php": ">=8.1" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/contracts", + "name": "symfony/contracts" + }, + "branch-alias": { + "dev-main": "3.5-dev" + } + }, + "autoload": { + "psr-4": { + "Symfony\\Contracts\\HttpClient\\": "" + }, + "exclude-from-classmap": [ + "/Test/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Generic abstractions related to HTTP clients", + "homepage": "https://symfony.com", + "keywords": [ + "abstractions", + "contracts", + "decoupling", + "interfaces", + "interoperability", + "standards" + ], + "support": { + "source": "https://github.com/symfony/http-client-contracts/tree/v3.5.2" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2024-12-07T08:49:48+00:00" + }, { "name": "symfony/http-foundation", "version": "v7.2.3", diff --git a/config/secrets/dev/dev.HOME_ASSISTANT_TOKEN.ad10d3.php b/config/secrets/dev/dev.HOME_ASSISTANT_TOKEN.ad10d3.php new file mode 100644 index 0000000..fb72b7d --- /dev/null +++ b/config/secrets/dev/dev.HOME_ASSISTANT_TOKEN.ad10d3.php @@ -0,0 +1,3 @@ + null, + 'OPENAI_API_KEY' => null, +]; diff --git a/config/secrets/prod/prod.HOME_ASSISTANT_TOKEN.ad10d3.php b/config/secrets/prod/prod.HOME_ASSISTANT_TOKEN.ad10d3.php new file mode 100644 index 0000000..00fa270 --- /dev/null +++ b/config/secrets/prod/prod.HOME_ASSISTANT_TOKEN.ad10d3.php @@ -0,0 +1,3 @@ + null, + 'OPENAI_API_KEY' => null, +]; diff --git a/config/services.yaml b/config/services.yaml index 2d6a76f..8247f30 100644 --- a/config/services.yaml +++ b/config/services.yaml @@ -4,6 +4,10 @@ # Put parameters here that don't need to change on each machine where the app is deployed # https://symfony.com/doc/current/best_practices.html#use-parameters-for-application-configuration parameters: + # Calendar configuration + app.calendars.ics: + tim_calendar: 'webcal://p101-caldav.icloud.com/published/2/MTIwODk2NzA4MjIxMjA4OX8U9-11KVNdAw-HVVfEeHJioeELY1BwErQansnsIRnd' + household_calendar: 'webcal://p101-caldav.icloud.com/published/2/MTIwODk2NzA4MjIxMjA4OX8U9-11KVNdAw-HVVfEeHJDG4lEVQV-T3I5sEk0H6vfdGGP0X9Mpef_3zp3JNiiYvbAqzkgkukXO0nsKSxY1FA' services: # default configuration for services in *this* file @@ -20,5 +24,16 @@ services: - '../src/Entity/' - '../src/Kernel.php' + # Calendar configuration services + App\Core\Home\Calendar\CalendarConfig: + factory: ['@App\Core\Home\Calendar\CalendarConfigFactory', 'createCalendarConfig'] + + App\Core\Home\Calendar\CalendarConfigFactory: + arguments: + $icsCalendars: '%app.calendars.ics%' + + App\Core\Home\Calendar\CalendarService: + factory: ['@App\Core\Home\Calendar\CalendarFactory', 'createCalendarService'] + # add more service definitions when explicit configuration is needed # please note that last definitions always *replace* previous ones diff --git a/src/Command/ChatGPTCommand.php b/src/Command/ChatGPTCommand.php new file mode 100644 index 0000000..c85ec56 --- /dev/null +++ b/src/Command/ChatGPTCommand.php @@ -0,0 +1,63 @@ +addOption( + 'system-prompt', + 's', + InputOption::VALUE_OPTIONAL, + 'Initial system prompt to set the context' + ); + } + + protected function execute(InputInterface $input, OutputInterface $output): int + { + $io = new SymfonyStyle($input, $output); + + $systemPrompt = $input->getOption('system-prompt'); + $conversation = $this->chatGPTService->createChatConversation($systemPrompt ? [$systemPrompt] : []); + + $io->info('Starting chat with ChatGPT (type "exit" to quit)'); + + while (true) { + $userMessage = $io->ask('You'); + + if ($userMessage === 'exit') { + return Command::SUCCESS; + } + + try { + $response = $this->chatGPTService->sendMessage($userMessage, $conversation); + $this->chatGPTService->addMessageToConversation($conversation, $userMessage, 'user'); + $this->chatGPTService->addMessageToConversation($conversation, $response, 'assistant'); + + $io->text(['ChatGPT > ' . $response, '']); + } catch (\Exception $e) { + $io->error($e->getMessage()); + return Command::FAILURE; + } + } + } +} diff --git a/src/Command/HomeAssistantCommand.php b/src/Command/HomeAssistantCommand.php new file mode 100644 index 0000000..fbcf126 --- /dev/null +++ b/src/Command/HomeAssistantCommand.php @@ -0,0 +1,122 @@ +addOption('list-domains', null, InputOption::VALUE_NONE, 'List all available domains') + ->addOption('list-entities', null, InputOption::VALUE_NONE, 'List all entities') + ->addOption('domain', null, InputOption::VALUE_REQUIRED, 'Filter entities by domain') + ->addOption('entity-id', null, InputOption::VALUE_REQUIRED, 'Entity ID to interact with') + ->addOption('turn-on', null, InputOption::VALUE_NONE, 'Turn on the specified entity') + ->addOption('turn-off', null, InputOption::VALUE_NONE, 'Turn off the specified entity'); + } + + protected function execute(InputInterface $input, OutputInterface $output): int + { + $io = new SymfonyStyle($input, $output); + + if ($input->getOption('list-domains')) { + return $this->listDomains($io); + } + + if ($input->getOption('list-entities')) { + return $this->listEntities($io, $input->getOption('domain')); + } + + $entityId = $input->getOption('entity-id'); + if ($entityId === null) { + $io->error('You must specify an entity ID using --entity-id option'); + return Command::FAILURE; + } + + if ($input->getOption('turn-on')) { + return $this->turnOn($io, $entityId); + } + + if ($input->getOption('turn-off')) { + return $this->turnOff($io, $entityId); + } + + $this->showEntityState($io, $entityId); + return Command::SUCCESS; + } + + private function listDomains(SymfonyStyle $io): int + { + $domains = $this->homeAssistant->getAvailableDomains(); + $io->listing($domains); + return Command::SUCCESS; + } + + private function listEntities(SymfonyStyle $io, string|null $domain): int + { + $entities = $domain !== null + ? $this->homeAssistant->getEntitiesByDomain($domain) + : $this->homeAssistant->getAllEntityStates(); + + $rows = array_map( + static fn($entity) => [ + $entity->entityId, + $entity->getName(), + $entity->state, + ], + $entities + ); + + $io->table(['Entity ID', 'Name', 'State'], $rows); + return Command::SUCCESS; + } + + private function turnOn(SymfonyStyle $io, string $entityId): int + { + $state = $this->homeAssistant->turnOn($entityId); + $io->success(sprintf('Entity %s turned on. Current state: %s', $entityId, $state->state)); + return Command::SUCCESS; + } + + private function turnOff(SymfonyStyle $io, string $entityId): int + { + $state = $this->homeAssistant->turnOff($entityId); + $io->success(sprintf('Entity %s turned off. Current state: %s', $entityId, $state->state)); + return Command::SUCCESS; + } + + private function showEntityState(SymfonyStyle $io, string $entityId): void + { + $state = $this->homeAssistant->getEntityState($entityId); + $io->table( + ['Property', 'Value'], + [ + ['Entity ID', $state->entityId], + ['Name', $state->getName()], + ['State', $state->state], + ['Last Changed', $state->lastChanged], + ['Last Updated', $state->lastUpdated], + ] + ); + } +} diff --git a/src/Command/ReadCalendarCommand.php b/src/Command/ReadCalendarCommand.php new file mode 100644 index 0000000..913d88f --- /dev/null +++ b/src/Command/ReadCalendarCommand.php @@ -0,0 +1,79 @@ +addOption('days', 'd', InputOption::VALUE_OPTIONAL, 'Number of days to look ahead', 7) + ->addOption('group', 'g', InputOption::VALUE_NONE, 'Group events by calendar'); + } + + protected function execute(InputInterface $input, OutputInterface $output): int + { + $io = new SymfonyStyle($input, $output); + + $days = (int)$input->getOption('days'); + $group = $input->getOption('group'); + + $from = new \DateTime(); + $to = (new \DateTime())->modify("+$days days"); + + $calendarService = $this->calendarFactory->createCalendarService(); + + if ($group) { + $events = $calendarService->getEventsGroupedByCalendar($from, $to); + + foreach ($events as $calendarName => $calendarEvents) { + $io->section($calendarName); + $this->displayEvents($io, $calendarEvents); + } + + return Command::SUCCESS; + } + + $events = $calendarService->getEvents($from, $to); + $this->displayEvents($io, $events); + + return Command::SUCCESS; + } + + private function displayEvents(SymfonyStyle $io, array $events): void + { + $rows = []; + foreach ($events as $event) { + $rows[] = [ + $event->getStart()->format('Y-m-d H:i'), + $event->getEnd()->format('Y-m-d H:i'), + $event->getTitle(), + $event->getLocation(), + $event->isAllDay() ? 'Yes' : 'No' + ]; + } + + $io->table( + ['Start', 'End', 'Title', 'Location', 'All Day'], + $rows + ); + } +} diff --git a/src/Command/RunAgentCommand.php b/src/Command/RunAgentCommand.php new file mode 100644 index 0000000..836497d --- /dev/null +++ b/src/Command/RunAgentCommand.php @@ -0,0 +1,37 @@ +agent->run(); + $output->writeln($result['prompt']); + $output->writeln($result['response']); + + return Command::SUCCESS; + } catch (\Exception $e) { + $output->writeln('' . $e->getMessage() . ''); + + return Command::FAILURE; + } + } +} diff --git a/src/Controller/ChatController.php b/src/Controller/ChatController.php new file mode 100644 index 0000000..8f98720 --- /dev/null +++ b/src/Controller/ChatController.php @@ -0,0 +1,52 @@ +getContent(), true); + + if (!isset($data['message'])) { + return $this->json(['error' => 'Missing message parameter'], Response::HTTP_BAD_REQUEST); + } + + $previousMessages = $data['conversation'] ?? []; + + try { + $response = $this->chatGPTService->sendMessage($data['message'], $previousMessages); + + // Add user message and AI response to the conversation history + if (empty($previousMessages)) { + $conversation = $this->chatGPTService->createChatConversation(); + } else { + $conversation = $previousMessages; + } + + $this->chatGPTService->addMessageToConversation($conversation, $data['message'], 'user'); + $this->chatGPTService->addMessageToConversation($conversation, $response, 'assistant'); + + return $this->json([ + 'response' => $response, + 'conversation' => $conversation + ]); + } catch (\Exception $e) { + return $this->json(['error' => $e->getMessage()], Response::HTTP_BAD_REQUEST); + } + } +} \ No newline at end of file diff --git a/src/Controller/WebController.php b/src/Controller/WebController.php new file mode 100644 index 0000000..2b6f50e --- /dev/null +++ b/src/Controller/WebController.php @@ -0,0 +1,16 @@ +render('chat/index.html.twig'); + } +} \ No newline at end of file diff --git a/src/Core/Agent/Agent.php b/src/Core/Agent/Agent.php new file mode 100644 index 0000000..0738a47 --- /dev/null +++ b/src/Core/Agent/Agent.php @@ -0,0 +1,54 @@ +getPrompt(); + $response = $this->chatGPTService->sendMessage($prompt); + + return [ + 'prompt' => $prompt, + 'response' => $response + ]; + } + + public function getPrompt(): string + { + $now = new DateTimeImmutable(); + $from = $now->modify('-1 day'); + $to = $now->modify('+7 days'); + + $events = $this->calendarService->getEvents($from, $to); + $calendarEventsText = ''; + + foreach ($events as $event) { + $calendarEventsText .= sprintf( + "- %s: %s from %s to %s\n", + $event->getCalendarName(), + $event->getTitle(), + $event->getStart()->format('Y-m-d H:i'), + $event->getEnd()->format('Y-m-d H:i') + ); + } + + return strtr($this->promptProvider->getPromptTemplate(), [ + '{calendar_events}' => $calendarEventsText, + '{current_time}' => $now->format('Y-m-d H:i:s') + ]); + } +} diff --git a/src/Core/Agent/PromptProvider.php b/src/Core/Agent/PromptProvider.php new file mode 100644 index 0000000..5cfeb1a --- /dev/null +++ b/src/Core/Agent/PromptProvider.php @@ -0,0 +1,29 @@ + */ + private array $icsCalendars = []; + + public function addIcsCalendar(string $name, string $url): self + { + $this->icsCalendars[$name] = $url; + + return $this; + } + + /** + * @return array + */ + public function getIcsCalendars(): array + { + return $this->icsCalendars; + } + + public function getIcsCalendarUrl(string $name): ?string + { + return $this->icsCalendars[$name] ?? null; + } + + public function hasIcsCalendar(string $name): bool + { + return isset($this->icsCalendars[$name]); + } +} \ No newline at end of file diff --git a/src/Core/Home/Calendar/CalendarConfigFactory.php b/src/Core/Home/Calendar/CalendarConfigFactory.php new file mode 100644 index 0000000..8e4aa1c --- /dev/null +++ b/src/Core/Home/Calendar/CalendarConfigFactory.php @@ -0,0 +1,25 @@ + $icsCalendars + */ + public function __construct( + private readonly array $icsCalendars = [] + ) { + } + + public function createCalendarConfig(): CalendarConfig + { + $config = new CalendarConfig(); + + foreach ($this->icsCalendars as $name => $url) { + $config->addIcsCalendar($name, $url); + } + + return $config; + } +} \ No newline at end of file diff --git a/src/Core/Home/Calendar/CalendarEvent.php b/src/Core/Home/Calendar/CalendarEvent.php new file mode 100644 index 0000000..a58c3e2 --- /dev/null +++ b/src/Core/Home/Calendar/CalendarEvent.php @@ -0,0 +1,64 @@ +id; + } + + public function getTitle(): string + { + return $this->title; + } + + public function getStart(): \DateTimeInterface + { + return $this->start; + } + + public function getEnd(): \DateTimeInterface + { + return $this->end; + } + + public function getDescription(): string + { + return $this->description; + } + + public function getLocation(): string + { + return $this->location; + } + + public function getCalendarName(): string + { + return $this->calendarName; + } + + public function getAttendees(): array + { + return $this->attendees; + } + + public function isAllDay(): bool + { + return $this->allDay; + } +} \ No newline at end of file diff --git a/src/Core/Home/Calendar/CalendarFactory.php b/src/Core/Home/Calendar/CalendarFactory.php new file mode 100644 index 0000000..56c41d4 --- /dev/null +++ b/src/Core/Home/Calendar/CalendarFactory.php @@ -0,0 +1,25 @@ +httpClient); + + foreach ($this->config->getIcsCalendars() as $name => $url) { + $service->addIcsCalendar($url, $name); + } + + return $service; + } +} \ No newline at end of file diff --git a/src/Core/Home/Calendar/CalendarInterface.php b/src/Core/Home/Calendar/CalendarInterface.php new file mode 100644 index 0000000..36c4951 --- /dev/null +++ b/src/Core/Home/Calendar/CalendarInterface.php @@ -0,0 +1,19 @@ +calendarProviders[] = $calendar; + + return $this; + } + + public function addIcsCalendar(string $url, ?string $name = null): self + { + $provider = new IcsCalendarProvider($this->httpClient, $url, $name); + $this->calendarProviders[] = $provider; + + return $this; + } + + public function getCalendars(): array + { + return $this->calendarProviders; + } + + public function getEvents(\DateTimeInterface $from, \DateTimeInterface $to): array + { + $allEvents = []; + + foreach ($this->calendarProviders as $calendar) { + $events = $calendar->getEvents($from, $to); + $allEvents = array_merge($allEvents, $events); + } + + // Sort events by start date + usort($allEvents, function (CalendarEvent $a, CalendarEvent $b) { + return $a->getStart() <=> $b->getStart(); + }); + + return $allEvents; + } + + public function getEventsGroupedByCalendar(\DateTimeInterface $from, \DateTimeInterface $to): array + { + $groupedEvents = []; + + foreach ($this->calendarProviders as $calendar) { + $calendarName = $calendar->getName(); + $events = $calendar->getEvents($from, $to); + + if (!empty($events)) { + $groupedEvents[$calendarName] = $events; + } + } + + return $groupedEvents; + } +} \ No newline at end of file diff --git a/src/Core/Home/Calendar/IcsCalendarProvider.php b/src/Core/Home/Calendar/IcsCalendarProvider.php new file mode 100644 index 0000000..e8ad66d --- /dev/null +++ b/src/Core/Home/Calendar/IcsCalendarProvider.php @@ -0,0 +1,136 @@ +url = $url; + $this->name = $name ?? parse_url($url, PHP_URL_HOST) ?? 'Unknown'; + } + + public function getName(): string + { + return $this->name; + } + + public function getEvents(\DateTimeInterface $from, \DateTimeInterface $to): array + { + $icsContent = $this->fetchIcsContent(); + + return $this->parseIcsContent($icsContent, $from, $to); + } + + private function fetchIcsContent(): string + { + // Cache for 5 minutes + if ($this->cachedContent !== null && $this->lastFetch !== null && + $this->lastFetch->getTimestamp() > (time() - 300)) { + return $this->cachedContent; + } + + $requestUrl = $this->url; + + // Convert webcal:// to https:// for the HTTP client + if (stripos($requestUrl, 'webcal://') === 0) { + $requestUrl = str_replace('webcal://', 'https://', $requestUrl); + } + + $response = $this->httpClient->request('GET', $requestUrl); + $content = $response->getContent(); + + $this->cachedContent = $content; + $this->lastFetch = new \DateTime(); + + return $content; + } + + private function parseIcsContent(string $icsContent, \DateTimeInterface $from, \DateTimeInterface $to): array + { + $events = []; + $lines = explode("\n", $icsContent); + + $inEvent = false; + $currentEvent = null; + $eventData = []; + + foreach ($lines as $line) { + $line = trim($line); + + if ($line === 'BEGIN:VEVENT') { + $inEvent = true; + $eventData = []; + continue; + } + + if ($line === 'END:VEVENT') { + $inEvent = false; + + if (isset($eventData['DTSTART'], $eventData['DTEND'], $eventData['UID'])) { + $startDate = $this->parseIcsDate($eventData['DTSTART']); + $endDate = $this->parseIcsDate($eventData['DTEND']); + + // Skip events outside the requested range + if ($endDate < $from || $startDate > $to) { + continue; + } + + $allDay = false; + if (isset($eventData['DTSTART;VALUE=DATE'])) { + $allDay = true; + } + + $events[] = new CalendarEvent( + $eventData['UID'], + $eventData['SUMMARY'] ?? 'Untitled Event', + $startDate, + $endDate, + $eventData['DESCRIPTION'] ?? '', + $eventData['LOCATION'] ?? '', + $this->name, + [], // attendees not parsed in this basic implementation + $allDay + ); + } + + continue; + } + + if ($inEvent && strpos($line, ':') !== false) { + [$key, $value] = explode(':', $line, 2); + + // Handle property parameters + if (strpos($key, ';') !== false) { + $parts = explode(';', $key); + $key = $parts[0]; + } + + $eventData[$key] = $value; + } + } + + return $events; + } + + private function parseIcsDate(string $dateString): \DateTimeInterface + { + $date = new DateTimeImmutable($dateString); + if ($date === false) { + return new \DateTime(); + } + + return $date; + } +} \ No newline at end of file diff --git a/src/Core/HomeAssistant/EntityState.php b/src/Core/HomeAssistant/EntityState.php new file mode 100644 index 0000000..26ef1c7 --- /dev/null +++ b/src/Core/HomeAssistant/EntityState.php @@ -0,0 +1,51 @@ +state, ['on', 'home', 'open', 'unlocked', 'active'], true); + } + + public function isOff(): bool + { + return in_array($this->state, ['off', 'away', 'closed', 'locked', 'inactive'], true); + } + + public function getDomain(): string + { + $parts = explode('.', $this->entityId, 2); + return $parts[0]; + } + + public function getName(): string + { + return $this->attributes['friendly_name'] ?? $this->entityId; + } +} \ No newline at end of file diff --git a/src/Core/HomeAssistant/HomeAssistantClient.php b/src/Core/HomeAssistant/HomeAssistantClient.php new file mode 100644 index 0000000..297b425 --- /dev/null +++ b/src/Core/HomeAssistant/HomeAssistantClient.php @@ -0,0 +1,90 @@ +request('GET', '/api/states'); + } + + public function getServices(): array + { + return $this->request('GET', '/api/services'); + } + + public function getEntityState(string $entityId): array + { + return $this->request('GET', "/api/states/{$entityId}"); + } + + public function callService(string $domain, string $service, array $data = []): array + { + return $this->request('POST', "/api/services/{$domain}/{$service}", $data); + } + + public function turnOn(string $entityId): array + { + $domain = explode('.', $entityId)[0]; + return $this->callService($domain, 'turn_on', ['entity_id' => $entityId]); + } + + public function turnOff(string $entityId): array + { + $domain = explode('.', $entityId)[0]; + return $this->callService($domain, 'turn_off', ['entity_id' => $entityId]); + } + + private function request(string $method, string $endpoint, array $data = []): array + { + $options = [ + 'headers' => [ + 'Authorization' => "Bearer {$this->token}", + 'Content-Type' => 'application/json', + ], + 'verify_peer' => $this->verifySSL, + 'verify_host' => $this->verifySSL, + ]; + + if (!empty($data)) { + $options['json'] = $data; + } + + $response = $this->httpClient->request( + $method, + $this->baseUrl . $endpoint, + $options + ); + + return $this->handleResponse($response); + } + + private function handleResponse(ResponseInterface $response): array + { + $statusCode = $response->getStatusCode(); + + if ($statusCode >= 200 && $statusCode < 300) { + return $response->toArray(); + } + + $content = $response->getContent(false); + throw new HomeAssistantException($content, $statusCode); + } +} \ No newline at end of file diff --git a/src/Core/HomeAssistant/HomeAssistantException.php b/src/Core/HomeAssistant/HomeAssistantException.php new file mode 100644 index 0000000..a9d0784 --- /dev/null +++ b/src/Core/HomeAssistant/HomeAssistantException.php @@ -0,0 +1,11 @@ +client->getStates(); + + return array_map( + static fn (array $state): EntityState => EntityState::fromArray($state), + $states + ); + } + + public function getEntityState(string $entityId): EntityState + { + $state = $this->client->getEntityState($entityId); + + return EntityState::fromArray($state); + } + + /** + * @return EntityState[] + */ + public function getEntitiesByDomain(string $domain): array + { + $allStates = $this->getAllEntityStates(); + + return array_filter( + $allStates, + static fn (EntityState $state): bool => $state->getDomain() === $domain + ); + } + + public function turnOn(string $entityId): EntityState + { + $result = $this->client->turnOn($entityId); + + return $this->getEntityState($entityId); + } + + public function turnOff(string $entityId): EntityState + { + $result = $this->client->turnOff($entityId); + + return $this->getEntityState($entityId); + } + + public function callService(string $domain, string $service, array $data = []): array + { + return $this->client->callService($domain, $service, $data); + } + + /** + * @return string[] + */ + public function getAvailableDomains(): array + { + $services = $this->client->getServices(); + + return array_keys($services); + } +} \ No newline at end of file diff --git a/src/Core/OpenAI/ChatGPTService.php b/src/Core/OpenAI/ChatGPTService.php new file mode 100644 index 0000000..3170488 --- /dev/null +++ b/src/Core/OpenAI/ChatGPTService.php @@ -0,0 +1,56 @@ + 'user', + 'content' => $userMessage, + ]; + + try { + $response = $this->openAIClient->chat($messages); + + if (!isset($response['choices'][0]['message']['content'])) { + throw new BadRequestException('Invalid response from OpenAI API'); + } + + return $response['choices'][0]['message']['content']; + } catch (\Exception $e) { + throw new BadRequestException('Error communicating with OpenAI API: ' . $e->getMessage()); + } + } + + public function createChatConversation(array $systemPrompt = []): array + { + $conversation = []; + + if (!empty($systemPrompt)) { + $conversation[] = [ + 'role' => 'system', + 'content' => $systemPrompt, + ]; + } + + return $conversation; + } + + public function addMessageToConversation(array &$conversation, string $message, string $role = 'user'): void + { + $conversation[] = [ + 'role' => $role, + 'content' => $message, + ]; + } +} \ No newline at end of file diff --git a/src/Core/OpenAI/OpenAIClient.php b/src/Core/OpenAI/OpenAIClient.php new file mode 100644 index 0000000..7ecd37a --- /dev/null +++ b/src/Core/OpenAI/OpenAIClient.php @@ -0,0 +1,44 @@ +sendRequest('/chat/completions', [ + 'model' => $this->model, + 'messages' => $messages, + 'temperature' => $temperature, + 'max_tokens' => $maxTokens, + ]); + + return $response->toArray(); + } + + public function sendRequest(string $endpoint, array $data): ResponseInterface + { + return $this->httpClient->request('POST', "{$this->apiUrl}{$endpoint}", [ + 'headers' => [ + 'Authorization' => "Bearer {$this->apiKey}", + 'Content-Type' => 'application/json', + ], + 'json' => $data, + ]); + } +} \ No newline at end of file diff --git a/templates/base.html.twig b/templates/base.html.twig index 802a51f..4b648c2 100644 --- a/templates/base.html.twig +++ b/templates/base.html.twig @@ -6,20 +6,21 @@ {% block title %}Task Manager{% endblock %} + {% block stylesheets %}{% endblock %} - +
- - Task Manager + + Tars AI
{% block body %}{% endblock %}
- © {{ 'now'|date('Y') }} Task Manager + © {{ 'now'|date('Y') }} Tars AI
diff --git a/templates/calendar/add.html.twig b/templates/calendar/add.html.twig new file mode 100644 index 0000000..26e2252 --- /dev/null +++ b/templates/calendar/add.html.twig @@ -0,0 +1,56 @@ +{% extends 'base.html.twig' %} + +{% block title %}Add Calendar{% endblock %} + +{% block body %} +
+

Add Calendar

+ + {% for label, messages in app.flashes %} + {% for message in messages %} +
+ {{ message }} +
+ {% endfor %} + {% endfor %} + +
+
+
Add New Calendar
+
+
+
+
+ + +
+
+ + +
+ For Apple Calendar, use the webcal URL from iCloud.com Calendar sharing options. +
+
+ +
+
+
+ +
+
+
How to find your Apple Calendar webcal URL
+
+
+
    +
  1. Open Calendar app on your Mac or go to iCloud.com and open Calendar
  2. +
  3. Right-click on the calendar you want to share
  4. +
  5. Select "Share Calendar..." option
  6. +
  7. Check "Public Calendar"
  8. +
  9. Copy the URL that appears (it should start with webcal://)
  10. +
  11. Paste it in the form above
  12. +
+
+
+
+{% endblock %} \ No newline at end of file diff --git a/templates/calendar/list.html.twig b/templates/calendar/list.html.twig new file mode 100644 index 0000000..cafa84a --- /dev/null +++ b/templates/calendar/list.html.twig @@ -0,0 +1,50 @@ +{% extends 'base.html.twig' %} + +{% block title %}Calendar List{% endblock %} + +{% block body %} +
+

Calendar List

+ + + + {% if calendars|length > 0 %} +
+ + + + + + + + + + {% for name, url in calendars %} + + + + + + {% endfor %} + +
NameURLType
{{ name }} + {{ url }} + + {% if url starts with 'webcal://' and 'icloud.com' in url %} + Apple Calendar + {% elseif url starts with 'webcal://' %} + Webcal + {% else %} + ICS + {% endif %} +
+
+ {% else %} +
+ No calendars have been added yet. Add your first calendar. +
+ {% endif %} +
+{% endblock %} \ No newline at end of file diff --git a/templates/chat/index.html.twig b/templates/chat/index.html.twig new file mode 100644 index 0000000..d774937 --- /dev/null +++ b/templates/chat/index.html.twig @@ -0,0 +1,120 @@ +{% extends 'base.html.twig' %} + +{% block title %}ChatGPT Integration{% endblock %} + +{% block body %} +
+

ChatGPT Integration

+ +
+
+
+

Hello! How can I help you today?

+
+
+
+ +
+ + +
+
+{% endblock %} + +{% block javascripts %} +{{ parent() }} + +{% endblock %} \ No newline at end of file diff --git a/tests/Core/Home/Calendar/CalendarServiceTest.php b/tests/Core/Home/Calendar/CalendarServiceTest.php new file mode 100644 index 0000000..01b7bdf --- /dev/null +++ b/tests/Core/Home/Calendar/CalendarServiceTest.php @@ -0,0 +1,80 @@ +httpClient = $this->createMock(HttpClientInterface::class); + $this->calendarService = new CalendarService($this->httpClient); + } + + public function testGetEventsFromMultipleCalendars(): void + { + // Create mock calendar providers + $calendar1 = $this->createMock(CalendarInterface::class); + $calendar1->method('getName')->willReturn('Calendar 1'); + $calendar1->method('getEvents')->willReturn([ + $this->createEvent('Event 1', '2023-01-01 10:00', '2023-01-01 11:00', 'Calendar 1'), + $this->createEvent('Event 2', '2023-01-02 15:00', '2023-01-02 16:00', 'Calendar 1'), + ]); + + $calendar2 = $this->createMock(CalendarInterface::class); + $calendar2->method('getName')->willReturn('Calendar 2'); + $calendar2->method('getEvents')->willReturn([ + $this->createEvent('Event 3', '2023-01-01 12:00', '2023-01-01 13:00', 'Calendar 2'), + $this->createEvent('Event 4', '2023-01-03 09:00', '2023-01-03 10:00', 'Calendar 2'), + ]); + + // Add calendar providers to service + $this->calendarService->addCalendar($calendar1); + $this->calendarService->addCalendar($calendar2); + + // Test getting all events + $from = new \DateTime('2023-01-01'); + $to = new \DateTime('2023-01-03'); + + $events = $this->calendarService->getEvents($from, $to); + + // Assertions + $this->assertCount(4, $events); + + // Check if events are sorted by start date + $this->assertEquals('Event 1', $events[0]->getTitle()); + $this->assertEquals('Event 3', $events[1]->getTitle()); + $this->assertEquals('Event 2', $events[2]->getTitle()); + $this->assertEquals('Event 4', $events[3]->getTitle()); + + // Test getting events grouped by calendar + $groupedEvents = $this->calendarService->getEventsGroupedByCalendar($from, $to); + + $this->assertCount(2, $groupedEvents); + $this->assertArrayHasKey('Calendar 1', $groupedEvents); + $this->assertArrayHasKey('Calendar 2', $groupedEvents); + $this->assertCount(2, $groupedEvents['Calendar 1']); + $this->assertCount(2, $groupedEvents['Calendar 2']); + } + + private function createEvent(string $title, string $start, string $end, string $calendarName): CalendarEvent + { + return new CalendarEvent( + md5($title . $start), + $title, + new \DateTime($start), + new \DateTime($end), + 'Description', + 'Location', + $calendarName + ); + } +} \ No newline at end of file