Add the agent scaffold, mobile web UI, voice auto-leave, and text notifications

This commit is contained in:
Fisto
2026-09-14 16:38:14 +02:00
parent c7aad7547f
commit eab94132bb
27 changed files with 1521 additions and 73 deletions
+1
View File
@@ -322,6 +322,7 @@ function buildServer(): McpServer {
fetchCooldownSeconds: z.number().int().min(0).max(300).optional(),
continuationSeconds: z.number().int().min(0).max(300).optional(),
pollSeconds: z.number().int().min(10).max(3600).optional(),
voiceLeaveDelaySeconds: z.number().int().min(0).max(600).optional(),
responsePolicy: z
.object({
defaultMode: z.enum(["channel", "dm"]).optional(),
+21
View File
@@ -61,6 +61,27 @@ export class MessageCache {
return merged;
}
/** Update cached content after an edit. */
edit(channelId: string, messageId: string, content: string): boolean {
const messages = this.get(channelId);
const message = messages.find(item => item.id === messageId);
if (!message) return false;
message.content = content;
this.write(channelId, messages);
return true;
}
/** Drop a deleted message from the cache. */
remove(channelId: string, messageId: string): boolean {
const messages = this.get(channelId);
const next = messages.filter(item => item.id !== messageId);
if (next.length === messages.length) return false;
this.write(channelId, next);
return true;
}
private file(channelId: string): string {
return join(this.dir, `${channelId}.json`);
}
+22 -4
View File
@@ -70,6 +70,9 @@ const FAKE_HISTORY = Array.from({ length: 8 }, (_, index) => ({
},
content: `history message ${index}`,
timestamp: new Date(Date.now() - (8 - index) * 60_000).toISOString(),
editedAt: index === 4 ? new Date().toISOString() : null,
replyTo:
index === 6 ? { id: "400000000000000004", authorId: "999", authorName: "mockuser" } : null,
attachments:
index === 6
? [
@@ -96,6 +99,7 @@ const FAKE_HISTORY = Array.from({ length: 8 }, (_, index) => ({
const MOCK_VOICE_CHANNEL = "200000000000000002";
let botVoiceChannel: string | null = MOCK_VOICE_CHANNEL;
let ownerVoiceChannel: string | null = MOCK_VOICE_CHANNEL;
const STATE_FAIL_MS = Number(process.env.VESKSLOP_MOCK_STATE_FAIL_MS ?? 0);
const startedAt = Date.now();
@@ -116,16 +120,22 @@ function handle(action: string | undefined, params: any) {
throw new Error("renderer is not ready");
}
return { user: { id: "999", username: "mockuser", avatar: null }, guilds: FAKE_GUILDS };
case "voice.get":
if (!botVoiceChannel) {
case "voice.get": {
const target = params?.userId;
const isOwner = target === "177117296140091393";
const channelId = isOwner ? ownerVoiceChannel : botVoiceChannel;
if (!channelId) {
return { guildId: params?.guildId ?? null, channelId: null, channelName: null, members: [] };
}
return {
guildId: params?.guildId ?? "100000000000000001",
channelId: botVoiceChannel,
channelId,
channelName: "voice",
members: mockVoiceMembers()
};
}
case "voice.overview":
return {
guildId: params?.guildId,
@@ -237,7 +247,15 @@ function handle(action: string | undefined, params: any) {
case "dm.send":
return { userId: params?.userId, channelId: `dm-${params?.userId}`, sent: true };
case "debug.emit": {
const line = JSON.stringify({ type: "event", event: params?.event }) + "\n";
const event = params?.event;
// Keep the mock's own voice state in step with emitted events.
if (event?.type === "VOICE_STATE_UPDATE") {
if (event.userId === "177117296140091393") ownerVoiceChannel = event.channelId ?? null;
else if (event.userId === "999") botVoiceChannel = event.channelId ?? null;
}
const line = JSON.stringify({ type: "event", event }) + "\n";
for (const subscriber of subscribers) subscriber.write(line);
return { emitted: true };
}
+217 -4
View File
@@ -51,6 +51,7 @@ export interface PuppetConfig {
fetchCooldownSeconds: number;
continuationSeconds: number;
pollSeconds: number;
voiceLeaveDelaySeconds: number;
responsePolicy: ResponsePolicy;
}
@@ -60,6 +61,7 @@ export interface PuppetConfigPatch {
fetchCooldownSeconds?: number;
continuationSeconds?: number;
pollSeconds?: number;
voiceLeaveDelaySeconds?: number;
responsePolicy?: {
defaultMode?: "channel" | "dm";
sensitiveCommands?: string[];
@@ -73,6 +75,7 @@ const DEFAULT_CONFIG: PuppetConfig = {
fetchCooldownSeconds: 5,
continuationSeconds: 10,
pollSeconds: 120,
voiceLeaveDelaySeconds: clamp(Number(process.env.VESKSLOP_VOICE_LEAVE_DELAY ?? 30), 0, 600),
responsePolicy: {
defaultMode: "channel",
sensitiveCommands: ["whitelist", "config"],
@@ -97,6 +100,8 @@ export interface PuppetTransport {
options?: { before?: string; after?: string }
): Promise<{ messages?: unknown[] }>;
reactionAdd(channelId: string, messageId: string, emoji: string): Promise<unknown>;
voiceGet(userId?: string, guildId?: string): Promise<{ channelId?: string | null } | void>;
voiceLeave(): Promise<unknown>;
}
/** The audio actions a chat command can trigger. */
@@ -143,6 +148,10 @@ export class Puppet {
private readonly pending = new Map<string, { entryId: string; expiresAt: number }>();
private readonly polls = new Map<string, PollState>();
private voiceChannelId?: string | null;
private ownerVoiceChannelId?: string | null;
private voiceLeaveTimer?: ReturnType<typeof setTimeout>;
private ownerId = DEFAULT_OWNER_ID;
private config: PuppetConfig = structuredClone(DEFAULT_CONFIG);
private selfId?: string;
@@ -180,6 +189,11 @@ export class Puppet {
300
),
pollSeconds: clamp(state.config.pollSeconds ?? DEFAULT_CONFIG.pollSeconds, 10, 3600),
voiceLeaveDelaySeconds: clamp(
state.config.voiceLeaveDelaySeconds ?? DEFAULT_CONFIG.voiceLeaveDelaySeconds,
0,
600
),
responsePolicy: {
defaultMode: state.config.responsePolicy?.defaultMode ?? DEFAULT_CONFIG.responsePolicy.defaultMode,
sensitiveCommands:
@@ -228,6 +242,11 @@ export class Puppet {
),
continuationSeconds: clamp(patch.continuationSeconds ?? this.config.continuationSeconds, 0, 300),
pollSeconds: clamp(patch.pollSeconds ?? this.config.pollSeconds, 10, 3600),
voiceLeaveDelaySeconds: clamp(
patch.voiceLeaveDelaySeconds ?? this.config.voiceLeaveDelaySeconds,
0,
600
),
responsePolicy: {
defaultMode: policy.defaultMode ?? this.config.responsePolicy.defaultMode,
sensitiveCommands: policy.sensitiveCommands ?? this.config.responsePolicy.sensitiveCommands,
@@ -360,6 +379,28 @@ export class Puppet {
return;
}
if (event?.type === "MESSAGE_UPDATE") {
this.handleMessageUpdate(event);
return;
}
if (event?.type === "MESSAGE_DELETE") {
this.handleMessageDelete(event);
return;
}
if (event?.type === "MESSAGE_DELETE_BULK") {
for (const id of event.ids ?? []) {
this.handleMessageDelete({ id, channelId: event.channelId, guildId: event.guildId });
}
return;
}
if (event?.type === "VOICE_STATE_UPDATE") {
await this.handleVoiceStateUpdate(event);
return;
}
if (event?.type !== "MESSAGE_CREATE") return;
const { author, channelId, guildId, channelType, content, mentions } = event;
@@ -381,7 +422,15 @@ export class Puppet {
const isGroupDm = channelType === GROUP_DM_CHANNEL;
const isDirectDm = !guildId && (channelType === DM_CHANNEL || (!isGroupDm && channelType === null));
const mentioned = new Set<string>(mentions ?? []).has(this.selfId);
const isTrigger = isDirectDm || mentioned;
// A reply to a message the puppet sent counts as a trigger too.
const replyTo = event.replyTo as
| { id?: string; authorId?: string | null; authorName?: string | null; content?: string }
| null
| undefined;
const repliesToPuppet = !!replyTo?.authorId && replyTo.authorId === this.selfId;
const isTrigger = isDirectDm || mentioned || repliesToPuppet;
const key = `${channelId}:${author.id}`;
@@ -437,6 +486,21 @@ export class Puppet {
}
}
// Keep the referenced message in the request context.
if (replyTo?.id && replyTo.authorId) {
const referenced: CachedMessage = {
id: replyTo.id,
authorId: replyTo.authorId,
authorName: replyTo.authorName ?? replyTo.authorId,
content: replyTo.content ?? "",
timestamp: ""
};
if (!entry.context?.some(message => message.id === referenced.id)) {
entry.context = [...(entry.context ?? []), referenced];
}
}
this.log.add(entry);
if (this.config.continuationSeconds > 0) {
@@ -454,6 +518,7 @@ export class Puppet {
authorId: author.id,
tier,
command: entry.command,
replyTo: replyTo?.id ?? null,
contextCount: entry.context?.length ?? 0
});
@@ -675,13 +740,161 @@ export class Puppet {
].join("\n");
}
private async handleReactionEvent(event: any) {
const poll = this.polls.get(event.messageId);
if (!poll) return;
private handleMessageUpdate(event: any) {
const cached = this.cache.edit(event.channelId, event.id, event.content ?? "");
this.actionLog.append({
type: "message-edit",
messageId: event.id,
channelId: event.channelId,
guildId: event.guildId ?? null,
authorId: event.author?.id ?? null,
contentLength: String(event.content ?? "").length,
cached
});
}
private handleMessageDelete(event: any) {
const cached = this.cache.remove(event.channelId, event.id);
this.actionLog.append({
type: "message-delete",
messageId: event.id,
channelId: event.channelId,
guildId: event.guildId ?? null,
cached
});
}
/* Voice auto-leave: the bot follows the owner (zzib) for now. */
private async handleVoiceStateUpdate(event: any) {
const userId = event?.userId;
if (!userId) return;
this.selfId ??= await this.core.getSelfId();
if (!this.selfId) return;
if (userId === this.selfId) {
const previous = this.voiceChannelId;
this.voiceChannelId = event.channelId ?? null;
if (!this.voiceChannelId) {
this.cancelAutoLeave("the bot left the call");
} else if (previous !== this.voiceChannelId) {
await this.checkOwnerPresence();
}
return;
}
if (userId !== this.ownerId) return;
this.ownerVoiceChannelId = event.channelId ?? null;
if (this.ownerVoiceChannelId && this.ownerVoiceChannelId === this.voiceChannelId) {
this.cancelAutoLeave("the owner is in the call");
} else if (this.voiceChannelId) {
this.scheduleAutoLeave();
}
}
private async checkOwnerPresence() {
try {
const owner = (await this.core.voiceGet(this.ownerId)) as { channelId?: string | null } | void;
this.ownerVoiceChannelId = owner?.channelId ?? null;
} catch (error) {
this.actionLog.append({ type: "error", scope: "voice-presence", message: String(error) });
return;
}
if (this.ownerVoiceChannelId && this.ownerVoiceChannelId === this.voiceChannelId) {
this.cancelAutoLeave("the owner is in the call");
} else if (this.voiceChannelId) {
this.scheduleAutoLeave();
}
}
private scheduleAutoLeave() {
if (this.voiceLeaveTimer) return;
const delaySeconds = this.config.voiceLeaveDelaySeconds;
if (delaySeconds <= 0) {
void this.leaveVoice("the owner left");
return;
}
this.actionLog.append({
type: "voice",
action: "auto-leave-scheduled",
channelId: this.voiceChannelId ?? null,
delaySeconds
});
this.voiceLeaveTimer = setTimeout(() => {
this.voiceLeaveTimer = undefined;
void this.leaveIfOwnerAway();
}, delaySeconds * 1000);
}
private cancelAutoLeave(reason: string) {
if (!this.voiceLeaveTimer) return;
clearTimeout(this.voiceLeaveTimer);
this.voiceLeaveTimer = undefined;
this.actionLog.append({ type: "voice", action: "auto-leave-cancelled", reason });
}
private async leaveIfOwnerAway() {
if (!this.voiceChannelId) return;
if (!this.ownerVoiceChannelId) {
try {
const owner = (await this.core.voiceGet(this.ownerId)) as { channelId?: string | null } | void;
this.ownerVoiceChannelId = owner?.channelId ?? null;
} catch (error) {
this.actionLog.append({ type: "error", scope: "voice-presence", message: String(error) });
}
}
if (this.ownerVoiceChannelId === this.voiceChannelId) {
this.cancelAutoLeave("the owner rejoined");
return;
}
await this.leaveVoice("the owner is not in the call");
}
private async leaveVoice(reason: string) {
try {
await this.core.voiceLeave();
this.actionLog.append({ type: "voice", action: "auto-leave", reason, channelId: this.voiceChannelId ?? null });
} catch (error) {
this.actionLog.append({ type: "voice", action: "auto-leave-failed", reason, error: String(error) });
}
this.voiceChannelId = null;
}
private async handleReactionEvent(event: any) {
this.selfId ??= await this.core.getSelfId();
if (!this.selfId || event.userId === this.selfId) return;
// Trusted reactions are notifications; poll votes do not need a tier.
if (this.isTrusted(event.userId)) {
this.actionLog.append({
type: "reaction",
action: event.type === "MESSAGE_REACTION_ADD" ? "add" : "remove",
channelId: event.channelId,
guildId: event.guildId ?? null,
messageId: event.messageId,
userId: event.userId,
emoji: event.emoji?.name ?? event.emoji?.id ?? null
});
}
const poll = this.polls.get(event.messageId);
if (!poll) return;
const emoji = event.emoji?.id ?? event.emoji?.name;
const index = POLL_EMOJIS.indexOf(emoji);
if (index < 0 || index >= poll.options.length) return;
+50 -1
View File
@@ -28,9 +28,34 @@ const messageEvent = {
author: { id: authorId, username: "zzib", bot: false },
content: mention ? `<@999> ${content}` : content,
mentions: mention ? ["999"] : [],
replyTo: process.env.TEST_TRIGGER_REPLY_TO
? {
id: process.env.TEST_TRIGGER_REPLY_TO,
authorId: process.env.TEST_TRIGGER_REPLY_AUTHOR ?? "999",
authorName: "mockuser",
content: "the referenced message"
}
: null,
timestamp: new Date().toISOString()
};
const updateEvent = {
type: "MESSAGE_UPDATE",
id: process.env.TEST_TRIGGER_MESSAGE_ID ?? "400000000000000004",
channelId: process.env.TEST_TRIGGER_CHANNEL ?? "200000000000000001",
guildId: guildId ?? "100000000000000001",
content: content === "whitelist list" ? "edited content" : content,
editedAt: new Date().toISOString(),
author: { id: authorId, username: "zzib", bot: false }
};
const deleteEvent = {
type: "MESSAGE_DELETE",
id: process.env.TEST_TRIGGER_MESSAGE_ID ?? "400000000000000004",
channelId: process.env.TEST_TRIGGER_CHANNEL ?? "200000000000000001",
guildId: guildId ?? "100000000000000001"
};
const reactionEvent = {
type: process.env.TEST_TRIGGER_REMOVE === "1" ? "MESSAGE_REACTION_REMOVE" : "MESSAGE_REACTION_ADD",
messageId: process.env.TEST_REACTION_MESSAGE_ID ?? "msg-1",
@@ -48,7 +73,31 @@ const speakingEvent = {
channelId: process.env.TEST_TRIGGER_CHANNEL ?? "200000000000000002"
};
const event = isReaction ? reactionEvent : speakingUser ? speakingEvent : messageEvent;
const voiceUser = process.env.TEST_TRIGGER_VOICE_USER;
const voiceChannelRaw = process.env.TEST_TRIGGER_VOICE_CHANNEL;
const voiceStateEvent = {
type: "VOICE_STATE_UPDATE",
userId: voiceUser ?? "999",
channelId: voiceChannelRaw === "null" || voiceChannelRaw === "" ? null : voiceChannelRaw ?? "200000000000000002",
guildId: guildId ?? "100000000000000001",
mute: false,
deaf: false,
selfMute: false,
selfDeaf: false
};
const event =
process.env.TEST_TRIGGER_UPDATE === "1"
? updateEvent
: process.env.TEST_TRIGGER_DELETE === "1"
? deleteEvent
: isReaction
? reactionEvent
: voiceUser
? voiceStateEvent
: speakingUser
? speakingEvent
: messageEvent;
await new Promise<void>((resolve, reject) => {
const socket = net.connect(coreSocketPath);