Added chatgpt, calendar and home assistant integration

This commit is contained in:
Tim Lappe 2025-03-14 08:13:27 +01:00
parent 27b847a9a9
commit e8547bd341
41 changed files with 1704 additions and 6 deletions

View File

@ -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.

12
.env
View File

@ -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 ###

View File

@ -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.*"

175
composer.lock generated
View File

@ -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",

View File

@ -0,0 +1,3 @@
<?php // dev.HOME_ASSISTANT_TOKEN.ad10d3 on Fri, 14 Mar 2025 07:10:37 +0000
return "\xBF\x9Bv\x9F\x06\x28\x3A\x1E\xBD\xF0\xE7P\xD9\xCE\x0A\x82I\xC4\x2Bx\xCB\x0F\x7Fs\x94v\x15\x5C\x03M\xC4\x0F\x80h\xFC\x9A\xC4\xE0d\xF5\x94~\x8B\x26-\x0F\xBB\xD7\xCB\xF1Vx\x8F\xF8\x3DV\x94r\xDBE3\xBD\xB0\x3CV\x1B\xE9\x87\xE9\xD1\x14yzRn\xB6\x96\x22\xBC\x9B\x7B\x99AL\x87\xC5\x29y\x0B\x3F\x0F\x3C\xEF\xAEe\x1E\xAEh\xADR\x15\x28h\x03\x28\xA9fq\x40\x3F\xE0F\x86\xF8P\x1Dk\xC6\x97\x28\x01\xF9\x2CV8-4\x60\xFC\x3DzK\x86\xC2\x93d\x8DT\x85h\xB5Y\x9F\x29\xF7\x8D\x19\xD1\xC1\x94\x86o\xAA\x9B\x14\x24L\x00\xAB\xC3n\xE9\x9D\x99\x18\x5D\x08\xDF\x29\x997\x80\x1C_\xC36kY\x40S\xD08\x0Bf\xFB\x3C\x06v\xB43U3\xCB\xF5\xC0\xF0\xCF\x1B\xD6U\xA4FA\xF4\xF7b\xEA\xA7\x3D\x26\x27\x3FH\x23\xB7\x0B\x1Db\x9F\x83\xEC\x99\x8E\x18\x7D\xBFRt\xF7\xA0\xBF";

View File

@ -0,0 +1,3 @@
<?php // dev.OPENAI_API_KEY.66949e on Fri, 14 Mar 2025 06:35:08 +0000
return "\x118\x9BuI\xD93\xEB\xB7\x90\xA7\xD9\x04\xE5\xA2\xAE\x93\x12\x06i\xA8\x09\x15\x1E\x83\xF8\x01\xB5\x5B\x7C\xA1\x22\x60\x3E\xC3\xB1\x02J\xC4\xB5\x3E3\xAB\xADZ\xDE\x98I\xE76\xE4l\x3E\xA8\x24\xB6y\xEA\xA9\xCB\x5B\xDF\xA3\x96\xED\x1FV\x14\xDA\xC5\xF8\xB4\x22\xA9\x11\xD5.\xAA\x95D\xE3\xC7Vg\x7B\x2C\x40\x8F\xC9\x12\x00\x26g\x00\x0BE\x96ga\xB1\xF3\x2500\xFF\x09JO\xB9D\xE8\xD4\x0DB\x5CBD\xC5\xC7\x84\xF1\xC5\x95\x3F\x25\xBF\x92\x15\x8C\x9E\x3F\x90\xE4S\x8E\x7C\xA9\x0FQ\x88\xA5\xEDE\x3D\x04Q\xC22\x0F\xD5v\xEE\x0Be\xDE\xCB\xE7\x90i\xC7\xD9\x02\x8F\xD4w\xC6n\x87\x5C\x0B\x3D\xA1\xD1\xC7\xEB\x00\x23\x2B\xFF\xBC\xE4\xD1\x2F\x9A\xCB\xCC\x23\xD37\x18E\xFAM_\x40lK\xFE8\xC2\xA8\x9Cb\xFA\x96\x0AMY\xFAm\x8Dc";

