neoagent 2.3.0 → 2.3.1-beta.10
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.env.example +13 -0
- package/README.md +3 -1
- package/docs/automation.md +1 -1
- package/docs/capabilities.md +2 -2
- package/docs/configuration.md +14 -1
- package/docs/integrations.md +6 -1
- package/lib/manager.js +127 -1
- package/package.json +2 -1
- package/server/db/database.js +68 -0
- package/server/http/middleware.js +50 -0
- package/server/http/routes.js +3 -1
- package/server/index.js +1 -0
- package/server/public/.last_build_id +1 -1
- package/server/public/assets/NOTICES +61 -0
- package/server/public/assets/fonts/MaterialIcons-Regular.otf +0 -0
- package/server/public/flutter_bootstrap.js +1 -1
- package/server/public/main.dart.js +61049 -60444
- package/server/routes/integrations.js +97 -8
- package/server/routes/memory.js +11 -2
- package/server/routes/screenHistory.js +46 -0
- package/server/routes/triggers.js +81 -0
- package/server/services/ai/engine.js +9 -0
- package/server/services/ai/models.js +30 -0
- package/server/services/ai/providers/githubCopilot.js +97 -0
- package/server/services/ai/providers/openaiCodex.js +26 -0
- package/server/services/ai/settings.js +20 -0
- package/server/services/ai/systemPrompt.js +1 -1
- package/server/services/ai/tools.js +150 -11
- package/server/services/browser/controller.js +47 -3
- package/server/services/desktop/screenRecorder.js +126 -0
- package/server/services/integrations/env.js +19 -0
- package/server/services/integrations/github/common.js +106 -0
- package/server/services/integrations/github/provider.js +499 -0
- package/server/services/integrations/github/repos.js +1124 -0
- package/server/services/integrations/home_assistant/provider.js +630 -0
- package/server/services/integrations/manager.js +63 -7
- package/server/services/integrations/oauth_provider.js +13 -6
- package/server/services/integrations/provider_config_store.js +76 -0
- package/server/services/integrations/registry.js +10 -0
- package/server/services/integrations/spotify/provider.js +487 -0
- package/server/services/integrations/weather/provider.js +559 -0
- package/server/services/integrations/whatsapp/provider.js +6 -2
- package/server/services/manager.js +22 -0
- package/server/services/memory/manager.js +39 -2
- package/server/services/messaging/manager.js +29 -7
- package/server/services/skills/base_catalog.js +33 -0
- package/server/services/tasks/adapters/index.js +2 -0
- package/server/services/tasks/adapters/manual.js +12 -0
- package/server/services/tasks/adapters/schedule.js +33 -5
- package/server/services/tasks/adapters/weather_event.js +84 -0
- package/server/services/tasks/integration_runtime.js +85 -0
- package/server/services/tasks/runtime.js +2 -2
- package/server/services/voice/agentBridge.js +20 -4
- package/server/services/voice/message.js +3 -0
- package/server/services/voice/openaiClient.js +4 -1
- package/server/services/voice/providers.js +2 -1
- package/server/services/voice/runtimeManager.js +136 -1
- package/server/services/widgets/service.js +49 -4
- package/server/utils/local_secrets.js +56 -0
- package/server/utils/logger.js +37 -9
|
@@ -0,0 +1,630 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const crypto = require('crypto');
|
|
4
|
+
const net = require('net');
|
|
5
|
+
const ipaddr = require('ipaddr.js');
|
|
6
|
+
const {
|
|
7
|
+
describeEnvStatus,
|
|
8
|
+
resolveHomeAssistantOAuthConfig,
|
|
9
|
+
} = require('../env');
|
|
10
|
+
const {
|
|
11
|
+
deleteProviderConfig,
|
|
12
|
+
getProviderConfig,
|
|
13
|
+
setProviderConfig,
|
|
14
|
+
} = require('../provider_config_store');
|
|
15
|
+
const {
|
|
16
|
+
appendQuery,
|
|
17
|
+
createOAuthProvider,
|
|
18
|
+
fetchJson,
|
|
19
|
+
} = require('../oauth_provider');
|
|
20
|
+
|
|
21
|
+
const HOME_ASSISTANT_APPS = [
|
|
22
|
+
{
|
|
23
|
+
id: 'home_assistant',
|
|
24
|
+
label: 'Home Assistant',
|
|
25
|
+
description: 'Connect Home Assistant for entity state queries, service calls, and API access.',
|
|
26
|
+
scopes: ['homeassistant'],
|
|
27
|
+
},
|
|
28
|
+
];
|
|
29
|
+
|
|
30
|
+
const homeAssistantToolDefinitions = [
|
|
31
|
+
{
|
|
32
|
+
appId: 'home_assistant',
|
|
33
|
+
name: 'home_assistant_get_config',
|
|
34
|
+
access: 'read',
|
|
35
|
+
description: 'Get Home Assistant configuration details for the connected instance.',
|
|
36
|
+
parameters: { type: 'object', properties: {} },
|
|
37
|
+
},
|
|
38
|
+
{
|
|
39
|
+
appId: 'home_assistant',
|
|
40
|
+
name: 'home_assistant_list_states',
|
|
41
|
+
access: 'read',
|
|
42
|
+
description: 'List entity states from Home Assistant. Optional filters are applied client-side.',
|
|
43
|
+
parameters: {
|
|
44
|
+
type: 'object',
|
|
45
|
+
properties: {
|
|
46
|
+
domain: {
|
|
47
|
+
type: 'string',
|
|
48
|
+
description: 'Optional entity domain filter, for example light, climate, or switch.',
|
|
49
|
+
},
|
|
50
|
+
limit: {
|
|
51
|
+
type: 'number',
|
|
52
|
+
description: 'Optional max entities to return, default 100.',
|
|
53
|
+
},
|
|
54
|
+
},
|
|
55
|
+
},
|
|
56
|
+
},
|
|
57
|
+
{
|
|
58
|
+
appId: 'home_assistant',
|
|
59
|
+
name: 'home_assistant_get_state',
|
|
60
|
+
access: 'read',
|
|
61
|
+
description: 'Get state for a single Home Assistant entity by entity_id.',
|
|
62
|
+
parameters: {
|
|
63
|
+
type: 'object',
|
|
64
|
+
properties: {
|
|
65
|
+
entity_id: { type: 'string', description: 'Entity ID, for example light.living_room.' },
|
|
66
|
+
},
|
|
67
|
+
required: ['entity_id'],
|
|
68
|
+
},
|
|
69
|
+
},
|
|
70
|
+
{
|
|
71
|
+
appId: 'home_assistant',
|
|
72
|
+
name: 'home_assistant_call_service',
|
|
73
|
+
access: 'write',
|
|
74
|
+
description: 'Call a Home Assistant service such as light.turn_on or climate.set_temperature.',
|
|
75
|
+
parameters: {
|
|
76
|
+
type: 'object',
|
|
77
|
+
properties: {
|
|
78
|
+
domain: { type: 'string', description: 'Service domain, for example light, climate, or switch.' },
|
|
79
|
+
service: { type: 'string', description: 'Service name, for example turn_on, turn_off, or set_temperature.' },
|
|
80
|
+
service_data: { type: 'object', description: 'Optional Home Assistant service data payload.' },
|
|
81
|
+
},
|
|
82
|
+
required: ['domain', 'service'],
|
|
83
|
+
},
|
|
84
|
+
},
|
|
85
|
+
{
|
|
86
|
+
appId: 'home_assistant',
|
|
87
|
+
name: 'home_assistant_api_request',
|
|
88
|
+
access: 'dynamic_http_method',
|
|
89
|
+
description: 'Make an authenticated Home Assistant REST API request for advanced operations.',
|
|
90
|
+
parameters: {
|
|
91
|
+
type: 'object',
|
|
92
|
+
properties: {
|
|
93
|
+
method: { type: 'string', description: 'HTTP method: GET, POST, PUT, PATCH, or DELETE.' },
|
|
94
|
+
path: { type: 'string', description: 'API path under the configured instance, for example /api/history/period.' },
|
|
95
|
+
query: { type: 'object', description: 'Optional query parameters.' },
|
|
96
|
+
body: { type: 'object', description: 'Optional JSON body.' },
|
|
97
|
+
},
|
|
98
|
+
required: ['method', 'path'],
|
|
99
|
+
},
|
|
100
|
+
},
|
|
101
|
+
];
|
|
102
|
+
|
|
103
|
+
function trimText(value) {
|
|
104
|
+
return String(value || '').trim();
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function isTruthyEnv(name) {
|
|
108
|
+
const value = String(process.env[name] || '').trim().toLowerCase();
|
|
109
|
+
return value === '1' || value === 'true' || value === 'yes' || value === 'on';
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function isLikelyLocalHostname(hostname) {
|
|
113
|
+
const host = String(hostname || '').trim().toLowerCase();
|
|
114
|
+
if (!host) return false;
|
|
115
|
+
if (host === 'localhost' || host === 'host.docker.internal') return true;
|
|
116
|
+
if (host.endsWith('.localhost')) return true;
|
|
117
|
+
if (host.endsWith('.local') || host.endsWith('.lan') || host.endsWith('.internal')) {
|
|
118
|
+
return true;
|
|
119
|
+
}
|
|
120
|
+
return false;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function isPrivateIpv4(hostname) {
|
|
124
|
+
const parts = String(hostname || '').split('.').map((part) => Number(part));
|
|
125
|
+
if (parts.length !== 4 || parts.some((part) => !Number.isInteger(part) || part < 0 || part > 255)) {
|
|
126
|
+
return false;
|
|
127
|
+
}
|
|
128
|
+
const [a, b] = parts;
|
|
129
|
+
if (a === 10) return true;
|
|
130
|
+
if (a === 127) return true;
|
|
131
|
+
if (a === 169 && b === 254) return true;
|
|
132
|
+
if (a === 172 && b >= 16 && b <= 31) return true;
|
|
133
|
+
if (a === 192 && b === 168) return true;
|
|
134
|
+
if (a === 100 && b >= 64 && b <= 127) return true;
|
|
135
|
+
if (a === 0) return true;
|
|
136
|
+
return false;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function isPrivateIpv6(hostname) {
|
|
140
|
+
const host = String(hostname || '').trim().replace(/^\[|\]$/g, '');
|
|
141
|
+
if (!host) return false;
|
|
142
|
+
try {
|
|
143
|
+
const parsed = ipaddr.parse(host);
|
|
144
|
+
if (parsed.kind() !== 'ipv6') return false;
|
|
145
|
+
|
|
146
|
+
// Normalize IPv4-mapped IPv6 literals and enforce the same local/private rules.
|
|
147
|
+
if (parsed.isIPv4MappedAddress()) {
|
|
148
|
+
return isPrivateIpv4(parsed.toIPv4Address().toString());
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
const range = parsed.range();
|
|
152
|
+
return range === 'loopback' || range === 'uniqueLocal' || range === 'linkLocal' || range === 'unspecified';
|
|
153
|
+
} catch {
|
|
154
|
+
return false;
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function isPrivateOrLocalIp(hostname) {
|
|
159
|
+
const host = String(hostname || '').trim().replace(/^\[|\]$/g, '');
|
|
160
|
+
const kind = net.isIP(host);
|
|
161
|
+
if (kind === 4) return isPrivateIpv4(host);
|
|
162
|
+
if (kind === 6) return isPrivateIpv6(host);
|
|
163
|
+
return false;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function validateHomeAssistantBaseUrlSafety(parsedUrl) {
|
|
167
|
+
const allowPrivate = isTruthyEnv('HOME_ASSISTANT_ALLOW_PRIVATE_BASE_URL');
|
|
168
|
+
const host = String(parsedUrl.hostname || '').trim();
|
|
169
|
+
const localHostname = isLikelyLocalHostname(host);
|
|
170
|
+
const localIp = isPrivateOrLocalIp(host);
|
|
171
|
+
const isLocalTarget = localHostname || localIp;
|
|
172
|
+
|
|
173
|
+
if (isLocalTarget && !allowPrivate) {
|
|
174
|
+
throw new Error(
|
|
175
|
+
'Home Assistant base URL cannot target localhost/private network addresses unless HOME_ASSISTANT_ALLOW_PRIVATE_BASE_URL=1 is set on the server.',
|
|
176
|
+
);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
if (parsedUrl.protocol === 'http:' && !isLocalTarget) {
|
|
180
|
+
throw new Error('Home Assistant base URL must use HTTPS for non-local hosts.');
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function normalizeBaseUrl(value) {
|
|
185
|
+
const text = trimText(value);
|
|
186
|
+
if (!text) return '';
|
|
187
|
+
return text.replace(/\/$/, '');
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function normalizeOptionalAbsoluteUrl(value, label) {
|
|
191
|
+
const text = trimText(value);
|
|
192
|
+
if (!text) return '';
|
|
193
|
+
try {
|
|
194
|
+
const parsed = new URL(text);
|
|
195
|
+
if (!/^https?:$/.test(parsed.protocol)) {
|
|
196
|
+
throw new Error(`${label} must use http or https.`);
|
|
197
|
+
}
|
|
198
|
+
return parsed.toString().replace(/\/$/, '');
|
|
199
|
+
} catch {
|
|
200
|
+
throw new Error(`${label} must be a valid absolute URL.`);
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
function normalizeHomeAssistantBaseUrl(value) {
|
|
205
|
+
const text = trimText(value);
|
|
206
|
+
if (!text) return '';
|
|
207
|
+
let parsed;
|
|
208
|
+
try {
|
|
209
|
+
parsed = new URL(text);
|
|
210
|
+
} catch {
|
|
211
|
+
throw new Error('Home Assistant base URL must be a valid absolute URL.');
|
|
212
|
+
}
|
|
213
|
+
if (!/^https?:$/.test(parsed.protocol)) {
|
|
214
|
+
throw new Error('Home Assistant base URL must use http or https.');
|
|
215
|
+
}
|
|
216
|
+
validateHomeAssistantBaseUrlSafety(parsed);
|
|
217
|
+
return parsed.toString().replace(/\/$/, '');
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
function normalizeUserHomeAssistantConfig(rawConfig) {
|
|
221
|
+
const source = rawConfig && typeof rawConfig === 'object' ? rawConfig : {};
|
|
222
|
+
return {
|
|
223
|
+
baseUrl: normalizeBaseUrl(source.baseUrl),
|
|
224
|
+
clientId: trimText(source.clientId),
|
|
225
|
+
clientSecret: trimText(source.clientSecret),
|
|
226
|
+
redirectUri: trimText(source.redirectUri),
|
|
227
|
+
};
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
function resolveUserHomeAssistantConfig(userId) {
|
|
231
|
+
const userConfig = normalizeUserHomeAssistantConfig(
|
|
232
|
+
Number.isInteger(Number(userId)) && Number(userId) > 0
|
|
233
|
+
? getProviderConfig(Number(userId), 'home_assistant')
|
|
234
|
+
: {},
|
|
235
|
+
);
|
|
236
|
+
const envConfig = resolveHomeAssistantOAuthConfig();
|
|
237
|
+
return {
|
|
238
|
+
baseUrl: userConfig.baseUrl || envConfig.baseUrl,
|
|
239
|
+
clientId: userConfig.clientId || envConfig.clientId,
|
|
240
|
+
clientSecret: userConfig.clientSecret || envConfig.clientSecret,
|
|
241
|
+
redirectUri: userConfig.redirectUri || envConfig.redirectUri,
|
|
242
|
+
};
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
function validateResolvedConfig(config) {
|
|
246
|
+
const missing = [];
|
|
247
|
+
if (!trimText(config.baseUrl)) missing.push('baseUrl');
|
|
248
|
+
if (!trimText(config.clientId)) missing.push('clientId');
|
|
249
|
+
if (!trimText(config.clientSecret)) missing.push('clientSecret');
|
|
250
|
+
return {
|
|
251
|
+
configured: missing.length === 0,
|
|
252
|
+
missing,
|
|
253
|
+
};
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
function resolveHomeAssistantConfigForUser(userId) {
|
|
257
|
+
const merged = resolveUserHomeAssistantConfig(userId);
|
|
258
|
+
const validatedBaseUrl = merged.baseUrl
|
|
259
|
+
? normalizeHomeAssistantBaseUrl(merged.baseUrl)
|
|
260
|
+
: '';
|
|
261
|
+
const validatedRedirectUri = merged.redirectUri
|
|
262
|
+
? normalizeOptionalAbsoluteUrl(
|
|
263
|
+
merged.redirectUri,
|
|
264
|
+
'Home Assistant OAuth redirect URI',
|
|
265
|
+
)
|
|
266
|
+
: '';
|
|
267
|
+
const result = {
|
|
268
|
+
baseUrl: validatedBaseUrl,
|
|
269
|
+
clientId: trimText(merged.clientId),
|
|
270
|
+
clientSecret: trimText(merged.clientSecret),
|
|
271
|
+
redirectUri: validatedRedirectUri,
|
|
272
|
+
};
|
|
273
|
+
const status = validateResolvedConfig(result);
|
|
274
|
+
return {
|
|
275
|
+
...result,
|
|
276
|
+
configured: status.configured,
|
|
277
|
+
missing: status.missing,
|
|
278
|
+
};
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
function sanitizeHomeAssistantUserConfigForClient(rawConfig) {
|
|
282
|
+
const config = normalizeUserHomeAssistantConfig(rawConfig);
|
|
283
|
+
return {
|
|
284
|
+
baseUrl: config.baseUrl,
|
|
285
|
+
clientId: config.clientId,
|
|
286
|
+
redirectUri: config.redirectUri,
|
|
287
|
+
hasClientSecret: Boolean(config.clientSecret),
|
|
288
|
+
};
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
function parseHomeAssistantConfigInput(input, existingConfig = {}) {
|
|
292
|
+
const source = input && typeof input === 'object' ? input : {};
|
|
293
|
+
const baseUrl = normalizeHomeAssistantBaseUrl(source.baseUrl);
|
|
294
|
+
const clientId = trimText(source.clientId);
|
|
295
|
+
const clientSecret =
|
|
296
|
+
trimText(source.clientSecret) || trimText(existingConfig.clientSecret);
|
|
297
|
+
const redirectUri = source.redirectUri
|
|
298
|
+
? normalizeOptionalAbsoluteUrl(
|
|
299
|
+
source.redirectUri,
|
|
300
|
+
'Home Assistant OAuth redirect URI',
|
|
301
|
+
)
|
|
302
|
+
: '';
|
|
303
|
+
|
|
304
|
+
if (!baseUrl) {
|
|
305
|
+
throw new Error('Home Assistant base URL is required.');
|
|
306
|
+
}
|
|
307
|
+
if (!clientId) {
|
|
308
|
+
throw new Error('Home Assistant OAuth client ID is required.');
|
|
309
|
+
}
|
|
310
|
+
if (!clientSecret) {
|
|
311
|
+
throw new Error('Home Assistant OAuth client secret is required.');
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
return {
|
|
315
|
+
baseUrl,
|
|
316
|
+
clientId,
|
|
317
|
+
clientSecret,
|
|
318
|
+
redirectUri,
|
|
319
|
+
};
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
function saveHomeAssistantUserConfig(userId, input) {
|
|
323
|
+
const normalizedUserId = Number(userId);
|
|
324
|
+
if (!Number.isInteger(normalizedUserId) || normalizedUserId <= 0) {
|
|
325
|
+
throw new Error('A valid user is required to save Home Assistant configuration.');
|
|
326
|
+
}
|
|
327
|
+
const existingConfig = normalizeUserHomeAssistantConfig(
|
|
328
|
+
getProviderConfig(normalizedUserId, 'home_assistant'),
|
|
329
|
+
);
|
|
330
|
+
const config = parseHomeAssistantConfigInput(input, existingConfig);
|
|
331
|
+
setProviderConfig(normalizedUserId, 'home_assistant', config);
|
|
332
|
+
return sanitizeHomeAssistantUserConfigForClient(config);
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
function getHomeAssistantUserConfig(userId) {
|
|
336
|
+
const normalizedUserId = Number(userId);
|
|
337
|
+
if (!Number.isInteger(normalizedUserId) || normalizedUserId <= 0) {
|
|
338
|
+
return sanitizeHomeAssistantUserConfigForClient({});
|
|
339
|
+
}
|
|
340
|
+
return sanitizeHomeAssistantUserConfigForClient(
|
|
341
|
+
getProviderConfig(normalizedUserId, 'home_assistant'),
|
|
342
|
+
);
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
function requireText(value, label) {
|
|
346
|
+
const text = String(value || '').trim();
|
|
347
|
+
if (!text) throw new Error(`${label} is required.`);
|
|
348
|
+
return text;
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
function homeAssistantUrl(baseUrl, path, query) {
|
|
352
|
+
const normalizedBase = String(baseUrl || '').trim().replace(/\/$/, '');
|
|
353
|
+
if (!normalizedBase) {
|
|
354
|
+
throw new Error('Home Assistant base URL is required.');
|
|
355
|
+
}
|
|
356
|
+
const url = new URL(
|
|
357
|
+
String(path || '').startsWith('http')
|
|
358
|
+
? String(path)
|
|
359
|
+
: `${normalizedBase}${String(path || '').startsWith('/') ? '' : '/'}${path}`,
|
|
360
|
+
);
|
|
361
|
+
if (new URL(normalizedBase).origin !== url.origin) {
|
|
362
|
+
throw new Error('Home Assistant request URL must stay on HOME_ASSISTANT_BASE_URL origin.');
|
|
363
|
+
}
|
|
364
|
+
for (const [key, value] of Object.entries(query || {})) {
|
|
365
|
+
if (value !== undefined && value !== null) url.searchParams.set(key, String(value));
|
|
366
|
+
}
|
|
367
|
+
return url.toString();
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
async function homeAssistantRequest(credentials, options = {}) {
|
|
371
|
+
const config = resolveHomeAssistantConfigForUser(options.userId);
|
|
372
|
+
const accessToken = String(credentials?.access_token || '').trim();
|
|
373
|
+
if (!accessToken) {
|
|
374
|
+
throw new Error('Home Assistant access token is missing. Reconnect this integration account.');
|
|
375
|
+
}
|
|
376
|
+
const method = String(options.method || 'GET').toUpperCase();
|
|
377
|
+
return fetchJson(
|
|
378
|
+
homeAssistantUrl(config.baseUrl, options.path, options.query),
|
|
379
|
+
{
|
|
380
|
+
method,
|
|
381
|
+
headers: {
|
|
382
|
+
Authorization: `Bearer ${accessToken}`,
|
|
383
|
+
},
|
|
384
|
+
...(options.body === undefined ? {} : { json: options.body }),
|
|
385
|
+
},
|
|
386
|
+
{ serviceName: 'Home Assistant' },
|
|
387
|
+
);
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
async function executeHomeAssistantTool(toolName, args, { connection, credentials }) {
|
|
391
|
+
switch (toolName) {
|
|
392
|
+
case 'home_assistant_get_config':
|
|
393
|
+
return {
|
|
394
|
+
result: await homeAssistantRequest(credentials, {
|
|
395
|
+
path: '/api/config',
|
|
396
|
+
userId: connection?.user_id,
|
|
397
|
+
}),
|
|
398
|
+
};
|
|
399
|
+
case 'home_assistant_list_states': {
|
|
400
|
+
const states = await homeAssistantRequest(credentials, {
|
|
401
|
+
path: '/api/states',
|
|
402
|
+
userId: connection?.user_id,
|
|
403
|
+
});
|
|
404
|
+
const domain = String(args.domain || '').trim().toLowerCase();
|
|
405
|
+
const limit = Math.max(1, Math.min(Number(args.limit) || 100, 500));
|
|
406
|
+
const filtered = Array.isArray(states)
|
|
407
|
+
? states.filter((item) => {
|
|
408
|
+
if (!domain) return true;
|
|
409
|
+
const entityId = String(item?.entity_id || '').toLowerCase();
|
|
410
|
+
return entityId.startsWith(`${domain}.`);
|
|
411
|
+
})
|
|
412
|
+
: [];
|
|
413
|
+
return {
|
|
414
|
+
result: filtered.slice(0, limit),
|
|
415
|
+
};
|
|
416
|
+
}
|
|
417
|
+
case 'home_assistant_get_state':
|
|
418
|
+
return {
|
|
419
|
+
result: await homeAssistantRequest(credentials, {
|
|
420
|
+
path: `/api/states/${encodeURIComponent(requireText(args.entity_id, 'entity_id'))}`,
|
|
421
|
+
userId: connection?.user_id,
|
|
422
|
+
}),
|
|
423
|
+
};
|
|
424
|
+
case 'home_assistant_call_service':
|
|
425
|
+
return {
|
|
426
|
+
result: await homeAssistantRequest(credentials, {
|
|
427
|
+
method: 'POST',
|
|
428
|
+
path: `/api/services/${encodeURIComponent(requireText(args.domain, 'domain'))}/${encodeURIComponent(requireText(args.service, 'service'))}`,
|
|
429
|
+
body: args.service_data || {},
|
|
430
|
+
userId: connection?.user_id,
|
|
431
|
+
}),
|
|
432
|
+
};
|
|
433
|
+
case 'home_assistant_api_request':
|
|
434
|
+
return {
|
|
435
|
+
result: await homeAssistantRequest(credentials, {
|
|
436
|
+
method: args.method,
|
|
437
|
+
path: requireText(args.path, 'path'),
|
|
438
|
+
query: args.query,
|
|
439
|
+
body: args.body,
|
|
440
|
+
userId: connection?.user_id,
|
|
441
|
+
}),
|
|
442
|
+
};
|
|
443
|
+
default:
|
|
444
|
+
return null;
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
function resolveHomeAssistantEnvStatus(userId) {
|
|
449
|
+
try {
|
|
450
|
+
const config = resolveHomeAssistantConfigForUser(userId);
|
|
451
|
+
return {
|
|
452
|
+
configured: config.configured,
|
|
453
|
+
missing: config.missing,
|
|
454
|
+
summary: config.configured
|
|
455
|
+
? 'Home Assistant is ready for account connections.'
|
|
456
|
+
: 'Complete your personal Home Assistant setup to connect an account.',
|
|
457
|
+
setupMode: 'user',
|
|
458
|
+
};
|
|
459
|
+
} catch (error) {
|
|
460
|
+
return {
|
|
461
|
+
configured: false,
|
|
462
|
+
missing: ['baseUrl'],
|
|
463
|
+
summary: `Home Assistant setup is invalid: ${error?.message || 'unknown error'}`,
|
|
464
|
+
setupMode: 'user',
|
|
465
|
+
};
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
function normalizeCurrentUser(currentUser) {
|
|
470
|
+
const payload = currentUser && typeof currentUser === 'object' ? currentUser : {};
|
|
471
|
+
return {
|
|
472
|
+
id: payload.id || null,
|
|
473
|
+
name: payload.name || null,
|
|
474
|
+
username: payload.username || null,
|
|
475
|
+
email: payload.email || null,
|
|
476
|
+
isOwner: Boolean(payload.is_owner),
|
|
477
|
+
};
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
function stableAccountEmailLikeIdentifier(user, config) {
|
|
481
|
+
const normalized = normalizeCurrentUser(user);
|
|
482
|
+
const preferred = [normalized.email, normalized.username, normalized.id, normalized.name]
|
|
483
|
+
.map((value) => String(value || '').trim())
|
|
484
|
+
.find(Boolean);
|
|
485
|
+
if (preferred) {
|
|
486
|
+
return preferred;
|
|
487
|
+
}
|
|
488
|
+
const host = new URL(config.baseUrl).host;
|
|
489
|
+
return `homeassistant@${host}`;
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
async function fetchCurrentUser(token, userId) {
|
|
493
|
+
const config = resolveHomeAssistantConfigForUser(userId);
|
|
494
|
+
return fetchJson(
|
|
495
|
+
homeAssistantUrl(config.baseUrl, '/api/auth/current_user'),
|
|
496
|
+
{
|
|
497
|
+
method: 'GET',
|
|
498
|
+
headers: {
|
|
499
|
+
Authorization: `Bearer ${token}`,
|
|
500
|
+
},
|
|
501
|
+
},
|
|
502
|
+
{ serviceName: 'Home Assistant' },
|
|
503
|
+
).catch(() => null);
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
function createHomeAssistantProvider() {
|
|
507
|
+
return createOAuthProvider({
|
|
508
|
+
key: 'home_assistant',
|
|
509
|
+
label: 'Home Assistant',
|
|
510
|
+
description:
|
|
511
|
+
'Official Home Assistant account connections for entity state reads, service control, and automation support.',
|
|
512
|
+
icon: 'home_assistant',
|
|
513
|
+
apps: HOME_ASSISTANT_APPS,
|
|
514
|
+
toolDefinitions: homeAssistantToolDefinitions,
|
|
515
|
+
connectPrompt:
|
|
516
|
+
'Connect your Home Assistant account to let the agent read entity states and control services with structured tools.',
|
|
517
|
+
getEnvStatus(context = {}) {
|
|
518
|
+
return resolveHomeAssistantEnvStatus(context.userId);
|
|
519
|
+
},
|
|
520
|
+
async beginOAuth({ state, codeVerifier, app, userId }) {
|
|
521
|
+
const config = resolveHomeAssistantConfigForUser(userId);
|
|
522
|
+
const codeChallenge = String(codeChallengeForVerifier(codeVerifier));
|
|
523
|
+
return {
|
|
524
|
+
url: appendQuery(homeAssistantUrl(config.baseUrl, '/auth/authorize'), {
|
|
525
|
+
response_type: 'code',
|
|
526
|
+
client_id: config.clientId,
|
|
527
|
+
redirect_uri: config.redirectUri,
|
|
528
|
+
state,
|
|
529
|
+
scope: app.scopes.join(' '),
|
|
530
|
+
code_challenge: codeChallenge,
|
|
531
|
+
code_challenge_method: 'S256',
|
|
532
|
+
}),
|
|
533
|
+
appId: app.id,
|
|
534
|
+
};
|
|
535
|
+
},
|
|
536
|
+
async finishOAuth({ code, codeVerifier, app, userId }) {
|
|
537
|
+
const config = resolveHomeAssistantConfigForUser(userId);
|
|
538
|
+
const token = await fetchJson(
|
|
539
|
+
homeAssistantUrl(config.baseUrl, '/auth/token'),
|
|
540
|
+
{
|
|
541
|
+
method: 'POST',
|
|
542
|
+
form: {
|
|
543
|
+
grant_type: 'authorization_code',
|
|
544
|
+
client_id: config.clientId,
|
|
545
|
+
client_secret: config.clientSecret,
|
|
546
|
+
code,
|
|
547
|
+
code_verifier: codeVerifier,
|
|
548
|
+
redirect_uri: config.redirectUri,
|
|
549
|
+
},
|
|
550
|
+
},
|
|
551
|
+
{ serviceName: 'Home Assistant' },
|
|
552
|
+
);
|
|
553
|
+
|
|
554
|
+
const accessToken = String(token?.access_token || '').trim();
|
|
555
|
+
if (!accessToken) {
|
|
556
|
+
throw new Error('Home Assistant OAuth did not return an access token.');
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
const refreshToken = String(token?.refresh_token || '').trim();
|
|
560
|
+
if (!refreshToken) {
|
|
561
|
+
throw new Error('Home Assistant OAuth did not return a refresh token.');
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
const currentUser = await fetchCurrentUser(accessToken, userId);
|
|
565
|
+
const normalizedUser = normalizeCurrentUser(currentUser);
|
|
566
|
+
const accountEmail = stableAccountEmailLikeIdentifier(normalizedUser, config);
|
|
567
|
+
|
|
568
|
+
return {
|
|
569
|
+
appId: app.id,
|
|
570
|
+
accountEmail,
|
|
571
|
+
credentials: {
|
|
572
|
+
access_token: accessToken,
|
|
573
|
+
refresh_token: refreshToken,
|
|
574
|
+
token_type: token?.token_type || 'Bearer',
|
|
575
|
+
expires_in: token?.expires_in || null,
|
|
576
|
+
scope: token?.scope || app.scopes.join(' '),
|
|
577
|
+
},
|
|
578
|
+
scopes: Array.isArray(token?.scope)
|
|
579
|
+
? token.scope
|
|
580
|
+
: String(token?.scope || app.scopes.join(' '))
|
|
581
|
+
.split(/\s+/)
|
|
582
|
+
.map((scope) => scope.trim())
|
|
583
|
+
.filter(Boolean),
|
|
584
|
+
metadata: {
|
|
585
|
+
homeAssistantUrl: config.baseUrl,
|
|
586
|
+
userId: normalizedUser.id,
|
|
587
|
+
name: normalizedUser.name,
|
|
588
|
+
username: normalizedUser.username,
|
|
589
|
+
email: normalizedUser.email,
|
|
590
|
+
isOwner: normalizedUser.isOwner,
|
|
591
|
+
},
|
|
592
|
+
};
|
|
593
|
+
},
|
|
594
|
+
executeTool: executeHomeAssistantTool,
|
|
595
|
+
getUserConfig({ userId }) {
|
|
596
|
+
return getHomeAssistantUserConfig(userId);
|
|
597
|
+
},
|
|
598
|
+
saveUserConfig({ userId, config }) {
|
|
599
|
+
return saveHomeAssistantUserConfig(userId, config);
|
|
600
|
+
},
|
|
601
|
+
clearUserConfig({ userId }) {
|
|
602
|
+
const normalizedUserId = Number(userId);
|
|
603
|
+
if (!Number.isInteger(normalizedUserId) || normalizedUserId <= 0) {
|
|
604
|
+
throw new Error('A valid user is required to clear Home Assistant configuration.');
|
|
605
|
+
}
|
|
606
|
+
deleteProviderConfig(normalizedUserId, 'home_assistant');
|
|
607
|
+
return { cleared: true };
|
|
608
|
+
},
|
|
609
|
+
});
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
function base64UrlSha256(value) {
|
|
613
|
+
return crypto
|
|
614
|
+
.createHash('sha256')
|
|
615
|
+
.update(String(value || ''))
|
|
616
|
+
.digest('base64')
|
|
617
|
+
.replace(/\+/g, '-')
|
|
618
|
+
.replace(/\//g, '_')
|
|
619
|
+
.replace(/=+$/g, '');
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
function codeChallengeForVerifier(codeVerifier) {
|
|
623
|
+
return base64UrlSha256(String(codeVerifier || ''));
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
module.exports = {
|
|
627
|
+
getHomeAssistantUserConfig,
|
|
628
|
+
saveHomeAssistantUserConfig,
|
|
629
|
+
createHomeAssistantProvider,
|
|
630
|
+
};
|