Add the agent scaffold, mobile web UI, voice auto-leave, and text notifications
This commit is contained in:
+108
-3
@@ -45,6 +45,7 @@ const state = {
|
||||
takeover: localStorage.getItem("veskslop-takeover") === "1",
|
||||
consoleOpen: false,
|
||||
consoleTab: "events",
|
||||
mobileTab: localStorage.getItem("veskslop-mobile-tab") ?? "chat",
|
||||
voiceReloadAt: 0
|
||||
};
|
||||
|
||||
@@ -419,9 +420,10 @@ async function loadDms() {
|
||||
} catch {
|
||||
state.dms = [];
|
||||
}
|
||||
renderDirect();
|
||||
renderDmRail();
|
||||
renderRightDms();
|
||||
renderRail();
|
||||
updateMobileBadge();
|
||||
}
|
||||
|
||||
async function loadVoice() {
|
||||
@@ -529,6 +531,7 @@ async function selectGuild(guildId) {
|
||||
async function selectChannel(channelId, guildId) {
|
||||
if (guildId && guildId !== state.guildId) state.guildId = guildId;
|
||||
|
||||
const switched = Boolean(state.channelId) && state.channelId !== channelId;
|
||||
state.channelId = channelId;
|
||||
state.channel = findGuildChannel(channelId);
|
||||
|
||||
@@ -544,6 +547,10 @@ async function selectChannel(channelId, guildId) {
|
||||
renderComposer();
|
||||
await loadMessages({ refresh: true });
|
||||
await loadVoice();
|
||||
|
||||
if (switched && isMobileLayout()) {
|
||||
setMobileTab(VOICE_TYPES.has(state.channel?.type) ? "call" : "chat");
|
||||
}
|
||||
}
|
||||
|
||||
async function sendMessage() {
|
||||
@@ -1068,8 +1075,15 @@ function renderMessages() {
|
||||
{ class: "vk-message-head" },
|
||||
el("span", { class: "vk-message-author", text: author }),
|
||||
isAgent ? el("span", { class: "tag tag-outline vk-tag-agent", text: "agent" }) : null,
|
||||
el("span", { class: "vk-mono vk-dim", text: formatTime(message.timestamp) })
|
||||
el("span", { class: "vk-mono vk-dim", text: formatTime(message.timestamp) }),
|
||||
message.editedAt ? el("span", { class: "vk-mono vk-dim", text: "· edited" }) : null
|
||||
),
|
||||
message.replyTo
|
||||
? el("span", {
|
||||
class: "vk-message-reply",
|
||||
text: `↳ replying to ${message.replyTo.authorName ?? message.replyTo.authorId ?? "a message"}`
|
||||
})
|
||||
: null,
|
||||
el("span", { class: "vk-message-body", text: message.content })
|
||||
);
|
||||
|
||||
@@ -1203,6 +1217,46 @@ function renderRightVoice() {
|
||||
if (!root) return;
|
||||
root.replaceChildren();
|
||||
|
||||
// The phone Call tab shows the live call, not just favourites.
|
||||
const callMembers = isMobileLayout() && state.mobileTab === "call" ? (state.voiceSelf?.members ?? []) : [];
|
||||
|
||||
if (callMembers.length) {
|
||||
$("#voice-count").textContent = `${callMembers.length} in call`;
|
||||
const card = el(
|
||||
"div",
|
||||
{ class: "vk-right-group" },
|
||||
el(
|
||||
"div",
|
||||
{ class: "vk-right-group-head" },
|
||||
el("span", { class: "vk-icon", style: "color: var(--color-accent)", html: VK.icon("volume") }),
|
||||
el("span", { class: "vk-name", text: state.voiceSelf?.channelName ?? "call" })
|
||||
),
|
||||
...callMembers.map(member =>
|
||||
el(
|
||||
"div",
|
||||
{ class: "vk-right-row" },
|
||||
avatar(member, { size: "sm" }),
|
||||
el(
|
||||
"span",
|
||||
{ class: "vk-right-name" },
|
||||
el("span", {
|
||||
class: `vk-name ${state.speaking.has(member.id) ? "speaking" : ""}`.trim(),
|
||||
text: member.username ?? member.id
|
||||
}),
|
||||
el("span", {
|
||||
class: "vk-sub",
|
||||
text: member.id === state.user?.id ? "this client" : member.status ?? ""
|
||||
})
|
||||
),
|
||||
el("span", { class: "vk-right-trail" }, voiceMarks(member))
|
||||
)
|
||||
)
|
||||
);
|
||||
|
||||
root.append(card);
|
||||
return;
|
||||
}
|
||||
|
||||
const inCall = state.favouriteUsers.filter(user => user.channelId);
|
||||
$("#voice-count").textContent = inCall.length ? `${inCall.length} in call` : "";
|
||||
|
||||
@@ -1403,6 +1457,48 @@ function setConsoleTab(tab) {
|
||||
if (tab === "transcript") void refreshTranscript();
|
||||
}
|
||||
|
||||
/* Mobile layout (1c): one pane at a time behind a four-tab bar. */
|
||||
|
||||
const MOBILE_QUERY = window.matchMedia("(max-width: 768px)");
|
||||
const MOBILE_TABS = new Set(["users", "chat", "call", "activity"]);
|
||||
|
||||
function isMobileLayout() {
|
||||
return MOBILE_QUERY.matches;
|
||||
}
|
||||
|
||||
function setMobileTab(tab) {
|
||||
if (!MOBILE_TABS.has(tab)) return;
|
||||
|
||||
state.mobileTab = tab;
|
||||
localStorage.setItem("veskslop-mobile-tab", tab);
|
||||
|
||||
for (const button of $$(".vk-tab")) button.classList.toggle("active", button.dataset.mobileTab === tab);
|
||||
if (isMobileLayout()) $(".vk-app").dataset.mobileTab = tab;
|
||||
renderRightVoice();
|
||||
updateMobileBadge();
|
||||
}
|
||||
|
||||
function syncMobileLayout() {
|
||||
const app = $(".vk-app");
|
||||
if (!app) return;
|
||||
|
||||
if (isMobileLayout()) app.dataset.mobileTab = state.mobileTab;
|
||||
else delete app.dataset.mobileTab;
|
||||
|
||||
for (const button of $$(".vk-tab")) {
|
||||
button.classList.toggle("active", button.dataset.mobileTab === state.mobileTab);
|
||||
}
|
||||
}
|
||||
|
||||
function updateMobileBadge() {
|
||||
const badgeEl = $("#mobile-chat-badge");
|
||||
if (!badgeEl) return;
|
||||
|
||||
const total = state.dms.reduce((sum, dm) => sum + ((dm.unread ?? 0) || (dm.mentions ?? 0)), 0);
|
||||
badgeEl.hidden = total === 0;
|
||||
badgeEl.textContent = total > 99 ? "99+" : String(total);
|
||||
}
|
||||
|
||||
/* Settings */
|
||||
|
||||
function openSettings() {
|
||||
@@ -2175,7 +2271,16 @@ function init() {
|
||||
tab.addEventListener("click", () => setConsoleTab(tab.dataset.consoleTab));
|
||||
}
|
||||
|
||||
$("#raw-events").addEventListener("click", () => setConsoleTab("events"));
|
||||
for (const tab of $$(".vk-tab")) {
|
||||
tab.addEventListener("click", () => setMobileTab(tab.dataset.mobileTab));
|
||||
}
|
||||
MOBILE_QUERY.addEventListener("change", syncMobileLayout);
|
||||
syncMobileLayout();
|
||||
|
||||
$("#raw-events").addEventListener("click", () => {
|
||||
setConsoleTab("events");
|
||||
if (isMobileLayout()) setMobileTab("activity");
|
||||
});
|
||||
$("#transcript-refresh").addEventListener("click", () => void refreshTranscript());
|
||||
$("#channel-refresh").addEventListener("click", () => void loadMessages({ refresh: true }));
|
||||
|
||||
|
||||
+3
-1
@@ -23,7 +23,9 @@ const ICON_PATHS = {
|
||||
warning: '<path d="M8 2 1.5 13.5h13z"/><path d="M8 6.5v3.5M8 12v.5"/>',
|
||||
refresh: '<path d="M13.5 8a5.5 5.5 0 1 1-1.6-3.9"/><path d="M13.5 2.5V6H10"/>',
|
||||
play: '<path d="M5 3.5 12.5 8 5 12.5z"/>',
|
||||
microphone: '<rect x="5.5" y="1.5" width="5" height="7.5" rx="2.5"/><path d="M3 7.5a5 5 0 0 0 10 0M8 12.5v2"/>'
|
||||
microphone: '<rect x="5.5" y="1.5" width="5" height="7.5" rx="2.5"/><path d="M3 7.5a5 5 0 0 0 10 0M8 12.5v2"/>',
|
||||
users: '<circle cx="6" cy="5.5" r="2.5"/><path d="M1.5 13.5c0-2.5 2-4 4.5-4s4.5 1.5 4.5 4"/><path d="M11 3.5a2.5 2.5 0 0 1 0 5"/>',
|
||||
activity: '<path d="M1.5 8h3l2-4.5 2.5 9L11 8h3.5"/>'
|
||||
};
|
||||
|
||||
const FILLED = new Set(["star"]);
|
||||
|
||||
+23
-2
@@ -47,7 +47,8 @@
|
||||
type="button"
|
||||
title="Play the call in this browser"
|
||||
>
|
||||
Client audio
|
||||
<span class="vk-icon" data-icon="speaker"></span>
|
||||
<span class="vk-btn-text">Client audio</span>
|
||||
</button>
|
||||
<label class="vk-takeover" title="Read-only unless takeover is on">
|
||||
<input type="checkbox" id="takeover" />
|
||||
@@ -56,7 +57,7 @@
|
||||
</label>
|
||||
<button id="settings-open" class="btn btn-secondary vk-settings" type="button">
|
||||
<span class="vk-icon" data-icon="gear"></span>
|
||||
Settings
|
||||
<span class="vk-btn-text">Settings</span>
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
@@ -222,6 +223,26 @@
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<nav id="mobile-tabs" class="vk-tabbar" aria-label="Sections">
|
||||
<button class="vk-tab" data-mobile-tab="users" type="button">
|
||||
<span class="vk-icon" data-icon="users"></span>
|
||||
<span>Users</span>
|
||||
</button>
|
||||
<button class="vk-tab" data-mobile-tab="chat" type="button">
|
||||
<span class="vk-icon" data-icon="hash"></span>
|
||||
<span>Chat</span>
|
||||
<span id="mobile-chat-badge" class="vk-rail-badge unread" hidden></span>
|
||||
</button>
|
||||
<button class="vk-tab" data-mobile-tab="call" type="button">
|
||||
<span class="vk-icon" data-icon="volume"></span>
|
||||
<span>Call</span>
|
||||
</button>
|
||||
<button class="vk-tab" data-mobile-tab="activity" type="button">
|
||||
<span class="vk-icon" data-icon="activity"></span>
|
||||
<span>Activity</span>
|
||||
</button>
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
<div id="settings-overlay" class="vk-overlay" hidden>
|
||||
|
||||
+177
-3
@@ -835,6 +835,11 @@ button {
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.vk-message-reply {
|
||||
font-size: 11px;
|
||||
color: var(--color-neutral-500);
|
||||
}
|
||||
|
||||
.vk-message-image {
|
||||
display: block;
|
||||
margin-top: 6px;
|
||||
@@ -1723,15 +1728,184 @@ a.vk-attachment:hover {
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.vk-body {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
:root {
|
||||
--vk-header-h: 46px;
|
||||
}
|
||||
|
||||
.vk-rail {
|
||||
.vk-app {
|
||||
height: 100dvh;
|
||||
}
|
||||
|
||||
.vk-header {
|
||||
gap: 8px;
|
||||
padding: 0 10px;
|
||||
}
|
||||
|
||||
.vk-brand {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.vk-health {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
flex: none;
|
||||
}
|
||||
|
||||
#health-uptime,
|
||||
#health-bridge {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.vk-btn-text {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.vk-takeover-label {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.vk-scope-btn {
|
||||
padding: 4px 8px;
|
||||
font-size: 11.5px;
|
||||
}
|
||||
|
||||
.vk-guild-picker {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.vk-guild-button {
|
||||
width: 100%;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
#guild-picker-label {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.vk-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
grid-template-columns: none;
|
||||
}
|
||||
|
||||
.vk-rail,
|
||||
.vk-center,
|
||||
.vk-right,
|
||||
.vk-console {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.vk-app[data-mobile-tab="users"] .vk-rail {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.vk-app[data-mobile-tab="chat"] .vk-center {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.vk-app[data-mobile-tab="call"] .vk-right {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.vk-app[data-mobile-tab="activity"] .vk-body {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.vk-app[data-mobile-tab="activity"] .vk-console {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.vk-app[data-mobile-tab="activity"] .vk-console-bar {
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.vk-app[data-mobile-tab="activity"] #console-toggle,
|
||||
.vk-app[data-mobile-tab="activity"] .vk-ticker {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.vk-app[data-mobile-tab="activity"] .vk-console-drawer {
|
||||
display: flex !important;
|
||||
height: auto;
|
||||
flex: 1;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.vk-settings-panel {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.vk-tts-field,
|
||||
.vk-tts-meta .btn,
|
||||
.vk-tts .vk-select {
|
||||
min-height: 44px;
|
||||
}
|
||||
|
||||
.vk-send,
|
||||
.vk-composer .vk-icon-btn {
|
||||
min-height: 38px;
|
||||
min-width: 38px;
|
||||
}
|
||||
}
|
||||
|
||||
/* Mobile tab bar (1c) */
|
||||
|
||||
.vk-tabbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.vk-tabbar {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: 2px;
|
||||
flex: none;
|
||||
padding: 6px 8px calc(10px + env(safe-area-inset-bottom, 0px));
|
||||
background: var(--color-surface);
|
||||
box-shadow: inset 0 1px 0 var(--color-neutral-800);
|
||||
}
|
||||
|
||||
.vk-tab {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
min-height: 44px;
|
||||
padding: 7px 0;
|
||||
border: none;
|
||||
border-radius: var(--radius-sm);
|
||||
background: none;
|
||||
color: var(--color-neutral-500);
|
||||
font: 400 10px/1 var(--font-body);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.vk-tab .vk-icon {
|
||||
width: 19px;
|
||||
height: 19px;
|
||||
}
|
||||
|
||||
.vk-tab.active {
|
||||
color: var(--color-accent);
|
||||
background: var(--color-accent-900);
|
||||
box-shadow: inset 0 0 0 1px var(--color-accent-700);
|
||||
font-family: var(--font-heading);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.vk-tab .vk-rail-badge {
|
||||
position: absolute;
|
||||
top: 2px;
|
||||
right: calc(50% - 26px);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user