talon-agent 3.33.4 → 3.34.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +2 -1
- package/src/app.ts +16 -8
- package/src/backend/claude-sdk/stream.ts +76 -53
- package/src/backend/remote-server/turn.ts +58 -55
- package/src/bootstrap.ts +120 -79
- package/src/cli/setup.ts +375 -349
- package/src/core/background/cron-spec.ts +273 -0
- package/src/core/background/heartbeat/agent.ts +167 -104
- package/src/core/engine/backend-controller/pool.ts +2 -0
- package/src/core/engine/backend-controller/state.ts +25 -12
- package/src/core/engine/gateway-actions/cron.ts +28 -292
- package/src/core/engine/gateway-routes.ts +239 -0
- package/src/core/engine/gateway.ts +66 -238
- package/src/core/vfs/mounts/files.ts +128 -115
- package/src/core/weaver/shuttle.ts +29 -2
- package/src/core/weaver/weaver.ts +37 -6
- package/src/frontend/discord/callbacks/components/agent-buttons.ts +82 -0
- package/src/frontend/discord/callbacks/components/backend-select.ts +149 -0
- package/src/frontend/discord/callbacks/components/effort.ts +72 -0
- package/src/frontend/discord/callbacks/components/index.ts +120 -0
- package/src/frontend/discord/callbacks/components/metrics.ts +24 -0
- package/src/frontend/discord/callbacks/components/model-nav.ts +93 -0
- package/src/frontend/discord/callbacks/components/model-select.ts +118 -0
- package/src/frontend/discord/callbacks/components/model.ts +33 -0
- package/src/frontend/discord/callbacks/components/pulse.ts +93 -0
- package/src/frontend/discord/callbacks/components/settings.ts +243 -0
- package/src/frontend/discord/callbacks/components/types.ts +34 -0
- package/src/frontend/discord/callbacks/index.ts +4 -4
- package/src/frontend/discord/connection.ts +36 -0
- package/src/frontend/discord/diagnostics.ts +62 -0
- package/src/frontend/discord/guild-policy.ts +89 -0
- package/src/frontend/discord/index.ts +44 -305
- package/src/frontend/discord/outbound.ts +61 -0
- package/src/frontend/discord/ready.ts +73 -0
- package/src/frontend/discord/runtime.ts +55 -0
- package/src/frontend/native/chat-lifecycle.ts +39 -0
- package/src/frontend/native/chat-wire.ts +69 -0
- package/src/frontend/native/context.ts +106 -0
- package/src/frontend/native/control.ts +78 -0
- package/src/frontend/native/emit.ts +167 -0
- package/src/frontend/native/empty-chat-sweep.ts +50 -0
- package/src/frontend/native/handlers.ts +121 -0
- package/src/frontend/native/history.ts +101 -0
- package/src/frontend/native/index.ts +90 -1293
- package/src/frontend/native/media.ts +43 -0
- package/src/frontend/native/models.ts +221 -0
- package/src/frontend/native/queue.ts +47 -0
- package/src/frontend/native/reset.ts +50 -0
- package/src/frontend/native/routes/chats.ts +115 -0
- package/src/frontend/native/routes/daemon.ts +70 -0
- package/src/frontend/native/routes/host.ts +151 -0
- package/src/frontend/native/routes/index.ts +22 -0
- package/src/frontend/native/routes/mesh.ts +72 -0
- package/src/frontend/native/routes/models.ts +54 -0
- package/src/frontend/native/routes/params.ts +29 -0
- package/src/frontend/native/routes/pre-auth.ts +94 -0
- package/src/frontend/native/routes/table.ts +92 -0
- package/src/frontend/native/runtime.ts +109 -0
- package/src/frontend/native/server.ts +30 -555
- package/src/frontend/native/status.ts +26 -0
- package/src/frontend/native/tool-result.ts +48 -0
- package/src/frontend/native/turn.ts +341 -0
- package/src/frontend/whatsapp/access.ts +67 -0
- package/src/frontend/whatsapp/connection.ts +280 -0
- package/src/frontend/whatsapp/inbound.ts +327 -0
- package/src/frontend/whatsapp/index.ts +28 -599
- package/src/frontend/whatsapp/runtime.ts +74 -0
- package/src/storage/metrics.ts +18 -0
- package/src/storage/session-record.ts +29 -0
- package/src/storage/sessions.ts +24 -0
- package/src/util/boot-timer.ts +31 -0
- package/src/util/concurrency.ts +28 -0
- package/src/frontend/discord/callbacks/components.ts +0 -793
- package/src/frontend/discord/callbacks/shared.ts +0 -22
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `model:select` — the /model select menu (separate from settings).
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { type StringSelectMenuInteraction, MessageFlags } from "discord.js";
|
|
6
|
+
import {
|
|
7
|
+
buildModelPickerView,
|
|
8
|
+
MODEL_NAV_PREFIX,
|
|
9
|
+
MODEL_PAGE_SIZE,
|
|
10
|
+
} from "../../model-picker.js";
|
|
11
|
+
import {
|
|
12
|
+
setChatModelForBackend,
|
|
13
|
+
setChatBackend,
|
|
14
|
+
} from "../../../../storage/chat-settings.js";
|
|
15
|
+
import { resolveModelId as resolveModelName } from "../../../../core/models/catalog.js";
|
|
16
|
+
import {
|
|
17
|
+
getBackendIdForChat,
|
|
18
|
+
resolveChatBackend,
|
|
19
|
+
} from "../../../../core/engine/backend-controller/index.js";
|
|
20
|
+
import { resolveActiveModelForChat } from "../../../../core/models/active-model.js";
|
|
21
|
+
import type { ComponentContext } from "./types.js";
|
|
22
|
+
|
|
23
|
+
export async function handleModelSelect(
|
|
24
|
+
interaction: StringSelectMenuInteraction,
|
|
25
|
+
{ config, gateway, chatId }: ComponentContext,
|
|
26
|
+
): Promise<void> {
|
|
27
|
+
const value = interaction.values[0];
|
|
28
|
+
// Resolve and snapshot against the per-chat backend — never the
|
|
29
|
+
// global default — so this works in channels with a backend
|
|
30
|
+
// override pinned (e.g. switched to openai-agents to use
|
|
31
|
+
// OpenRouter models).
|
|
32
|
+
const be = resolveChatBackend(chatId, gateway?.backend);
|
|
33
|
+
const beId = getBackendIdForChat(chatId);
|
|
34
|
+
|
|
35
|
+
// In the provider view the select carries chips, not models — picking
|
|
36
|
+
// one drills into that provider rather than choosing anything.
|
|
37
|
+
if (value?.startsWith("provider:") && be?.models?.getSettingsPresentation) {
|
|
38
|
+
const providerId = value.slice("provider:".length);
|
|
39
|
+
const { model: activeNow } = await resolveActiveModelForChat(
|
|
40
|
+
chatId,
|
|
41
|
+
be,
|
|
42
|
+
beId,
|
|
43
|
+
config,
|
|
44
|
+
);
|
|
45
|
+
const pres = await be.models.getSettingsPresentation(activeNow ?? "", {
|
|
46
|
+
callbackPrefix: "model:",
|
|
47
|
+
navCallbackPrefix: MODEL_NAV_PREFIX,
|
|
48
|
+
pageSize: MODEL_PAGE_SIZE,
|
|
49
|
+
provider: providerId,
|
|
50
|
+
});
|
|
51
|
+
const view = buildModelPickerView(
|
|
52
|
+
pres,
|
|
53
|
+
activeNow ?? "_No model selected_",
|
|
54
|
+
beId,
|
|
55
|
+
);
|
|
56
|
+
try {
|
|
57
|
+
await interaction.update({
|
|
58
|
+
content: view.content,
|
|
59
|
+
components: view.components,
|
|
60
|
+
});
|
|
61
|
+
} catch {
|
|
62
|
+
/* ignore */
|
|
63
|
+
}
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
if (value === "reset") {
|
|
68
|
+
// Clear THIS backend's slot only — other backends stay intact.
|
|
69
|
+
setChatModelForBackend(chatId, beId, undefined);
|
|
70
|
+
} else if (be?.models?.resolveModelInfo) {
|
|
71
|
+
const resolution = await be.models?.resolveModelInfo(value);
|
|
72
|
+
if (resolution.kind === "exact" && resolution.model.selectable) {
|
|
73
|
+
setChatModelForBackend(chatId, beId, resolution.storedValue);
|
|
74
|
+
setChatBackend(chatId, beId);
|
|
75
|
+
} else {
|
|
76
|
+
await interaction.reply({
|
|
77
|
+
content: "Model unavailable.",
|
|
78
|
+
flags: MessageFlags.Ephemeral,
|
|
79
|
+
});
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
} else {
|
|
83
|
+
setChatModelForBackend(chatId, beId, resolveModelName(value));
|
|
84
|
+
setChatBackend(chatId, beId);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// Resolve display through the active-model helper so the panel
|
|
88
|
+
// matches what queries will actually run. Null surfaces as
|
|
89
|
+
// "No model selected".
|
|
90
|
+
const { model: resolvedCurrent } = await resolveActiveModelForChat(
|
|
91
|
+
chatId,
|
|
92
|
+
be,
|
|
93
|
+
beId,
|
|
94
|
+
config,
|
|
95
|
+
);
|
|
96
|
+
if (be?.models?.getSettingsPresentation) {
|
|
97
|
+
const current = resolvedCurrent ?? "";
|
|
98
|
+
const pres = await be.models?.getSettingsPresentation(current, {
|
|
99
|
+
callbackPrefix: "model:",
|
|
100
|
+
navCallbackPrefix: MODEL_NAV_PREFIX,
|
|
101
|
+
pageSize: MODEL_PAGE_SIZE,
|
|
102
|
+
});
|
|
103
|
+
const modelInfo = resolvedCurrent
|
|
104
|
+
? await be.models?.getRawModelInfo?.(resolvedCurrent)
|
|
105
|
+
: undefined;
|
|
106
|
+
const displayName =
|
|
107
|
+
modelInfo?.displayName ?? resolvedCurrent ?? "_No model selected_";
|
|
108
|
+
const view = buildModelPickerView(pres, displayName, beId);
|
|
109
|
+
try {
|
|
110
|
+
await interaction.update({
|
|
111
|
+
content: view.content,
|
|
112
|
+
components: view.components,
|
|
113
|
+
});
|
|
114
|
+
} catch {
|
|
115
|
+
/* ignore */
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `model:*` — the /model panel, three ids under one prefix:
|
|
3
|
+
*
|
|
4
|
+
* model:select (select menu → model-select.ts)
|
|
5
|
+
* model:nav:* (pager buttons → model-nav.ts)
|
|
6
|
+
* model:backend-select (select menu → backend-select.ts)
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { MODEL_NAV_PREFIX } from "../../model-picker.js";
|
|
10
|
+
import { handleBackendSelect } from "./backend-select.js";
|
|
11
|
+
import { handleModelNav } from "./model-nav.js";
|
|
12
|
+
import { handleModelSelect } from "./model-select.js";
|
|
13
|
+
import type { ComponentContext, ComponentInteraction } from "./types.js";
|
|
14
|
+
|
|
15
|
+
export async function handleModelComponent(
|
|
16
|
+
interaction: ComponentInteraction,
|
|
17
|
+
context: ComponentContext,
|
|
18
|
+
): Promise<boolean> {
|
|
19
|
+
const customId = interaction.customId;
|
|
20
|
+
if (customId === "model:select" && interaction.isStringSelectMenu()) {
|
|
21
|
+
await handleModelSelect(interaction, context);
|
|
22
|
+
return true;
|
|
23
|
+
}
|
|
24
|
+
if (customId.startsWith(`${MODEL_NAV_PREFIX}:`)) {
|
|
25
|
+
await handleModelNav(interaction, context);
|
|
26
|
+
return true;
|
|
27
|
+
}
|
|
28
|
+
if (customId === "model:backend-select" && interaction.isStringSelectMenu()) {
|
|
29
|
+
await handleBackendSelect(interaction, context);
|
|
30
|
+
return true;
|
|
31
|
+
}
|
|
32
|
+
return false;
|
|
33
|
+
}
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `pulse:*` — the standalone /pulse panel.
|
|
3
|
+
*
|
|
4
|
+
* pulse:interval (button → opens the interval modal)
|
|
5
|
+
* pulse:on | pulse:off
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import {
|
|
9
|
+
type ButtonInteraction,
|
|
10
|
+
ActionRowBuilder,
|
|
11
|
+
ButtonBuilder,
|
|
12
|
+
ButtonStyle,
|
|
13
|
+
ModalBuilder,
|
|
14
|
+
TextInputBuilder,
|
|
15
|
+
TextInputStyle,
|
|
16
|
+
} from "discord.js";
|
|
17
|
+
import {
|
|
18
|
+
registerChat,
|
|
19
|
+
disablePulse,
|
|
20
|
+
enablePulse,
|
|
21
|
+
isPulseEnabled,
|
|
22
|
+
} from "../../../../core/background/pulse.js";
|
|
23
|
+
import type { ComponentContext, ComponentInteraction } from "./types.js";
|
|
24
|
+
|
|
25
|
+
// ── /pulse "Set interval…" button → opens modal ───────────────────────
|
|
26
|
+
async function openIntervalModal(
|
|
27
|
+
interaction: ButtonInteraction,
|
|
28
|
+
): Promise<void> {
|
|
29
|
+
const modal = new ModalBuilder()
|
|
30
|
+
.setCustomId("modal:pulse-interval")
|
|
31
|
+
.setTitle("Pulse interval");
|
|
32
|
+
const input = new TextInputBuilder()
|
|
33
|
+
.setCustomId("interval")
|
|
34
|
+
.setLabel("How often (min 5m)")
|
|
35
|
+
.setPlaceholder("30m, 2h, 90m, 1d…")
|
|
36
|
+
.setStyle(TextInputStyle.Short)
|
|
37
|
+
.setMinLength(2)
|
|
38
|
+
.setMaxLength(16)
|
|
39
|
+
.setRequired(true);
|
|
40
|
+
modal.addComponents(
|
|
41
|
+
new ActionRowBuilder<TextInputBuilder>().addComponents(input),
|
|
42
|
+
);
|
|
43
|
+
await interaction.showModal(modal);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// ── Standalone /pulse on|off buttons ──────────────────────────────────────
|
|
47
|
+
async function togglePulse(
|
|
48
|
+
interaction: ComponentInteraction,
|
|
49
|
+
chatId: string,
|
|
50
|
+
): Promise<void> {
|
|
51
|
+
const val = interaction.customId.slice(6);
|
|
52
|
+
if (val === "on") {
|
|
53
|
+
enablePulse(chatId);
|
|
54
|
+
registerChat(chatId);
|
|
55
|
+
} else if (val === "off") {
|
|
56
|
+
disablePulse(chatId);
|
|
57
|
+
}
|
|
58
|
+
const enabled = isPulseEnabled(chatId);
|
|
59
|
+
const row = new ActionRowBuilder<ButtonBuilder>().addComponents(
|
|
60
|
+
new ButtonBuilder()
|
|
61
|
+
.setCustomId("pulse:on")
|
|
62
|
+
.setLabel(enabled ? "✓ On" : "On")
|
|
63
|
+
.setStyle(enabled ? ButtonStyle.Success : ButtonStyle.Secondary),
|
|
64
|
+
new ButtonBuilder()
|
|
65
|
+
.setCustomId("pulse:off")
|
|
66
|
+
.setLabel(!enabled ? "✓ Off" : "Off")
|
|
67
|
+
.setStyle(ButtonStyle.Secondary),
|
|
68
|
+
);
|
|
69
|
+
try {
|
|
70
|
+
await (interaction as ButtonInteraction).update({
|
|
71
|
+
content: [
|
|
72
|
+
`**🔔 Pulse:** ${enabled ? "on" : "off"}`,
|
|
73
|
+
"",
|
|
74
|
+
"Reads along every few minutes and jumps in when there's something to add.",
|
|
75
|
+
].join("\n"),
|
|
76
|
+
components: [row],
|
|
77
|
+
});
|
|
78
|
+
} catch {
|
|
79
|
+
/* ignore */
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export async function handlePulseComponent(
|
|
84
|
+
interaction: ComponentInteraction,
|
|
85
|
+
{ chatId }: ComponentContext,
|
|
86
|
+
): Promise<boolean> {
|
|
87
|
+
if (interaction.customId === "pulse:interval" && interaction.isButton()) {
|
|
88
|
+
await openIntervalModal(interaction);
|
|
89
|
+
return true;
|
|
90
|
+
}
|
|
91
|
+
await togglePulse(interaction, chatId);
|
|
92
|
+
return true;
|
|
93
|
+
}
|
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `settings:*` — the /settings panel. Every callback updates the original
|
|
3
|
+
* message in place (Discord supports update() on the interaction).
|
|
4
|
+
*
|
|
5
|
+
* settings:done
|
|
6
|
+
* settings:model (select menu, value=modelId)
|
|
7
|
+
* settings:effort:select (select menu)
|
|
8
|
+
* settings:proactive:on|off
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import {
|
|
12
|
+
ActionRowBuilder,
|
|
13
|
+
ButtonBuilder,
|
|
14
|
+
ButtonStyle,
|
|
15
|
+
StringSelectMenuBuilder,
|
|
16
|
+
MessageFlags,
|
|
17
|
+
} from "discord.js";
|
|
18
|
+
import type { TalonConfig } from "../../../../util/config.js";
|
|
19
|
+
import type { Gateway } from "../../../../core/engine/gateway.js";
|
|
20
|
+
import {
|
|
21
|
+
getChatSettings,
|
|
22
|
+
setChatModelForBackend,
|
|
23
|
+
setChatBackend,
|
|
24
|
+
setChatEffort,
|
|
25
|
+
type EffortLevel,
|
|
26
|
+
} from "../../../../storage/chat-settings.js";
|
|
27
|
+
import { resolveModelId as resolveModelName } from "../../../../core/models/catalog.js";
|
|
28
|
+
import {
|
|
29
|
+
registerChat,
|
|
30
|
+
disablePulse,
|
|
31
|
+
enablePulse,
|
|
32
|
+
isPulseEnabled,
|
|
33
|
+
} from "../../../../core/background/pulse.js";
|
|
34
|
+
import { renderSettingsText, EFFORT_DESCRIPTIONS } from "../../helpers.js";
|
|
35
|
+
import {
|
|
36
|
+
getBackendIdForChat,
|
|
37
|
+
resolveChatBackend,
|
|
38
|
+
} from "../../../../core/engine/backend-controller/index.js";
|
|
39
|
+
import { resolveActiveModelForChat } from "../../../../core/models/active-model.js";
|
|
40
|
+
import {
|
|
41
|
+
displayReasoningEffort,
|
|
42
|
+
getActiveReasoningLevels,
|
|
43
|
+
supportsReasoningLevel,
|
|
44
|
+
} from "../../../shared/reasoning-levels.js";
|
|
45
|
+
import { logError } from "../../../../util/log.js";
|
|
46
|
+
import { safeSlice } from "../../formatting.js";
|
|
47
|
+
import type { ComponentContext, ComponentInteraction } from "./types.js";
|
|
48
|
+
|
|
49
|
+
export async function handleSettingsComponent(
|
|
50
|
+
interaction: ComponentInteraction,
|
|
51
|
+
{ config, gateway, chatId }: ComponentContext,
|
|
52
|
+
): Promise<boolean> {
|
|
53
|
+
const parts = interaction.customId.split(":");
|
|
54
|
+
const category = parts[1];
|
|
55
|
+
|
|
56
|
+
if (category === "done") {
|
|
57
|
+
try {
|
|
58
|
+
await interaction.update({ components: [], content: "Done." });
|
|
59
|
+
} catch {
|
|
60
|
+
/* ignore */
|
|
61
|
+
}
|
|
62
|
+
return true;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
if (category === "model" && interaction.isStringSelectMenu()) {
|
|
66
|
+
const value = interaction.values[0];
|
|
67
|
+
const settingsBe = resolveChatBackend(chatId, gateway?.backend);
|
|
68
|
+
const settingsBeId = getBackendIdForChat(chatId);
|
|
69
|
+
if (value === "reset") {
|
|
70
|
+
// Clear THIS backend's slot only — other backends' picks stay.
|
|
71
|
+
setChatModelForBackend(chatId, settingsBeId, undefined);
|
|
72
|
+
} else if (settingsBe?.models?.resolveModelInfo) {
|
|
73
|
+
const resolution = await settingsBe.models?.resolveModelInfo(value);
|
|
74
|
+
if (resolution.kind !== "exact" || !resolution.model.selectable) {
|
|
75
|
+
await interaction.reply({
|
|
76
|
+
content: `Model unavailable.`,
|
|
77
|
+
flags: MessageFlags.Ephemeral,
|
|
78
|
+
});
|
|
79
|
+
return true;
|
|
80
|
+
}
|
|
81
|
+
setChatModelForBackend(chatId, settingsBeId, resolution.storedValue);
|
|
82
|
+
setChatBackend(chatId, settingsBeId);
|
|
83
|
+
} else {
|
|
84
|
+
setChatModelForBackend(chatId, settingsBeId, resolveModelName(value));
|
|
85
|
+
setChatBackend(chatId, settingsBeId);
|
|
86
|
+
}
|
|
87
|
+
await refreshSettingsPanel(interaction, config, gateway, chatId);
|
|
88
|
+
return true;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
if (category === "effort" && interaction.isStringSelectMenu()) {
|
|
92
|
+
// settings:effort:select — current shape (emitted by handleSettings +
|
|
93
|
+
// refreshSettingsPanel).
|
|
94
|
+
const level = interaction.values[0];
|
|
95
|
+
const settingsBe = resolveChatBackend(chatId, gateway?.backend);
|
|
96
|
+
const settingsBeId = getBackendIdForChat(chatId);
|
|
97
|
+
const reasoning = await getActiveReasoningLevels({
|
|
98
|
+
chatId,
|
|
99
|
+
backend: settingsBe,
|
|
100
|
+
backendId: settingsBeId,
|
|
101
|
+
config,
|
|
102
|
+
});
|
|
103
|
+
if (level === "adaptive") {
|
|
104
|
+
setChatEffort(chatId, undefined);
|
|
105
|
+
} else if (supportsReasoningLevel(level, reasoning.levels)) {
|
|
106
|
+
setChatEffort(chatId, level as EffortLevel);
|
|
107
|
+
} else {
|
|
108
|
+
await interaction.reply({
|
|
109
|
+
content: "No valid reasoning level for this model.",
|
|
110
|
+
flags: MessageFlags.Ephemeral,
|
|
111
|
+
});
|
|
112
|
+
return true;
|
|
113
|
+
}
|
|
114
|
+
await refreshSettingsPanel(interaction, config, gateway, chatId);
|
|
115
|
+
return true;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
if (category === "proactive") {
|
|
119
|
+
const value = parts[2] ?? "";
|
|
120
|
+
if (value === "on") {
|
|
121
|
+
enablePulse(chatId);
|
|
122
|
+
registerChat(chatId);
|
|
123
|
+
} else {
|
|
124
|
+
disablePulse(chatId);
|
|
125
|
+
}
|
|
126
|
+
await refreshSettingsPanel(interaction, config, gateway, chatId);
|
|
127
|
+
return true;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
return false;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
async function refreshSettingsPanel(
|
|
134
|
+
interaction: ComponentInteraction,
|
|
135
|
+
config: TalonConfig,
|
|
136
|
+
gateway: Gateway,
|
|
137
|
+
chatId: string,
|
|
138
|
+
): Promise<void> {
|
|
139
|
+
const chatSets = getChatSettings(chatId);
|
|
140
|
+
const settingsBe = resolveChatBackend(chatId, gateway?.backend);
|
|
141
|
+
const settingsBeId = getBackendIdForChat(chatId);
|
|
142
|
+
// Match the Telegram path — resolve via the 5-step chain so the
|
|
143
|
+
// panel's "Model:" line stays coherent on backend-overridden
|
|
144
|
+
// chats and surfaces "No model selected" when applicable.
|
|
145
|
+
const { model: resolvedActive } = await resolveActiveModelForChat(
|
|
146
|
+
chatId,
|
|
147
|
+
settingsBe,
|
|
148
|
+
settingsBeId,
|
|
149
|
+
config,
|
|
150
|
+
);
|
|
151
|
+
const activeModel = resolvedActive ?? "No model selected";
|
|
152
|
+
const pulseOn = isPulseEnabled(chatId);
|
|
153
|
+
const reasoning = await getActiveReasoningLevels({
|
|
154
|
+
chatId,
|
|
155
|
+
backend: settingsBe,
|
|
156
|
+
backendId: settingsBeId,
|
|
157
|
+
config,
|
|
158
|
+
});
|
|
159
|
+
const effortName = displayReasoningEffort(chatSets.effort, reasoning.levels);
|
|
160
|
+
|
|
161
|
+
let modelDetails: Array<string> | undefined;
|
|
162
|
+
let modelButtons: Array<{ text: string; callback_data: string }> | undefined;
|
|
163
|
+
// `settingsBe` already resolved above for the activeModel lookup —
|
|
164
|
+
// reuse it instead of re-resolving (it points at the per-chat
|
|
165
|
+
// backend, override-aware).
|
|
166
|
+
if (settingsBe?.models?.getSettingsPresentation) {
|
|
167
|
+
const presModelId = resolvedActive ?? "";
|
|
168
|
+
const pres = await settingsBe.models?.getSettingsPresentation(presModelId);
|
|
169
|
+
modelDetails = pres.modelDetails;
|
|
170
|
+
modelButtons = pres.modelButtons;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
const components: ActionRowBuilder<
|
|
174
|
+
StringSelectMenuBuilder | ButtonBuilder
|
|
175
|
+
>[] = [];
|
|
176
|
+
|
|
177
|
+
if (modelButtons?.length) {
|
|
178
|
+
const menu = new StringSelectMenuBuilder()
|
|
179
|
+
.setCustomId("settings:model")
|
|
180
|
+
.setPlaceholder("Select a model")
|
|
181
|
+
.addOptions(
|
|
182
|
+
modelButtons.slice(0, 25).map((b) => ({
|
|
183
|
+
label: safeSlice(b.text.replace(/^✓ /, ""), 100),
|
|
184
|
+
value: safeSlice(
|
|
185
|
+
b.callback_data.replace(/^settings:model:/, ""),
|
|
186
|
+
100,
|
|
187
|
+
),
|
|
188
|
+
default: b.text.startsWith("✓"),
|
|
189
|
+
})),
|
|
190
|
+
);
|
|
191
|
+
components.push(
|
|
192
|
+
new ActionRowBuilder<StringSelectMenuBuilder>().addComponents(menu),
|
|
193
|
+
);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
if (reasoning.levels.length > 0) {
|
|
197
|
+
const effortMenu = new StringSelectMenuBuilder()
|
|
198
|
+
.setCustomId("settings:effort:select")
|
|
199
|
+
.setPlaceholder(`Effort: ${effortName}`)
|
|
200
|
+
.addOptions(
|
|
201
|
+
[...reasoning.levels, "adaptive"].map((v) => ({
|
|
202
|
+
label: v,
|
|
203
|
+
value: v,
|
|
204
|
+
description: EFFORT_DESCRIPTIONS[v],
|
|
205
|
+
default: effortName === v,
|
|
206
|
+
})),
|
|
207
|
+
);
|
|
208
|
+
components.push(
|
|
209
|
+
new ActionRowBuilder<StringSelectMenuBuilder>().addComponents(effortMenu),
|
|
210
|
+
);
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
components.push(
|
|
214
|
+
new ActionRowBuilder<ButtonBuilder>().addComponents(
|
|
215
|
+
new ButtonBuilder()
|
|
216
|
+
.setCustomId(`settings:proactive:${pulseOn ? "off" : "on"}`)
|
|
217
|
+
.setLabel(pulseOn ? "Pulse: ON" : "Pulse: OFF")
|
|
218
|
+
.setStyle(pulseOn ? ButtonStyle.Success : ButtonStyle.Secondary),
|
|
219
|
+
new ButtonBuilder()
|
|
220
|
+
.setCustomId("settings:done")
|
|
221
|
+
.setLabel("Done")
|
|
222
|
+
.setStyle(ButtonStyle.Secondary),
|
|
223
|
+
),
|
|
224
|
+
);
|
|
225
|
+
|
|
226
|
+
try {
|
|
227
|
+
await interaction.update({
|
|
228
|
+
content: renderSettingsText(
|
|
229
|
+
activeModel,
|
|
230
|
+
effortName,
|
|
231
|
+
pulseOn,
|
|
232
|
+
chatSets.pulseIntervalMs,
|
|
233
|
+
modelDetails,
|
|
234
|
+
),
|
|
235
|
+
components,
|
|
236
|
+
});
|
|
237
|
+
} catch (err) {
|
|
238
|
+
logError(
|
|
239
|
+
"discord",
|
|
240
|
+
`Failed to refresh settings panel: ${err instanceof Error ? err.message : err}`,
|
|
241
|
+
);
|
|
242
|
+
}
|
|
243
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Component-handler types.
|
|
3
|
+
*
|
|
4
|
+
* The router (index.ts) keys `ComponentHandlers` by the custom-id prefix —
|
|
5
|
+
* the first `:`-terminated segment (`settings:`, `pulse:`, `model:`, …).
|
|
6
|
+
* A handler owns everything under its prefix and returns `false` for an id
|
|
7
|
+
* it doesn't recognise, so the router can ack it the way it acks an id with
|
|
8
|
+
* no handler at all (stale buttons from older panels).
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import type {
|
|
12
|
+
ButtonInteraction,
|
|
13
|
+
StringSelectMenuInteraction,
|
|
14
|
+
} from "discord.js";
|
|
15
|
+
import type { TalonConfig } from "../../../../util/config.js";
|
|
16
|
+
import type { Gateway } from "../../../../core/engine/gateway.js";
|
|
17
|
+
|
|
18
|
+
export type ComponentInteraction =
|
|
19
|
+
ButtonInteraction | StringSelectMenuInteraction;
|
|
20
|
+
|
|
21
|
+
export interface ComponentContext {
|
|
22
|
+
config: TalonConfig;
|
|
23
|
+
gateway: Gateway;
|
|
24
|
+
/** Talon chat id (`discord_guild_…` / `discord_dm_…`), already registered. */
|
|
25
|
+
chatId: string;
|
|
26
|
+
numericChatId: number;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
type ComponentHandler = (
|
|
30
|
+
interaction: ComponentInteraction,
|
|
31
|
+
context: ComponentContext,
|
|
32
|
+
) => Promise<boolean>;
|
|
33
|
+
|
|
34
|
+
export type ComponentHandlers = Record<string, ComponentHandler>;
|
|
@@ -2,13 +2,13 @@
|
|
|
2
2
|
* Discord interaction callbacks — components, modals, and autocomplete.
|
|
3
3
|
*
|
|
4
4
|
* Mirrors src/frontend/telegram/callbacks. Split by interaction kind:
|
|
5
|
-
* -
|
|
6
|
-
*
|
|
7
|
-
* + ai: forwarding)
|
|
5
|
+
* - components/ — button + select-menu router, a dispatch table keyed by
|
|
6
|
+
* custom-id prefix with one handler module per prefix
|
|
7
|
+
* (settings/pulse/effort/model/metrics + ai: forwarding)
|
|
8
8
|
* - modals — modal submissions (pulse interval)
|
|
9
9
|
* - autocomplete — /model name autocomplete with a cached model list
|
|
10
10
|
*/
|
|
11
11
|
|
|
12
|
-
export { handleComponentInteraction } from "./components.js";
|
|
12
|
+
export { handleComponentInteraction } from "./components/index.js";
|
|
13
13
|
export { handleModalSubmit } from "./modals.js";
|
|
14
14
|
export { handleAutocomplete } from "./autocomplete.js";
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Client connection lifecycle — login (blocking until READY) and the
|
|
3
|
+
* shutdown sequence that takes the gateway down with the client.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { Events } from "discord.js";
|
|
7
|
+
import { once } from "node:events";
|
|
8
|
+
import { log, logError } from "../../util/log.js";
|
|
9
|
+
import type { DiscordRuntime } from "./runtime.js";
|
|
10
|
+
|
|
11
|
+
export async function connect(runtime: DiscordRuntime): Promise<void> {
|
|
12
|
+
const { client, discord: dc } = runtime;
|
|
13
|
+
await client.login(dc.botToken);
|
|
14
|
+
// Wait for the WebSocket READY event before returning — login() resolves
|
|
15
|
+
// after HTTP IDENTIFY but client.user / client.guilds.cache are empty
|
|
16
|
+
// until READY fires. Anything called after start() can then safely read
|
|
17
|
+
// gateway state.
|
|
18
|
+
if (!client.isReady()) {
|
|
19
|
+
await once(client, Events.ClientReady);
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export async function disconnect(runtime: DiscordRuntime): Promise<void> {
|
|
24
|
+
try {
|
|
25
|
+
await runtime.client.destroy();
|
|
26
|
+
log("shutdown", "Discord client disconnected");
|
|
27
|
+
} catch (err) {
|
|
28
|
+
logError("shutdown", "Discord stop error", err);
|
|
29
|
+
}
|
|
30
|
+
try {
|
|
31
|
+
await runtime.gateway.stop();
|
|
32
|
+
log("shutdown", "Gateway stopped");
|
|
33
|
+
} catch (err) {
|
|
34
|
+
logError("shutdown", "Gateway stop error", err);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Client diagnostics — the error/warn/disconnect/rate-limit listeners that
|
|
3
|
+
* only log. discord.js handles reconnects and retries itself; these exist
|
|
4
|
+
* so a silent failure mode (a fatal close code, an approaching IP ban) is
|
|
5
|
+
* visible in the daemon log.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { type Client, Events } from "discord.js";
|
|
9
|
+
import { logError, logWarn } from "../../util/log.js";
|
|
10
|
+
|
|
11
|
+
function shardDisconnectLabel(code: number | undefined): string {
|
|
12
|
+
return code === 4004
|
|
13
|
+
? "AUTHENTICATION_FAILED — bot token invalid/revoked"
|
|
14
|
+
: code === 4013
|
|
15
|
+
? "INVALID_INTENTS — declared intent doesn't exist"
|
|
16
|
+
: code === 4014
|
|
17
|
+
? "DISALLOWED_INTENTS — privileged intent (GuildMembers/MessageContent/Presence) not enabled in Developer Portal"
|
|
18
|
+
: `code=${code}`;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function bindClientDiagnostics(client: Client): void {
|
|
22
|
+
client.on("error", (err) => {
|
|
23
|
+
logError("discord", "Client error", err);
|
|
24
|
+
});
|
|
25
|
+
client.on("warn", (msg) => {
|
|
26
|
+
logWarn("discord", msg);
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
// Surface gateway close codes — discord.js auto-reconnects on most, but
|
|
30
|
+
// 4004 (auth failed), 4013 (invalid intents), 4014 (disallowed intents)
|
|
31
|
+
// are terminal: the bot will hang silent in IDENTIFY without an alert.
|
|
32
|
+
client.on(Events.ShardDisconnect, (event, shardId) => {
|
|
33
|
+
const code = event?.code;
|
|
34
|
+
const reason = event?.reason || "(no reason)";
|
|
35
|
+
const fatal = code === 4004 || code === 4013 || code === 4014;
|
|
36
|
+
const label = shardDisconnectLabel(code);
|
|
37
|
+
if (fatal) {
|
|
38
|
+
logError(
|
|
39
|
+
"discord",
|
|
40
|
+
`Shard ${shardId} disconnected fatally: ${label} (${reason})`,
|
|
41
|
+
);
|
|
42
|
+
} else {
|
|
43
|
+
logWarn("discord", `Shard ${shardId} disconnected: ${label} (${reason})`);
|
|
44
|
+
}
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
// Rate-limit / invalid-request observability. discord.js handles retries
|
|
48
|
+
// automatically; we just want logs so we know when we're flirting with
|
|
49
|
+
// the Cloudflare 10k-invalid-requests/10min cap.
|
|
50
|
+
client.rest.on("rateLimited", (info) => {
|
|
51
|
+
logWarn(
|
|
52
|
+
"discord",
|
|
53
|
+
`Rate-limited ${info.method} ${info.route} — wait ${info.timeToReset}ms (global=${info.global})`,
|
|
54
|
+
);
|
|
55
|
+
});
|
|
56
|
+
client.rest.on("invalidRequestWarning", (info) => {
|
|
57
|
+
logError(
|
|
58
|
+
"discord",
|
|
59
|
+
`Invalid-request warning: ${info.count} bad requests in last ${info.remainingTime}ms — approaching IP ban threshold`,
|
|
60
|
+
);
|
|
61
|
+
});
|
|
62
|
+
}
|