View File

@ -0,0 +1,4 @@
<?php // dev.decrypt.private on Fri, 14 Mar 2025 06:35:08 +0000
// SYMFONY_DECRYPTION_SECRET=r6ziQYfjL7E7QKl5+i1vEW0eCdubVW5QbBdFLShN14R7G7yb+SucBXzPU9HTIw27SPKyYKRlTrPZ1f/naFkREw==
return "\xAF\xAC\xE2A\x87\xE3\x2F\xB1\x3B\x40\xA9y\xFA-o\x11m\x1E\x09\xDB\x9BUnPl\x17E-\x28M\xD7\x84\x7B\x1B\xBC\x9B\xF9\x2B\x9C\x05\x7C\xCFS\xD1\xD3\x23\x0D\xBBH\xF2\xB2\x60\xA4eN\xB3\xD9\xD5\xFF\xE7hY\x11\x13";

View File

@ -0,0 +1,3 @@
<?php // dev.encrypt.public on Fri, 14 Mar 2025 06:35:08 +0000
return "\x7B\x1B\xBC\x9B\xF9\x2B\x9C\x05\x7C\xCFS\xD1\xD3\x23\x0D\xBBH\xF2\xB2\x60\xA4eN\xB3\xD9\xD5\xFF\xE7hY\x11\x13";

View File

@ -0,0 +1,6 @@
<?php
return [
'HOME_ASSISTANT_TOKEN' => null,
'OPENAI_API_KEY' => null,
];

View File

@ -0,0 +1,3 @@
<?php // prod.HOME_ASSISTANT_TOKEN.ad10d3 on Fri, 14 Mar 2025 07:10:47 +0000
return "\x1A\xE1\xB7\xDB\xA2\xFE\x862\xC7\x0B\x3D\xAE\x93\x1E\x2C0\xB2\x5D\x97\xAEXe\x29\xA0C\x1D\x80p\xE1H\x9A\x5D\x92\xBELL\xC5M\xE6a\xED\x0F.\xC0\x9F\xFA\xCC\x9C\xC1\x3E\x8BN\x07\xA8\x05\xFB\xDA\xA9\x05u\x7D\x16\x7Cx\x15\x81A\xCF\xBF\x7C\xCEq\xFF\xC3x\x97l9\xE6\xA5\xE9\xD7\x7F\xAD\x1C\x8A\x95\xF8\xC0\x1B9dB\xA4\xB7o\xF7q\xF4\xCA\xD4r\xFAM\x7Cj\xEFK\x10\xD5\x1C\x25P30h\xE8\x9C\xB9\x1B\xF6\xDF\x3BT\xCB\x05\xA0\x92qN\xB0\xCD\x18\xA7\xAE\xBB\x90\x840\xAE\xB2\x86U\xF6\x25\x1B\xC4\xB6\xB6B\xA9\x13\xBD\x0F\xF5\xDE\x7B\xFA\x01\xBE\xC3n\xBC\xA7\xC0\xE5W\xC3\x3B\x8B\x22\xA2\x5E\xB0\xF8\x28\x3B\xDD\x27\xD5\xC2x\xA5\x2F\x16\xF5x\xD1\x24G9\xA9\xB63\xCCq\xF6\x04\xFC\x5E\xF0\xDBH-\xD1\xBE\xA5\x92\x0E\xA0\x27\xB4a\x06U\x0E\x88MQ\x98\x40\x5EEmk\xE7\xA1\xA3\xB5u0";

View File

