Add voice audio, capture and transcript, browser monitor, and DM UI

This commit is contained in:
Fisto
2026-09-14 11:19:06 +02:00
parent 65f0788502
commit c003d9532f
27 changed files with 4479 additions and 239 deletions
+64 -4
View File
@@ -99,6 +99,12 @@ export interface PuppetTransport {
reactionAdd(channelId: string, messageId: string, emoji: string): Promise<unknown>;
}
/** The audio actions a chat command can trigger. */
export interface PuppetAudio {
speak(text: string, options?: { provider?: "local" | "api"; voice?: string }): Promise<unknown>;
stop(): void;
}
interface PollState {
channelId: string;
authorId: string;
@@ -142,7 +148,10 @@ export class Puppet {
private selfId?: string;
private queue: Promise<void> = Promise.resolve();
constructor(private readonly core: PuppetTransport) {
constructor(
private readonly core: PuppetTransport,
private readonly audio?: PuppetAudio
) {
try {
const state = JSON.parse(readFileSync(STATE_FILE, "utf8")) as PersistedState;
if (state.ownerId) this.ownerId = state.ownerId;
@@ -159,8 +168,7 @@ export class Puppet {
if (state.config) {
this.config = {
contextMessages: clamp(state.config.contextMessages ?? DEFAULT_CONFIG.contextMessages, 1, 50),
contextChars: clamp(state.config.contextChars ?? DEFAULT_CONFIG.contextChars, 200, 20000),
contextMessages: clamp(state.config.contextMessages ?? DEFAULT_CONFIG.contextMessages, 1, 50), contextChars: clamp(state.config.contextChars ?? DEFAULT_CONFIG.contextChars, 200, 20000),
fetchCooldownSeconds: clamp(
state.config.fetchCooldownSeconds ?? DEFAULT_CONFIG.fetchCooldownSeconds,
0,
@@ -181,6 +189,7 @@ export class Puppet {
}
};
}
} catch {
// first run: defaults apply
}
@@ -263,7 +272,11 @@ export class Puppet {
writeFileSync(
STATE_FILE,
JSON.stringify(
{ ownerId: this.ownerId, whitelist: this.whitelist, config: this.config },
{
ownerId: this.ownerId,
whitelist: this.whitelist,
config: this.config
},
null,
4
)
@@ -446,6 +459,8 @@ export class Puppet {
if (command?.name === "whitelist") {
await this.handleWhitelistCommand(command, author.id, tier, channelId);
} else if (command?.name === "speak") {
await this.handleSpeakCommand(command, author.id, channelId);
} else if (POLL_ENABLED && command?.name === "poll") {
await this.handlePollCommand(author.id, channelId, text);
}
@@ -541,6 +556,51 @@ export class Puppet {
}
}
/* Text to speech */
private async handleSpeakCommand(command: ParsedCommand, authorId: string, channelId: string) {
if (!this.audio) {
return this.respond(authorId, channelId, "speak", "Voice audio is not available.");
}
const text = command.args.join(" ").trim();
if (!text || text === "help") {
return this.respond(
authorId,
channelId,
"speak",
["speak — text to speech.", "Usage:", " speak <text>", " speak stop"].join("\n")
);
}
if (text === "stop") {
this.audio.stop();
this.actionLog.append({ type: "command", command: "speak stop", requesterId: authorId, result: "ok" });
return this.respond(authorId, channelId, "speak", "Stopped.");
}
try {
await this.audio.speak(text, {});
this.actionLog.append({
type: "command",
command: "speak",
requesterId: authorId,
result: "queued",
chars: text.length
});
return this.respond(authorId, channelId, "speak", "Speaking.");
} catch (error) {
this.actionLog.append({
type: "command",
command: "speak",
requesterId: authorId,
result: "error",
error: String(error)
});
return this.respond(authorId, channelId, "speak", `Could not speak: ${(error as Error).message}`);
}
}
/* Polls */
private async handlePollCommand(authorId: string, channelId: string, text: string) {