stella-coder 5.3.1 → 5.3.3
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/COMMANDS.md +47 -0
- package/install-av.bat +17 -0
- package/install-stella-pkg.bat +21 -0
- package/oneline-av.ps1 +7 -0
- package/oneline-stella.ps1 +2 -0
- package/package.json +1 -1
- package/releases/stella-antivirus/database.mjs +871 -0
- package/releases/stella-antivirus/index.mjs +8 -0
- package/releases/stella-antivirus/install-av.bat +12 -0
- package/releases/stella-antivirus/scanner.mjs +591 -0
- package/releases/stella-antivirus/ui.mjs +570 -0
- package/releases/stella-antivirus.zip +0 -0
- package/releases/stella-coder/README.md +67 -0
- package/releases/stella-coder/adb.mjs +200 -0
- package/releases/stella-coder/autonomous-agent.mjs +550 -0
- package/releases/stella-coder/banner.mjs +46 -0
- package/releases/stella-coder/browser-control.mjs +274 -0
- package/releases/stella-coder/build.mjs +151 -0
- package/releases/stella-coder/charts.mjs +411 -0
- package/releases/stella-coder/coding-brain.mjs +753 -0
- package/releases/stella-coder/game-engine.mjs +708 -0
- package/releases/stella-coder/gdrive-backup.mjs +338 -0
- package/releases/stella-coder/git-api.mjs +407 -0
- package/releases/stella-coder/gmail.mjs +415 -0
- package/releases/stella-coder/home-assistant.mjs +168 -0
- package/releases/stella-coder/index.mjs +5810 -0
- package/releases/stella-coder/install-stella.bat +12 -0
- package/releases/stella-coder/markdown.mjs +100 -0
- package/releases/stella-coder/mcp.mjs +296 -0
- package/releases/stella-coder/package.json +67 -0
- package/releases/stella-coder/presentations.mjs +1106 -0
- package/releases/stella-coder/protect.mjs +182 -0
- package/releases/stella-coder/screen-monitor.mjs +334 -0
- package/releases/stella-coder/sea-config.json +5 -0
- package/releases/stella-coder/security.mjs +237 -0
- package/releases/stella-coder/subagents.mjs +142 -0
- package/releases/stella-coder/telegram-bot.mjs +824 -0
- package/releases/stella-coder/tg-server.mjs +116 -0
- package/releases/stella-coder/theme.mjs +91 -0
- package/releases/stella-coder/tools.mjs +3143 -0
- package/releases/stella-coder/web-parser.mjs +229 -0
- package/releases/stella-coder/yandex-maps.mjs +426 -0
- package/releases/stella-coder.zip +0 -0
- package/run-antivirus.bat +4 -0
- package/setup-antivirus.bat +64 -0
- package/setup-full.bat +65 -0
- package/setup-full.ps1 +84 -0
- package/setup-stella.bat +46 -0
- package/stella-cli/index.mjs +12 -2
- package/stella-cli/protect.mjs +182 -0
- package/stella.bat +3 -0
- package/tests/test-modules.mjs +101 -0
|
@@ -0,0 +1,415 @@
|
|
|
1
|
+
import fs from "node:fs"
|
|
2
|
+
import path from "node:path"
|
|
3
|
+
import os from "node:os"
|
|
4
|
+
import { execSync } from "node:child_process"
|
|
5
|
+
|
|
6
|
+
const CONFIG_DIR = path.join(os.homedir(), ".stella", "gmail")
|
|
7
|
+
const TOKEN_FILE = path.join(CONFIG_DIR, "token.json")
|
|
8
|
+
const CREDS_FILE = path.join(CONFIG_DIR, "credentials.json")
|
|
9
|
+
const SENT_DIR = path.join(CONFIG_DIR, "sent")
|
|
10
|
+
|
|
11
|
+
function ensureDirs() {
|
|
12
|
+
for (const dir of [CONFIG_DIR, SENT_DIR]) {
|
|
13
|
+
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true })
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function getOAuth2URL(clientId, redirectUri) {
|
|
18
|
+
const scopes = [
|
|
19
|
+
"https://www.googleapis.com/auth/gmail.send",
|
|
20
|
+
"https://www.googleapis.com/auth/gmail.readonly",
|
|
21
|
+
"https://www.googleapis.com/auth/gmail.modify",
|
|
22
|
+
"https://www.googleapis.com/auth/gmail.labels",
|
|
23
|
+
]
|
|
24
|
+
const params = new URLSearchParams({
|
|
25
|
+
client_id: clientId,
|
|
26
|
+
redirect_uri: redirectUri,
|
|
27
|
+
response_type: "code",
|
|
28
|
+
scope: scopes.join(" "),
|
|
29
|
+
access_type: "offline",
|
|
30
|
+
prompt: "consent",
|
|
31
|
+
})
|
|
32
|
+
return `https://accounts.google.com/o/oauth2/v2/auth?${params.toString()}`
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
async function exchangeCode(clientId, clientSecret, code, redirectUri) {
|
|
36
|
+
const resp = await fetch("https://oauth2.googleapis.com/token", {
|
|
37
|
+
method: "POST",
|
|
38
|
+
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
39
|
+
body: new URLSearchParams({
|
|
40
|
+
client_id: clientId,
|
|
41
|
+
client_secret: clientSecret,
|
|
42
|
+
code,
|
|
43
|
+
grant_type: "authorization_code",
|
|
44
|
+
redirect_uri: redirectUri,
|
|
45
|
+
}).toString(),
|
|
46
|
+
})
|
|
47
|
+
return await resp.json()
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
async function refreshToken(clientId, clientSecret, refreshTokenValue) {
|
|
51
|
+
const resp = await fetch("https://oauth2.googleapis.com/token", {
|
|
52
|
+
method: "POST",
|
|
53
|
+
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
54
|
+
body: new URLSearchParams({
|
|
55
|
+
client_id: clientId,
|
|
56
|
+
client_secret: clientSecret,
|
|
57
|
+
refresh_token: refreshTokenValue,
|
|
58
|
+
grant_type: "refresh_token",
|
|
59
|
+
}).toString(),
|
|
60
|
+
})
|
|
61
|
+
return await resp.json()
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function loadToken() {
|
|
65
|
+
if (!fs.existsSync(TOKEN_FILE)) return null
|
|
66
|
+
return JSON.parse(fs.readFileSync(TOKEN_FILE, "utf8"))
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function saveToken(token) {
|
|
70
|
+
ensureDirs()
|
|
71
|
+
fs.writeFileSync(TOKEN_FILE, JSON.stringify(token, null, 2))
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function loadCredentials() {
|
|
75
|
+
if (!fs.existsSync(CREDS_FILE)) return null
|
|
76
|
+
return JSON.parse(fs.readFileSync(CREDS_FILE, "utf8"))
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function buildMimeMessage({ to, cc, bcc, subject, body, isHtml, attachments }) {
|
|
80
|
+
const boundary = `stella_boundary_${Date.now()}_${Math.random().toString(36).slice(2)}`
|
|
81
|
+
const lines = []
|
|
82
|
+
|
|
83
|
+
lines.push(`To: ${to}`)
|
|
84
|
+
if (cc) lines.push(`Cc: ${cc}`)
|
|
85
|
+
if (bcc) lines.push(`Bcc: ${bcc}`)
|
|
86
|
+
lines.push(`Subject: ${subject}`)
|
|
87
|
+
lines.push("MIME-Version: 1.0")
|
|
88
|
+
|
|
89
|
+
if (attachments && attachments.length > 0) {
|
|
90
|
+
lines.push(`Content-Type: multipart/mixed; boundary="${boundary}"`)
|
|
91
|
+
lines.push("")
|
|
92
|
+
lines.push(`--${boundary}`)
|
|
93
|
+
if (isHtml) {
|
|
94
|
+
lines.push("Content-Type: text/html; charset=UTF-8")
|
|
95
|
+
} else {
|
|
96
|
+
lines.push("Content-Type: text/plain; charset=UTF-8")
|
|
97
|
+
}
|
|
98
|
+
lines.push("")
|
|
99
|
+
lines.push(body)
|
|
100
|
+
for (const att of attachments) {
|
|
101
|
+
const filePath = att
|
|
102
|
+
if (fs.existsSync(filePath)) {
|
|
103
|
+
const content = fs.readFileSync(filePath)
|
|
104
|
+
const b64 = content.toString("base64")
|
|
105
|
+
const name = path.basename(filePath)
|
|
106
|
+
lines.push("")
|
|
107
|
+
lines.push(`--${boundary}`)
|
|
108
|
+
lines.push(`Content-Type: application/octet-stream; name="${name}"`)
|
|
109
|
+
lines.push(`Content-Disposition: attachment; filename="${name}"`)
|
|
110
|
+
lines.push("Content-Transfer-Encoding: base64")
|
|
111
|
+
lines.push("")
|
|
112
|
+
lines.push(b64.match(/.{1,76}/g).join("\n"))
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
lines.push("")
|
|
116
|
+
lines.push(`--${boundary}--`)
|
|
117
|
+
} else {
|
|
118
|
+
if (isHtml) {
|
|
119
|
+
lines.push("Content-Type: text/html; charset=UTF-8")
|
|
120
|
+
} else {
|
|
121
|
+
lines.push("Content-Type: text/plain; charset=UTF-8")
|
|
122
|
+
}
|
|
123
|
+
lines.push("")
|
|
124
|
+
lines.push(body)
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
return Buffer.from(lines.join("\r\n")).toString("base64url")
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
async function gmailApiRequest(accessToken, method, url, body) {
|
|
131
|
+
const opts = {
|
|
132
|
+
method,
|
|
133
|
+
headers: { Authorization: `Bearer ${accessToken}` },
|
|
134
|
+
}
|
|
135
|
+
if (body) {
|
|
136
|
+
opts.headers["Content-Type"] = "application/json"
|
|
137
|
+
opts.body = JSON.stringify(body)
|
|
138
|
+
}
|
|
139
|
+
const resp = await fetch(url, opts)
|
|
140
|
+
return await resp.json()
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
export class GmailClient {
|
|
144
|
+
constructor() {
|
|
145
|
+
ensureDirs()
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
isConfigured() {
|
|
149
|
+
return fs.existsSync(CREDS_FILE) && fs.existsSync(TOKEN_FILE)
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
getSetupURL() {
|
|
153
|
+
const creds = loadCredentials()
|
|
154
|
+
if (!creds || !creds.installed || !creds.installed.client_id) return null
|
|
155
|
+
const redirectUri = creds.installed.redirect_uris?.[0] || "http://localhost:3456/callback"
|
|
156
|
+
return getOAuth2URL(creds.installed.client_id, redirectUri)
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
async completeAuth(code) {
|
|
160
|
+
const creds = loadCredentials()
|
|
161
|
+
if (!creds || !creds.installed) return { success: false, error: "No credentials.json" }
|
|
162
|
+
const redirectUri = creds.installed.redirect_uris?.[0] || "http://localhost:3456/callback"
|
|
163
|
+
const token = await exchangeCode(
|
|
164
|
+
creds.installed.client_id,
|
|
165
|
+
creds.installed.client_secret,
|
|
166
|
+
code,
|
|
167
|
+
redirectUri
|
|
168
|
+
)
|
|
169
|
+
if (token.error) return { success: false, error: token.error_description || token.error }
|
|
170
|
+
saveToken(token)
|
|
171
|
+
return { success: true }
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
async getAccessToken() {
|
|
175
|
+
const creds = loadCredentials()
|
|
176
|
+
const token = loadToken()
|
|
177
|
+
if (!creds || !token) return null
|
|
178
|
+
if (token.expiry_date && token.expiry_date > Date.now()) return token.access_token
|
|
179
|
+
const refreshed = await refreshToken(
|
|
180
|
+
creds.installed.client_id,
|
|
181
|
+
creds.installed.client_secret,
|
|
182
|
+
token.refresh_token
|
|
183
|
+
)
|
|
184
|
+
if (refreshed.access_token) {
|
|
185
|
+
token.access_token = refreshed.access_token
|
|
186
|
+
token.expiry_date = Date.now() + (refreshed.expires_in || 3600) * 1000
|
|
187
|
+
saveToken(token)
|
|
188
|
+
return refreshed.access_token
|
|
189
|
+
}
|
|
190
|
+
return null
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
async sendEmail({ to, cc, bcc, subject, body, isHtml = false, attachments = [] }) {
|
|
194
|
+
const accessToken = await this.getAccessToken()
|
|
195
|
+
if (!accessToken) return { success: false, error: "Not authenticated. Run /gmail-setup first." }
|
|
196
|
+
|
|
197
|
+
const raw = buildMimeMessage({ to, cc, bcc, subject, body, isHtml, attachments })
|
|
198
|
+
const result = await gmailApiRequest(
|
|
199
|
+
accessToken,
|
|
200
|
+
"POST",
|
|
201
|
+
"https://gmail.googleapis.com/gmail/v1/users/me/messages/send",
|
|
202
|
+
{ raw }
|
|
203
|
+
)
|
|
204
|
+
|
|
205
|
+
if (result.id) {
|
|
206
|
+
const sentFile = path.join(SENT_DIR, `${Date.now()}.json`)
|
|
207
|
+
fs.writeFileSync(sentFile, JSON.stringify({
|
|
208
|
+
id: result.id,
|
|
209
|
+
to, cc, bcc, subject,
|
|
210
|
+
date: new Date().toISOString(),
|
|
211
|
+
}, null, 2))
|
|
212
|
+
return { success: true, messageId: result.id }
|
|
213
|
+
}
|
|
214
|
+
return { success: false, error: result.error?.message || "Send failed" }
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
async listMessages({ query = "", maxResults = 20, labelIds = [] } = {}) {
|
|
218
|
+
const accessToken = await this.getAccessToken()
|
|
219
|
+
if (!accessToken) return { success: false, error: "Not authenticated" }
|
|
220
|
+
|
|
221
|
+
const params = new URLSearchParams({ maxResults: String(maxResults) })
|
|
222
|
+
if (query) params.set("q", query)
|
|
223
|
+
for (const lid of labelIds) params.append("labelIds", lid)
|
|
224
|
+
|
|
225
|
+
const list = await gmailApiRequest(
|
|
226
|
+
accessToken,
|
|
227
|
+
"GET",
|
|
228
|
+
`https://gmail.googleapis.com/gmail/v1/users/me/messages?${params.toString()}`
|
|
229
|
+
)
|
|
230
|
+
|
|
231
|
+
if (!list.messages) return { success: true, messages: [] }
|
|
232
|
+
|
|
233
|
+
const messages = []
|
|
234
|
+
for (const msg of list.messages.slice(0, maxResults)) {
|
|
235
|
+
const detail = await gmailApiRequest(
|
|
236
|
+
accessToken,
|
|
237
|
+
"GET",
|
|
238
|
+
`https://gmail.googleapis.com/gmail/v1/users/me/messages/${msg.id}?format=metadata`
|
|
239
|
+
)
|
|
240
|
+
const headers = detail.payload?.headers || []
|
|
241
|
+
const get = (name) => headers.find(h => h.name.toLowerCase() === name.toLowerCase())?.value || ""
|
|
242
|
+
messages.push({
|
|
243
|
+
id: msg.id,
|
|
244
|
+
threadId: msg.threadId,
|
|
245
|
+
subject: get("Subject"),
|
|
246
|
+
from: get("From"),
|
|
247
|
+
to: get("To"),
|
|
248
|
+
date: get("Date"),
|
|
249
|
+
snippet: detail.snippet || "",
|
|
250
|
+
labels: detail.labelIds || [],
|
|
251
|
+
})
|
|
252
|
+
}
|
|
253
|
+
return { success: true, messages }
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
async getMessage(messageId) {
|
|
257
|
+
const accessToken = await this.getAccessToken()
|
|
258
|
+
if (!accessToken) return { success: false, error: "Not authenticated" }
|
|
259
|
+
|
|
260
|
+
const msg = await gmailApiRequest(
|
|
261
|
+
accessToken,
|
|
262
|
+
"GET",
|
|
263
|
+
`https://gmail.googleapis.com/gmail/v1/users/me/messages/${messageId}?format=full`
|
|
264
|
+
)
|
|
265
|
+
|
|
266
|
+
if (!msg.id) return { success: false, error: "Message not found" }
|
|
267
|
+
|
|
268
|
+
const headers = msg.payload?.headers || []
|
|
269
|
+
const get = (name) => headers.find(h => h.name.toLowerCase() === name.toLowerCase())?.value || ""
|
|
270
|
+
|
|
271
|
+
let textBody = ""
|
|
272
|
+
let htmlBody = ""
|
|
273
|
+
const attachments = []
|
|
274
|
+
|
|
275
|
+
function extractParts(payload) {
|
|
276
|
+
if (!payload) return
|
|
277
|
+
if (payload.mimeType === "text/plain" && payload.body?.data) {
|
|
278
|
+
textBody += Buffer.from(payload.body.data, "base64url").toString("utf8")
|
|
279
|
+
}
|
|
280
|
+
if (payload.mimeType === "text/html" && payload.body?.data) {
|
|
281
|
+
htmlBody += Buffer.from(payload.body.data, "base64url").toString("utf8")
|
|
282
|
+
}
|
|
283
|
+
if (payload.filename && payload.body?.attachmentId) {
|
|
284
|
+
attachments.push({
|
|
285
|
+
filename: payload.filename,
|
|
286
|
+
mimeType: payload.mimeType,
|
|
287
|
+
size: payload.body.size,
|
|
288
|
+
attachmentId: payload.body.attachmentId,
|
|
289
|
+
})
|
|
290
|
+
}
|
|
291
|
+
for (const part of payload.parts || []) {
|
|
292
|
+
extractParts(part)
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
extractParts(msg.payload)
|
|
297
|
+
|
|
298
|
+
return {
|
|
299
|
+
success: true,
|
|
300
|
+
message: {
|
|
301
|
+
id: msg.id,
|
|
302
|
+
threadId: msg.threadId,
|
|
303
|
+
subject: get("Subject"),
|
|
304
|
+
from: get("From"),
|
|
305
|
+
to: get("To"),
|
|
306
|
+
cc: get("Cc"),
|
|
307
|
+
date: get("Date"),
|
|
308
|
+
textBody,
|
|
309
|
+
htmlBody,
|
|
310
|
+
attachments,
|
|
311
|
+
labels: msg.labelIds || [],
|
|
312
|
+
snippet: msg.snippet || "",
|
|
313
|
+
},
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
async getAttachment(messageId, attachmentId, filename) {
|
|
318
|
+
const accessToken = await this.getAccessToken()
|
|
319
|
+
if (!accessToken) return { success: false, error: "Not authenticated" }
|
|
320
|
+
|
|
321
|
+
const resp = await fetch(
|
|
322
|
+
`https://gmail.googleapis.com/gmail/v1/users/me/messages/${messageId}/attachments/${attachmentId}`,
|
|
323
|
+
{ headers: { Authorization: `Bearer ${accessToken}` } }
|
|
324
|
+
)
|
|
325
|
+
const data = await resp.json()
|
|
326
|
+
if (data.data) {
|
|
327
|
+
const buffer = Buffer.from(data.data, "base64url")
|
|
328
|
+
const savePath = path.join(SENT_DIR, filename)
|
|
329
|
+
fs.writeFileSync(savePath, buffer)
|
|
330
|
+
return { success: true, path: savePath, size: buffer.length }
|
|
331
|
+
}
|
|
332
|
+
return { success: false, error: "Attachment not found" }
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
async listLabels() {
|
|
336
|
+
const accessToken = await this.getAccessToken()
|
|
337
|
+
if (!accessToken) return { success: false, error: "Not authenticated" }
|
|
338
|
+
|
|
339
|
+
const result = await gmailApiRequest(
|
|
340
|
+
accessToken,
|
|
341
|
+
"GET",
|
|
342
|
+
"https://gmail.googleapis.com/gmail/v1/users/me/labels"
|
|
343
|
+
)
|
|
344
|
+
|
|
345
|
+
return {
|
|
346
|
+
success: true,
|
|
347
|
+
labels: (result.labels || []).map(l => ({ id: l.id, name: l.name, type: l.type })),
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
async markAsRead(messageId) {
|
|
352
|
+
const accessToken = await this.getAccessToken()
|
|
353
|
+
if (!accessToken) return { success: false, error: "Not authenticated" }
|
|
354
|
+
|
|
355
|
+
await gmailApiRequest(
|
|
356
|
+
accessToken,
|
|
357
|
+
"POST",
|
|
358
|
+
`https://gmail.googleapis.com/gmail/v1/users/me/messages/${messageId}/modify`,
|
|
359
|
+
{ removeLabelIds: ["UNREAD"] }
|
|
360
|
+
)
|
|
361
|
+
return { success: true }
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
async markAsUnread(messageId) {
|
|
365
|
+
const accessToken = await this.getAccessToken()
|
|
366
|
+
if (!accessToken) return { success: false, error: "Not authenticated" }
|
|
367
|
+
|
|
368
|
+
await gmailApiRequest(
|
|
369
|
+
accessToken,
|
|
370
|
+
"POST",
|
|
371
|
+
`https://gmail.googleapis.com/gmail/v1/users/me/messages/${messageId}/modify`,
|
|
372
|
+
{ addLabelIds: ["UNREAD"] }
|
|
373
|
+
)
|
|
374
|
+
return { success: true }
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
async deleteMessage(messageId) {
|
|
378
|
+
const accessToken = await this.getAccessToken()
|
|
379
|
+
if (!accessToken) return { success: false, error: "Not authenticated" }
|
|
380
|
+
|
|
381
|
+
await gmailApiRequest(
|
|
382
|
+
accessToken,
|
|
383
|
+
"DELETE",
|
|
384
|
+
`https://gmail.googleapis.com/gmail/v1/users/me/messages/${messageId}`
|
|
385
|
+
)
|
|
386
|
+
return { success: true }
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
async getProfile() {
|
|
390
|
+
const accessToken = await this.getAccessToken()
|
|
391
|
+
if (!accessToken) return { success: false, error: "Not authenticated" }
|
|
392
|
+
|
|
393
|
+
const result = await gmailApiRequest(
|
|
394
|
+
accessToken,
|
|
395
|
+
"GET",
|
|
396
|
+
"https://gmail.googleapis.com/gmail/v1/users/me/profile"
|
|
397
|
+
)
|
|
398
|
+
return {
|
|
399
|
+
success: true,
|
|
400
|
+
email: result.emailAddress,
|
|
401
|
+
totalMessages: result.messagesTotal,
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
async searchMessages(query, maxResults = 10) {
|
|
406
|
+
return this.listMessages({ query, maxResults })
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
async getUnreadCount() {
|
|
410
|
+
const result = await this.listMessages({ query: "is:unread", maxResults: 1, labelIds: ["INBOX"] })
|
|
411
|
+
if (!result.success) return 0
|
|
412
|
+
const full = await this.listMessages({ query: "is:unread", maxResults: 100 })
|
|
413
|
+
return full.messages?.length || 0
|
|
414
|
+
}
|
|
415
|
+
}
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
import fs from "node:fs"
|
|
2
|
+
import path from "node:path"
|
|
3
|
+
import os from "node:os"
|
|
4
|
+
|
|
5
|
+
const CONFIG_DIR = path.join(os.homedir(), ".stella", "ha")
|
|
6
|
+
|
|
7
|
+
export class HomeAssistant {
|
|
8
|
+
constructor(url = "", token = "") {
|
|
9
|
+
this.url = (url || process.env.HA_URL || "").replace(/\/$/, "")
|
|
10
|
+
this.token = token || process.env.HA_TOKEN || ""
|
|
11
|
+
if (!this.url && fs.existsSync(path.join(CONFIG_DIR, "config.json"))) {
|
|
12
|
+
try {
|
|
13
|
+
const cfg = JSON.parse(fs.readFileSync(path.join(CONFIG_DIR, "config.json"), "utf8"))
|
|
14
|
+
this.url = cfg.url || ""
|
|
15
|
+
this.token = cfg.token || ""
|
|
16
|
+
} catch {}
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
isConfigured() { return !!(this.url && this.token) }
|
|
21
|
+
|
|
22
|
+
configure(url, token) {
|
|
23
|
+
this.url = url.replace(/\/$/, "")
|
|
24
|
+
this.token = token
|
|
25
|
+
if (!fs.existsSync(CONFIG_DIR)) fs.mkdirSync(CONFIG_DIR, { recursive: true })
|
|
26
|
+
fs.writeFileSync(path.join(CONFIG_DIR, "config.json"), JSON.stringify({ url: this.url, token: this.token }, null, 2))
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
async request(method, endpoint, body) {
|
|
30
|
+
const resp = await fetch(`${this.url}/api/${endpoint}`, {
|
|
31
|
+
method,
|
|
32
|
+
headers: { Authorization: `Bearer ${this.token}`, "Content-Type": "application/json" },
|
|
33
|
+
...(body ? { body: JSON.stringify(body) } : {}),
|
|
34
|
+
})
|
|
35
|
+
if (resp.status === 404) return { success: false, error: "Not found" }
|
|
36
|
+
if (!resp.ok) return { success: false, error: `HTTP ${resp.status}` }
|
|
37
|
+
const text = await resp.text()
|
|
38
|
+
try { return { success: true, data: JSON.parse(text) } }
|
|
39
|
+
catch { return { success: true, data: text } }
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
async getStates() { return this.request("GET", "states") }
|
|
43
|
+
async getState(entityId) { return this.request("GET", `states/${entityId}`) }
|
|
44
|
+
|
|
45
|
+
async callService(domain, service, data = {}) {
|
|
46
|
+
return this.request("POST", `services/${domain}/${service}`, data)
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
async toggle(entityId) { return this.callService("homeassistant", "toggle", { entity_id: entityId }) }
|
|
50
|
+
async turnOn(entityId) { return this.callService("homeassistant", "turn_on", { entity_id: entityId }) }
|
|
51
|
+
async turnOff(entityId) { return this.callService("homeassistant", "turn_off", { entity_id: entityId }) }
|
|
52
|
+
|
|
53
|
+
async getConfig() { return this.request("GET", "config") }
|
|
54
|
+
async getLogbook(startTimestamp) {
|
|
55
|
+
const start = startTimestamp || new Date(Date.now() - 86400000).toISOString()
|
|
56
|
+
return this.request("GET", `logbook/${start}`)
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
async getHistory(entityId, hours = 24) {
|
|
60
|
+
const start = new Date(Date.now() - hours * 3600000).toISOString()
|
|
61
|
+
return this.request("GET", `history/period/${start}?filter_entity_id=${entityId}`)
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
async getTemplate(template) {
|
|
65
|
+
return this.request("POST", "template", { template })
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
async fireEvent(eventType, data = {}) {
|
|
69
|
+
return this.request("POST", `events/${eventType}`, data)
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
async getServices() { return this.request("GET", "services") }
|
|
73
|
+
|
|
74
|
+
async getEntitySources() { return this.request("GET", "config/entity_registry") }
|
|
75
|
+
|
|
76
|
+
async getAutoInfo() {
|
|
77
|
+
const s = await this.request("GET", "config")
|
|
78
|
+
return { success: true, haVersion: s.data?.version, locationName: s.data?.location_name, timezone: s.data?.time_zone }
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
async getLights() {
|
|
82
|
+
const states = await this.getStates()
|
|
83
|
+
if (!states.success) return states
|
|
84
|
+
const lights = states.data.filter(s => s.entity_id.startsWith("light."))
|
|
85
|
+
return { success: true, lights: lights.map(l => ({ entity: l.entity_id, state: l.state, brightness: l.attributes?.brightness, friendlyName: l.attributes?.friendly_name })) }
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
async getSensors() {
|
|
89
|
+
const states = await this.getStates()
|
|
90
|
+
if (!states.success) return states
|
|
91
|
+
const sensors = states.data.filter(s => s.entity_id.startsWith("sensor."))
|
|
92
|
+
return { success: true, sensors: sensors.map(s => ({ entity: s.entity_id, state: s.state, unit: s.attributes?.unit_of_measurement, friendlyName: s.attributes?.friendly_name })) }
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
async getSwitches() {
|
|
96
|
+
const states = await this.getStates()
|
|
97
|
+
if (!states.success) return states
|
|
98
|
+
const switches = states.data.filter(s => s.entity_id.startsWith("switch."))
|
|
99
|
+
return { success: true, switches: switches.map(s => ({ entity: s.entity_id, state: s.state, friendlyName: s.attributes?.friendly_name })) }
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
async getClimate() {
|
|
103
|
+
const states = await this.getStates()
|
|
104
|
+
if (!states.success) return states
|
|
105
|
+
const climate = states.data.filter(s => s.entity_id.startsWith("climate."))
|
|
106
|
+
return { success: true, devices: climate.map(c => ({ entity: c.entity_id, state: c.state, temp: c.attributes?.temperature, currentTemp: c.attributes?.current_temperature, mode: c.attributes?.hvac_mode, friendlyName: c.attributes?.friendly_name })) }
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
async setClimateTemp(entityId, temp) {
|
|
110
|
+
return this.callService("climate", "set_temperature", { entity_id: entityId, temperature: temp })
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
async setClimateMode(entityId, mode) {
|
|
114
|
+
return this.callService("climate", "set_hvac_mode", { entity_id: entityId, hvac_mode: mode })
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
async getMediaPlayers() {
|
|
118
|
+
const states = await this.getStates()
|
|
119
|
+
if (!states.success) return states
|
|
120
|
+
return { success: true, players: states.data.filter(s => s.entity_id.startsWith("media_player.")).map(p => ({ entity: p.entity_id, state: p.state, friendlyName: p.attributes?.friendly_name, source: p.attributes?.source, volume: p.attributes?.volume_level })) }
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
async mediaPlay(entityId) { return this.callService("media_player", "media_play", { entity_id: entityId }) }
|
|
124
|
+
async mediaPause(entityId) { return this.callService("media_player", "media_pause", { entity_id: entityId }) }
|
|
125
|
+
async mediaStop(entityId) { return this.callService("media_player", "media_stop", { entity_id: entityId }) }
|
|
126
|
+
async mediaNext(entityId) { return this.callService("media_player", "media_next_track", { entity_id: entityId }) }
|
|
127
|
+
async mediaPrev(entityId) { return this.callService("media_player", "media_previous_track", { entity_id: entityId }) }
|
|
128
|
+
async setVolume(entityId, level) { return this.callService("media_player", "volume_set", { entity_id: entityId, volume_level: level }) }
|
|
129
|
+
async selectSource(entityId, source) { return this.callService("media_player", "select_source", { entity_id: entityId, source }) }
|
|
130
|
+
|
|
131
|
+
async getCovers() {
|
|
132
|
+
const states = await this.getStates()
|
|
133
|
+
if (!states.success) return states
|
|
134
|
+
return { success: true, covers: states.data.filter(s => s.entity_id.startsWith("cover.")).map(c => ({ entity: c.entity_id, state: c.state, friendlyName: c.attributes?.friendly_name, position: c.attributes?.current_position })) }
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
async openCover(entityId) { return this.callService("cover", "open_cover", { entity_id: entityId }) }
|
|
138
|
+
async closeCover(entityId) { return this.callService("cover", "close_cover", { entity_id: entityId }) }
|
|
139
|
+
async stopCover(entityId) { return this.callService("cover", "stop_cover", { entity_id: entityId }) }
|
|
140
|
+
async setCoverPosition(entityId, pos) { return this.callService("cover", "set_cover_position", { entity_id: entityId, position: pos }) }
|
|
141
|
+
|
|
142
|
+
async setBrightness(entityId, brightness) {
|
|
143
|
+
return this.callService("light", "turn_on", { entity_id: entityId, brightness })
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
async setColor(entityId, color) {
|
|
147
|
+
return this.callService("light", "turn_on", { entity_id: entityId, rgb_color: color })
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
async getGroup(entityId) {
|
|
151
|
+
return this.request("GET", `states/${entityId}`)
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
async ping() {
|
|
155
|
+
try {
|
|
156
|
+
const resp = await fetch(`${this.url}/api/`, { headers: { Authorization: `Bearer ${this.token}` } })
|
|
157
|
+
return { success: resp.ok, status: resp.status }
|
|
158
|
+
} catch (e) {
|
|
159
|
+
return { success: false, error: e.message }
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
async getAllEntityIds() {
|
|
164
|
+
const states = await this.getStates()
|
|
165
|
+
if (!states.success) return states
|
|
166
|
+
return { success: true, entities: states.data.map(s => s.entity_id).sort() }
|
|
167
|
+
}
|
|
168
|
+
}
|