chore: format to 2-space and bump changelog

This commit is contained in:
Peter Steinberger
2025-11-26 00:53:53 +01:00
parent a67f4db5e2
commit e5f677803f
81 changed files with 7086 additions and 6999 deletions

View File

@@ -10,145 +10,145 @@ export type ClaudeOutputFormat = "text" | "json" | "stream-json";
export type SessionScope = "per-sender" | "global";
export type SessionConfig = {
scope?: SessionScope;
resetTriggers?: string[];
idleMinutes?: number;
store?: string;
sessionArgNew?: string[];
sessionArgResume?: string[];
sessionArgBeforeBody?: boolean;
sendSystemOnce?: boolean;
sessionIntro?: string;
typingIntervalSeconds?: number;
scope?: SessionScope;
resetTriggers?: string[];
idleMinutes?: number;
store?: string;
sessionArgNew?: string[];
sessionArgResume?: string[];
sessionArgBeforeBody?: boolean;
sendSystemOnce?: boolean;
sessionIntro?: string;
typingIntervalSeconds?: number;
};
export type LoggingConfig = {
level?: "silent" | "fatal" | "error" | "warn" | "info" | "debug" | "trace";
file?: string;
level?: "silent" | "fatal" | "error" | "warn" | "info" | "debug" | "trace";
file?: string;
};
export type WarelayConfig = {
logging?: LoggingConfig;
inbound?: {
allowFrom?: string[]; // E.164 numbers allowed to trigger auto-reply (without whatsapp:)
transcribeAudio?: {
// Optional CLI to turn inbound audio into text; templated args, must output transcript to stdout.
command: string[];
timeoutSeconds?: number;
};
reply?: {
mode: ReplyMode;
text?: string; // for mode=text, can contain {{Body}}
command?: string[]; // for mode=command, argv with templates
cwd?: string; // working directory for command execution
template?: string; // prepend template string when building command/prompt
timeoutSeconds?: number; // optional command timeout; defaults to 600s
bodyPrefix?: string; // optional string prepended to Body before templating
mediaUrl?: string; // optional media attachment (path or URL)
session?: SessionConfig;
claudeOutputFormat?: ClaudeOutputFormat; // when command starts with `claude`, force an output format
mediaMaxMb?: number; // optional cap for outbound media (default 5MB)
typingIntervalSeconds?: number; // how often to refresh typing indicator while command runs
};
};
logging?: LoggingConfig;
inbound?: {
allowFrom?: string[]; // E.164 numbers allowed to trigger auto-reply (without whatsapp:)
transcribeAudio?: {
// Optional CLI to turn inbound audio into text; templated args, must output transcript to stdout.
command: string[];
timeoutSeconds?: number;
};
reply?: {
mode: ReplyMode;
text?: string; // for mode=text, can contain {{Body}}
command?: string[]; // for mode=command, argv with templates
cwd?: string; // working directory for command execution
template?: string; // prepend template string when building command/prompt
timeoutSeconds?: number; // optional command timeout; defaults to 600s
bodyPrefix?: string; // optional string prepended to Body before templating
mediaUrl?: string; // optional media attachment (path or URL)
session?: SessionConfig;
claudeOutputFormat?: ClaudeOutputFormat; // when command starts with `claude`, force an output format
mediaMaxMb?: number; // optional cap for outbound media (default 5MB)
typingIntervalSeconds?: number; // how often to refresh typing indicator while command runs
};
};
};
export const CONFIG_PATH = path.join(os.homedir(), ".warelay", "warelay.json");
const ReplySchema = z
.object({
mode: z.union([z.literal("text"), z.literal("command")]),
text: z.string().optional(),
command: z.array(z.string()).optional(),
cwd: z.string().optional(),
template: z.string().optional(),
timeoutSeconds: z.number().int().positive().optional(),
bodyPrefix: z.string().optional(),
mediaUrl: z.string().optional(),
mediaMaxMb: z.number().positive().optional(),
typingIntervalSeconds: z.number().int().positive().optional(),
session: z
.object({
scope: z
.union([z.literal("per-sender"), z.literal("global")])
.optional(),
resetTriggers: z.array(z.string()).optional(),
idleMinutes: z.number().int().positive().optional(),
store: z.string().optional(),
sessionArgNew: z.array(z.string()).optional(),
sessionArgResume: z.array(z.string()).optional(),
sessionArgBeforeBody: z.boolean().optional(),
sendSystemOnce: z.boolean().optional(),
sessionIntro: z.string().optional(),
typingIntervalSeconds: z.number().int().positive().optional(),
})
.optional(),
claudeOutputFormat: z
.union([
z.literal("text"),
z.literal("json"),
z.literal("stream-json"),
z.undefined(),
])
.optional(),
})
.refine(
(val) => (val.mode === "text" ? Boolean(val.text) : Boolean(val.command)),
{
message:
"reply.text is required for mode=text; reply.command is required for mode=command",
},
);
.object({
mode: z.union([z.literal("text"), z.literal("command")]),
text: z.string().optional(),
command: z.array(z.string()).optional(),
cwd: z.string().optional(),
template: z.string().optional(),
timeoutSeconds: z.number().int().positive().optional(),
bodyPrefix: z.string().optional(),
mediaUrl: z.string().optional(),
mediaMaxMb: z.number().positive().optional(),
typingIntervalSeconds: z.number().int().positive().optional(),
session: z
.object({
scope: z
.union([z.literal("per-sender"), z.literal("global")])
.optional(),
resetTriggers: z.array(z.string()).optional(),
idleMinutes: z.number().int().positive().optional(),
store: z.string().optional(),
sessionArgNew: z.array(z.string()).optional(),
sessionArgResume: z.array(z.string()).optional(),
sessionArgBeforeBody: z.boolean().optional(),
sendSystemOnce: z.boolean().optional(),
sessionIntro: z.string().optional(),
typingIntervalSeconds: z.number().int().positive().optional(),
})
.optional(),
claudeOutputFormat: z
.union([
z.literal("text"),
z.literal("json"),
z.literal("stream-json"),
z.undefined(),
])
.optional(),
})
.refine(
(val) => (val.mode === "text" ? Boolean(val.text) : Boolean(val.command)),
{
message:
"reply.text is required for mode=text; reply.command is required for mode=command",
},
);
const WarelaySchema = z.object({
logging: z
.object({
level: z
.union([
z.literal("silent"),
z.literal("fatal"),
z.literal("error"),
z.literal("warn"),
z.literal("info"),
z.literal("debug"),
z.literal("trace"),
])
.optional(),
file: z.string().optional(),
})
.optional(),
inbound: z
.object({
allowFrom: z.array(z.string()).optional(),
transcribeAudio: z
.object({
command: z.array(z.string()),
timeoutSeconds: z.number().int().positive().optional(),
})
.optional(),
reply: ReplySchema.optional(),
})
.optional(),
logging: z
.object({
level: z
.union([
z.literal("silent"),
z.literal("fatal"),
z.literal("error"),
z.literal("warn"),
z.literal("info"),
z.literal("debug"),
z.literal("trace"),
])
.optional(),
file: z.string().optional(),
})
.optional(),
inbound: z
.object({
allowFrom: z.array(z.string()).optional(),
transcribeAudio: z
.object({
command: z.array(z.string()),
timeoutSeconds: z.number().int().positive().optional(),
})
.optional(),
reply: ReplySchema.optional(),
})
.optional(),
});
export function loadConfig(): WarelayConfig {
// Read ~/.warelay/warelay.json (JSON5) if present.
try {
if (!fs.existsSync(CONFIG_PATH)) return {};
const raw = fs.readFileSync(CONFIG_PATH, "utf-8");
const parsed = JSON5.parse(raw);
if (typeof parsed !== "object" || parsed === null) return {};
const validated = WarelaySchema.safeParse(parsed);
if (!validated.success) {
console.error("Invalid warelay config:");
for (const iss of validated.error.issues) {
console.error(`- ${iss.path.join(".")}: ${iss.message}`);
}
return {};
}
return validated.data as WarelayConfig;
} catch (err) {
console.error(`Failed to read config at ${CONFIG_PATH}`, err);
return {};
}
// Read ~/.warelay/warelay.json (JSON5) if present.
try {
if (!fs.existsSync(CONFIG_PATH)) return {};
const raw = fs.readFileSync(CONFIG_PATH, "utf-8");
const parsed = JSON5.parse(raw);
if (typeof parsed !== "object" || parsed === null) return {};
const validated = WarelaySchema.safeParse(parsed);
if (!validated.success) {
console.error("Invalid warelay config:");
for (const iss of validated.error.issues) {
console.error(`- ${iss.path.join(".")}: ${iss.message}`);
}
return {};
}
return validated.data as WarelayConfig;
} catch (err) {
console.error(`Failed to read config at ${CONFIG_PATH}`, err);
return {};
}
}

