talon-agent 3.34.0 → 3.35.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/LICENSE +1 -1
- package/README.md +0 -10
- package/package.json +6 -4
- package/src/backend/claude-sdk/handler.ts +428 -415
- package/src/backend/codex/auth.ts +40 -1
- package/src/backend/codex/handler/message.ts +290 -366
- package/src/backend/codex/handler/rollout-accounting.ts +137 -0
- package/src/backend/codex/plan-usage.ts +27 -2
- package/src/backend/kilo/handler/message.ts +2 -0
- package/src/backend/kilo/server.ts +1 -0
- package/src/backend/openai-agents/handler/message.ts +282 -356
- package/src/backend/opencode/handler/message.ts +2 -0
- package/src/backend/opencode/server.ts +1 -0
- package/src/backend/remote-server/chat-turn.ts +76 -122
- package/src/backend/remote-server/index.ts +4 -0
- package/src/backend/remote-server/lifecycle.ts +37 -0
- package/src/backend/remote-server/mcp.ts +73 -9
- package/src/backend/remote-server/model-catalog/presentation.ts +269 -228
- package/src/backend/remote-server/server-bindings.ts +12 -0
- package/src/backend/remote-server/sessions.ts +2 -2
- package/src/backend/remote-server/state.ts +8 -0
- package/src/backend/remote-server/turn.ts +93 -23
- package/src/backend/shared/cache-telemetry.ts +17 -1
- package/src/backend/shared/handler-to-events.ts +11 -16
- package/src/backend/shared/index.ts +14 -15
- package/src/backend/shared/result-events.ts +30 -0
- package/src/backend/shared/turn-phases.ts +277 -0
- package/src/bootstrap.ts +24 -10
- package/src/core/auth/expiry-monitor.ts +89 -0
- package/src/core/auth/login-flow.ts +247 -0
- package/src/core/auth/status.ts +193 -0
- package/src/core/background/triggers/exit.ts +19 -2
- package/src/core/background/triggers/resume.ts +14 -7
- package/src/core/background/triggers/state.ts +9 -0
- package/src/core/engine/gateway-actions/history.ts +12 -1
- package/src/core/errors.ts +1 -1
- package/src/core/mcp-hub/child-transport.ts +215 -0
- package/src/core/mcp-hub/children.ts +72 -13
- package/src/core/mcp-hub/index.ts +32 -13
- package/src/core/models/active-model.ts +29 -6
- package/src/core/tools/history.ts +10 -1
- package/src/core/weaver/shuttle.ts +4 -0
- package/src/frontend/shared/model-commands.ts +400 -0
- package/src/frontend/telegram/admin.ts +75 -52
- package/src/frontend/telegram/auth-panel.ts +205 -0
- package/src/frontend/telegram/callbacks/auth.ts +70 -0
- package/src/frontend/telegram/callbacks/index.ts +16 -0
- package/src/frontend/telegram/callbacks/whatsapp.ts +50 -0
- package/src/frontend/telegram/commands/auth.ts +58 -0
- package/src/frontend/telegram/commands/definitions.ts +4 -0
- package/src/frontend/telegram/commands/index.ts +10 -4
- package/src/frontend/telegram/commands/whatsapp-pairing.ts +121 -79
- package/src/frontend/whatsapp/actions/chat-info.ts +2 -36
- package/src/frontend/whatsapp/actions/history.ts +100 -0
- package/src/frontend/whatsapp/actions/index.ts +4 -1
- package/src/frontend/whatsapp/commands.ts +371 -0
- package/src/frontend/whatsapp/inbound.ts +24 -46
- package/src/frontend/whatsapp/index.ts +12 -1
- package/src/frontend/whatsapp/media-store.ts +20 -1
- package/src/frontend/whatsapp/message-store.ts +101 -24
- package/src/storage/history.ts +22 -1
- package/src/storage/repositories/history-repo.ts +14 -0
- package/src/storage/repositories/whatsapp-messages-repo.ts +101 -0
- package/src/storage/sql/history.sql +12 -0
- package/src/storage/sql/schema.sql +21 -0
- package/src/storage/sql/statements.generated.ts +50 -1
- package/src/storage/sql/whatsapp-messages.sql +29 -0
- package/src/storage/trigger-store.ts +8 -0
- package/src/storage/whatsapp-messages.ts +51 -0
- package/src/util/watchdog.ts +32 -7
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* /auth panel — sign the daemon's Claude and Codex CLIs back in from
|
|
3
|
+
* Telegram, admin only.
|
|
4
|
+
*
|
|
5
|
+
* The panel lists each provider's login state with a sign-in button. A
|
|
6
|
+
* tap starts the core login flow and rewrites the same message into the
|
|
7
|
+
* instructions the CLI printed: an "Open sign-in page" URL button plus,
|
|
8
|
+
* for Codex, the one-time device code, or, for Claude, a request to reply
|
|
9
|
+
* with the code the browser shows. The message is edited again when the
|
|
10
|
+
* CLI finishes, so the whole exchange lives in one bubble.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import type { Context } from "grammy";
|
|
14
|
+
import type { InlineKeyboardButton } from "grammy/types";
|
|
15
|
+
import {
|
|
16
|
+
activeLoginFlow,
|
|
17
|
+
startLogin,
|
|
18
|
+
type LoginBinaries,
|
|
19
|
+
type LoginFlow,
|
|
20
|
+
type LoginPrompt,
|
|
21
|
+
} from "../../core/auth/login-flow.js";
|
|
22
|
+
import {
|
|
23
|
+
AUTH_PROVIDERS,
|
|
24
|
+
describeProviderStatus,
|
|
25
|
+
PROVIDER_LABELS,
|
|
26
|
+
readAllProviderStatus,
|
|
27
|
+
type AuthProvider,
|
|
28
|
+
type ProviderAuthStatus,
|
|
29
|
+
} from "../../core/auth/status.js";
|
|
30
|
+
import { logWarn } from "../../util/log.js";
|
|
31
|
+
import { escapeHtml } from "./formatting.js";
|
|
32
|
+
|
|
33
|
+
type AuthKeyboard = InlineKeyboardButton[][];
|
|
34
|
+
|
|
35
|
+
export interface AuthPanel {
|
|
36
|
+
text: string;
|
|
37
|
+
keyboard: AuthKeyboard;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function isAuthProvider(value: string): value is AuthProvider {
|
|
41
|
+
return (AUTH_PROVIDERS as readonly string[]).includes(value);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function statusIcon(s: ProviderAuthStatus): string {
|
|
45
|
+
if (!s.loggedIn || s.expired) return "🔴";
|
|
46
|
+
if (
|
|
47
|
+
s.loginExpiresAt !== undefined &&
|
|
48
|
+
s.loginExpiresAt - Date.now() < 7 * 86_400_000
|
|
49
|
+
)
|
|
50
|
+
return "🟡";
|
|
51
|
+
return "🟢";
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** The resting panel: one status line + one sign-in button per provider. */
|
|
55
|
+
export function renderAuthPanel(statuses: ProviderAuthStatus[]): AuthPanel {
|
|
56
|
+
const lines = ["<b>🔑 Backend logins</b>", ""];
|
|
57
|
+
const keyboard: AuthKeyboard = [];
|
|
58
|
+
for (const s of statuses) {
|
|
59
|
+
const label = PROVIDER_LABELS[s.provider];
|
|
60
|
+
lines.push(
|
|
61
|
+
`${statusIcon(s)} <b>${label}</b> — ${escapeHtml(describeProviderStatus(s))}`,
|
|
62
|
+
);
|
|
63
|
+
const pending = activeLoginFlow(s.provider);
|
|
64
|
+
keyboard.push([
|
|
65
|
+
pending
|
|
66
|
+
? {
|
|
67
|
+
text: `⏳ ${label} sign-in in progress…`,
|
|
68
|
+
callback_data: `auth:resume:${s.provider}`,
|
|
69
|
+
}
|
|
70
|
+
: {
|
|
71
|
+
text: `${s.loggedIn && !s.expired ? "🔄 Re-sign in to" : "🔑 Sign in to"} ${label}`,
|
|
72
|
+
callback_data: `auth:login:${s.provider}`,
|
|
73
|
+
},
|
|
74
|
+
]);
|
|
75
|
+
}
|
|
76
|
+
keyboard.push([{ text: "↻ Refresh", callback_data: "auth:refresh" }]);
|
|
77
|
+
return { text: lines.join("\n"), keyboard };
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** The in-progress panel: the CLI's instructions, an open-link button, cancel. */
|
|
81
|
+
export function renderLoginPrompt(
|
|
82
|
+
provider: AuthProvider,
|
|
83
|
+
prompt: LoginPrompt,
|
|
84
|
+
): AuthPanel {
|
|
85
|
+
const label = PROVIDER_LABELS[provider];
|
|
86
|
+
const lines = [
|
|
87
|
+
`<b>🔑 Sign in to ${label}</b>`,
|
|
88
|
+
"",
|
|
89
|
+
"1. Open the sign-in page (button below) and log in.",
|
|
90
|
+
];
|
|
91
|
+
if (prompt.code) {
|
|
92
|
+
lines.push(
|
|
93
|
+
`2. Enter this one-time code: <code>${escapeHtml(prompt.code)}</code>`,
|
|
94
|
+
);
|
|
95
|
+
}
|
|
96
|
+
if (prompt.needsCode) {
|
|
97
|
+
lines.push(
|
|
98
|
+
`${prompt.code ? 3 : 2}. Copy the code the page shows and <b>reply to this message</b> with it.`,
|
|
99
|
+
);
|
|
100
|
+
}
|
|
101
|
+
lines.push("", "<i>This link expires in 15 minutes.</i>");
|
|
102
|
+
return {
|
|
103
|
+
text: lines.join("\n"),
|
|
104
|
+
keyboard: [
|
|
105
|
+
[{ text: "🌐 Open sign-in page", url: prompt.url }],
|
|
106
|
+
[{ text: "✖ Cancel", callback_data: `auth:cancel:${provider}` }],
|
|
107
|
+
],
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export async function currentAuthPanel(): Promise<AuthPanel> {
|
|
112
|
+
return renderAuthPanel(await readAllProviderStatus());
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** Messages awaiting a pasted code, keyed by chat → prompt message id. */
|
|
116
|
+
const awaitingCode = new Map<
|
|
117
|
+
string,
|
|
118
|
+
{ provider: AuthProvider; messageId: number }
|
|
119
|
+
>();
|
|
120
|
+
|
|
121
|
+
export function pendingCodePrompt(
|
|
122
|
+
chatId: string,
|
|
123
|
+
): { provider: AuthProvider; messageId: number } | undefined {
|
|
124
|
+
return awaitingCode.get(chatId);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
async function editPanel(
|
|
128
|
+
ctx: Context,
|
|
129
|
+
chatId: number,
|
|
130
|
+
messageId: number,
|
|
131
|
+
panel: AuthPanel,
|
|
132
|
+
): Promise<void> {
|
|
133
|
+
try {
|
|
134
|
+
await ctx.api.editMessageText(chatId, messageId, panel.text, {
|
|
135
|
+
parse_mode: "HTML",
|
|
136
|
+
reply_markup: { inline_keyboard: panel.keyboard },
|
|
137
|
+
});
|
|
138
|
+
} catch (err) {
|
|
139
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
140
|
+
if (!/message is not modified/i.test(msg))
|
|
141
|
+
logWarn("bot", `auth panel edit failed: ${msg}`);
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Start (or attach to) a login for `provider` and drive the panel message
|
|
147
|
+
* through prompt → outcome. Returns once the prompt is on screen; the
|
|
148
|
+
* outcome edit happens when the CLI exits.
|
|
149
|
+
*/
|
|
150
|
+
export async function driveLogin(
|
|
151
|
+
ctx: Context,
|
|
152
|
+
chatId: number,
|
|
153
|
+
messageId: number,
|
|
154
|
+
provider: AuthProvider,
|
|
155
|
+
bins: LoginBinaries,
|
|
156
|
+
): Promise<void> {
|
|
157
|
+
const flow: LoginFlow =
|
|
158
|
+
activeLoginFlow(provider) ?? startLogin(provider, bins);
|
|
159
|
+
let prompt: LoginPrompt;
|
|
160
|
+
try {
|
|
161
|
+
prompt = await flow.prompt;
|
|
162
|
+
} catch (err) {
|
|
163
|
+
const detail = err instanceof Error ? err.message : String(err);
|
|
164
|
+
const panel = await currentAuthPanel();
|
|
165
|
+
await editPanel(ctx, chatId, messageId, {
|
|
166
|
+
text: `${panel.text}\n\n❌ Couldn't start ${PROVIDER_LABELS[provider]} sign-in: ${escapeHtml(detail)}`,
|
|
167
|
+
keyboard: panel.keyboard,
|
|
168
|
+
});
|
|
169
|
+
return;
|
|
170
|
+
}
|
|
171
|
+
if (prompt.needsCode)
|
|
172
|
+
awaitingCode.set(String(chatId), { provider, messageId });
|
|
173
|
+
await editPanel(ctx, chatId, messageId, renderLoginPrompt(provider, prompt));
|
|
174
|
+
|
|
175
|
+
void flow.done.then(async (outcome) => {
|
|
176
|
+
if (awaitingCode.get(String(chatId))?.messageId === messageId)
|
|
177
|
+
awaitingCode.delete(String(chatId));
|
|
178
|
+
const panel = await currentAuthPanel();
|
|
179
|
+
const label = PROVIDER_LABELS[provider];
|
|
180
|
+
const note = outcome.ok
|
|
181
|
+
? `✅ ${label} signed in.`
|
|
182
|
+
: outcome.reason === "cancelled"
|
|
183
|
+
? `✖ ${label} sign-in cancelled.`
|
|
184
|
+
: outcome.reason === "timeout"
|
|
185
|
+
? `⌛ ${label} sign-in link expired. Tap the button to try again.`
|
|
186
|
+
: `❌ ${label} sign-in failed: ${escapeHtml(outcome.detail ?? "unknown error")}`;
|
|
187
|
+
await editPanel(ctx, chatId, messageId, {
|
|
188
|
+
text: `${panel.text}\n\n${note}`,
|
|
189
|
+
keyboard: panel.keyboard,
|
|
190
|
+
});
|
|
191
|
+
});
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/** Feed a pasted code to the pending flow for this chat. True when consumed. */
|
|
195
|
+
export function submitPendingCode(chatId: string, text: string): boolean {
|
|
196
|
+
const pending = awaitingCode.get(chatId);
|
|
197
|
+
if (!pending) return false;
|
|
198
|
+
const flow = activeLoginFlow(pending.provider);
|
|
199
|
+
if (!flow) {
|
|
200
|
+
awaitingCode.delete(chatId);
|
|
201
|
+
return false;
|
|
202
|
+
}
|
|
203
|
+
flow.submitCode(text);
|
|
204
|
+
return true;
|
|
205
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `auth:*` callbacks — the /auth panel's buttons.
|
|
3
|
+
*
|
|
4
|
+
* auth:login:<provider> start a sign-in and show its instructions
|
|
5
|
+
* auth:resume:<provider> re-show the instructions of a running sign-in
|
|
6
|
+
* auth:cancel:<provider> abort a running sign-in
|
|
7
|
+
* auth:refresh re-read credential files and redraw
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import type { Context } from "grammy";
|
|
11
|
+
import { activeLoginFlow } from "../../../core/auth/login-flow.js";
|
|
12
|
+
import { currentAuthPanel, driveLogin, isAuthProvider } from "../auth-panel.js";
|
|
13
|
+
import { loginBinariesFrom } from "../commands/auth.js";
|
|
14
|
+
import { isAuthorizedAdmin } from "../commands/state.js";
|
|
15
|
+
import { answerCallbackQuerySafe, type CallbackDeps } from "./shared.js";
|
|
16
|
+
|
|
17
|
+
export async function handleAuthCallback(
|
|
18
|
+
ctx: Context,
|
|
19
|
+
data: string,
|
|
20
|
+
{ config }: CallbackDeps,
|
|
21
|
+
): Promise<void> {
|
|
22
|
+
if (!isAuthorizedAdmin(ctx)) {
|
|
23
|
+
await answerCallbackQuerySafe(ctx, { text: "Not authorized." });
|
|
24
|
+
return;
|
|
25
|
+
}
|
|
26
|
+
const chatId = ctx.chat?.id;
|
|
27
|
+
const messageId = ctx.callbackQuery?.message?.message_id;
|
|
28
|
+
if (chatId === undefined || messageId === undefined) {
|
|
29
|
+
await answerCallbackQuerySafe(ctx);
|
|
30
|
+
return;
|
|
31
|
+
}
|
|
32
|
+
const [, action, provider = ""] = data.split(":");
|
|
33
|
+
|
|
34
|
+
if (action === "refresh") {
|
|
35
|
+
await answerCallbackQuerySafe(ctx);
|
|
36
|
+
const panel = await currentAuthPanel();
|
|
37
|
+
await ctx.api
|
|
38
|
+
.editMessageText(chatId, messageId, panel.text, {
|
|
39
|
+
parse_mode: "HTML",
|
|
40
|
+
reply_markup: { inline_keyboard: panel.keyboard },
|
|
41
|
+
})
|
|
42
|
+
.catch(() => {});
|
|
43
|
+
return;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
if (!isAuthProvider(provider)) {
|
|
47
|
+
await answerCallbackQuerySafe(ctx, { text: "Unknown provider" });
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
if (action === "cancel") {
|
|
52
|
+
activeLoginFlow(provider)?.cancel();
|
|
53
|
+
await answerCallbackQuerySafe(ctx, { text: "Cancelled" });
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
if (action === "login" || action === "resume") {
|
|
58
|
+
await answerCallbackQuerySafe(ctx, { text: "Starting sign-in…" });
|
|
59
|
+
await driveLogin(
|
|
60
|
+
ctx,
|
|
61
|
+
chatId,
|
|
62
|
+
messageId,
|
|
63
|
+
provider,
|
|
64
|
+
loginBinariesFrom(config),
|
|
65
|
+
);
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
await answerCallbackQuerySafe(ctx, { text: "Invalid callback data" });
|
|
70
|
+
}
|
|
@@ -9,6 +9,8 @@
|
|
|
9
9
|
* - `effort` — `effort:*`
|
|
10
10
|
* - `metrics` — `metrics:*` (today ↔ all-time panel grain)
|
|
11
11
|
* - `model` — `model:*` (menu / backend / browse controller)
|
|
12
|
+
* - `auth` — `auth:*` (backend sign-in panel, admin only)
|
|
13
|
+
* - `whatsapp` — `whatsapp:*` (WhatsApp link panel, admin only)
|
|
12
14
|
*
|
|
13
15
|
* `registerCallbacks` installs one `callback_query:data` listener that
|
|
14
16
|
* dispatches on the data prefix, preserving the original order and the
|
|
@@ -25,6 +27,8 @@ import { handlePulseCallback } from "./pulse.js";
|
|
|
25
27
|
import { handleEffortCallback } from "./effort.js";
|
|
26
28
|
import { handleMetricsCallback } from "./metrics.js";
|
|
27
29
|
import { handleModelCallback } from "./model.js";
|
|
30
|
+
import { handleAuthCallback } from "./auth.js";
|
|
31
|
+
import { handleWhatsAppCallback } from "./whatsapp.js";
|
|
28
32
|
|
|
29
33
|
export { answerCallbackQuerySafe } from "./shared.js";
|
|
30
34
|
|
|
@@ -70,6 +74,18 @@ export function registerCallbacks(
|
|
|
70
74
|
return;
|
|
71
75
|
}
|
|
72
76
|
|
|
77
|
+
// Handle /auth (backend CLI sign-in) callbacks.
|
|
78
|
+
if (data.startsWith("auth:")) {
|
|
79
|
+
await handleAuthCallback(ctx, data, deps);
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// Handle /whatsapp (link status / pairing) callbacks.
|
|
84
|
+
if (data.startsWith("whatsapp:")) {
|
|
85
|
+
await handleWhatsAppCallback(ctx, data);
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
|
|
73
89
|
// Forward other callbacks to the AI backend
|
|
74
90
|
handleCallbackQuery(ctx, bot, config);
|
|
75
91
|
});
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `whatsapp:*` callbacks — the /whatsapp panel's buttons.
|
|
3
|
+
*
|
|
4
|
+
* whatsapp:pair run one bounded pairing attempt
|
|
5
|
+
* whatsapp:refresh re-read link state and redraw the panel
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type { Context } from "grammy";
|
|
9
|
+
import {
|
|
10
|
+
runWhatsAppPairing,
|
|
11
|
+
whatsAppPanel,
|
|
12
|
+
} from "../commands/whatsapp-pairing.js";
|
|
13
|
+
import { isAuthorizedAdmin } from "../commands/state.js";
|
|
14
|
+
import { answerCallbackQuerySafe } from "./shared.js";
|
|
15
|
+
|
|
16
|
+
export async function handleWhatsAppCallback(
|
|
17
|
+
ctx: Context,
|
|
18
|
+
data: string,
|
|
19
|
+
): Promise<void> {
|
|
20
|
+
if (!isAuthorizedAdmin(ctx)) {
|
|
21
|
+
await answerCallbackQuerySafe(ctx, { text: "Not authorized." });
|
|
22
|
+
return;
|
|
23
|
+
}
|
|
24
|
+
const [, action] = data.split(":");
|
|
25
|
+
|
|
26
|
+
if (action === "refresh") {
|
|
27
|
+
await answerCallbackQuerySafe(ctx);
|
|
28
|
+
const panel = whatsAppPanel();
|
|
29
|
+
const chatId = ctx.chat?.id;
|
|
30
|
+
const messageId = ctx.callbackQuery?.message?.message_id;
|
|
31
|
+
if (chatId === undefined || messageId === undefined) return;
|
|
32
|
+
await ctx.api
|
|
33
|
+
.editMessageText(chatId, messageId, panel.text, {
|
|
34
|
+
parse_mode: "HTML",
|
|
35
|
+
...(panel.keyboard.length
|
|
36
|
+
? { reply_markup: { inline_keyboard: panel.keyboard } }
|
|
37
|
+
: {}),
|
|
38
|
+
})
|
|
39
|
+
.catch(() => {});
|
|
40
|
+
return;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
if (action === "pair") {
|
|
44
|
+
await answerCallbackQuerySafe(ctx, { text: "Starting pairing…" });
|
|
45
|
+
await runWhatsAppPairing(ctx);
|
|
46
|
+
return;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
await answerCallbackQuerySafe(ctx, { text: "Invalid callback data" });
|
|
50
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* /auth — backend login panel (admin only). Also captures the code an
|
|
3
|
+
* admin replies with during a Claude sign-in, before the message reaches
|
|
4
|
+
* the agent.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import type { Bot } from "grammy";
|
|
8
|
+
import type { LoginBinaries } from "../../../core/auth/login-flow.js";
|
|
9
|
+
import {
|
|
10
|
+
currentAuthPanel,
|
|
11
|
+
pendingCodePrompt,
|
|
12
|
+
submitPendingCode,
|
|
13
|
+
} from "../auth-panel.js";
|
|
14
|
+
import { isAuthorizedAdmin, type RegisterDeps } from "./state.js";
|
|
15
|
+
|
|
16
|
+
export function loginBinariesFrom(
|
|
17
|
+
config: RegisterDeps["config"],
|
|
18
|
+
): LoginBinaries {
|
|
19
|
+
return {
|
|
20
|
+
claude: config.claudeBinary,
|
|
21
|
+
codex: process.env.TALON_CODEX_BINARY || config.codexBinary,
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function registerAuthCommand(bot: Bot): void {
|
|
26
|
+
bot.command("auth", async (ctx) => {
|
|
27
|
+
if (!isAuthorizedAdmin(ctx)) {
|
|
28
|
+
await ctx.reply("Not authorized.");
|
|
29
|
+
return;
|
|
30
|
+
}
|
|
31
|
+
const panel = await currentAuthPanel();
|
|
32
|
+
await ctx.reply(panel.text, {
|
|
33
|
+
parse_mode: "HTML",
|
|
34
|
+
reply_markup: { inline_keyboard: panel.keyboard },
|
|
35
|
+
});
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
// A reply to the sign-in prompt carries the pasted code. Only admins,
|
|
39
|
+
// only while a flow is waiting, only as a reply to that exact message —
|
|
40
|
+
// anything else falls through to the normal pipeline untouched.
|
|
41
|
+
bot.on("message:text", async (ctx, next) => {
|
|
42
|
+
const chatId = String(ctx.chat.id);
|
|
43
|
+
const pending = pendingCodePrompt(chatId);
|
|
44
|
+
if (
|
|
45
|
+
!pending ||
|
|
46
|
+
!isAuthorizedAdmin(ctx) ||
|
|
47
|
+
ctx.message.reply_to_message?.message_id !== pending.messageId
|
|
48
|
+
) {
|
|
49
|
+
await next();
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
52
|
+
if (submitPendingCode(chatId, ctx.message.text)) {
|
|
53
|
+
await ctx.reply("Got it — finishing sign-in…");
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
await next();
|
|
57
|
+
});
|
|
58
|
+
}
|
|
@@ -33,6 +33,10 @@ export const TELEGRAM_COMMANDS: ReadonlyArray<{
|
|
|
33
33
|
{ command: "reset", description: "Clear session and start fresh" },
|
|
34
34
|
{ command: "restart", description: "Restart the bot (admin)" },
|
|
35
35
|
{ command: "whatsapp", description: "WhatsApp link status / pair (admin)" },
|
|
36
|
+
{
|
|
37
|
+
command: "auth",
|
|
38
|
+
description: "Backend logins — sign in to Claude/Codex (admin)",
|
|
39
|
+
},
|
|
36
40
|
{ command: "usage", description: "Plan limits across every backend" },
|
|
37
41
|
{ command: "metrics", description: "Aggregate performance metrics" },
|
|
38
42
|
{
|
|
@@ -10,9 +10,9 @@
|
|
|
10
10
|
* - `admin` — /admin /metrics /doctor /dream /soul /restart /update
|
|
11
11
|
* + the unknown-command suggester
|
|
12
12
|
*
|
|
13
|
-
* `registerCommands` wires every group onto the bot
|
|
14
|
-
*
|
|
15
|
-
*
|
|
13
|
+
* `registerCommands` wires every group onto the bot in an order that ends
|
|
14
|
+
* with `admin`, because admin owns the unknown-command catch-all and that
|
|
15
|
+
* must be the last handler to see a bare /command.
|
|
16
16
|
*/
|
|
17
17
|
|
|
18
18
|
import type { Bot } from "grammy";
|
|
@@ -23,6 +23,7 @@ import { registerSessionCommands } from "./session.js";
|
|
|
23
23
|
import { registerSettingsCommands } from "./settings.js";
|
|
24
24
|
import { registerAdminCommands } from "./admin.js";
|
|
25
25
|
import { registerWhatsAppPairingCommand } from "./whatsapp-pairing.js";
|
|
26
|
+
import { registerAuthCommand } from "./auth.js";
|
|
26
27
|
|
|
27
28
|
export { telegramCommandMenu } from "./definitions.js";
|
|
28
29
|
export { setAdminUserId } from "./state.js";
|
|
@@ -36,6 +37,11 @@ export function registerCommands(
|
|
|
36
37
|
registerInfoCommands(bot);
|
|
37
38
|
registerSessionCommands(bot, deps);
|
|
38
39
|
registerSettingsCommands(bot, deps);
|
|
39
|
-
registerAdminCommands(bot, deps);
|
|
40
40
|
registerWhatsAppPairingCommand(bot);
|
|
41
|
+
registerAuthCommand(bot);
|
|
42
|
+
// admin LAST: it owns the unknown-command catch-all, which must only
|
|
43
|
+
// be reached after every real command has had its chance to match.
|
|
44
|
+
// Registering anything after it makes that command look unknown
|
|
45
|
+
// ("Unknown command /whatsapp — did you mean /whatsapp?").
|
|
46
|
+
registerAdminCommands(bot, deps);
|
|
41
47
|
}
|
|
@@ -1,19 +1,125 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* /whatsapp — drive WhatsApp device pairing from Telegram.
|
|
3
3
|
*
|
|
4
|
-
* `/whatsapp`
|
|
5
|
-
*
|
|
6
|
-
* a photo (plus the phone-number code
|
|
7
|
-
* configured). The human runs it when they are
|
|
8
|
-
* pairing is never automatic, because automatic
|
|
9
|
-
* account got rate-limited into "couldn't connect
|
|
4
|
+
* Bare `/whatsapp` draws a status panel with a button per sub-command;
|
|
5
|
+
* `/whatsapp pair` runs ONE bounded pairing attempt via the core pairing
|
|
6
|
+
* broker and replies with the QR as a photo (plus the phone-number code
|
|
7
|
+
* when the account has one configured). The human runs it when they are
|
|
8
|
+
* holding the phone — pairing is never automatic, because automatic
|
|
9
|
+
* retries are how the account got rate-limited into "couldn't connect
|
|
10
|
+
* device".
|
|
10
11
|
*/
|
|
11
12
|
|
|
12
|
-
import { InputFile, type Bot } from "grammy";
|
|
13
|
+
import { InputFile, type Bot, type Context } from "grammy";
|
|
13
14
|
import { getPairingProvider } from "../../../core/pairing-broker.js";
|
|
14
15
|
import { log } from "../../../util/log.js";
|
|
15
16
|
import { isAuthorizedAdmin } from "./state.js";
|
|
16
17
|
|
|
18
|
+
const WHATSAPP_NOT_ENABLED =
|
|
19
|
+
'WhatsApp isn\'t enabled on this daemon — add "whatsapp" to the ' +
|
|
20
|
+
"frontend list and configure the whatsapp block, then restart.";
|
|
21
|
+
|
|
22
|
+
export type WhatsAppPanel = {
|
|
23
|
+
text: string;
|
|
24
|
+
keyboard: Array<Array<{ text: string; callback_data: string }>>;
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* The bare-`/whatsapp` panel: link state plus one button per
|
|
29
|
+
* sub-command, so the commands are discoverable instead of folklore.
|
|
30
|
+
*/
|
|
31
|
+
export function whatsAppPanel(): WhatsAppPanel {
|
|
32
|
+
const provider = getPairingProvider();
|
|
33
|
+
if (!provider) {
|
|
34
|
+
return { text: WHATSAPP_NOT_ENABLED, keyboard: [] };
|
|
35
|
+
}
|
|
36
|
+
const linked = provider.isLinked();
|
|
37
|
+
const text = linked
|
|
38
|
+
? "<b>WhatsApp</b>\n✅ Linked and connected.\n\n" +
|
|
39
|
+
"Re-link only if the phone has unlinked this device."
|
|
40
|
+
: "<b>WhatsApp</b>\n📴 Not linked.\n\n" +
|
|
41
|
+
"Tap <b>Pair device</b> when you're holding the phone — I'll reply " +
|
|
42
|
+
"with a QR to scan (WhatsApp → ⋮ → Linked devices → Link a device).";
|
|
43
|
+
return {
|
|
44
|
+
text,
|
|
45
|
+
keyboard: [
|
|
46
|
+
[
|
|
47
|
+
{
|
|
48
|
+
text: linked ? "🔗 Re-pair" : "🔗 Pair device",
|
|
49
|
+
callback_data: "whatsapp:pair",
|
|
50
|
+
},
|
|
51
|
+
{ text: "🔄 Refresh", callback_data: "whatsapp:refresh" },
|
|
52
|
+
],
|
|
53
|
+
],
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* One bounded pairing attempt, shared by `/whatsapp pair` and the panel
|
|
59
|
+
* button so both paths behave identically.
|
|
60
|
+
*/
|
|
61
|
+
export async function runWhatsAppPairing(ctx: Context): Promise<void> {
|
|
62
|
+
const provider = getPairingProvider();
|
|
63
|
+
if (!provider) {
|
|
64
|
+
await ctx.reply(WHATSAPP_NOT_ENABLED);
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
if (provider.isLinked()) {
|
|
69
|
+
await ctx.reply(
|
|
70
|
+
"WhatsApp is already linked. Unlink it on the phone first if you " +
|
|
71
|
+
"want to re-pair.",
|
|
72
|
+
);
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
let attempt;
|
|
77
|
+
try {
|
|
78
|
+
attempt = await provider.begin();
|
|
79
|
+
} catch (err) {
|
|
80
|
+
await ctx.reply(
|
|
81
|
+
`Couldn't start pairing: ${err instanceof Error ? err.message : err}`,
|
|
82
|
+
);
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
if (attempt.qrPng) {
|
|
87
|
+
const codeLine = attempt.code
|
|
88
|
+
? `\nOr enter code: ${attempt.code} (Link with phone number instead)`
|
|
89
|
+
: "";
|
|
90
|
+
await ctx.replyWithPhoto(new InputFile(attempt.qrPng, "whatsapp-qr.png"), {
|
|
91
|
+
caption:
|
|
92
|
+
"Scan within ~2 minutes:\n" +
|
|
93
|
+
"WhatsApp → ⋮ → Linked devices → Link a device" +
|
|
94
|
+
codeLine,
|
|
95
|
+
});
|
|
96
|
+
} else {
|
|
97
|
+
await ctx.reply(
|
|
98
|
+
"No QR came back — WhatsApp may still be rate-limiting. Try later.",
|
|
99
|
+
);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const outcome = await attempt.result;
|
|
103
|
+
if (outcome.ok) {
|
|
104
|
+
log("bot", `WhatsApp paired via /whatsapp pair (${outcome.identity})`);
|
|
105
|
+
await ctx.reply(
|
|
106
|
+
`✅ Linked as ${outcome.identity}. WhatsApp is connecting now.`,
|
|
107
|
+
);
|
|
108
|
+
} else if (outcome.reason === "expired") {
|
|
109
|
+
await ctx.reply(
|
|
110
|
+
"⌛ That window expired without a successful scan. Run " +
|
|
111
|
+
"/whatsapp pair again when you're ready — if the phone says " +
|
|
112
|
+
'"couldn\'t connect device", WhatsApp is still rate-limiting; ' +
|
|
113
|
+
"wait an hour and try once more.",
|
|
114
|
+
);
|
|
115
|
+
} else if (outcome.reason !== "cancelled") {
|
|
116
|
+
await ctx.reply(
|
|
117
|
+
`❌ Pairing failed${outcome.detail ? `: ${outcome.detail}` : ""}. ` +
|
|
118
|
+
"Run /whatsapp pair to try again.",
|
|
119
|
+
);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
17
123
|
export function registerWhatsAppPairingCommand(bot: Bot): void {
|
|
18
124
|
bot.command("whatsapp", async (ctx) => {
|
|
19
125
|
if (!isAuthorizedAdmin(ctx)) {
|
|
@@ -21,82 +127,18 @@ export function registerWhatsAppPairingCommand(bot: Bot): void {
|
|
|
21
127
|
return;
|
|
22
128
|
}
|
|
23
129
|
|
|
24
|
-
const provider = getPairingProvider();
|
|
25
|
-
if (!provider) {
|
|
26
|
-
await ctx.reply(
|
|
27
|
-
'WhatsApp isn\'t enabled on this daemon — add "whatsapp" to the ' +
|
|
28
|
-
"frontend list and configure the whatsapp block, then restart.",
|
|
29
|
-
);
|
|
30
|
-
return;
|
|
31
|
-
}
|
|
32
|
-
|
|
33
130
|
const arg = (ctx.match ?? "").trim().toLowerCase();
|
|
34
131
|
if (arg !== "pair") {
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
);
|
|
132
|
+
const panel = whatsAppPanel();
|
|
133
|
+
await ctx.reply(panel.text, {
|
|
134
|
+
parse_mode: "HTML",
|
|
135
|
+
...(panel.keyboard.length
|
|
136
|
+
? { reply_markup: { inline_keyboard: panel.keyboard } }
|
|
137
|
+
: {}),
|
|
138
|
+
});
|
|
42
139
|
return;
|
|
43
140
|
}
|
|
44
141
|
|
|
45
|
-
|
|
46
|
-
await ctx.reply(
|
|
47
|
-
"WhatsApp is already linked. Unlink it on the phone first if you " +
|
|
48
|
-
"want to re-pair.",
|
|
49
|
-
);
|
|
50
|
-
return;
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
let attempt;
|
|
54
|
-
try {
|
|
55
|
-
attempt = await provider.begin();
|
|
56
|
-
} catch (err) {
|
|
57
|
-
await ctx.reply(
|
|
58
|
-
`Couldn't start pairing: ${err instanceof Error ? err.message : err}`,
|
|
59
|
-
);
|
|
60
|
-
return;
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
if (attempt.qrPng) {
|
|
64
|
-
const codeLine = attempt.code
|
|
65
|
-
? `\nOr enter code: ${attempt.code} (Link with phone number instead)`
|
|
66
|
-
: "";
|
|
67
|
-
await ctx.replyWithPhoto(
|
|
68
|
-
new InputFile(attempt.qrPng, "whatsapp-qr.png"),
|
|
69
|
-
{
|
|
70
|
-
caption:
|
|
71
|
-
"Scan within ~2 minutes:\n" +
|
|
72
|
-
"WhatsApp → ⋮ → Linked devices → Link a device" +
|
|
73
|
-
codeLine,
|
|
74
|
-
},
|
|
75
|
-
);
|
|
76
|
-
} else {
|
|
77
|
-
await ctx.reply(
|
|
78
|
-
"No QR came back — WhatsApp may still be rate-limiting. Try later.",
|
|
79
|
-
);
|
|
80
|
-
}
|
|
81
|
-
|
|
82
|
-
const outcome = await attempt.result;
|
|
83
|
-
if (outcome.ok) {
|
|
84
|
-
log("bot", `WhatsApp paired via /whatsapp pair (${outcome.identity})`);
|
|
85
|
-
await ctx.reply(
|
|
86
|
-
`✅ Linked as ${outcome.identity}. WhatsApp is connecting now.`,
|
|
87
|
-
);
|
|
88
|
-
} else if (outcome.reason === "expired") {
|
|
89
|
-
await ctx.reply(
|
|
90
|
-
"⌛ That window expired without a successful scan. Run " +
|
|
91
|
-
"/whatsapp pair again when you're ready — if the phone says " +
|
|
92
|
-
'"couldn\'t connect device", WhatsApp is still rate-limiting; ' +
|
|
93
|
-
"wait an hour and try once more.",
|
|
94
|
-
);
|
|
95
|
-
} else if (outcome.reason !== "cancelled") {
|
|
96
|
-
await ctx.reply(
|
|
97
|
-
`❌ Pairing failed${outcome.detail ? `: ${outcome.detail}` : ""}. ` +
|
|
98
|
-
"Run /whatsapp pair to try again.",
|
|
99
|
-
);
|
|
100
|
-
}
|
|
142
|
+
await runWhatsAppPairing(ctx);
|
|
101
143
|
});
|
|
102
144
|
}
|