@ -0,0 +1,3 @@
<?php // prod.OPENAI_API_KEY.66949e on Fri, 14 Mar 2025 06:35:16 +0000
return "\x5E\xA7\x7B\x9D\x11\xDD\x14op\x3B\x05\xDE\xF5\xA1W\x05\xEC\xAF\xCD\xC8\xAE\x09\x97\x0D\xABh\xD2JfH\xCFP\x1C\xF3\xA3\x87Jo\xDF\x13\x3FjI\xAAH\xB3\xC9\x91j\xA5e\xE6\x26\xD5\x8E\xBF\xBD\x1D\xF4\x13\xF7\xF9\x96\xFB\x96\x90a\xA9\x8EP\xE6\xF5\xF73o\x7D\xAD\xF2\xDC\xE3\x8C\xAB\xFB3\xA4U7\xF4\xD8\x20h\xD7\xA8J\xF2L\x0D\xE8\xB4aZ\xB4\x09\xCF\x5E\x05\x1AEg\x26\x9C\x7F\x2C\x3C\x1AZ\x40\xAF0\xA4\x27\xC6\xFE\xBC\xC3\x84\xA2m\x26\x20\x1C\x3C\x9A\xC2\x82\x0E\x03nf\xD4\x1C3\x9Fs\x16C\x3C\xD8\xD8\xDD\x9C\x83e7\x238\xC4\x90\xCA\x25\x0C\x1F\x08\x18\x60\xAF\x5ET\xA3Z7\x8B\x02\x27n\x02\xF7\x90\x28\xDD\x8A\xB1_\xA5p\x5E\x18\xC9\x08m\x01g\x93iF\xD5\xAF~\xFB\xBC\x05z\xCD\xA0\xA1\x16\xF4hX\xA9hb";

View File

@ -0,0 +1,4 @@
<?php // prod.decrypt.private on Fri, 14 Mar 2025 06:35:16 +0000
// SYMFONY_DECRYPTION_SECRET=1Ru/SwYazDStILKDg3hNEenjwoO+VPBiroxGadXOYBjymRWNyX0oLUKN/09Dxs0JS/CMsQFYgVc+TWFW/i6bFg==
return "\xD5\x1B\xBFK\x06\x1A\xCC4\xAD\x20\xB2\x83\x83xM\x11\xE9\xE3\xC2\x83\xBET\xF0b\xAE\x8CFi\xD5\xCE\x60\x18\xF2\x99\x15\x8D\xC9\x7D\x28-B\x8D\xFFOC\xC6\xCD\x09K\xF0\x8C\xB1\x01X\x81W\x3EMaV\xFE.\x9B\x16";

View File

@ -0,0 +1,3 @@
<?php // prod.encrypt.public on Fri, 14 Mar 2025 06:35:16 +0000
return "\xF2\x99\x15\x8D\xC9\x7D\x28-B\x8D\xFFOC\xC6\xCD\x09K\xF0\x8C\xB1\x01X\x81W\x3EMaV\xFE.\x9B\x16";

View File

@ -0,0 +1,6 @@
<?php
return [
'HOME_ASSISTANT_TOKEN' => null,
'OPENAI_API_KEY' => null,
];

View File

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

View File

@ -0,0 +1,63 @@
<?php
namespace App\Command;
use App\Core\OpenAI\ChatGPTService;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
#[AsCommand(
name: 'app:chat',
description: 'Interactive chat with ChatGPT',
)]
class ChatGPTCommand extends Command
{
public function __construct(
private readonly ChatGPTService $chatGPTService
) {
parent::__construct();
}
protected function configure(): void
{
$this->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;
}
}
}
}

View File

@ -0,0 +1,122 @@
<?php
declare(strict_types=1);
namespace App\Command;
use App\Core\HomeAssistant\HomeAssistantService;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
#[AsCommand(
name: 'app:home-assistant',
description: 'Interact with Home Assistant',
)]
final class HomeAssistantCommand extends Command
{
public function __construct(
private readonly HomeAssistantService $homeAssistant
) {
parent::__construct();
}
protected function configure(): void
{
$this
->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],
]
);
}
}

View File

@ -0,0 +1,79 @@
<?php
namespace App\Command;
use App\Core\Home\Calendar\CalendarFactory;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
#[AsCommand(
name: 'app:calendar:read',
description: 'Read calendar events',
)]
class ReadCalendarCommand extends Command
{
public function __construct(
private readonly CalendarFactory $calendarFactory
) {
parent::__construct();
}
protected function configure(): void
{
$this
->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
);
}
}

View File

