Asks in Claude before a Telegram message is sent: who it goes to and what it says.

A Claude Code mod: before Claude sends a Telegram message with tg-session send … --confirm, it asks you in Claude's own question dialog.
The question shows who the message goes to, from which account, the full text and any attachment. Отправить sends it; Отмена or closing the dialog cancels it, and Claude is told nothing was sent.
Pressing Отправить writes a one-time approval to ~/.claude/.tg-claude-approval: the account, recipient, text and attachment you approved. The sending tool checks that file instead of showing its own macOS dialog: it sends only if every field matches, deletes the file on first use, and ignores it after 2 minutes. The mod refuses any tool call that mentions that file, so Claude can't write the approval itself.
To use this with your own sender, read and delete the file before sending, and compare it with what you are about to send.
In Claude Code:
/plugin install blast-radius --marketplace sstani-bgv/claude-blast-radius
Started from Blast Radius in anthropics/claude-code-playground (Apache-2.0). See LICENSE.
hooks/blast-radius.mjs 102 lines1// Telegram send confirmation for Claude Code.
2//
3// A `tg-session send ... --confirm` call is held, and the user is asked in
4// Claude's own question dialog: who it goes to and what it says. "Отправить"
5// writes a one-time approval (account, target, text, media) that the sending
6// tool checks and deletes; anything else refuses the call.
7//
8// The host reads `on(...)` and `$.noun.method(...)` from source, so they are
9// spelled literally, and helpers that take `$` are top-level functions.
10
11// Nothing but this mod may touch the approval file.
12const APPROVAL_FILE = ".tg-claude-approval";
13const APPROVAL_SCRIPT = `umask 077; printf %s "$1" > "$HOME/.claude/${APPROVAL_FILE}"`;
14
15// One question at a time: a second send waits for the first answer.
16let queue = Promise.resolve();
17
18export function register(on) {
19 on("tool.call", async ($, e, next) => {
20 if (JSON.stringify(e).includes(APPROVAL_FILE)) {
21 return { deny: "Файл подтверждения Telegram записывается только ответом пользователя. Не трогай его." };
22 }
23 return next(e);
24 });
25
26 on("tool.call", { tool: "Bash" }, async ($, e, next) => {
27 const send = parseSend(String(e.command ?? ""));
28 if (send === null) {
29 return next(e);
30 }
31 const turn = queue.then(() => ask($, send));
32 queue = turn.catch(() => {});
33 let answer = null;
34 try {
35 answer = await turn;
36 } catch {
37 // dismissed, or nobody to ask
38 }
39 if (answer !== "Отправить") {
40 return { deny: "Пользователь не подтвердил отправку в Telegram, ничего не отправлено. Не повторяй без его просьбы." };
41 }
42 const approved = JSON.stringify({ account: send.account, target: send.target, text: send.text ?? null, media: send.media ?? null });
43 const run = await $.process.run(["bash", "-c", APPROVAL_SCRIPT, "tg-confirm", approved], { timeoutMs: 5000 });
44 if (run.exitCode !== 0) {
45 return { deny: "Не удалось записать подтверждение, ничего не отправлено." };
46 }
47 return next(e);
48 });
49}
50
51async function ask($, send) {
52 if (send.text === undefined && send.file !== undefined) {
53 const run = await $.process.run(["cat", "--", send.file], { cwd: await $.session.cwd(), timeoutMs: 5000 });
54 send.text = run.exitCode === 0 ? run.stdout : undefined;
55 }
56 const parts = [
57 "Отправить сообщение в Telegram?",
58 `Кому: ${send.target === "me" ? "Избранное" : send.target}\nС аккаунта: ${send.account}`,
59 send.text ?? (send.media ? null : "(не удалось прочитать текст)"),
60 send.media ? `Вложение: ${send.media}` : null,
61 ];
62 return $.ui.ask(parts.filter(Boolean).join("\n\n"), { header: "Telegram", options: ["Отмена", "Отправить"] });
63}
64
65/** `tg-session send ... --confirm`, or null. Read from the whole line so quoted text with ; or newlines stays intact. */
66function parseSend(command) {
67 if (!/tg-session|tg_user_session/.test(command) || !/--confirm\b/.test(command)) {
68 return null;
69 }
70 const words = tokenize(command);
71 const at = words.findIndex((w) => /(^|\/)tg-session$/.test(w) || w === "tg_user_session.cli");
72 if (at < 0 || words[at + 1] !== "send" || !words.includes("--confirm")) {
73 return null;
74 }
75 const args = words.slice(at + 2);
76 const opt = (name) => {
77 const i = args.indexOf(name);
78 if (i >= 0) return args[i + 1];
79 return args.find((a) => a.startsWith(`${name}=`))?.slice(name.length + 1);
80 };
81 const positional = [];
82 for (let i = 0; i < args.length; i += 1) {
83 if (args[i].startsWith("--")) {
84 if (!args[i].includes("=") && !["--confirm", "--yes", "--json"].includes(args[i])) i += 1;
85 } else if (positional.length < 2) {
86 positional.push(args[i]);
87 }
88 }
89 return { account: positional[0] ?? "?", target: positional[1] ?? "?", text: opt("--text"), file: opt("--file"), media: opt("--media") };
90}
91
92/** Splits a command into words, honouring quotes. */
93function tokenize(text) {
94 const words = [];
95 const re = /"((?:[^"\\]|\\.)*)"|'([^']*)'|(\S+)/g;
96 let m;
97 while ((m = re.exec(text)) !== null) {
98 words.push(m[1] !== undefined ? m[1].replace(/\\(.)/g, "$1") : (m[2] ?? m[3]));
99 }
100 return words;
101}
102