Added chatgpt, calendar and home assistant integration
This commit is contained in:
parent
27b847a9a9
commit
e8547bd341
@ -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 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 are a full stack developer with expert knowledge Symfony and Docker.
|
||||||
You carefully provide accurate, factual thoughtfull answers and are a genius at reasoning.
|
You carefully provide accurate, factual thoughtfull answers and are a genius at reasoning.
|
||||||
12
.env
12
.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="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"
|
DATABASE_URL="postgresql://app:!ChangeMe!@127.0.0.1:5432/app?serverVersion=16&charset=utf8"
|
||||||
###< doctrine/doctrine-bundle ###
|
###< 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 ###
|
||||||
|
|||||||
@ -15,6 +15,7 @@
|
|||||||
"symfony/dotenv": "7.2.*",
|
"symfony/dotenv": "7.2.*",
|
||||||
"symfony/flex": "^2",
|
"symfony/flex": "^2",
|
||||||
"symfony/framework-bundle": "7.2.*",
|
"symfony/framework-bundle": "7.2.*",
|
||||||
|
"symfony/http-client": "7.2.*",
|
||||||
"symfony/runtime": "7.2.*",
|
"symfony/runtime": "7.2.*",
|
||||||
"symfony/twig-bundle": "7.2.*",
|
"symfony/twig-bundle": "7.2.*",
|
||||||
"symfony/yaml": "7.2.*"
|
"symfony/yaml": "7.2.*"
|
||||||
|
|||||||
175
composer.lock
generated
175
composer.lock
generated
@ -4,7 +4,7 @@
|
|||||||
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
|
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
|
||||||
"This file is @generated automatically"
|
"This file is @generated automatically"
|
||||||
],
|
],
|
||||||
"content-hash": "b6035c82f9404fcb793ed5458974888b",
|
"content-hash": "7d2859d190dfcf0c235a4cf526f044af",
|
||||||
"packages": [
|
"packages": [
|
||||||
{
|
{
|
||||||
"name": "doctrine/cache",
|
"name": "doctrine/cache",
|
||||||
@ -2673,6 +2673,179 @@
|
|||||||
],
|
],
|
||||||
"time": "2025-02-26T08:19:39+00:00"
|
"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",
|
"name": "symfony/http-foundation",
|
||||||
"version": "v7.2.3",
|
"version": "v7.2.3",
|
||||||
|
|||||||
3
config/secrets/dev/dev.HOME_ASSISTANT_TOKEN.ad10d3.php
Normal file
3
config/secrets/dev/dev.HOME_ASSISTANT_TOKEN.ad10d3.php
Normal 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";
|
||||||
3
config/secrets/dev/dev.OPENAI_API_KEY.66949e.php
Normal file
3
config/secrets/dev/dev.OPENAI_API_KEY.66949e.php
Normal 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";
|
||||||
4
config/secrets/dev/dev.decrypt.private.php
Normal file
4
config/secrets/dev/dev.decrypt.private.php
Normal 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";
|
||||||
3
config/secrets/dev/dev.encrypt.public.php
Normal file
3
config/secrets/dev/dev.encrypt.public.php
Normal 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";
|
||||||
6
config/secrets/dev/dev.list.php
Normal file
6
config/secrets/dev/dev.list.php
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
return [
|
||||||
|
'HOME_ASSISTANT_TOKEN' => null,
|
||||||
|
'OPENAI_API_KEY' => null,
|
||||||
|
];
|
||||||
3
config/secrets/prod/prod.HOME_ASSISTANT_TOKEN.ad10d3.php
Normal file
3
config/secrets/prod/prod.HOME_ASSISTANT_TOKEN.ad10d3.php
Normal 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";
|
||||||
3
config/secrets/prod/prod.OPENAI_API_KEY.66949e.php
Normal file
3
config/secrets/prod/prod.OPENAI_API_KEY.66949e.php
Normal 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";
|
||||||
4
config/secrets/prod/prod.decrypt.private.php
Normal file
4
config/secrets/prod/prod.decrypt.private.php
Normal 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";
|
||||||
3
config/secrets/prod/prod.encrypt.public.php
Normal file
3
config/secrets/prod/prod.encrypt.public.php
Normal 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";
|
||||||
6
config/secrets/prod/prod.list.php
Normal file
6
config/secrets/prod/prod.list.php
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
return [
|
||||||
|
'HOME_ASSISTANT_TOKEN' => null,
|
||||||
|
'OPENAI_API_KEY' => null,
|
||||||
|
];
|
||||||
@ -4,6 +4,10 @@
|
|||||||
# Put parameters here that don't need to change on each machine where the app is deployed
|
# 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
|
# https://symfony.com/doc/current/best_practices.html#use-parameters-for-application-configuration
|
||||||
parameters:
|
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:
|
services:
|
||||||
# default configuration for services in *this* file
|
# default configuration for services in *this* file
|
||||||
@ -20,5 +24,16 @@ services:
|
|||||||
- '../src/Entity/'
|
- '../src/Entity/'
|
||||||
- '../src/Kernel.php'
|
- '../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
|
# add more service definitions when explicit configuration is needed
|
||||||
# please note that last definitions always *replace* previous ones
|
# please note that last definitions always *replace* previous ones
|
||||||
|
|||||||
63
src/Command/ChatGPTCommand.php
Normal file
63
src/Command/ChatGPTCommand.php
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
122
src/Command/HomeAssistantCommand.php
Normal file
122
src/Command/HomeAssistantCommand.php
Normal 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],
|
||||||
|
]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
79
src/Command/ReadCalendarCommand.php
Normal file
79
src/Command/ReadCalendarCommand.php
Normal 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
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
37
src/Command/RunAgentCommand.php
Normal file
37
src/Command/RunAgentCommand.php
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
52
src/Controller/ChatController.php
Normal file
52
src/Controller/ChatController.php
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
16
src/Controller/WebController.php
Normal file
16
src/Controller/WebController.php
Normal 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
54
src/Core/Agent/Agent.php
Normal 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')
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
29
src/Core/Agent/PromptProvider.php
Normal file
29
src/Core/Agent/PromptProvider.php
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
34
src/Core/Home/Calendar/CalendarConfig.php
Normal file
34
src/Core/Home/Calendar/CalendarConfig.php
Normal 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]);
|
||||||
|
}
|
||||||
|
}
|
||||||
25
src/Core/Home/Calendar/CalendarConfigFactory.php
Normal file
25
src/Core/Home/Calendar/CalendarConfigFactory.php
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
64
src/Core/Home/Calendar/CalendarEvent.php
Normal file
64
src/Core/Home/Calendar/CalendarEvent.php
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
25
src/Core/Home/Calendar/CalendarFactory.php
Normal file
25
src/Core/Home/Calendar/CalendarFactory.php
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
19
src/Core/Home/Calendar/CalendarInterface.php
Normal file
19
src/Core/Home/Calendar/CalendarInterface.php
Normal 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;
|
||||||
|
}
|
||||||
69
src/Core/Home/Calendar/CalendarService.php
Normal file
69
src/Core/Home/Calendar/CalendarService.php
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
136
src/Core/Home/Calendar/IcsCalendarProvider.php
Normal file
136
src/Core/Home/Calendar/IcsCalendarProvider.php
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
51
src/Core/HomeAssistant/EntityState.php
Normal file
51
src/Core/HomeAssistant/EntityState.php
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
90
src/Core/HomeAssistant/HomeAssistantClient.php
Normal file
90
src/Core/HomeAssistant/HomeAssistantClient.php
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
11
src/Core/HomeAssistant/HomeAssistantException.php
Normal file
11
src/Core/HomeAssistant/HomeAssistantException.php
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Core\HomeAssistant;
|
||||||
|
|
||||||
|
use RuntimeException;
|
||||||
|
|
||||||
|
final class HomeAssistantException extends RuntimeException
|
||||||
|
{
|
||||||
|
}
|
||||||
75
src/Core/HomeAssistant/HomeAssistantService.php
Normal file
75
src/Core/HomeAssistant/HomeAssistantService.php
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
56
src/Core/OpenAI/ChatGPTService.php
Normal file
56
src/Core/OpenAI/ChatGPTService.php
Normal 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,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
44
src/Core/OpenAI/OpenAIClient.php
Normal file
44
src/Core/OpenAI/OpenAIClient.php
Normal 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,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -6,20 +6,21 @@
|
|||||||
<title>{% block title %}Task Manager{% endblock %}</title>
|
<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 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">
|
<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 %}
|
{% block stylesheets %}{% endblock %}
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body class="bg-gray-50">
|
||||||
<div class="container py-4">
|
<div class="container py-4">
|
||||||
<header class="pb-3 mb-4 border-bottom">
|
<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">
|
<a href="/" class="d-flex align-items-center text-dark text-decoration-none">
|
||||||
<span class="fs-4">Task Manager</span>
|
<span class="fs-4">Tars AI</span>
|
||||||
</a>
|
</a>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
{% block body %}{% endblock %}
|
{% block body %}{% endblock %}
|
||||||
|
|
||||||
<footer class="pt-3 mt-4 text-muted border-top">
|
<footer class="pt-3 mt-4 text-muted border-top">
|
||||||
© {{ 'now'|date('Y') }} Task Manager
|
© {{ 'now'|date('Y') }} Tars AI
|
||||||
</footer>
|
</footer>
|
||||||
</div>
|
</div>
|
||||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
|
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
|
||||||
|
|||||||
56
templates/calendar/add.html.twig
Normal file
56
templates/calendar/add.html.twig
Normal 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 %}
|
||||||
50
templates/calendar/list.html.twig
Normal file
50
templates/calendar/list.html.twig
Normal 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 %}
|
||||||
120
templates/chat/index.html.twig
Normal file
120
templates/chat/index.html.twig
Normal 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 %}
|
||||||
80
tests/Core/Home/Calendar/CalendarServiceTest.php
Normal file
80
tests/Core/Home/Calendar/CalendarServiceTest.php
Normal 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
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
x
Reference in New Issue
Block a user