@ -0,0 +1,37 @@
<?php
namespace App\Command;
use App\Core\Agent\Agent;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
#[AsCommand(
name: 'app:run-agent',
description: 'Runs the home assistant agent',
)]
class RunAgentCommand extends Command
{
public function __construct(
private readonly Agent $agent
) {
parent::__construct();
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
try {
$result = $this->agent->run();
$output->writeln($result['prompt']);
$output->writeln($result['response']);
return Command::SUCCESS;
} catch (\Exception $e) {
$output->writeln('<error>' . $e->getMessage() . '</error>');
return Command::FAILURE;
}
}
}

View File

@ -0,0 +1,52 @@
<?php
namespace App\Controller;
use App\Core\OpenAI\ChatGPTService;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
#[Route('/api', name: 'api_')]
class ChatController extends AbstractController
{
public function __construct(
private readonly ChatGPTService $chatGPTService
) {
}
#[Route('/chat', name: 'chat', methods: ['POST'])]
public function chat(Request $request): JsonResponse
{
$data = json_decode($request->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);
}
}
}

View File

@ -0,0 +1,16 @@
<?php
namespace App\Controller;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
class WebController extends AbstractController
{
#[Route('/', name: 'app_home')]
public function index(): Response
{
return $this->render('chat/index.html.twig');
}
}

54
src/Core/Agent/Agent.php Normal file
View File

@ -0,0 +1,54 @@
<?php
namespace App\Core\Agent;
use App\Core\Agent\PromptProvider;
use App\Core\Home\Calendar\CalendarService;
use App\Core\OpenAI\ChatGPTService;
use DateTimeImmutable;
class Agent
{
public function __construct(
private readonly PromptProvider $promptProvider,
private readonly CalendarService $calendarService,
private readonly ChatGPTService $chatGPTService
) {
}
public function run(): array
{
$prompt = $this->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')
]);
}
}

View File

@ -0,0 +1,29 @@
<?php
namespace App\Core\Agent;
class PromptProvider
{
public function getPromptTemplate(): string
{
return <<<'EOT'
You are a high level smart home assistant.
You can answer questions and help with tasks.
You can also control the smart home devices.
You can also control the calendar.
Tim and Cara are both members of the household.
You have access to the following calendars:
- Tim's calendar
- Cara's calendar
- Household calendar
These are the events from the calendars:
{calendar_events}
Its currently {current_time}
I will ask you every 5 minutes to perform actions in the smart home.
Sometimes you have to do something, but sometimes you dont.
So its your turn. What actions to you want to perform?
Answer with a JSON array of actions.
EOT;
}
}

View File

@ -0,0 +1,34 @@
<?php
namespace App\Core\Home\Calendar;
class CalendarConfig
{
/** @var array<string, string> */
private array $icsCalendars = [];
public function addIcsCalendar(string $name, string $url): self
{
$this->icsCalendars[$name] = $url;
return $this;
}
/**
* @return array<string, string>
*/
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]);
}
}

View File

