ohmypetbook-daemon 1.0.2

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/daemon.js ADDED
@@ -0,0 +1,430 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { initializeApp } from "firebase/app";
4
+ import { getAuth, signInWithCustomToken, signInAnonymously } from "firebase/auth";
5
+ import { getFirestore, doc, setDoc, getDoc, onSnapshot } from "firebase/firestore";
6
+ import { execSync } from "child_process";
7
+ import fs from "fs";
8
+ import path from "path";
9
+ import os from "os";
10
+ import crypto from "crypto";
11
+ import readline from "readline";
12
+
13
+ import { firebaseConfig, PETBOOK_CONFIG, CONFIG_FILE, LOG_FILE, OPENCLAW_HOME, CLIENT_URL, CLAIM_DEVICE_URL, REFRESH_SESSION_URL, DECRYPT_SECRETS_URL } from "./lib/config.js";
14
+ import { log } from "./lib/log.js";
15
+ import {
16
+ loadAuth, saveAuth, savePetbookConfig, loadPetbookConfig,
17
+ generatePetId, deviceInfo, validatePet, setPetOffline
18
+ } from "./lib/auth.js";
19
+ import { pushToFirestore, listenFirestore, watchLocal, initRemoteHash, setLoadEnvSecretsCallback, setPushRef, setGetIdTokenCallback, startHeartbeat } from "./lib/sync.js";
20
+ import { listenChats } from "./lib/chat.js";
21
+ import { installService, uninstallService } from "./lib/service.js";
22
+
23
+ const app = initializeApp(firebaseConfig);
24
+ const auth = getAuth(app);
25
+ const db = getFirestore(app);
26
+
27
+ // ── Helpers ──
28
+
29
+ function prompt(question) {
30
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
31
+ return new Promise(resolve => rl.question(question, ans => { rl.close(); resolve(ans.trim()); }));
32
+ }
33
+
34
+ function generateRequestId() {
35
+ return crypto.randomBytes(16).toString("base64url");
36
+ }
37
+
38
+ async function restoreSession(refreshToken) {
39
+ const tokenResp = await fetch(
40
+ `https://securetoken.googleapis.com/v1/token?key=${firebaseConfig.apiKey}`,
41
+ {
42
+ method: "POST",
43
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
44
+ body: `grant_type=refresh_token&refresh_token=${encodeURIComponent(refreshToken)}`,
45
+ }
46
+ );
47
+ if (!tokenResp.ok) throw new Error("refresh token 만료");
48
+ const tokenData = await tokenResp.json();
49
+
50
+ const resp = await fetch(REFRESH_SESSION_URL, {
51
+ method: "POST",
52
+ headers: { "Content-Type": "application/json" },
53
+ body: JSON.stringify({ idToken: tokenData.id_token }),
54
+ });
55
+ if (!resp.ok) throw new Error("세션 복원 실패");
56
+ const { customToken } = await resp.json();
57
+
58
+ const cred = await signInWithCustomToken(auth, customToken);
59
+ const saved = loadAuth();
60
+ if (cred.user.refreshToken !== saved.refreshToken) {
61
+ saveAuth({ ...saved, refreshToken: cred.user.refreshToken });
62
+ }
63
+ }
64
+
65
+ // ── 환경변수 & 시크릿 로딩 ──
66
+
67
+ async function loadEnvAndSecrets(uid, petId) {
68
+ try {
69
+ // 1. 계정 레벨
70
+ const userSnap = await getDoc(doc(db, "users", uid));
71
+ const userData = userSnap.exists() ? userSnap.data() : {};
72
+
73
+ // 2. 디바이스 레벨 (pet 메인 doc)
74
+ const petSnap = await getDoc(doc(db, "users", uid, "pets", petId));
75
+ const petData = petSnap.exists() ? petSnap.data() : {};
76
+
77
+ // 환경변수 머지 (계정 < 디바이스)
78
+ const envVars = {
79
+ ...(userData.envVars || {}),
80
+ ...(petData.deviceEnvVars || {}),
81
+ };
82
+
83
+ // 시크릿 머지 (계정 < 디바이스)
84
+ const allSecrets = {
85
+ ...(userData.secrets || {}),
86
+ ...(petData.deviceSecrets || {}),
87
+ };
88
+
89
+ // 시크릿 복호화
90
+ if (Object.keys(allSecrets).length > 0) {
91
+ try {
92
+ const idToken = await auth.currentUser.getIdToken();
93
+ const resp = await fetch(DECRYPT_SECRETS_URL, {
94
+ method: "POST",
95
+ headers: { "Content-Type": "application/json" },
96
+ body: JSON.stringify({ idToken, secrets: allSecrets }),
97
+ });
98
+ if (resp.ok) {
99
+ const { values } = await resp.json();
100
+ for (const [key, value] of Object.entries(values)) {
101
+ if (value !== null) envVars[key] = String(value);
102
+ }
103
+ } else {
104
+ log("⚠️ 시크릿 복호화 실패");
105
+ }
106
+ } catch (e) {
107
+ log(`⚠️ 시크릿 복호화 에러: ${e.message}`);
108
+ }
109
+ }
110
+
111
+ // .env 파일에 쓰기 → openclaw gateway가 읽음
112
+ if (Object.keys(envVars).length > 0) {
113
+ const envPath = path.join(OPENCLAW_HOME, ".env");
114
+ const lines = Object.entries(envVars)
115
+ .filter(([k, v]) => k && v !== undefined)
116
+ .map(([k, v]) => `${k}=${v}`);
117
+ fs.writeFileSync(envPath, lines.join("\n") + "\n", "utf-8");
118
+ fs.chmodSync(envPath, 0o600);
119
+ log(`🔑 .env 업데이트 (${lines.length}개 변수)`);
120
+ }
121
+ } catch (e) {
122
+ log(`⚠️ 환경변수 로드 실패: ${e.message}`);
123
+ }
124
+ }
125
+
126
+ // ── Commands ──
127
+
128
+ async function cmdLogin() {
129
+ const codeArg = process.argv[3];
130
+ const petId = generatePetId();
131
+ const info = deviceInfo();
132
+
133
+ // 코드가 직접 주어진 경우 → 바로 등록
134
+ if (codeArg) {
135
+ return doClaimDevice(codeArg, petId, info);
136
+ }
137
+
138
+ // 1. 익명 로그인
139
+ await signInAnonymously(auth);
140
+ const requestId = generateRequestId();
141
+
142
+ // 2. loginRequests 문서 생성
143
+ await setDoc(doc(db, "loginRequests", requestId), {
144
+ petId,
145
+ ...info,
146
+ status: "pending",
147
+ createdAt: new Date().toISOString(),
148
+ expiresAt: new Date(Date.now() + 10 * 60 * 1000).toISOString(),
149
+ });
150
+
151
+ // 3. URL 표시
152
+ const url = `${CLIENT_URL}/auth/device?requestId=${requestId}`;
153
+
154
+ console.log("");
155
+ console.log(" \x1b[1m🐾 OhMyPetBook 기기 등록\x1b[0m");
156
+ console.log("");
157
+ console.log(" 아래 URL을 브라우저에서 열어주세요:");
158
+ console.log(` \x1b[4m\x1b[36m${url}\x1b[0m`);
159
+ console.log("");
160
+
161
+ // 4. Firestore 구독 + 수동 입력 동시 대기
162
+ const result = await new Promise((resolve, reject) => {
163
+ let settled = false;
164
+
165
+ // Firestore 실시간 구독
166
+ const unsub = onSnapshot(doc(db, "loginRequests", requestId), (snap) => {
167
+ const data = snap.data();
168
+ if (data?.status === "approved" && data?.customToken && !settled) {
169
+ settled = true;
170
+ unsub();
171
+ resolve({ type: "auto", customToken: data.customToken, uid: data.uid, email: data.email });
172
+ }
173
+ });
174
+
175
+ // 수동 코드 입력 (폴백)
176
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
177
+ console.log(" 브라우저에서 승인하면 자동으로 진행됩니다.");
178
+ console.log(" 또는 등록 코드를 직접 입력하세요:");
179
+ console.log("");
180
+ rl.question(" 등록 코드 (자동 대기 중...): ", (code) => {
181
+ rl.close();
182
+ if (!settled && code.trim()) {
183
+ settled = true;
184
+ unsub();
185
+ resolve({ type: "manual", code: code.trim() });
186
+ }
187
+ });
188
+
189
+ // 10분 타임아웃
190
+ setTimeout(() => {
191
+ if (!settled) {
192
+ settled = true;
193
+ unsub();
194
+ reject(new Error("등록 시간 초과 (10분)"));
195
+ }
196
+ }, 10 * 60 * 1000);
197
+ });
198
+
199
+ if (result.type === "auto") {
200
+ // 자동 승인 — customToken으로 바로 로그인
201
+ console.log("\n ✅ 브라우저에서 승인됨!");
202
+ const cred = await signInWithCustomToken(auth, result.customToken);
203
+ const petName = info.hostname || os.hostname();
204
+
205
+ saveAuth({
206
+ uid: result.uid,
207
+ email: result.email,
208
+ petId,
209
+ petName,
210
+ refreshToken: cred.user.refreshToken,
211
+ savedAt: new Date().toISOString()
212
+ });
213
+
214
+ printSuccess(result.email, petName, petId);
215
+ process.exit(0);
216
+ } else {
217
+ // 수동 코드 입력
218
+ await doClaimDevice(result.code, petId, info);
219
+ }
220
+ }
221
+
222
+ async function doClaimDevice(code, petId, info) {
223
+ console.log(" 🔐 등록 중...");
224
+
225
+ const resp = await fetch(CLAIM_DEVICE_URL, {
226
+ method: "POST",
227
+ headers: { "Content-Type": "application/json" },
228
+ body: JSON.stringify({ code, petId, deviceInfo: info }),
229
+ });
230
+
231
+ if (!resp.ok) {
232
+ const err = await resp.json().catch(() => ({}));
233
+ console.error(` ❌ ${err.error || `등록 실패 (${resp.status})`}`);
234
+ process.exit(1);
235
+ }
236
+
237
+ const { customToken, uid, email } = await resp.json();
238
+ const cred = await signInWithCustomToken(auth, customToken);
239
+ const petName = info.hostname || os.hostname();
240
+
241
+ saveAuth({
242
+ uid,
243
+ email,
244
+ petId,
245
+ petName,
246
+ refreshToken: cred.user.refreshToken,
247
+ savedAt: new Date().toISOString()
248
+ });
249
+
250
+ printSuccess(email, petName, petId);
251
+ process.exit(0);
252
+ }
253
+
254
+ function printSuccess(email, petName, petId) {
255
+ console.log(` ✓ 계정: \x1b[1m${email}\x1b[0m`);
256
+ console.log(` ✓ Pet: \x1b[1m${petName}\x1b[0m (\x1b[2m${petId.slice(0, 16)}...\x1b[0m)`);
257
+ console.log("");
258
+ console.log(" 다음 단계:");
259
+ console.log(" \x1b[1mohmypetbook install\x1b[0m — 서비스 등록 (자동 시작)");
260
+ console.log(" \x1b[1mohmypetbook run\x1b[0m — 포그라운드 실행");
261
+ console.log("");
262
+ }
263
+
264
+ async function cmdRun() {
265
+ const saved = loadAuth();
266
+ if (!saved?.refreshToken) {
267
+ log("❌ 인증 정보 없음. `ohmypetbook login` 먼저 실행하세요.");
268
+ process.exit(1);
269
+ }
270
+
271
+ log(`🐾 ${saved.petName || "pet"} 시작 (${saved.email})`);
272
+
273
+ try {
274
+ await restoreSession(saved.refreshToken);
275
+ } catch (e) {
276
+ log(`❌ 인증 실패: ${e.message}. 재등록 필요: ohmypetbook login`);
277
+ process.exit(1);
278
+ }
279
+
280
+ const uid = auth.currentUser.uid;
281
+ const petId = saved.petId;
282
+ log(`✓ 인증 완료: ${auth.currentUser.email}`);
283
+
284
+ if (!(await validatePet(db, uid, petId))) {
285
+ process.exit(1);
286
+ }
287
+
288
+ await loadEnvAndSecrets(uid, petId);
289
+ setLoadEnvSecretsCallback(() => loadEnvAndSecrets(uid, petId));
290
+ setPushRef(() => pushToFirestore(db, uid, petId));
291
+ setGetIdTokenCallback(() => auth.currentUser.getIdToken());
292
+
293
+ initRemoteHash();
294
+ await pushToFirestore(db, uid, petId);
295
+ listenFirestore(db, uid, petId);
296
+ log("👂 Firestore 실시간 리스너 시작");
297
+
298
+ watchLocal(db, uid, petId);
299
+ log("👀 로컬 파일 감시 시작");
300
+
301
+ startHeartbeat(db, uid, petId);
302
+ log("💓 Heartbeat 시작 (60초)");
303
+
304
+ listenChats(db, uid, petId);
305
+
306
+ log("🚀 데몬 실행 중...");
307
+
308
+ const shutdown = async () => {
309
+ log("종료 중...");
310
+ await setPetOffline(db, uid, petId);
311
+ process.exit(0);
312
+ };
313
+ process.on("SIGTERM", shutdown);
314
+ process.on("SIGINT", shutdown);
315
+ }
316
+
317
+ function cmdStatus() {
318
+ const saved = loadAuth();
319
+ console.log("\n \x1b[1m🐾 OhMyPetBook Status\x1b[0m\n");
320
+ if (saved) {
321
+ const expired = saved.expiresAt && new Date(saved.expiresAt) < new Date();
322
+ console.log(` Pet: ${saved.petName || "N/A"} (${saved.petId?.slice(0, 16) || "N/A"}...)`);
323
+ console.log(` 계정: ${saved.email}`);
324
+ console.log(` 만료: ${saved.expiresAt ? new Date(saved.expiresAt).toLocaleDateString("ko-KR") : "N/A"} ${expired ? "\x1b[31m(만료됨)\x1b[0m" : "\x1b[32m(유효)\x1b[0m"}`);
325
+ console.log(` 경로: ${OPENCLAW_HOME}`);
326
+ console.log(` 설정: ${CONFIG_FILE}`);
327
+ } else {
328
+ console.log(" \x1b[33m등록된 pet 없음. ohmypetbook login 실행하세요.\x1b[0m");
329
+ }
330
+
331
+ const platform = os.platform();
332
+ if (platform === "darwin") {
333
+ try {
334
+ execSync("launchctl list | grep ohmypetbook", { stdio: "pipe" });
335
+ console.log(" 서비스: \x1b[32m실행 중\x1b[0m");
336
+ } catch { console.log(" 서비스: \x1b[33m미등록\x1b[0m"); }
337
+ } else if (platform === "linux") {
338
+ try {
339
+ const s = execSync("systemctl --user is-active petbook-daemon", { encoding: "utf-8" }).trim();
340
+ console.log(` 서비스: \x1b[32m${s}\x1b[0m`);
341
+ } catch { console.log(" 서비스: \x1b[33m미등록\x1b[0m"); }
342
+ }
343
+ console.log(` 로그: ${LOG_FILE}\n`);
344
+ }
345
+
346
+ // ── Main ──
347
+
348
+ const [,, command] = process.argv;
349
+
350
+ switch (command) {
351
+ case "login": await cmdLogin(); break;
352
+ case "run": await cmdRun(); break;
353
+ case "install": {
354
+ if (!loadAuth()?.refreshToken) { console.error("❌ 먼저 등록: ohmypetbook login"); process.exit(1); }
355
+ installService();
356
+ console.log("\n ✓ 서비스 등록 완료. 데몬이 백그라운드에서 실행됩니다.\n");
357
+ break;
358
+ }
359
+ case "uninstall":
360
+ uninstallService();
361
+ console.log(" ✓ 서비스 제거 완료\n");
362
+ break;
363
+ case "status":
364
+ cmdStatus();
365
+ break;
366
+ case "config": {
367
+ const [,,, key, ...rest] = process.argv;
368
+ if (key === "openclawPath" && rest.length) {
369
+ savePetbookConfig({ openclawPath: rest.join(" ") });
370
+ console.log(` ✓ openclawPath = ${rest.join(" ")}`);
371
+ } else {
372
+ const cfg = loadPetbookConfig();
373
+ console.log("\n \x1b[1m~/.ohmypetbook/ohmypetbook.json\x1b[0m\n");
374
+ console.log(` openclawPath: ${cfg.openclawPath || "~/.openclaw (기본값)"}`);
375
+ console.log(` pet: ${cfg.auth?.petName || "없음"} (${cfg.auth?.petId?.slice(0, 16) || "N/A"})`);
376
+ console.log(` 계정: ${cfg.auth?.email || "없음"}`);
377
+ console.log("");
378
+ console.log(" 변경: ohmypetbook config openclawPath /path/to/.openclaw");
379
+ console.log("");
380
+ }
381
+ break;
382
+ }
383
+ case "update": {
384
+ console.log("📦 업데이트 확인 중...");
385
+ try {
386
+ const current = JSON.parse(fs.readFileSync(new URL("./package.json", import.meta.url), "utf-8")).version;
387
+ const latest = execSync("npm view ohmypetbook version", { encoding: "utf-8", timeout: 15000 }).trim();
388
+ if (current === latest) {
389
+ console.log(`✅ 이미 최신 버전입니다 (v${current})`);
390
+ } else {
391
+ console.log(`🔄 v${current} → v${latest} 업데이트 중...`);
392
+ execSync("npm update -g ohmypetbook", { encoding: "utf-8", timeout: 60000, stdio: "inherit" });
393
+ console.log(`✅ 업데이트 완료! v${latest}`);
394
+ console.log(" 서비스 재시작: launchctl kickstart -k gui/$(id -u)/com.ohmypetbook.daemon");
395
+ }
396
+ } catch (e) {
397
+ console.error(`❌ 업데이트 실패: ${e.message}`);
398
+ }
399
+ break;
400
+ }
401
+ case "logout": {
402
+ const saved = loadAuth();
403
+ if (saved?.petId && saved?.uid && saved?.refreshToken) {
404
+ try {
405
+ await restoreSession(saved.refreshToken);
406
+ await setPetOffline(db, saved.uid, saved.petId);
407
+ } catch {}
408
+ }
409
+ uninstallService();
410
+ if (fs.existsSync(PETBOOK_CONFIG)) fs.unlinkSync(PETBOOK_CONFIG);
411
+ console.log("\n ✓ 로그아웃 + 서비스 제거 완료\n");
412
+ break;
413
+ }
414
+ default:
415
+ console.log(`
416
+ \x1b[1m🐾 ohmypetbook\x1b[0m — OpenClaw 디바이스 동기화 데몬
417
+
418
+ 각 디바이스 = 1 pet. Firestore와 실시간 동기화.
419
+
420
+ \x1b[1mCommands:\x1b[0m
421
+ ohmypetbook login [코드] 기기 등록 (URL + 자동승인 / 코드 수동입력)
422
+ ohmypetbook install 서비스 등록 (부팅 시 자동 시작)
423
+ ohmypetbook uninstall 서비스 제거
424
+ ohmypetbook run 포그라운드 실행
425
+ ohmypetbook status 상태 확인
426
+ ohmypetbook config 설정 확인/변경 (openclawPath 등)
427
+ ohmypetbook update 최신 버전으로 업데이트
428
+ ohmypetbook logout pet 해제 + 서비스 제거
429
+ `);
430
+ }
package/lib/auth.js ADDED
@@ -0,0 +1,137 @@
1
+ import {
2
+ signInWithEmailAndPassword,
3
+ signInWithCredential, GoogleAuthProvider
4
+ } from "firebase/auth";
5
+ import { doc, getDoc, setDoc, updateDoc } from "firebase/firestore";
6
+ import crypto from "crypto";
7
+ import { execSync } from "child_process";
8
+ import fs from "fs";
9
+ import os from "os";
10
+ import { PETBOOK_HOME, PETBOOK_CONFIG, OPENCLAW_HOME, TOKEN_EXPIRY_DAYS } from "./config.js";
11
+ import { ensureDir, log } from "./log.js";
12
+
13
+ // ── ohmypetbook.json 관리 ──
14
+
15
+ export function savePetbookConfig(data) {
16
+ ensureDir(PETBOOK_HOME);
17
+ const existing = loadPetbookConfig();
18
+ const merged = { ...existing, ...data };
19
+ fs.writeFileSync(PETBOOK_CONFIG, JSON.stringify(merged, null, 2), "utf-8");
20
+ fs.chmodSync(PETBOOK_CONFIG, 0o600);
21
+ }
22
+
23
+ export function loadPetbookConfig() {
24
+ try { return JSON.parse(fs.readFileSync(PETBOOK_CONFIG, "utf-8")); } catch { return {}; }
25
+ }
26
+
27
+ export function saveAuth(data) {
28
+ savePetbookConfig({ auth: data, openclawPath: OPENCLAW_HOME });
29
+ }
30
+
31
+ export function loadAuth() {
32
+ return loadPetbookConfig().auth || null;
33
+ }
34
+
35
+ // ── Token / Verification ──
36
+
37
+ export function generateVerificationCode() {
38
+ const chars = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789";
39
+ let code = "";
40
+ for (let i = 0; i < 4; i++) code += chars[crypto.randomInt(chars.length)];
41
+ return code;
42
+ }
43
+
44
+ export function getDeviceId() {
45
+ try {
46
+ const platform = os.platform();
47
+ let raw;
48
+ if (platform === "darwin") {
49
+ raw = execSync("/usr/sbin/ioreg -rd1 -c IOPlatformExpertDevice", { encoding: "utf-8" });
50
+ const match = raw.match(/"IOPlatformUUID"\s*=\s*"([^"]+)"/);
51
+ if (match) return match[1];
52
+ } else if (platform === "linux") {
53
+ raw = fs.readFileSync("/etc/machine-id", "utf-8").trim();
54
+ if (raw) return raw;
55
+ }
56
+ } catch {}
57
+ // fallback: hostname + arch
58
+ return `${os.hostname()}-${os.arch()}`;
59
+ }
60
+
61
+ export function generatePetId() {
62
+ const deviceId = getDeviceId();
63
+ const hash = crypto.createHash("sha256").update(deviceId).digest("hex").slice(0, 16);
64
+ return `pet_${hash}`;
65
+ }
66
+
67
+ // ── Firebase Auth ──
68
+
69
+ export async function signInFromCredential(auth, credData) {
70
+ if (credData.type === "email") {
71
+ return signInWithEmailAndPassword(auth, credData.email, credData.password);
72
+ } else if (credData.type === "google") {
73
+ const credential = GoogleAuthProvider.credential(credData.googleIdToken);
74
+ return signInWithCredential(auth, credential);
75
+ }
76
+ throw new Error(`지원하지 않는 인증 타입: ${credData.type}`);
77
+ }
78
+
79
+ // ── Pet 등록/검증 (Firestore) ──
80
+
81
+ export function deviceInfo() {
82
+ return {
83
+ hostname: os.hostname(),
84
+ platform: os.platform(),
85
+ arch: os.arch(),
86
+ nodeVersion: process.version
87
+ };
88
+ }
89
+
90
+ export async function registerPet(db, uid, petName) {
91
+ const petId = generatePetId();
92
+ const expiresAt = new Date(Date.now() + TOKEN_EXPIRY_DAYS * 24 * 60 * 60 * 1000).toISOString();
93
+ const now = new Date().toISOString();
94
+
95
+ await setDoc(doc(db, "users", uid, "pets", petId), {
96
+ name: petName || os.hostname(),
97
+ ...deviceInfo(),
98
+ openclawPath: OPENCLAW_HOME,
99
+ createdAt: now,
100
+ expiresAt,
101
+ lastSeen: now,
102
+ status: "online",
103
+ revoked: false
104
+ }, { merge: true });
105
+
106
+ return { petId, expiresAt };
107
+ }
108
+
109
+ export async function validatePet(db, uid, petId) {
110
+ if (!petId) return true;
111
+ const snap = await getDoc(doc(db, "users", uid, "pets", petId));
112
+ if (!snap.exists()) return true;
113
+
114
+ const data = snap.data();
115
+ if (data.revoked) {
116
+ log("🚫 이 pet이 폐기되었습니다. 재등록 필요.");
117
+ return false;
118
+ }
119
+
120
+ // lastSeen + status 업데이트
121
+ await updateDoc(doc(db, "users", uid, "pets", petId), {
122
+ lastSeen: new Date().toISOString(),
123
+ status: "online",
124
+ ...deviceInfo()
125
+ });
126
+ return true;
127
+ }
128
+
129
+ export async function setPetOffline(db, uid, petId) {
130
+ if (!petId) return;
131
+ try {
132
+ await updateDoc(doc(db, "users", uid, "pets", petId), {
133
+ status: "offline",
134
+ lastSeen: new Date().toISOString()
135
+ });
136
+ } catch {}
137
+ }