fix: improve telegram configuration safety

This commit is contained in:
Peter Steinberger
2026-01-11 03:57:44 +01:00
parent 11f897b7df
commit 36a21ae9b0
11 changed files with 177 additions and 19 deletions

View File

@@ -1,5 +1,12 @@
# Changelog # Changelog
## 2026.1.11-9
### Fixes
- Telegram: add `/whoami` + `/id` commands to reveal sender id for allowlists.
- Telegram/Onboarding: allow `@username` and prefixed ids in `allowFrom` prompts (with stability warning).
- Control UI: stop auto-writing `telegram.groups["*"]` and warn/confirm before enabling wildcard groups.
## 2026.1.11-6 ## 2026.1.11-6
### Fixes ### Fixes

View File

@@ -153,7 +153,7 @@ Send in the group:
Forward any message from the group to `@userinfobot` or `@getidsbot` on Telegram to see the chat ID (negative number like `-1001234567890`). Forward any message from the group to `@userinfobot` or `@getidsbot` on Telegram to see the chat ID (negative number like `-1001234567890`).
**Tip:** For your own user ID, DM `@userinfobot` with `/start`. Useful for allowlists or debugging access control. **Tip:** For your own user ID, DM the bot and it will reply with your user ID (pairing message), or use `/whoami` once commands are enabled.
**Privacy note:** `@userinfobot` is a third-party bot. If you prefer, use gateway logs (`clawdbot logs`) or Telegram developer tools to find user/chat IDs. **Privacy note:** `@userinfobot` is a third-party bot. If you prefer, use gateway logs (`clawdbot logs`) or Telegram developer tools to find user/chat IDs.
@@ -176,6 +176,7 @@ Private topics (DM forum mode) also include `message_thread_id`. Clawdbot:
- `clawdbot pairing list telegram` - `clawdbot pairing list telegram`
- `clawdbot pairing approve telegram <CODE>` - `clawdbot pairing approve telegram <CODE>`
- Pairing is the default token exchange used for Telegram DMs. Details: [Pairing](/start/pairing) - Pairing is the default token exchange used for Telegram DMs. Details: [Pairing](/start/pairing)
- `telegram.allowFrom` accepts numeric user IDs (recommended) or `@username` entries.
### Group access ### Group access

View File