@ -0,0 +1,25 @@
<?php
namespace App\Core\Home\Calendar;
class CalendarConfigFactory
{
/**
* @param array<string, string> $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;
}
}

View File

@ -0,0 +1,64 @@
<?php
namespace App\Core\Home\Calendar;
class CalendarEvent
{
public function __construct(
private readonly string $id,
private readonly string $title,
private readonly \DateTimeInterface $start,
private readonly \DateTimeInterface $end,
private readonly string $description = '',
private readonly string $location = '',
private readonly string $calendarName = '',
private readonly array $attendees = [],
private readonly bool $allDay = false,
) {
}
public function getId(): string
{
return $this->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;
}
}

View File

@ -0,0 +1,25 @@
<?php
namespace App\Core\Home\Calendar;
use Symfony\Contracts\HttpClient\HttpClientInterface;
class CalendarFactory
{
public function __construct(
private readonly HttpClientInterface $httpClient,
private readonly CalendarConfig $config
) {
}
public function createCalendarService(): CalendarService
{
$service = new CalendarService($this->httpClient);
foreach ($this->config->getIcsCalendars() as $name => $url) {
$service->addIcsCalendar($url, $name);
}
return $service;
}
}

View File

@ -0,0 +1,19 @@
<?php
namespace App\Core\Home\Calendar;
/**
* Interface for calendar providers
*/
interface CalendarInterface
{
/**
* Returns all calendar events within the given time range
*/
public function getEvents(\DateTimeInterface $from, \DateTimeInterface $to): array;
/**
* Returns the name of this calendar
*/
public function getName(): string;
}

View File

@ -0,0 +1,69 @@
<?php
namespace App\Core\Home\Calendar;
use Symfony\Contracts\HttpClient\HttpClientInterface;
class CalendarService
{
/** @var CalendarInterface[] */
private array $calendarProviders = [];
public function __construct(
private readonly HttpClientInterface $httpClient
) {
}
public function addCalendar(CalendarInterface $calendar): self
{
$this->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;
}
}

View File

@ -0,0 +1,136 @@
<?php
namespace App\Core\Home\Calendar;
use DateTimeImmutable;
use Symfony\Contracts\HttpClient\HttpClientInterface;
class IcsCalendarProvider implements CalendarInterface
{
private string $url;
private string $name;
private ?string $cachedContent = null;
private ?\DateTimeInterface $lastFetch = null;
public function __construct(
private readonly HttpClientInterface $httpClient,
string $url,
?string $name = null
) {
$this->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;
}
}

View File

@ -0,0 +1,51 @@
<?php
declare(strict_types=1);
namespace App\Core\HomeAssistant;
final readonly class EntityState
{
public function __construct(
public string $entityId,
public string $state,
public array $attributes,
public string $lastChanged,
public string $lastUpdated,
public array|string|null $context = null
) {
}
public static function fromArray(array $data): self
{
return new self(
$data['entity_id'],
$data['state'],
$data['attributes'] ?? [],
$data['last_changed'] ?? '',
$data['last_updated'] ?? '',
$data['context'] ?? null
);
}
public function isOn(): bool
{
return in_array($this->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;
}
}

View File

@ -0,0 +1,90 @@
<?php
declare(strict_types=1);
namespace App\Core\HomeAssistant;
use Symfony\Contracts\HttpClient\HttpClientInterface;
use Symfony\Contracts\HttpClient\ResponseInterface;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
final class HomeAssistantClient
{
public function __construct(
private readonly HttpClientInterface $httpClient,
#[Autowire('%env(HOME_ASSISTANT_URL)%')]
private readonly string $baseUrl,
#[Autowire('%env(HOME_ASSISTANT_TOKEN)%')]
private readonly string $token,
#[Autowire('%env(HOME_ASSISTANT_VERIFY_SSL)%')]
private readonly bool $verifySSL
) {
}
public function getStates(): array
{
return $this->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);
}
}

View File

@ -0,0 +1,11 @@
<?php
declare(strict_types=1);
namespace App\Core\HomeAssistant;
use RuntimeException;
final class HomeAssistantException extends RuntimeException
{
}

View File

@ -0,0 +1,75 @@
<?php
declare(strict_types=1);
namespace App\Core\HomeAssistant;
final readonly class HomeAssistantService
{
public function __construct(
private HomeAssistantClient $client
) {
}
/**
* @return EntityState[]
*/
public function getAllEntityStates(): array
{
$states = $this->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);
}
}

View File

@ -0,0 +1,56 @@
<?php
namespace App\Core\OpenAI;
use Symfony\Component\HttpFoundation\Exception\BadRequestException;
class ChatGPTService
{
public function __construct(
private readonly OpenAIClient $openAIClient
) {
}
public function sendMessage(string $userMessage, array $previousMessages = []): string
{
$messages = $previousMessages;
$messages[] = [
'role' => '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,
];
}
}

View File

@ -0,0 +1,44 @@
<?php
namespace App\Core\OpenAI;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Symfony\Contracts\HttpClient\HttpClientInterface;
use Symfony\Contracts\HttpClient\ResponseInterface;
class OpenAIClient
{
public function __construct(
private readonly HttpClientInterface $httpClient,
#[Autowire('%env(OPENAI_API_KEY)%')]
private readonly string $apiKey,
#[Autowire('%env(OPENAI_API_URL)%')]
private readonly string $apiUrl,
#[Autowire('%env(OPENAI_MODEL)%')]
private readonly string $model,
) {
}
public function chat(array $messages, float $temperature = 0.7, int $maxTokens = 2048): array
{
$response = $this->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,
]);
}
}

