pi-afk 0.1.0
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/CHANGELOG.md +11 -0
- package/LICENSE +21 -0
- package/README.md +60 -0
- package/extensions/README.md +12 -0
- package/extensions/ask-queue.ts +231 -0
- package/extensions/config.ts +172 -0
- package/extensions/controller.ts +316 -0
- package/extensions/index.ts +86 -0
- package/extensions/lock.ts +320 -0
- package/extensions/telegram-format.ts +58 -0
- package/extensions/telegram.ts +176 -0
- package/extensions/types.ts +65 -0
- package/package.json +63 -0
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to this project will be documented in this file.
|
|
4
|
+
|
|
5
|
+
## v0.1.0
|
|
6
|
+
|
|
7
|
+
- Initial Pi extension package scaffold.
|
|
8
|
+
- Added `/afk` and `/afk-settings` commands for session-scoped Telegram AFK mode.
|
|
9
|
+
- Added the `afk` agent tool for Telegram notifications and blocking questions.
|
|
10
|
+
- Added persistent Telegram config storage, bot-token locking, serialized ask queue, Telegram formatting, and grammY long-polling bridge.
|
|
11
|
+
- Added tests and documentation for the AFK MVP workflow.
|
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Hamdi Alareij
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
# afk
|
|
2
|
+
|
|
3
|
+
A Pi package extension that allows the agent to communicate with the user when they're afk.
|
|
4
|
+
|
|
5
|
+
## Status
|
|
6
|
+
|
|
7
|
+
AFK provides a Pi extension MVP with `/afk`, `/afk-settings`, and an `afk` agent tool backed by a linked Telegram bot.
|
|
8
|
+
|
|
9
|
+
## AFK MVP usage
|
|
10
|
+
|
|
11
|
+
1. Create a Telegram bot with BotFather and copy the bot token.
|
|
12
|
+
2. Start Pi with this package loaded: `pi -e .`.
|
|
13
|
+
3. Run `/afk-settings`.
|
|
14
|
+
4. Paste the bot token when prompted.
|
|
15
|
+
5. Send the displayed one-time code directly to the Telegram bot.
|
|
16
|
+
6. Run `/afk` to enable AFK mode for the current Pi session.
|
|
17
|
+
7. While AFK mode is on, the agent can use the `afk` tool to notify you or ask questions through Telegram.
|
|
18
|
+
8. Run `/afk` again to turn AFK mode off.
|
|
19
|
+
|
|
20
|
+
AFK mode is session-scoped. It turns off on `/new`, `/resume`, `/fork`, `/clone`, `/reload`, and Pi shutdown. Telegram bot settings remain saved in user config.
|
|
21
|
+
|
|
22
|
+
## Install for local development
|
|
23
|
+
|
|
24
|
+
```bash
|
|
25
|
+
npm install
|
|
26
|
+
npm run check
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
## Use with Pi during development
|
|
30
|
+
|
|
31
|
+
```bash
|
|
32
|
+
pi -e .
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
Pi loads the package through `package.json`:
|
|
36
|
+
|
|
37
|
+
```json
|
|
38
|
+
{ "pi": { "extensions": ["./extensions/index.ts"] } }
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
## Project layout
|
|
42
|
+
|
|
43
|
+
```text
|
|
44
|
+
extensions/index.ts Pi extension entry point
|
|
45
|
+
extensions/controller.ts AFK runtime orchestration
|
|
46
|
+
extensions/config.ts Telegram config persistence
|
|
47
|
+
extensions/lock.ts Bot-token process lock
|
|
48
|
+
extensions/ask-queue.ts Serialized blocking question queue
|
|
49
|
+
extensions/telegram.ts grammY bridge
|
|
50
|
+
extensions/telegram-format.ts Telegram question/button formatting
|
|
51
|
+
extensions/types.ts Shared schemas and types
|
|
52
|
+
extensions/README.md Extension-specific notes
|
|
53
|
+
tests/ Node test runner tests
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
## Release checks
|
|
57
|
+
|
|
58
|
+
```bash
|
|
59
|
+
npm run check
|
|
60
|
+
```
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
# AFK Extension
|
|
2
|
+
|
|
3
|
+
Runtime pieces:
|
|
4
|
+
|
|
5
|
+
- `index.ts` wires Pi commands, tool registration, and lifecycle hooks.
|
|
6
|
+
- `controller.ts` owns AFK runtime state and composes config, locking, Telegram, and the ask queue.
|
|
7
|
+
- `config.ts` persists Telegram bot settings in user config.
|
|
8
|
+
- `lock.ts` prevents two Pi processes from polling with the same bot token.
|
|
9
|
+
- `ask-queue.ts` serializes blocking AFK questions.
|
|
10
|
+
- `telegram.ts` adapts grammY long polling to the controller.
|
|
11
|
+
- `telegram-format.ts` formats Telegram question messages and buttons.
|
|
12
|
+
- `types.ts` contains shared schemas and TypeScript types.
|
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import type { AfkAnswer, AfkQuestion } from "./types.ts";
|
|
3
|
+
|
|
4
|
+
export interface ActiveTelegramQuestion {
|
|
5
|
+
requestId: string;
|
|
6
|
+
nonce: string;
|
|
7
|
+
questionIndex: number;
|
|
8
|
+
totalQuestions: number;
|
|
9
|
+
question: AfkQuestion;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export interface AskQueueTransport {
|
|
13
|
+
sendQuestion(active: ActiveTelegramQuestion): Promise<void>;
|
|
14
|
+
sendCancellation(reason: string): Promise<void>;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface AskQueueOptions {
|
|
18
|
+
makeNonce?: () => string;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
interface QueuedAsk {
|
|
22
|
+
requestId: string;
|
|
23
|
+
questions: AfkQuestion[];
|
|
24
|
+
answers: AfkAnswer[];
|
|
25
|
+
resolve: (answers: AfkAnswer[]) => void;
|
|
26
|
+
reject: (error: Error) => void;
|
|
27
|
+
removeAbortListener: () => void;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
interface ActiveAsk extends QueuedAsk {
|
|
31
|
+
questionIndex: number;
|
|
32
|
+
nonce: string;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export class AfkAskCancelledError extends Error {
|
|
36
|
+
constructor(message: string) {
|
|
37
|
+
super(message);
|
|
38
|
+
this.name = "AfkAskCancelledError";
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const abortReason = (signal: AbortSignal): string => {
|
|
43
|
+
const { reason } = signal;
|
|
44
|
+
if (typeof reason === "string" && reason.trim()) return reason;
|
|
45
|
+
return "AFK ask was aborted";
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
export class AskQueue {
|
|
49
|
+
private readonly queue: QueuedAsk[] = [];
|
|
50
|
+
private active: ActiveAsk | undefined;
|
|
51
|
+
private readonly makeNonce: () => string;
|
|
52
|
+
|
|
53
|
+
constructor(
|
|
54
|
+
private readonly transport: AskQueueTransport,
|
|
55
|
+
options: AskQueueOptions = {},
|
|
56
|
+
) {
|
|
57
|
+
this.makeNonce = options.makeNonce ?? (() => randomUUID().slice(0, 8));
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
enqueue(questions: AfkQuestion[], signal?: AbortSignal): Promise<AfkAnswer[]> {
|
|
61
|
+
if (questions.length === 0) {
|
|
62
|
+
return Promise.reject(new Error("AFK ask requires at least one question"));
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
return new Promise<AfkAnswer[]>((resolve, reject) => {
|
|
66
|
+
const request: QueuedAsk = {
|
|
67
|
+
requestId: randomUUID(),
|
|
68
|
+
questions: questions.map((item) => ({
|
|
69
|
+
...item,
|
|
70
|
+
options: item.options.map((option) => ({ ...option })),
|
|
71
|
+
})),
|
|
72
|
+
answers: [],
|
|
73
|
+
resolve,
|
|
74
|
+
reject,
|
|
75
|
+
removeAbortListener: () => {},
|
|
76
|
+
};
|
|
77
|
+
const abort = () => {
|
|
78
|
+
const reason = signal ? abortReason(signal) : "AFK ask was aborted";
|
|
79
|
+
if (this.active?.requestId === request.requestId) {
|
|
80
|
+
this.cancelActive(reason);
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
if (this.removeQueued(request.requestId)) {
|
|
85
|
+
request.removeAbortListener();
|
|
86
|
+
reject(new AfkAskCancelledError(reason));
|
|
87
|
+
}
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
if (signal?.aborted) {
|
|
91
|
+
reject(new AfkAskCancelledError(abortReason(signal)));
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
if (signal) {
|
|
96
|
+
signal.addEventListener("abort", abort, { once: true });
|
|
97
|
+
request.removeAbortListener = () => signal.removeEventListener("abort", abort);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
this.queue.push(request);
|
|
101
|
+
void this.pump();
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
async answerWithOption(nonce: string, optionIndex: number): Promise<boolean> {
|
|
106
|
+
const active = this.active;
|
|
107
|
+
if (!active || active.nonce !== nonce) return false;
|
|
108
|
+
|
|
109
|
+
const question = active.questions[active.questionIndex];
|
|
110
|
+
const option = question?.options[optionIndex];
|
|
111
|
+
if (!question || !option) return false;
|
|
112
|
+
|
|
113
|
+
active.answers.push({ id: question.id, value: option.value, label: option.label, wasCustom: false });
|
|
114
|
+
await this.advance();
|
|
115
|
+
return true;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
async answerWithText(text: string): Promise<boolean> {
|
|
119
|
+
const active = this.active;
|
|
120
|
+
if (!active) return false;
|
|
121
|
+
|
|
122
|
+
const trimmed = text.trim();
|
|
123
|
+
if (!trimmed) return false;
|
|
124
|
+
|
|
125
|
+
const question = active.questions[active.questionIndex];
|
|
126
|
+
if (!question) return false;
|
|
127
|
+
|
|
128
|
+
active.answers.push({ id: question.id, value: trimmed, label: trimmed, wasCustom: true });
|
|
129
|
+
await this.advance();
|
|
130
|
+
return true;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
cancelAll(reason: string): void {
|
|
134
|
+
const error = new AfkAskCancelledError(reason);
|
|
135
|
+
const hadActive = Boolean(this.active);
|
|
136
|
+
|
|
137
|
+
if (this.active) {
|
|
138
|
+
this.active.removeAbortListener();
|
|
139
|
+
this.active.reject(error);
|
|
140
|
+
this.active = undefined;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
for (const item of this.queue.splice(0)) {
|
|
144
|
+
item.removeAbortListener();
|
|
145
|
+
item.reject(error);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
if (hadActive) void this.transport.sendCancellation(reason).catch(() => {});
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
get hasPendingQuestion(): boolean {
|
|
152
|
+
return Boolean(this.active);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
private async pump(): Promise<void> {
|
|
156
|
+
if (this.active || this.queue.length === 0) return;
|
|
157
|
+
|
|
158
|
+
const next = this.queue.shift();
|
|
159
|
+
if (!next) return;
|
|
160
|
+
|
|
161
|
+
const active = { ...next, questionIndex: 0, nonce: this.makeNonce() };
|
|
162
|
+
this.active = active;
|
|
163
|
+
try {
|
|
164
|
+
await this.sendActiveQuestion(active);
|
|
165
|
+
} catch (error) {
|
|
166
|
+
await this.rejectActiveAndPump(error, active);
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
private async sendActiveQuestion(active: ActiveAsk): Promise<void> {
|
|
171
|
+
const question = active.questions[active.questionIndex];
|
|
172
|
+
if (!question) return;
|
|
173
|
+
|
|
174
|
+
await this.transport.sendQuestion({
|
|
175
|
+
requestId: active.requestId,
|
|
176
|
+
nonce: active.nonce,
|
|
177
|
+
questionIndex: active.questionIndex,
|
|
178
|
+
totalQuestions: active.questions.length,
|
|
179
|
+
question,
|
|
180
|
+
});
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
private async advance(): Promise<void> {
|
|
184
|
+
const active = this.active;
|
|
185
|
+
if (!active) return;
|
|
186
|
+
|
|
187
|
+
if (active.questionIndex + 1 >= active.questions.length) {
|
|
188
|
+
active.removeAbortListener();
|
|
189
|
+
active.resolve(active.answers);
|
|
190
|
+
this.active = undefined;
|
|
191
|
+
await this.pump();
|
|
192
|
+
return;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
active.questionIndex += 1;
|
|
196
|
+
active.nonce = this.makeNonce();
|
|
197
|
+
try {
|
|
198
|
+
await this.sendActiveQuestion(active);
|
|
199
|
+
} catch (error) {
|
|
200
|
+
await this.rejectActiveAndPump(error, active);
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
private removeQueued(requestId: string): boolean {
|
|
205
|
+
const index = this.queue.findIndex((item) => item.requestId === requestId);
|
|
206
|
+
if (index < 0) return false;
|
|
207
|
+
this.queue.splice(index, 1);
|
|
208
|
+
return true;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
private cancelActive(reason: string): void {
|
|
212
|
+
if (!this.active) return;
|
|
213
|
+
|
|
214
|
+
const active = this.active;
|
|
215
|
+
active.removeAbortListener();
|
|
216
|
+
active.reject(new AfkAskCancelledError(reason));
|
|
217
|
+
this.active = undefined;
|
|
218
|
+
void this.transport.sendCancellation(reason).catch(() => {});
|
|
219
|
+
void this.pump();
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
private async rejectActiveAndPump(error: unknown, failedActive: ActiveAsk): Promise<void> {
|
|
223
|
+
const active = this.active;
|
|
224
|
+
if (!active || active.requestId !== failedActive.requestId) return;
|
|
225
|
+
|
|
226
|
+
active.removeAbortListener();
|
|
227
|
+
active.reject(error instanceof Error ? error : new Error(String(error)));
|
|
228
|
+
this.active = undefined;
|
|
229
|
+
await this.pump();
|
|
230
|
+
}
|
|
231
|
+
}
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { chmod, lstat, mkdir, open, readdir, readFile, rename, rm } from "node:fs/promises";
|
|
3
|
+
import { homedir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import type { AfkConfig } from "./types.ts";
|
|
6
|
+
|
|
7
|
+
export function getAfkHome(env: NodeJS.ProcessEnv = process.env): string {
|
|
8
|
+
const afkHome = env.PI_AFK_HOME?.trim();
|
|
9
|
+
if (afkHome) return afkHome;
|
|
10
|
+
const home = env.HOME?.trim() || homedir();
|
|
11
|
+
return join(home, ".pi", "agent", "afk");
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function configPath(home = getAfkHome()): string {
|
|
15
|
+
return join(home, "config.json");
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function isAfkConfig(value: unknown): value is AfkConfig {
|
|
19
|
+
if (!value || typeof value !== "object") return false;
|
|
20
|
+
const candidate = value as Record<string, unknown>;
|
|
21
|
+
return (
|
|
22
|
+
typeof candidate.botToken === "string" &&
|
|
23
|
+
candidate.botToken.trim().length > 0 &&
|
|
24
|
+
typeof candidate.botUsername === "string" &&
|
|
25
|
+
candidate.botUsername.trim().length > 0 &&
|
|
26
|
+
typeof candidate.chatId === "number" &&
|
|
27
|
+
Number.isSafeInteger(candidate.chatId) &&
|
|
28
|
+
candidate.chatId > 0 &&
|
|
29
|
+
typeof candidate.userId === "number" &&
|
|
30
|
+
Number.isSafeInteger(candidate.userId) &&
|
|
31
|
+
candidate.userId > 0
|
|
32
|
+
);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function cleanConfig(config: AfkConfig): AfkConfig {
|
|
36
|
+
return {
|
|
37
|
+
botToken: config.botToken.trim(),
|
|
38
|
+
botUsername: config.botUsername.trim(),
|
|
39
|
+
chatId: config.chatId,
|
|
40
|
+
userId: config.userId,
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function hasErrorCode(error: unknown, code: string): boolean {
|
|
45
|
+
return Boolean(error && typeof error === "object" && "code" in error && error.code === code);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function isAfkOwnedEntry(entry: string): boolean {
|
|
49
|
+
return entry === "config.json" || entry === "locks" || /^\.config\.json\..+\.tmp$/.test(entry);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
async function validateExistingAfkHome(home: string): Promise<boolean> {
|
|
53
|
+
let stats;
|
|
54
|
+
try {
|
|
55
|
+
stats = await lstat(home);
|
|
56
|
+
} catch (error) {
|
|
57
|
+
if (hasErrorCode(error, "ENOENT")) return false;
|
|
58
|
+
throw error;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
if (stats.isSymbolicLink()) throw new Error(`Unsafe AFK home: ${home} is a symlink`);
|
|
62
|
+
if (!stats.isDirectory()) throw new Error(`Unsafe AFK home: ${home} is not a directory`);
|
|
63
|
+
|
|
64
|
+
const entries = await readdir(home);
|
|
65
|
+
const unrelatedEntry = entries.find((entry) => !isAfkOwnedEntry(entry));
|
|
66
|
+
if (unrelatedEntry) throw new Error(`Unsafe AFK home: contains unrelated entry ${unrelatedEntry}`);
|
|
67
|
+
return true;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
async function prepareAfkHomeForWrite(home: string): Promise<void> {
|
|
71
|
+
const exists = await validateExistingAfkHome(home);
|
|
72
|
+
if (!exists) await mkdir(home, { recursive: true, mode: 0o700 });
|
|
73
|
+
await chmod(home, 0o700);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
async function validateConfigFile(path: string): Promise<boolean> {
|
|
77
|
+
let stats;
|
|
78
|
+
try {
|
|
79
|
+
stats = await lstat(path);
|
|
80
|
+
} catch (error) {
|
|
81
|
+
if (hasErrorCode(error, "ENOENT")) return false;
|
|
82
|
+
throw error;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
if (stats.isSymbolicLink()) throw new Error(`Unsafe AFK config file: ${path} is a symlink`);
|
|
86
|
+
if (!stats.isFile()) throw new Error(`Unsafe AFK config file: ${path} is not a regular file`);
|
|
87
|
+
return true;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export async function readConfig(home = getAfkHome()): Promise<AfkConfig | undefined> {
|
|
91
|
+
const exists = await validateExistingAfkHome(home);
|
|
92
|
+
if (!exists) return undefined;
|
|
93
|
+
|
|
94
|
+
await chmod(home, 0o700);
|
|
95
|
+
|
|
96
|
+
const targetPath = configPath(home);
|
|
97
|
+
const configExists = await validateConfigFile(targetPath);
|
|
98
|
+
if (!configExists) return undefined;
|
|
99
|
+
|
|
100
|
+
let raw;
|
|
101
|
+
try {
|
|
102
|
+
raw = await readFile(targetPath, "utf8");
|
|
103
|
+
} catch (error) {
|
|
104
|
+
if (hasErrorCode(error, "ENOENT")) return undefined;
|
|
105
|
+
throw error;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
await validateConfigFile(targetPath);
|
|
109
|
+
await chmod(targetPath, 0o600);
|
|
110
|
+
|
|
111
|
+
try {
|
|
112
|
+
const parsed = JSON.parse(raw) as unknown;
|
|
113
|
+
if (!isAfkConfig(parsed)) return undefined;
|
|
114
|
+
return cleanConfig(parsed);
|
|
115
|
+
} catch (error) {
|
|
116
|
+
if (error instanceof SyntaxError) return undefined;
|
|
117
|
+
throw error;
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
async function fsyncDirectory(path: string): Promise<void> {
|
|
122
|
+
if (process.platform === "win32") return;
|
|
123
|
+
|
|
124
|
+
let handle;
|
|
125
|
+
try {
|
|
126
|
+
handle = await open(path, "r");
|
|
127
|
+
await handle.sync();
|
|
128
|
+
} catch (error) {
|
|
129
|
+
if (error && typeof error === "object" && "code" in error) {
|
|
130
|
+
const code = error.code;
|
|
131
|
+
if (code === "EINVAL" || code === "ENOTSUP" || code === "EISDIR" || code === "EPERM") return;
|
|
132
|
+
}
|
|
133
|
+
throw error;
|
|
134
|
+
} finally {
|
|
135
|
+
await handle?.close();
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
export async function writeConfig(config: AfkConfig, home = getAfkHome()): Promise<void> {
|
|
140
|
+
if (!isAfkConfig(config)) throw new Error("Invalid AFK config: refusing to write config.json");
|
|
141
|
+
|
|
142
|
+
await prepareAfkHomeForWrite(home);
|
|
143
|
+
|
|
144
|
+
const targetPath = configPath(home);
|
|
145
|
+
await validateConfigFile(targetPath);
|
|
146
|
+
const tempPath = join(home, `.config.json.${process.pid}.${randomUUID()}.tmp`);
|
|
147
|
+
let handle;
|
|
148
|
+
|
|
149
|
+
try {
|
|
150
|
+
handle = await open(tempPath, "wx", 0o600);
|
|
151
|
+
await handle.writeFile(`${JSON.stringify(cleanConfig(config), null, 2)}\n`, "utf8");
|
|
152
|
+
await handle.sync();
|
|
153
|
+
await handle.close();
|
|
154
|
+
handle = undefined;
|
|
155
|
+
await chmod(tempPath, 0o600);
|
|
156
|
+
await rename(tempPath, targetPath);
|
|
157
|
+
await validateConfigFile(targetPath);
|
|
158
|
+
await chmod(targetPath, 0o600);
|
|
159
|
+
await fsyncDirectory(home);
|
|
160
|
+
} catch (error) {
|
|
161
|
+
try {
|
|
162
|
+
await handle?.close();
|
|
163
|
+
} finally {
|
|
164
|
+
await rm(tempPath, { force: true });
|
|
165
|
+
}
|
|
166
|
+
throw error;
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
export function redactConfig(config: AfkConfig): Omit<AfkConfig, "botToken"> & { botToken: "<redacted>" } {
|
|
171
|
+
return { ...cleanConfig(config), botToken: "<redacted>" };
|
|
172
|
+
}
|