chore: format to 2-space and bump changelog
This commit is contained in:
@@ -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 {};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user