View File

@ -6,20 +6,21 @@
<title>{% block title %}Task Manager{% endblock %}</title>
<link rel="icon" href="data:image/svg+xml,<svg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 128 128%22><text y=%221.2em%22 font-size=%2296%22>⚫️</text></svg>">
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
<script src="https://cdn.tailwindcss.com"></script>
{% block stylesheets %}{% endblock %}
</head>
<body>
<body class="bg-gray-50">
<div class="container py-4">
<header class="pb-3 mb-4 border-bottom">
<a href="{{ path('app_task_index') }}" class="d-flex align-items-center text-dark text-decoration-none">
<span class="fs-4">Task Manager</span>
<a href="/" class="d-flex align-items-center text-dark text-decoration-none">
<span class="fs-4">Tars AI</span>
</a>
</header>
{% block body %}{% endblock %}
<footer class="pt-3 mt-4 text-muted border-top">
&copy; {{ 'now'|date('Y') }} Task Manager
&copy; {{ 'now'|date('Y') }} Tars AI
</footer>
</div>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>

View File

@ -0,0 +1,56 @@
{% extends 'base.html.twig' %}
{% block title %}Add Calendar{% endblock %}
{% block body %}
<div class="container mt-4">
<h1>Add Calendar</h1>
{% for label, messages in app.flashes %}
{% for message in messages %}
<div class="alert alert-{{ label }}">
{{ message }}
</div>
{% endfor %}
{% endfor %}
<div class="card mb-4">
<div class="card-header">
<h5>Add New Calendar</h5>
</div>
<div class="card-body">
<form action="{{ path('calendar_add_post') }}" method="post">
<div class="mb-3">
<label for="name" class="form-label">Calendar Name</label>
<input type="text" class="form-control" id="name" name="name" required>
</div>
<div class="mb-3">
<label for="url" class="form-label">Calendar URL</label>
<input type="url" class="form-control" id="url" name="url" required
placeholder="webcal://p##-caldav.icloud.com/published/2/...">
<div class="form-text">
For Apple Calendar, use the webcal URL from iCloud.com Calendar sharing options.
</div>
</div>
<button type="submit" class="btn btn-primary">Add Calendar</button>
</form>
</div>
</div>
<div class="card">
<div class="card-header">
<h5>How to find your Apple Calendar webcal URL</h5>
</div>
<div class="card-body">
<ol>
<li>Open Calendar app on your Mac or go to iCloud.com and open Calendar</li>
<li>Right-click on the calendar you want to share</li>
<li>Select "Share Calendar..." option</li>
<li>Check "Public Calendar"</li>
<li>Copy the URL that appears (it should start with webcal://)</li>
<li>Paste it in the form above</li>
</ol>
</div>
</div>
</div>
{% endblock %}

View File

@ -0,0 +1,50 @@
{% extends 'base.html.twig' %}
{% block title %}Calendar List{% endblock %}
{% block body %}
<div class="container mt-4">
<h1>Calendar List</h1>
<div class="d-flex justify-content-end mb-3">
<a href="{{ path('calendar_add') }}" class="btn btn-primary">Add Calendar</a>
</div>
{% if calendars|length > 0 %}
<div class="table-responsive">
<table class="table table-striped">
<thead>
<tr>
<th>Name</th>
<th>URL</th>
<th>Type</th>
</tr>
</thead>
<tbody>
{% for name, url in calendars %}
<tr>
<td>{{ name }}</td>
<td class="text-truncate" style="max-width: 300px;">
<small>{{ url }}</small>
</td>
<td>
{% if url starts with 'webcal://' and 'icloud.com' in url %}
<span class="badge bg-primary">Apple Calendar</span>
{% elseif url starts with 'webcal://' %}
<span class="badge bg-secondary">Webcal</span>
{% else %}
<span class="badge bg-info">ICS</span>
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<div class="alert alert-info">
No calendars have been added yet. <a href="{{ path('calendar_add') }}">Add your first calendar</a>.
</div>
{% endif %}
</div>
{% endblock %}

View File

@ -0,0 +1,120 @@
{% extends 'base.html.twig' %}
{% block title %}ChatGPT Integration{% endblock %}
{% block body %}
<div class="container mx-auto p-4 max-w-4xl">
<h1 class="text-3xl font-bold mb-6 text-gray-800">ChatGPT Integration</h1>
<div id="chat-container" class="bg-white rounded-lg shadow-md p-4 mb-4 min-h-80 max-h-96 overflow-y-auto flex flex-col">
<div id="chat-messages" class="flex-grow">
<div class="message system p-3 mb-3 bg-gray-100 rounded-lg">
<p>Hello! How can I help you today?</p>
</div>
</div>
</div>
<div class="flex mt-4">
<input
type="text"
id="user-input"
class="flex-grow px-4 py-2 border border-gray-300 rounded-l-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
placeholder="Type your message..."
>
<button
id="send-button"
class="bg-blue-500 text-white px-6 py-2 rounded-r-lg hover:bg-blue-600 focus:outline-none focus:ring-2 focus:ring-blue-500"
>
Send
</button>
</div>
</div>
{% endblock %}
{% block javascripts %}
{{ parent() }}
<script>
document.addEventListener('DOMContentLoaded', function() {
const chatMessages = document.getElementById('chat-messages');
const userInput = document.getElementById('user-input');
const sendButton = document.getElementById('send-button');
let conversation = [];
function addMessage(content, role) {
const messageDiv = document.createElement('div');
messageDiv.className = `message ${role} p-3 mb-3 rounded-lg ${role === 'user' ? 'bg-blue-100 ml-12' : 'bg-gray-100 mr-12'}`;
const messagePara = document.createElement('p');
messagePara.textContent = content;
messageDiv.appendChild(messagePara);
chatMessages.appendChild(messageDiv);
// Scroll to bottom
const chatContainer = document.getElementById('chat-container');
chatContainer.scrollTop = chatContainer.scrollHeight;
}
function sendMessage() {
const message = userInput.value.trim();
if (!message) return;
// Add user message to chat
addMessage(message, 'user');
// Clear input
userInput.value = '';
// Show loading indicator
const loadingDiv = document.createElement('div');
loadingDiv.className = 'message assistant p-3 mb-3 bg-gray-100 rounded-lg mr-12';
loadingDiv.innerHTML = '<p>Thinking...</p>';
chatMessages.appendChild(loadingDiv);
// Send to API
fetch('/api/chat', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
message: message,
conversation: conversation
})
})
.then(response => response.json())
.then(data => {
// Remove loading indicator
chatMessages.removeChild(loadingDiv);
if (data.error) {
// Show error
addMessage('Error: ' + data.error, 'system');
} else {
// Show response
addMessage(data.response, 'assistant');
// Update conversation history
conversation = data.conversation;
}
})
.catch(error => {
// Remove loading indicator
chatMessages.removeChild(loadingDiv);
// Show error
addMessage('Error communicating with the server. Please try again.', 'system');
console.error('Error:', error);
});
}
// Event listeners
sendButton.addEventListener('click', sendMessage);
userInput.addEventListener('keypress', function(e) {
if (e.key === 'Enter') {
sendMessage();
}
});
});
</script>
{% endblock %}

View File

@ -0,0 +1,80 @@
<?php
namespace App\Tests\Core\Home\Calendar;
use App\Core\Home\Calendar\CalendarEvent;
use App\Core\Home\Calendar\CalendarInterface;
use App\Core\Home\Calendar\CalendarService;
use PHPUnit\Framework\TestCase;
use Symfony\Contracts\HttpClient\HttpClientInterface;
class CalendarServiceTest extends TestCase
{
private CalendarService $calendarService;
private HttpClientInterface $httpClient;
protected function setUp(): void
{
$this->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
);
}
}