@@ -136,6 +136,12 @@ export const CHAT_COMMANDS: ChatCommandDefinition[] = (() => {
description: "Show current status.", description: "Show current status.",
textAlias: "/status", textAlias: "/status",
}), }),
defineChatCommand({
key: "whoami",
nativeName: "whoami",
description: "Show your sender id.",
textAlias: "/whoami",
}),
defineChatCommand({ defineChatCommand({
key: "config", key: "config",
nativeName: "config", nativeName: "config",
@@ -247,6 +253,7 @@ export const CHAT_COMMANDS: ChatCommandDefinition[] = (() => {
]; ];
registerAlias(commands, "status", "/usage"); registerAlias(commands, "status", "/usage");
registerAlias(commands, "whoami", "/id");
registerAlias(commands, "think", "/thinking", "/t"); registerAlias(commands, "think", "/thinking", "/t");
registerAlias(commands, "verbose", "/v"); registerAlias(commands, "verbose", "/v");
registerAlias(commands, "reasoning", "/reason"); registerAlias(commands, "reasoning", "/reason");

View File

@@ -643,6 +643,30 @@ export async function handleCommands(params: {
return { shouldContinue: false, reply }; return { shouldContinue: false, reply };
} }
const whoamiRequested = command.commandBodyNormalized === "/whoami";
if (allowTextCommands && whoamiRequested) {
const senderId = ctx.SenderId ?? "";
const senderUsername = ctx.SenderUsername ?? "";
const lines = ["🧭 Identity", `Provider: ${command.provider}`];
if (senderId) lines.push(`User id: ${senderId}`);
if (senderUsername) {
const handle = senderUsername.startsWith("@")
? senderUsername
: `@${senderUsername}`;
lines.push(`Username: ${handle}`);
}
if (ctx.ChatType === "group" && ctx.From) {
lines.push(`Chat: ${ctx.From}`);
}
if (ctx.MessageThreadId != null) {
lines.push(`Thread: ${ctx.MessageThreadId}`);
}
if (senderId) {
lines.push(`AllowFrom: ${senderId}`);
}
return { shouldContinue: false, reply: { text: lines.join("\n") } };
}
const configCommand = allowTextCommands const configCommand = allowTextCommands
? parseConfigCommand(command.commandBodyNormalized) ? parseConfigCommand(command.commandBodyNormalized)
: null; : null;

View File

@@ -470,6 +470,61 @@ async function maybeConfigureDmPolicies(params: {
return cfg; return cfg;
} }
function parseTelegramAllowFromEntries(raw: string): {
entries: string[];
hasUsernames: boolean;
error?: string;
} {
const parts = raw
.split(/[\n,;]+/g)
.map((part) => part.trim())
.filter(Boolean);
if (parts.length === 0) {
return { entries: [], hasUsernames: false, error: "Required" };
}
const entries: string[] = [];
let hasUsernames = false;
for (const part of parts) {
if (part === "*") {
entries.push(part);
continue;
}
const match = part.match(/^(telegram|tg):(.+)$/i);
const value = match ? match[2]?.trim() : part;
if (!value) {
return { entries: [], hasUsernames: false, error: `Invalid entry: ${part}` };
}
if (/^\d+$/.test(value)) {
entries.push(part);
continue;
}
if (value.startsWith("@")) {
const username = value.slice(1);
if (!/^[a-z0-9_]{5,32}$/i.test(username)) {
return {
entries: [],
hasUsernames: false,
error: `Invalid username: ${part}`,
};
}
hasUsernames = true;
entries.push(part);
continue;
}
if (/^[a-z0-9_]{5,32}$/i.test(value)) {
hasUsernames = true;
entries.push(`@${value}`);
continue;
}
return {
entries: [],
hasUsernames: false,
error: `Invalid entry: ${part}`,
};
}
return { entries, hasUsernames };
}
async function promptTelegramAllowFrom(params: { async function promptTelegramAllowFrom(params: {
cfg: ClawdbotConfig; cfg: ClawdbotConfig;
prompter: WizardPrompter; prompter: WizardPrompter;
@@ -479,22 +534,30 @@ async function promptTelegramAllowFrom(params: {
const resolved = resolveTelegramAccount({ cfg, accountId }); const resolved = resolveTelegramAccount({ cfg, accountId });
const existingAllowFrom = resolved.config.allowFrom ?? []; const existingAllowFrom = resolved.config.allowFrom ?? [];
const entry = await prompter.text({ const entry = await prompter.text({
message: "Telegram allowFrom (user id)", message: "Telegram allowFrom (user id or @username)",
placeholder: "123456789", placeholder: "123456789, @myhandle",
initialValue: existingAllowFrom[0] initialValue: existingAllowFrom[0]
? String(existingAllowFrom[0]) ? String(existingAllowFrom[0])
: undefined, : undefined,
validate: (value) => { validate: (value) => {
const raw = String(value ?? "").trim(); const raw = String(value ?? "").trim();
if (!raw) return "Required"; const parsed = parseTelegramAllowFromEntries(raw);
if (!/^\d+$/.test(raw)) return "Use a numeric Telegram user id"; return parsed.error;
return undefined;
}, },
}); });
const normalized = String(entry).trim(); const parsed = parseTelegramAllowFromEntries(String(entry).trim());
if (parsed.hasUsernames) {
await prompter.note(
[
"Usernames can change; numeric user IDs are more stable.",
"Tip: DM the bot and it will reply with your user ID (pairing message).",
].join("\n"),
"Telegram allowFrom",
);
}
const merged = [ const merged = [
...existingAllowFrom.map((item) => String(item).trim()).filter(Boolean), ...existingAllowFrom.map((item) => String(item).trim()).filter(Boolean),
normalized, ...parsed.entries,
]; ];
const unique = [...new Set(merged)]; const unique = [...new Set(merged)];

View File

@@ -257,6 +257,7 @@ export class ClawdbotApp extends LitElement {
@state() telegramForm: TelegramForm = { @state() telegramForm: TelegramForm = {
token: "", token: "",
requireMention: true, requireMention: true,
groupsWildcardEnabled: false,
allowFrom: "", allowFrom: "",
proxy: "", proxy: "",
webhookUrl: "", webhookUrl: "",

View File

@@ -20,6 +20,7 @@ import {
const baseTelegramForm: TelegramForm = { const baseTelegramForm: TelegramForm = {
token: "", token: "",
requireMention: true, requireMention: true,
groupsWildcardEnabled: false,
allowFrom: "", allowFrom: "",
proxy: "", proxy: "",
webhookUrl: "", webhookUrl: "",

View File

@@ -129,6 +129,7 @@ export function applyConfigSnapshot(state: ConfigState, snapshot: ConfigSnapshot
telegramGroups["*"] && typeof telegramGroups["*"] === "object" telegramGroups["*"] && typeof telegramGroups["*"] === "object"
? (telegramGroups["*"] as Record<string, unknown>) ? (telegramGroups["*"] as Record<string, unknown>)
: {}; : {};
const telegramHasWildcard = Boolean(telegramGroups["*"]);
const allowFrom = Array.isArray(telegram.allowFrom) const allowFrom = Array.isArray(telegram.allowFrom)
? toList(telegram.allowFrom) ? toList(telegram.allowFrom)
: typeof telegram.allowFrom === "string" : typeof telegram.allowFrom === "string"
@@ -141,6 +142,7 @@ export function applyConfigSnapshot(state: ConfigState, snapshot: ConfigSnapshot
typeof telegramDefaultGroup.requireMention === "boolean" typeof telegramDefaultGroup.requireMention === "boolean"
? telegramDefaultGroup.requireMention ? telegramDefaultGroup.requireMention
: true, : true,
groupsWildcardEnabled: telegramHasWildcard,
allowFrom, allowFrom,
proxy: typeof telegram.proxy === "string" ? telegram.proxy : "", proxy: typeof telegram.proxy === "string" ? telegram.proxy : "",
webhookUrl: typeof telegram.webhookUrl === "string" ? telegram.webhookUrl : "", webhookUrl: typeof telegram.webhookUrl === "string" ? telegram.webhookUrl : "",

View File

@@ -181,6 +181,15 @@ export async function saveTelegramConfig(state: ConnectionsState) {
state.telegramSaving = true; state.telegramSaving = true;
state.telegramConfigStatus = null; state.telegramConfigStatus = null;
try { try {
if (state.telegramForm.groupsWildcardEnabled) {
const confirmed = window.confirm(
'Telegram groups wildcard "*" allows all groups. Continue?',
);
if (!confirmed) {
state.telegramConfigStatus = "Save cancelled.";
return;
}
}
const base = state.configSnapshot?.config ?? {}; const base = state.configSnapshot?.config ?? {};
const config = { ...base } as Record<string, unknown>; const config = { ...base } as Record<string, unknown>;
const telegram = { ...(config.telegram ?? {}) } as Record<string, unknown>; const telegram = { ...(config.telegram ?? {}) } as Record<string, unknown>;
@@ -196,16 +205,22 @@ export async function saveTelegramConfig(state: ConnectionsState) {
unknown unknown
>) >)
: {}; : {};
const defaultGroup = if (state.telegramForm.groupsWildcardEnabled) {
groups["*"] && typeof groups["*"] === "object" const defaultGroup =
? ({ ...(groups["*"] as Record<string, unknown>) } as Record< groups["*"] && typeof groups["*"] === "object"
string, ? ({ ...(groups["*"] as Record<string, unknown>) } as Record<
unknown string,
>) unknown
: {}; >)
defaultGroup.requireMention = state.telegramForm.requireMention; : {};
groups["*"] = defaultGroup; defaultGroup.requireMention = state.telegramForm.requireMention;
telegram.groups = groups; groups["*"] = defaultGroup;
telegram.groups = groups;
} else if (groups["*"]) {
delete groups["*"];
if (Object.keys(groups).length > 0) telegram.groups = groups;
else delete telegram.groups;
}
delete telegram.requireMention; delete telegram.requireMention;
const allowFrom = parseList(state.telegramForm.allowFrom); const allowFrom = parseList(state.telegramForm.allowFrom);
if (allowFrom.length > 0) telegram.allowFrom = allowFrom; if (allowFrom.length > 0) telegram.allowFrom = allowFrom;

View File

@@ -1,6 +1,7 @@
export type TelegramForm = { export type TelegramForm = {
token: string; token: string;
requireMention: boolean; requireMention: boolean;
groupsWildcardEnabled: boolean;
allowFrom: string; allowFrom: string;
proxy: string; proxy: string;
webhookUrl: string; webhookUrl: string;

View File

@@ -356,10 +356,25 @@ function renderProvider(
})} })}
/> />
</label> </label>
<label class="field">
<span>Apply default group rules</span>
<select
.value=${props.telegramForm.groupsWildcardEnabled ? "yes" : "no"}
@change=${(e: Event) =>
props.onTelegramChange({
groupsWildcardEnabled:
(e.target as HTMLSelectElement).value === "yes",
})}
>
<option value="no">No</option>
<option value="yes">Yes (allow all groups)</option>
</select>
</label>
<label class="field"> <label class="field">
<span>Require mention in groups</span> <span>Require mention in groups</span>
<select <select
.value=${props.telegramForm.requireMention ? "yes" : "no"} .value=${props.telegramForm.requireMention ? "yes" : "no"}
?disabled=${!props.telegramForm.groupsWildcardEnabled}
@change=${(e: Event) => @change=${(e: Event) =>
props.onTelegramChange({ props.onTelegramChange({
requireMention: (e.target as HTMLSelectElement).value === "yes", requireMention: (e.target as HTMLSelectElement).value === "yes",
@@ -377,7 +392,7 @@ function renderProvider(
props.onTelegramChange({ props.onTelegramChange({
allowFrom: (e.target as HTMLInputElement).value, allowFrom: (e.target as HTMLInputElement).value,
})} })}
placeholder="123456789, @team" placeholder="123456789, @team, tg:123"
/> />
</label> </label>
<label class="field"> <label class="field">
@@ -426,12 +441,33 @@ function renderProvider(
</label> </label>
</div> </div>
<div class="callout" style="margin-top: 12px;">
Allow from supports numeric user IDs (recommended) or @usernames. DM the bot
to get your ID, or run /whoami.
</div>
${props.telegramTokenLocked ${props.telegramTokenLocked
? html`<div class="callout" style="margin-top: 12px;"> ? html`<div class="callout" style="margin-top: 12px;">
TELEGRAM_BOT_TOKEN is set in the environment. Config edits will not override it. TELEGRAM_BOT_TOKEN is set in the environment. Config edits will not override it.
</div>` </div>`
: nothing} : nothing}
${props.telegramForm.groupsWildcardEnabled
? html`<div class="callout danger" style="margin-top: 12px;">
This writes telegram.groups["*"] and allows all groups. Remove it
if you only want specific groups.
<div class="row" style="margin-top: 8px;">
<button
class="btn"
@click=${() =>
props.onTelegramChange({ groupsWildcardEnabled: false })}
>
Remove wildcard
</button>
</div>
</div>`
: nothing}
${props.telegramStatus ${props.telegramStatus
? html`<div class="callout" style="margin-top: 12px;"> ? html`<div class="callout" style="margin-top: 12px;">
${props.telegramStatus} ${props.telegramStatus}