View File

@@ -3,17 +3,17 @@ import { describe, expect, it } from "vitest";
import { deriveSessionKey } from "./sessions.js";
describe("sessions", () => {
it("returns normalized per-sender key", () => {
expect(deriveSessionKey("per-sender", { From: "whatsapp:+1555" })).toBe(
"+1555",
);
});
it("returns normalized per-sender key", () => {
expect(deriveSessionKey("per-sender", { From: "whatsapp:+1555" })).toBe(
"+1555",
);
});
it("falls back to unknown when sender missing", () => {
expect(deriveSessionKey("per-sender", {})).toBe("unknown");
});
it("falls back to unknown when sender missing", () => {
expect(deriveSessionKey("per-sender", {})).toBe("unknown");
});
it("global scope returns global", () => {
expect(deriveSessionKey("global", { From: "+1" })).toBe("global");
});
it("global scope returns global", () => {
expect(deriveSessionKey("global", { From: "+1" })).toBe("global");
});
});

View File

@@ -9,9 +9,9 @@ import { CONFIG_DIR, normalizeE164 } from "../utils.js";
export type SessionScope = "per-sender" | "global";
export type SessionEntry = {
sessionId: string;
updatedAt: number;
systemSent?: boolean;
sessionId: string;
updatedAt: number;
systemSent?: boolean;
};
export const SESSION_STORE_DEFAULT = path.join(CONFIG_DIR, "sessions.json");
@@ -19,42 +19,42 @@ export const DEFAULT_RESET_TRIGGER = "/new";
export const DEFAULT_IDLE_MINUTES = 60;
export function resolveStorePath(store?: string) {
if (!store) return SESSION_STORE_DEFAULT;
if (store.startsWith("~"))
return path.resolve(store.replace("~", os.homedir()));
return path.resolve(store);
if (!store) return SESSION_STORE_DEFAULT;
if (store.startsWith("~"))
return path.resolve(store.replace("~", os.homedir()));
return path.resolve(store);
}
export function loadSessionStore(
storePath: string,
storePath: string,
): Record<string, SessionEntry> {
try {
const raw = fs.readFileSync(storePath, "utf-8");
const parsed = JSON5.parse(raw);
if (parsed && typeof parsed === "object") {
return parsed as Record<string, SessionEntry>;
}
} catch {
// ignore missing/invalid store; we'll recreate it
}
return {};
try {
const raw = fs.readFileSync(storePath, "utf-8");
const parsed = JSON5.parse(raw);
if (parsed && typeof parsed === "object") {
return parsed as Record<string, SessionEntry>;
}
} catch {
// ignore missing/invalid store; we'll recreate it
}
return {};
}
export async function saveSessionStore(
storePath: string,
store: Record<string, SessionEntry>,
storePath: string,
store: Record<string, SessionEntry>,
) {
await fs.promises.mkdir(path.dirname(storePath), { recursive: true });
await fs.promises.writeFile(
storePath,
JSON.stringify(store, null, 2),
"utf-8",
);
await fs.promises.mkdir(path.dirname(storePath), { recursive: true });
await fs.promises.writeFile(
storePath,
JSON.stringify(store, null, 2),
"utf-8",
);
}
// Decide which session bucket to use (per-sender vs global).
export function deriveSessionKey(scope: SessionScope, ctx: MsgContext) {
if (scope === "global") return "global";
const from = ctx.From ? normalizeE164(ctx.From) : "";
return from || "unknown";
if (scope === "global") return "global";
const from = ctx.From ? normalizeE164(ctx.From) : "";
return from || "unknown";
}