Meldet lange Aufträge per Telegram, schickt unbeantwortete Rückfragen mit Knöpfen aufs Handy und nimmt Antworten aus Telegram als Nachricht an.

Claude-Code-Mod: Telegram als Draht zum Handy. Du schickst einen Auftrag ab, stehst auf, und Claude meldet sich, wenn es fertig ist oder eine Frage hat. Antworten kannst du direkt in Telegram.
ioreg), kommt der Abschluss aufs Handy, egal wie lange der Auftrag lief. Sitzt du am Rechner, kommt nichts. Lange Antworten kommen als Anfang und Ende, formatiert (fett, Code, Links). Ist der Leerlauf nicht messbar (kein Mac), gilt: ab 10 min Laufzeit./remote-control). Dann geht es in der Claude-App unter Code weiter.AskUserQuestion 1 min unbeantwortet (bist du schon weg: sofort), kommt sie mit Knöpfen aufs Handy. Mehrfachauswahl: antippen, dann „Fertig“. Eigener Text: einfach antworten. Es gilt die erste Antwort, Rechner oder Handy; die andere Seite wird abgeräumt.Die Mod spricht Deutsch.
/plugin install telegram-draht --marketplace Savo2610/claude-mods
/newbot schicken, Namen wählen. Er antwortet mit einem Token wie 123456789:AAH….~/.claude/telegram.env schreiben, nicht in den Claude-Chat (sonst steht er im Verlauf): ``bash echo 'TELEGRAM_BOT_TOKEN=123456789:AAH…' > ~/.claude/telegram.env chmod 600 ~/.claude/telegram.env ``/telegram einrichten. Die Mod sucht deinen Chat, trägt TELEGRAM_CHAT_ID in die Datei ein und schickt eine Bestätigung.Danach /telegram test: Es kommt eine Probe-Meldung, auf die du antworten kannst.
Der Bot reagiert nur auf den eingetragenen Chat; schreibt ihm jemand anderes, passiert nichts.
/telegram zeigt den Zustand (auch, ob der Leerlauf messbar ist).
| Befehl | Wirkung | ||
|---|---|---|---|
| `/telegram weg <min>\ | aus` | Ab so viel Leerlauf giltst du als weg (Standard 1) | |
| `/telegram fertig <min>\ | aus` | Zusätzlich jeden Auftrag ab so viel Laufzeit melden, auch wenn du da bist (Standard aus) | |
| `/telegram fragen <sek>\ | sofort\ | aus` | Wann Rückfragen aufs Handy gehen (Standard 60 s; bist du weg: sofort) |
/telegram test | Probe-Meldung schicken | ||
/telegram einrichten | Chat zum Token suchen und eintragen |
bin/tg schickt einen beliebigen Text an denselben Chat, praktisch, um eine Meldung an einen langen Befehl zu hängen:
cp bin/tg ~/.claude/bin/tg # aus dem Plugin-Ordner
make build; ~/.claude/bin/tg "Build fertig (Exit $?)"
Eine Zeile in deiner ~/.claude/CLAUDE.md bringt Claude bei, das bei „informier mich, wenn es fertig ist“ selbst zu tun:
- "Informier mich (bei Abschluss)" heißt: `~/.claude/bin/tg "Text"`. Läuft danach noch ein Prozess, hänge die Meldung an ihn: `befehl; ~/.claude/bin/tg "fertig (Exit $?)"`.
Hat Claude in einem Zug selbst per ~/.claude/bin/tg gemeldet, schickt die Mod keine zweite Meldung.
Beide Mods lesen denselben Bot und dieselbe telegram.env. Jede lässt die Telegram-Updates der anderen eine Minute liegen, damit keine Antwort verloren geht.
claude plugin validate telegram-draht und claude plugin test telegram-draht. Die Plugin-API für Mods ist Early Access und kann sich ändern.
hooks/register.ts 824 lines1import type { EngineInterface, Register, ToolCallResult } from 'claude-code'
2
3const SEK = 1000
4const MIN = 60 * SEK
5// Wer so lange weder Maus noch Tastatur angefasst hat, ist weg: dann meldet sich
6// jeder Abschluss, und Rückfragen gehen sofort aufs Handy. Wer am Rechner sitzt,
7// bekommt nichts: weg = informiert, da = kein Spam. (Erst 3 min, im Alltag
8// waren 1 min besser.)
9export const WEG_AB_MIN = 1
10// Lässt sich der Leerlauf nicht messen, gilt wieder: ab 10 min Laufzeit melden.
11export const FERTIG_OHNE_MESSUNG_MIN = 10
12// Wer am Rechner sitzt, antwortet in einer Minute; erst danach geht die Rückfrage
13// aufs Handy. (Sofort war zu viel; 25 s waren im Alltag zu knapp.)
14export const FRAGEN_NACH_SEK = 60
15// Solange eine Rückfrage offen ist, alle 4 s nachsehen: ein Knopfdruck wirkt spürbar sofort.
16const POLL_MS = 4 * SEK
17// Auf eine Fertig-Meldung antwortet man selten sofort: da reichen 15 s.
18const FADEN_POLL_MS = 15 * SEK
19// Wie lange man auf eine Fertig-Meldung antworten kann.
20const FADEN_MS = 12 * 60 * MIN
21// Updates der anderen Mod (cache-waechter, „cw:“) bleiben so lange liegen, bis sie
22// sie abholt; danach gelten sie als verwaist und werden quittiert.
23const FREMD_GNADE_MS = MIN
24const HAENGT_MS = 20 * SEK
25const TEXT_MAX = 3500
26// Von einer langen Antwort: der Anfang (meist das Ergebnis in einem Satz) und
27// das Ende (was offen ist, Rückfragen); die Mitte sind Einzelheiten.
28const AUSZUG_KOPF = 700
29const AUSZUG_ENDE = 2600
30const RC_TOOL = 'mcp__ccd_session_mgmt__set_remote_control'
31// Ein Bash-Aufruf dieses Skripts heißt: Claude hat selbst gemeldet.
32const TG_SKRIPT = /(^|[\s;&|(])(~|\$HOME|\/Users\/[^/\s]+)\/\.claude\/bin\/tg\b/
33
34type TgZugang = { token: string; chat: string }
35type TgNachricht = { message_id: number; date?: number; chat?: { id?: number }; text?: string; reply_to_message?: { message_id: number } }
36export type TgUpdate = { update_id: number; message?: TgNachricht; callback_query?: { id: string; data?: string; message?: TgNachricht } }
37
38export type Einordnung =
39 | { art: 'knopf'; rid: string; teil: number; wahl: string; cq: string; msg?: number }
40 | { art: 'text'; text: string; antwortAuf?: number; msg: number; date: number }
41 /** Gehört einer anderen Mod: liegen lassen, bis sie es holt. */
42 | { art: 'fremd' }
43 /** Interessiert niemanden: quittieren. */
44 | { art: 'egal' }
45
46type Teil = {
47 frage: string
48 kopf: string
49 optionen: { label: string; description?: string }[]
50 mehrfach: boolean
51 gewaehlt: number[]
52 messageId: number
53 text: string
54 antwort?: string
55}
56type Rueckfrage = {
57 id: string
58 teile: Teil[]
59 gesendet: boolean
60 geschlossen: boolean
61 zeit: number
62 fertig: (antworten: Record<string, string>) => void
63}
64/** Eine Fertig-Meldung, auf die man antworten kann; `html`: mit parse_mode HTML geschickt. */
65type Faden = { messageId: number; bis: number; text: string; html?: boolean }
66/** Was eine Sitzung im Store zeigt, damit andere Sitzungen ihr Antworten zustellen können. */
67type Offen = { rids: string[]; msgs: number[]; frageZeit: number; faeden: Faden[]; at: number }
68type Zug = { start: number; ausTelegram: boolean; selbst: boolean }
69
70type Zustand = {
71 sid: string
72 projekt: string
73 zugang: TgZugang | null | undefined
74 /** Ab so vielen Minuten Leerlauf gilt man als weg; 0 = nie. */
75 wegAb: number
76 /** Zusätzlich: ab so vielen Minuten Laufzeit immer melden; 0 = aus. */
77 fertigAb: number
78 fragenNach: number
79 rueckfragen: Map<string, Rueckfrage>
80 faeden: Faden[]
81 zug: Zug | null
82 naechsterAusTelegram: boolean
83 offset: number
84 holtSeit: number
85 zuletztGeholt: number
86 gesehen: number[]
87 fremdSeit: Map<number, number>
88 tgFehler: string
89}
90
91function neuerZustand(): Zustand {
92 return {
93 sid: '', projekt: '', zugang: undefined, wegAb: WEG_AB_MIN, fertigAb: 0, fragenNach: FRAGEN_NACH_SEK,
94 rueckfragen: new Map(), faeden: [], zug: null, naechsterAusTelegram: false,
95 offset: 0, holtSeit: 0, zuletztGeholt: 0, gesehen: [], fremdSeit: new Map(), tgFehler: '',
96 }
97}
98
99function leseWerte(text: string): Record<string, string> {
100 const werte: Record<string, string> = {}
101 for (const zeile of text.split('\n')) {
102 const m = /^\s*(?:export\s+)?([A-Z_]+)=(.*)$/.exec(zeile)
103 if (m?.[1] && m[2] !== undefined) werte[m[1]] = m[2].trim().replace(/^(['"])(.*)\1$/, '$2')
104 }
105 return werte
106}
107
108/** Liest ~/.claude/telegram.env (dieselbe Datei wie cache-waechter und bin/tg). */
109export function leseZugang(text: string): TgZugang | null {
110 const werte = leseWerte(text)
111 const token = werte.TELEGRAM_BOT_TOKEN
112 const chat = werte.TELEGRAM_CHAT_ID
113 return token && chat ? { token, chat } : null
114}
115
116export function fmtDauer(ms: number): string {
117 if (ms < MIN) return 'unter 1 min'
118 const m = Math.floor(ms / MIN)
119 const std = Math.floor(m / 60)
120 return std ? `${std} h ${String(m % 60).padStart(2, '0')} min` : `${m} min`
121}
122
123export function kuerze(text: string, max = TEXT_MAX): string {
124 const t = text.trim()
125 return t.length <= max ? t : `${t.slice(0, max - 1).trimEnd()}…`
126}
127
128/** Auszug einer langen Antwort: Anfang und Ende, an Zeilen geschnitten, wo es geht. */
129export function auszug(text: string, kopf = AUSZUG_KOPF, ende = AUSZUG_ENDE): string {
130 const t = text.trim()
131 if (t.length <= kopf + ende + 100) return t
132 let a = t.slice(0, kopf)
133 const aZeile = a.lastIndexOf('\n')
134 if (aZeile > kopf * 0.5) a = a.slice(0, aZeile)
135 let b = t.slice(-ende)
136 const bZeile = b.indexOf('\n')
137 if (bZeile >= 0 && bZeile < ende * 0.3) b = b.slice(bZeile + 1)
138 return `${a.trimEnd()}\n\n[…]\n\n${b.trimStart()}`
139}
140
141export function escHtml(t: string): string {
142 return t.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
143}
144
145/**
146 * Claudes Markdown in das HTML, das Telegram kann: fett, Code, Links,
147 * Überschriften als fett, Listen mit •. Tabellen und alles andere bleiben Text.
148 */
149export function alsHtml(md: string): string {
150 const fest: string[] = []
151 const merke = (html: string) => `\u0000${fest.push(html) - 1}\u0000`
152 let s = md.replace(/```[^\n]*\n([\s\S]*?)```/g, (_, code: string) => merke(`<pre>${escHtml(code.trimEnd())}</pre>`))
153 s = s.replace(/`([^`\n]+)`/g, (_, code: string) => merke(`<code>${escHtml(code)}</code>`))
154 s = escHtml(s)
155 s = s.replace(/\[([^\]\n]+)\]\((https?:\/\/[^)\s]+)\)/g, (_, t: string, u: string) => `<a href="${u.replace(/"/g, '"')}">${t}</a>`)
156 s = s.replace(/\*\*([^*\n]+)\*\*/g, '<b>$1</b>')
157 s = s.replace(/^#{1,6}\s+(.+)$/gm, '<b>$1</b>')
158 s = s.replace(/^(\s*)[-*]\s+/gm, '$1• ')
159 return s.replace(/\u0000(\d+)\u0000/g, (_, i: string) => fest[Number(i)] ?? '')
160}
161
162/** Leerlauf aus `ioreg -k HIDIdleTime` (Nanosekunden) in ms; null = nicht lesbar. */
163export function leseLeerlauf(ausgabe: string): number | null {
164 const m = /"HIDIdleTime"\s*=\s*(\d+)/.exec(ausgabe)
165 return m?.[1] ? Number(m[1]) / 1e6 : null
166}
167
168/** Wie lange Maus und Tastatur unberührt sind (macOS); null = unbekannt. */
169async function leerlaufMs($: EngineInterface): Promise<number | null> {
170 try {
171 const r = await $.process.run(['/usr/sbin/ioreg', '-r', '-k', 'HIDIdleTime', '-d', '1'], { timeoutMs: 5000 })
172 return r.exitCode === 0 ? leseLeerlauf(r.stdout) : null
173 } catch {
174 // Kein $.process (außerhalb der CLI) oder kein ioreg (kein Mac).
175 return null
176 }
177}
178
179/** Ob man weg ist; ohne Messung zählt die Laufzeit (ab 10 min). */
180export function istWeg(leer: number | null, wegAbMin: number, dauerMs: number): boolean {
181 if (leer === null) return dauerMs >= FERTIG_OHNE_MESSUNG_MIN * MIN
182 return wegAbMin > 0 && leer >= wegAbMin * MIN
183}
184
185export function einordnen(u: TgUpdate, chat: string): Einordnung {
186 const cq = u.callback_query
187 if (cq) {
188 if (String(cq.message?.chat?.id) !== chat) return { art: 'egal' }
189 const data = cq.data ?? ''
190 if (data.startsWith('td:')) {
191 const [, rid = '', teil = '', wahl = ''] = data.split(':')
192 return { art: 'knopf', rid, teil: Number(teil), wahl, cq: cq.id, msg: cq.message?.message_id }
193 }
194 return data.startsWith('cw:') ? { art: 'fremd' } : { art: 'egal' }
195 }
196 const m = u.message
197 if (!m || String(m.chat?.id) !== chat || !m.text || m.text.startsWith('/')) return { art: 'egal' }
198 return { art: 'text', text: m.text, antwortAuf: m.reply_to_message?.message_id, msg: m.message_id, date: (m.date ?? 0) * SEK }
199}
200
201/** Welche Sitzung ein Knopfdruck oder Text meint; null = keine. */
202export function zielSitzung(ein: Einordnung, offen: Map<string, Offen>): string | null {
203 if (ein.art === 'knopf') {
204 for (const [sid, o] of offen) {
205 if (ein.rid === 'rc' ? ein.msg !== undefined && o.msgs.includes(ein.msg) : o.rids.includes(ein.rid)) return sid
206 }
207 return null
208 }
209 if (ein.art !== 'text') return null
210 if (ein.antwortAuf !== undefined) {
211 for (const [sid, o] of offen) if (o.msgs.includes(ein.antwortAuf)) return sid
212 return null
213 }
214 // Ohne „Antworten auf“: an die jüngste offene Rückfrage, nie einfach an eine Sitzung.
215 let best: string | null = null
216 let zeit = 0
217 for (const [sid, o] of offen) if (o.frageZeit > zeit) { zeit = o.frageZeit; best = sid }
218 return best
219}
220
221function kennung(): string {
222 const b = new Uint8Array(4)
223 crypto.getRandomValues(b)
224 return [...b].map(x => x.toString(16).padStart(2, '0')).join('')
225}
226
227async function zugangLesen($: EngineInterface, z: Zustand): Promise<TgZugang | null> {
228 if (z.zugang !== undefined) return z.zugang
229 const home = await $.env.get('HOME')
230 try {
231 z.zugang = home ? leseZugang(await $.fs.read(`${home}/.claude/telegram.env`)) : null
232 } catch {
233 z.zugang = null
234 }
235 return z.zugang
236}
237
238// Die Fehlermeldung nennt nie den Bot-Token: er steckt in der URL.
239async function tg($: EngineInterface, z: Zustand, methode: string, body: object): Promise<unknown> {
240 const zg = await zugangLesen($, z)
241 if (!zg) throw new Error('keine Zugangsdaten in ~/.claude/telegram.env')
242 let r
243 try {
244 r = await $.http.fetch(`https://api.telegram.org/bot${zg.token}/${methode}`, {
245 method: 'POST',
246 headers: { 'content-type': 'application/json' },
247 body: JSON.stringify(body),
248 })
249 } catch (err) {
250 const text = (err instanceof Error ? err.message : String(err)).split(zg.token).join('…')
251 throw new Error(`Telegram ${methode}: nicht erreichbar (${text})`)
252 }
253 let d: { ok?: boolean; result?: unknown; description?: string } | null = null
254 try { d = JSON.parse(r.text) } catch { /* bleibt null */ }
255 if (!d?.ok) throw new Error(`Telegram ${methode}: ${r.status} ${d?.description ?? ''}`.trim())
256 z.tgFehler = ''
257 return d.result
258}
259
260function merkeFehler($: EngineInterface, z: Zustand, err: unknown) {
261 const text = err instanceof Error ? err.message : String(err)
262 if (text !== z.tgFehler) $.ui.log(`telegram-draht: ${text}`, { to: 'debug' })
263 z.tgFehler = text
264}
265
266async function tgLeise($: EngineInterface, z: Zustand, methode: string, body: object) {
267 try { await tg($, z, methode, body) } catch (err) { merkeFehler($, z, err) }
268}
269
270// Fürs Aufräumen: „message is not modified“, „query is too old“ sind erwartbar.
271async function tgStill($: EngineInterface, z: Zustand, methode: string, body: object) {
272 try { await tg($, z, methode, body) } catch { /* bewusst still */ }
273}
274
275// ---------- Was diese Sitzung offen hat, für alle Sitzungen sichtbar ----------
276
277async function speichereOffen($: EngineInterface, z: Zustand) {
278 z.sid = await $.session.id()
279 const now = await $.clock.now()
280 z.faeden = z.faeden.filter(f => f.bis > now)
281 const gesendet = [...z.rueckfragen.values()].filter(r => r.gesendet && !r.geschlossen)
282 const o: Offen = {
283 rids: gesendet.map(r => r.id),
284 msgs: [...gesendet.flatMap(r => r.teile.map(t => t.messageId)), ...z.faeden.map(f => f.messageId)],
285 frageZeit: Math.max(0, ...gesendet.map(r => r.zeit)),
286 faeden: z.faeden,
287 at: now,
288 }
289 const key = `offen:${z.sid}`
290 if (o.msgs.length) await $.store.set(key, o)
291 else await $.store.delete(key)
292}
293
294async function offeneSitzungen($: EngineInterface, now: number): Promise<Map<string, Offen>> {
295 const m = new Map<string, Offen>()
296 for (const key of await $.store.keys()) {
297 if (!key.startsWith('offen:')) continue
298 const o = await $.store.get(key) as Offen | undefined
299 if (o && now - o.at < FADEN_MS + MIN) m.set(key.slice('offen:'.length), o)
300 }
301 return m
302}
303
304function hatOffenes(z: Zustand): 'frage' | 'faden' | null {
305 for (const r of z.rueckfragen.values()) if (r.gesendet && !r.geschlossen) return 'frage'
306 return z.faeden.length ? 'faden' : null
307}
308
309// ---------- Rückfragen ----------
310
311function teilText(z: Zustand, t: Teil, nr: number, von: number): string {
312 const kopf = `❓ Claude fragt${von > 1 ? ` (${nr}/${von})` : ''} · 📁 ${z.projekt || 'Claude Code'}`
313 const optionen = t.optionen.map((o, i) => `${i + 1}. ${o.label}${o.description ? ` – ${o.description}` : ''}`)
314 const hinweis = t.mehrfach
315 ? 'Mehrere antippen, dann „Fertig“. Oder mit eigenem Text antworten.'
316 : 'Antippen oder mit eigenem Text antworten.'
317 return kuerze([kopf, '', `${t.kopf}: ${t.frage}`, '', ...optionen, '', hinweis].join('\n'))
318}
319
320function tastatur(rid: string, nr: number, t: Teil) {
321 const reihen = t.optionen.map((o, i) => [{
322 text: `${t.mehrfach ? (t.gewaehlt.includes(i) ? '☑️ ' : '☐ ') : ''}${o.label}`.slice(0, 60),
323 callback_data: `td:${rid}:${nr}:${i}`,
324 }])
325 if (t.mehrfach) reihen.push([{ text: '✔️ Fertig', callback_data: `td:${rid}:${nr}:ok` }])
326 return { inline_keyboard: reihen }
327}
328
329async function sendeRueckfrage($: EngineInterface, z: Zustand, rf: Rueckfrage) {
330 if (rf.gesendet || rf.geschlossen) return
331 const zg = await zugangLesen($, z)
332 if (!zg) return
333 try {
334 for (const [nr, t] of rf.teile.entries()) {
335 t.text = teilText(z, t, nr + 1, rf.teile.length)
336 const msg = await tg($, z, 'sendMessage', { chat_id: zg.chat, text: t.text, reply_markup: tastatur(rf.id, nr, t) }) as { message_id: number }
337 t.messageId = msg.message_id
338 }
339 rf.gesendet = true
340 rf.zeit = await $.clock.now()
341 // Inzwischen am Rechner beantwortet: die eben geschickten Knöpfe gleich wieder abräumen.
342 if (rf.geschlossen) {
343 for (const t of rf.teile) if (t.messageId) await tgStill($, z, 'editMessageText', { chat_id: zg.chat, message_id: t.messageId, text: `${t.text}\n\n💻 Am Rechner beantwortet.` })
344 return
345 }
346 await speichereOffen($, z)
347 } catch (err) {
348 merkeFehler($, z, err)
349 $.ui.toast('Telegram: Rückfrage ging nicht raus')
350 }
351}
352
353async function schliesseRueckfrage($: EngineInterface, z: Zustand, rf: Rueckfrage, zusatz: (t: Teil) => string) {
354 if (rf.geschlossen) return
355 rf.geschlossen = true
356 const zg = await zugangLesen($, z)
357 if (zg && rf.gesendet) {
358 for (const t of rf.teile) {
359 if (t.messageId) await tgStill($, z, 'editMessageText', { chat_id: zg.chat, message_id: t.messageId, text: `${t.text}\n\n${zusatz(t)}` })
360 }
361 }
362 await speichereOffen($, z)
363}
364
365function antwortenAus(r: ToolCallResult<'AskUserQuestion'>): Record<string, string> {
366 if (!('result' in r) || !r.result || typeof r.result !== 'object') return {}
367 const a = (r.result as { answers?: unknown }).answers
368 return a && typeof a === 'object' ? a as Record<string, string> : {}
369}
370
371async function pruefeFertig(rf: Rueckfrage) {
372 if (rf.geschlossen || !rf.teile.every(t => t.antwort !== undefined)) return
373 rf.fertig(Object.fromEntries(rf.teile.map(t => [t.frage, t.antwort ?? ''])))
374}
375
376// ---------- Fertig-Meldungen und Antworten darauf ----------
377
378async function meldeFertig($: EngineInterface, z: Zustand, kopf: string, antwort: string) {
379 const zg = await zugangLesen($, z)
380 if (!zg) return
381 const rumpf = auszug(antwort || '(keine Textantwort)')
382 const fuss = '↩️ Antworte hierauf, um Claude weiterzuschreiben.'
383 const knopf = { inline_keyboard: [[{ text: '📱 In der App weitermachen', callback_data: 'td:rc:0:0' }]] }
384 try {
385 let html = true
386 let text = [`<b>${escHtml(kopf)}</b>`, '', alsHtml(rumpf), '', escHtml(fuss)].join('\n')
387 let msg: { message_id: number }
388 try {
389 msg = await tg($, z, 'sendMessage', { chat_id: zg.chat, text, parse_mode: 'HTML', reply_markup: knopf }) as { message_id: number }
390 } catch (err) {
391 // Telegram lehnt kaputtes HTML ab („can't parse entities“): dann als reinen Text.
392 if (!/parse entities|too long/i.test(err instanceof Error ? err.message : '')) throw err
393 html = false
394 text = kuerze([kopf, '', rumpf, '', fuss].join('\n'), 4000)
395 msg = await tg($, z, 'sendMessage', { chat_id: zg.chat, text, reply_markup: knopf }) as { message_id: number }
396 }
397 z.faeden = [...z.faeden.slice(-4), { messageId: msg.message_id, bis: (await $.clock.now()) + FADEN_MS, text, html }]
398 await speichereOffen($, z)
399 } catch (err) {
400 merkeFehler($, z, err)
401 $.ui.toast('Telegram: Fertig-Meldung ging nicht raus')
402 }
403}
404
405async function schliesseFaeden($: EngineInterface, z: Zustand, zusatz: string) {
406 if (!z.faeden.length) return
407 const zg = await zugangLesen($, z)
408 const alt = z.faeden
409 z.faeden = []
410 await speichereOffen($, z)
411 if (!zg) return
412 for (const f of alt) {
413 await tgStill($, z, 'editMessageText', f.html
414 ? { chat_id: zg.chat, message_id: f.messageId, text: `${f.text}\n\n${escHtml(zusatz)}`, parse_mode: 'HTML' }
415 : { chat_id: zg.chat, message_id: f.messageId, text: `${f.text}\n\n${zusatz}` })
416 }
417}
418
419// ---------- Was aus Telegram kommt ----------
420
421async function verarbeite($: EngineInterface, z: Zustand, ein: Einordnung) {
422 const zg = await zugangLesen($, z)
423 if (!zg) return
424 if (ein.art === 'knopf' && ein.rid === 'rc') return fernsteuerung($, z, zg, ein.cq, ein.msg)
425 if (ein.art === 'knopf') {
426 const rf = z.rueckfragen.get(ein.rid)
427 const t = rf?.teile[ein.teil]
428 if (!rf || !t || rf.geschlossen || t.antwort !== undefined) {
429 await tgStill($, z, 'answerCallbackQuery', { callback_query_id: ein.cq, text: 'Schon erledigt.' })
430 return
431 }
432 if (ein.wahl === 'ok') {
433 if (!t.gewaehlt.length) {
434 await tgStill($, z, 'answerCallbackQuery', { callback_query_id: ein.cq, text: 'Erst etwas auswählen.' })
435 return
436 }
437 t.antwort = t.gewaehlt.slice().sort((a, b) => a - b).map(i => t.optionen[i]?.label ?? '').join(', ')
438 } else {
439 const i = Number(ein.wahl)
440 const o = t.optionen[i]
441 if (!o) return void tgStill($, z, 'answerCallbackQuery', { callback_query_id: ein.cq })
442 if (t.mehrfach) {
443 t.gewaehlt = t.gewaehlt.includes(i) ? t.gewaehlt.filter(x => x !== i) : [...t.gewaehlt, i]
444 await tgStill($, z, 'answerCallbackQuery', { callback_query_id: ein.cq })
445 await tgStill($, z, 'editMessageReplyMarkup', { chat_id: zg.chat, message_id: t.messageId, reply_markup: tastatur(rf.id, ein.teil, t) })
446 return
447 }
448 t.antwort = o.label
449 }
450 await tgStill($, z, 'answerCallbackQuery', { callback_query_id: ein.cq, text: '👍' })
451 await tgStill($, z, 'editMessageText', { chat_id: zg.chat, message_id: t.messageId, text: `${t.text}\n\n✅ ${t.antwort}` })
452 return pruefeFertig(rf)
453 }
454 if (ein.art !== 'text') return
455 // Antwort auf eine Rückfrage (oder ohne Bezug: an die jüngste offene).
456 const offene = [...z.rueckfragen.values()].filter(r => r.gesendet && !r.geschlossen).sort((a, b) => b.zeit - a.zeit)
457 for (const rf of offene) {
458 const t = ein.antwortAuf !== undefined
459 ? rf.teile.find(x => x.messageId === ein.antwortAuf)
460 : rf.teile.find(x => x.antwort === undefined)
461 if (!t) continue
462 if (t.antwort !== undefined) return
463 t.antwort = ein.text
464 await tgStill($, z, 'editMessageText', { chat_id: zg.chat, message_id: t.messageId, text: `${t.text}\n\n✏️ ${kuerze(ein.text, 300)}` })
465 return pruefeFertig(rf)
466 }
467 // Antwort auf eine Fertig-Meldung: als neue Nachricht in die Sitzung.
468 const f = z.faeden.find(x => x.messageId === ein.antwortAuf)
469 if (!f) return
470 z.faeden = z.faeden.filter(x => x !== f)
471 await speichereOffen($, z)
472 z.naechsterAusTelegram = true
473 await $.prompt.submit({ text: ein.text, asUser: true })
474 await tgStill($, z, 'sendMessage', { chat_id: zg.chat, text: '📨 Ist bei Claude angekommen.', reply_to_message_id: ein.msg })
475}
476
477/**
478 * Schaltet Remote Control für diese Sitzung ein, damit es in der Claude-App
479 * weitergeht. In der Desktop-App über ihr Werkzeug (ohne Modell, ohne Tokens),
480 * im Terminal über den Befehl /remote-control.
481 */
482async function fernsteuerung($: EngineInterface, z: Zustand, zg: TgZugang, cq: string, msg?: number) {
483 await tgStill($, z, 'answerCallbackQuery', { callback_query_id: cq, text: 'Wird eingeschaltet …' })
484 let text: string
485 try {
486 const r = await $.tool.call({ tool: RC_TOOL, session_id: 'self', enabled: true })
487 if (r.deny !== undefined) text = `⚠️ Remote Control ging nicht: ${r.deny}`
488 else if (r.isError) text = `⚠️ Remote Control ging nicht: ${r.text ?? 'Fehler'}`
489 else {
490 const stand = /\b(on|connecting|unavailable|off)\b/.exec(r.text ?? '')?.[1] ?? 'on'
491 text = stand === 'unavailable' || stand === 'off'
492 ? `⚠️ Remote Control ist für diese Sitzung nicht verfügbar (${stand}).`
493 : `📱 Remote Control ${stand === 'connecting' ? 'verbindet sich gerade' : 'ist an'}. Öffne die Claude-App → Code → Sitzung in „${z.projekt || 'Claude Code'}“.`
494 }
495 } catch {
496 try {
497 await $.command.run({ command: 'remote-control' })
498 text = `📱 Remote Control wird gestartet. Öffne gleich die Claude-App → Code → Sitzung in „${z.projekt || 'Claude Code'}“.`
499 } catch (err) {
500 text = `⚠️ Remote Control ließ sich hier nicht starten: ${err instanceof Error ? err.message : String(err)}`
501 }
502 }
503 await tgStill($, z, 'sendMessage', { chat_id: zg.chat, text, ...(msg ? { reply_to_message_id: msg } : {}) })
504}
505
506/** Ob ein fremdes Update lange genug lag, dass seine Mod es offenbar nicht mehr holt. */
507function verwaist(z: Zustand, id: number, now: number): boolean {
508 const seit = z.fremdSeit.get(id)
509 if (seit === undefined) {
510 z.fremdSeit.set(id, now)
511 return false
512 }
513 return now - seit >= FREMD_GNADE_MS
514}
515
516// Kurzes Abfragen statt Long-Polling (zwei Wartende wirft Telegram mit 409 raus).
517// Der Offset rückt nie über ein Update hinaus, das noch jemandem gehört: die
518// cache-waechter-Mod liest denselben Bot. Antworten für andere Sitzungen dieser
519// Mod landen unter post:<sitzung> im Store.
520async function hole($: EngineInterface, z: Zustand) {
521 const jetzt = await $.clock.now()
522 if (z.holtSeit && jetzt - z.holtSeit < HAENGT_MS) return
523 z.holtSeit = jetzt
524 z.zuletztGeholt = jetzt
525 try {
526 const zg = await zugangLesen($, z)
527 if (!zg) return
528 z.sid = await $.session.id()
529 await postAbholen($, z)
530 const gemeinsam = await $.store.get('tg:offset')
531 z.offset = Math.max(z.offset, typeof gemeinsam === 'number' ? gemeinsam : 0)
532 const updates = await tg($, z, 'getUpdates', { offset: z.offset, timeout: 0, allowed_updates: ['message', 'callback_query'] }) as TgUpdate[]
533 if (!updates.length) return
534 const erledigt = (await $.store.get('tg:erledigt') as number[] | undefined) ?? []
535 const offen = await offeneSitzungen($, jetzt)
536 let neu = z.offset
537 let frei = true
538 for (const u of updates) {
539 const ein = einordnen(u, zg.chat)
540 let weg = true
541 if (ein.art === 'fremd') weg = verwaist(z, u.update_id, jetzt)
542 else if ((ein.art === 'knopf' || ein.art === 'text') && !erledigt.includes(u.update_id)) {
543 erledigt.push(u.update_id)
544 const ziel = zielSitzung(ein, offen)
545 if (ziel === z.sid) await verarbeite($, z, ein)
546 else if (ziel) {
547 const liste = (await $.store.get(`post:${ziel}`) as TgUpdate[] | undefined) ?? []
548 await $.store.set(`post:${ziel}`, [...liste, u])
549 } else if (ein.art === 'knopf') {
550 await tgStill($, z, 'answerCallbackQuery', { callback_query_id: ein.cq, text: 'Diese Frage ist schon erledigt.' })
551 if (ein.msg) await tgStill($, z, 'editMessageReplyMarkup', { chat_id: zg.chat, message_id: ein.msg, reply_markup: { inline_keyboard: [] } })
552 } else if (jetzt - ein.date < 10 * MIN) {
553 await tgStill($, z, 'sendMessage', {
554 chat_id: zg.chat, reply_to_message_id: ein.msg,
555 text: '🤷 Das konnte ich keiner offenen Sitzung zuordnen. Antworte direkt auf eine Fertig-Meldung oder Rückfrage.',
556 })
557 }
558 }
559 if (weg && frei) neu = u.update_id + 1
560 if (!weg) frei = false
561 }
562 await $.store.set('tg:erledigt', erledigt.slice(-100))
563 if (neu > z.offset) {
564 z.offset = neu
565 await $.store.set('tg:offset', neu)
566 }
567 for (const id of z.fremdSeit.keys()) if (id < z.offset) z.fremdSeit.delete(id)
568 } catch (err) {
569 merkeFehler($, z, err)
570 } finally {
571 z.holtSeit = 0
572 }
573}
574
575async function postAbholen($: EngineInterface, z: Zustand) {
576 const key = `post:${z.sid}`
577 const liste = await $.store.get(key) as TgUpdate[] | undefined
578 if (!liste?.length) return
579 await $.store.delete(key)
580 const zg = await zugangLesen($, z)
581 if (!zg) return
582 for (const u of liste) {
583 if (z.gesehen.includes(u.update_id)) continue
584 z.gesehen = [...z.gesehen.slice(-49), u.update_id]
585 await verarbeite($, z, einordnen(u, zg.chat))
586 }
587}
588
589async function aufraeumen($: EngineInterface, now: number) {
590 for (const key of await $.store.keys()) {
591 if (key.startsWith('offen:')) {
592 const o = await $.store.get(key) as Offen | undefined
593 if (!o || now - o.at > FADEN_MS + MIN) await $.store.delete(key)
594 } else if (key.startsWith('post:')) {
595 // Post für Sitzungen, die nichts mehr offen haben.
596 if (!(await $.store.get(`offen:${key.slice('post:'.length)}`))) await $.store.delete(key)
597 }
598 }
599}
600
601// ---------- /telegram ----------
602
603const ANLEITUNG = [
604 'Einrichten in drei Schritten:',
605 '1. In Telegram @BotFather öffnen, /newbot schicken, Namen wählen. Er antwortet mit einem Token.',
606 '2. Den Token in die Datei ~/.claude/telegram.env schreiben (eine Zeile, nicht hier in den Chat):',
607 ' TELEGRAM_BOT_TOKEN=123456:ABC…',
608 '3. Deinem neuen Bot in Telegram eine Nachricht schicken (z. B. „hallo“), dann /telegram einrichten.',
609].join('\n')
610
611/**
612 * Sucht zum Token in telegram.env den Chat: den, aus dem zuletzt jemand dem Bot
613 * geschrieben hat, also den eigenen, direkt vor dem Einrichten. Der Token kommt
614 * nie über den Chat, damit er nicht im Verlauf landet.
615 */
616async function einrichten($: EngineInterface, z: Zustand): Promise<string> {
617 const home = await $.env.get('HOME')
618 if (!home) return 'HOME ist nicht gesetzt.'
619 const pfad = `${home}/.claude/telegram.env`
620 let roh = ''
621 try { roh = await $.fs.read(pfad) } catch { /* gibt es noch nicht */ }
622 const werte = leseWerte(roh)
623 const token = werte.TELEGRAM_BOT_TOKEN
624 if (!token) return `In ${pfad} steht noch kein TELEGRAM_BOT_TOKEN.\n\n${ANLEITUNG}`
625 z.zugang = { token, chat: werte.TELEGRAM_CHAT_ID ?? '' }
626 try {
627 const bot = await tg($, z, 'getMe', {}) as { username?: string }
628 const name = bot.username ? `@${bot.username}` : 'deinem Bot'
629 const updates = await tg($, z, 'getUpdates', { offset: -20, timeout: 0, allowed_updates: ['message'] }) as TgUpdate[]
630 const chat = updates.map(u => u.message?.chat?.id).filter((x): x is number => typeof x === 'number').at(-1)
631 if (chat === undefined) {
632 z.zugang = werte.TELEGRAM_CHAT_ID ? z.zugang : null
633 return `Token ok (${name}), aber noch keine Nachricht an ihn gefunden. Schick ${name} in Telegram eine Nachricht und führe /telegram einrichten noch einmal aus.`
634 }
635 const zeilen = roh.split('\n').filter(l => l.trim() && !/^\s*(?:export\s+)?TELEGRAM_CHAT_ID=/.test(l))
636 await $.fs.write(pfad, [...zeilen, `TELEGRAM_CHAT_ID=${chat}`, ''].join('\n'))
637 z.zugang = { token, chat: String(chat) }
638 // Was beim Suchen gelesen wurde, gilt als erledigt: sonst käme „hallo“ später als Antwort an.
639 const letzte = updates.at(-1)?.update_id
640 if (letzte !== undefined) {
641 z.offset = Math.max(z.offset, letzte + 1)
642 await $.store.set('tg:offset', z.offset)
643 }
644 await tg($, z, 'sendMessage', { chat_id: chat, text: '✅ Claude Code ist verbunden. Hier kommen ab jetzt Fertig-Meldungen und Rückfragen an.' })
645 return `Verbunden mit ${name}, Chat ${chat} steht in ${pfad}. In Telegram sollte eine Bestätigung angekommen sein.`
646 } catch (err) {
647 z.zugang = undefined
648 return `Einrichten ging nicht: ${err instanceof Error ? err.message : String(err)}`
649 }
650}
651
652function karte(z: Zustand, leer: number | null): string {
653 const zugang = z.zugang === null ? '⚠️ noch nicht eingerichtet: /telegram einrichten' : 'ok'
654 const offen = [...z.rueckfragen.values()].filter(r => r.gesendet && !r.geschlossen).length
655 return [
656 'telegram-draht',
657 `Weg ab ${z.wegAb > 0 ? `${z.wegAb} min ohne Maus und Tastatur: dann kommt jeder Abschluss aufs Handy` : 'aus'}`,
658 `Immer ab ${z.fertigAb > 0 ? `${z.fertigAb} min Laufzeit, auch wenn du da bist` : 'aus'}`,
659 `Rückfragen ${z.fragenNach < 0 ? 'aus' : z.fragenNach === 0 ? 'sofort aufs Handy' : `aufs Handy nach ${z.fragenNach} s ohne Antwort, sofort wenn du weg bist`}`,
660 `Offen ${offen} Rückfrage(n), ${z.faeden.length} Fertig-Meldung(en) zum Antworten`,
661 `Leerlauf ${leer === null ? 'nicht messbar, daher: Meldung ab 10 min Laufzeit' : `jetzt ${fmtDauer(leer)} ohne Maus und Tastatur`}`,
662 `Zugang ${zugang}`,
663 ...(z.tgFehler ? [`Letzter Fehler ${z.tgFehler}`] : []),
664 '',
665 '/telegram weg <min>|aus · fertig <min>|aus · fragen <sek>|sofort|aus · test · einrichten',
666 ].join('\n')
667}
668
669export const register: Register = on => {
670 const z = neuerZustand()
671
672 on('session.start', async ($, e, next) => {
673 const r = await next(e)
674 const now = await $.clock.now()
675 z.projekt = e.cwd.replace(/\/+$/, '').split('/').pop() ?? ''
676 z.sid = await $.session.id()
677 const w = await $.store.get('wegAb')
678 if (typeof w === 'number') z.wegAb = w
679 const f = await $.store.get('fertigAb')
680 if (typeof f === 'number') z.fertigAb = f
681 const q = await $.store.get('fragenNach')
682 if (typeof q === 'number') z.fragenNach = q
683 // Nach dem Neuladen der Mod: offene Fertig-Meldungen gelten weiter.
684 const o = await $.store.get(`offen:${z.sid}`) as Offen | undefined
685 if (o?.faeden) z.faeden = o.faeden.filter(x => x.bis > now)
686 await aufraeumen($, now)
687 await $.command.register({
688 name: 'telegram',
689 description: 'Telegram: Fertig-Meldungen und Rückfragen aufs Handy (telegram-draht)',
690 argumentHint: '[weg <min>|aus | fertig <min>|aus | fragen <sek>|sofort|aus | test | einrichten]',
691 immediate: true,
692 })
693 $.clock.every(POLL_MS, () => {
694 const was = hatOffenes(z)
695 if (!was) return
696 void $.clock.now().then(t => {
697 if (was === 'frage' || t - z.zuletztGeholt >= FADEN_POLL_MS) void hole($, z)
698 })
699 })
700 return r
701 })
702
703 on('session.end', async ($, e, next) => {
704 for (const rf of z.rueckfragen.values()) await schliesseRueckfrage($, z, rf, () => '👋 Sitzung beendet.')
705 await schliesseFaeden($, z,
706 e.reason === 'clear' ? '🧹 Sitzung mit /clear geleert, Antworten gehen nicht mehr.'
707 : e.reason === 'resume' ? '↪ Eine andere Sitzung wurde geöffnet.'
708 : '👋 Sitzung beendet, Antworten gehen nicht mehr.')
709 return next(e)
710 })
711
712 on('prompt.submit', async ($, e, next) => {
713 if (e.origin.kind === 'plugin' && e.origin.name === 'telegram-draht') z.naechsterAusTelegram = true
714 return next(e)
715 })
716
717 // turn.start kommt nur für den Hauptverlauf.
718 on('turn.start', async ($, e, next) => {
719 const ausTelegram = z.naechsterAusTelegram
720 z.naechsterAusTelegram = false
721 z.zug = { start: await $.clock.now(), ausTelegram, selbst: false }
722 if (!ausTelegram) await schliesseFaeden($, z, '💬 Du hast in der Sitzung weitergeschrieben.')
723 return next(e)
724 })
725
726 // Hat Claude selbst per ~/.claude/bin/tg gemeldet, kommt keine zweite Meldung.
727 // Im Hintergrund angehängt (befehl; tg …) zählt nicht: das meldet erst später.
728 on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
729 if (e.tool === 'Bash' && !e.agentId && z.zug && !e.run_in_background && TG_SKRIPT.test(e.command) && !/nohup|&\s*$/.test(e.command)) z.zug.selbst = true
730 return next(e)
731 })
732
733 on('turn.complete', async ($, e, next) => {
734 const r = await next(e)
735 if (e.agentId) return r
736 const zug = z.zug
737 z.zug = null
738 if (!zug || e.reason === 'aborted') return r
739 const weg = istWeg(await leerlaufMs($), z.wegAb, e.durationMs)
740 const lang = z.fertigAb > 0 && e.durationMs >= z.fertigAb * MIN
741 if (!zug.ausTelegram && (!(weg || lang) || zug.selbst)) return r
742 const ort = `📁 ${z.projekt || 'Claude Code'}`
743 const kopf = e.reason === 'answer'
744 ? (zug.ausTelegram && !weg && !lang ? `💬 Claude antwortet · ${ort}` : `✅ Fertig nach ${fmtDauer(e.durationMs)} · ${ort}`)
745 : `⚠️ Mit Fehler beendet nach ${fmtDauer(e.durationMs)} · ${ort}`
746 await meldeFertig($, z, kopf, e.answer)
747 return r
748 })
749
750 // Die Rückfrage läuft am Rechner wie immer. Bleibt sie unbeantwortet, geht sie
751 // zusätzlich aufs Handy; wer zuerst antwortet, gilt. Kommt die Antwort aus
752 // Telegram, schließt das Zurückgeben ohne next den Dialog am Rechner.
753 on('tool.call', { tool: 'AskUserQuestion' }, async ($, e, next) => {
754 if (e.tool !== 'AskUserQuestion' || z.fragenNach < 0 || !(await zugangLesen($, z))) return next(e)
755 let fertig: (a: Record<string, string>) => void = () => {}
756 const perTelegram = new Promise<Record<string, string>>(res => { fertig = res })
757 const rf: Rueckfrage = {
758 id: kennung(),
759 teile: e.questions.map(q => ({
760 frage: q.question, kopf: q.header, mehrfach: q.multiSelect, gewaehlt: [], messageId: 0, text: '',
761 optionen: q.options.map(o => ({ label: o.label, description: o.description })),
762 })),
763 gesendet: false, geschlossen: false, zeit: 0, fertig: a => fertig(a),
764 }
765 z.rueckfragen.set(rf.id, rf)
766 const lokal = next(e)
767 // Ohne Messung wird hier nicht geraten: dann gilt die Wartezeit.
768 const weg = istWeg(await leerlaufMs($), z.wegAb, 0)
769 const wecker = $.clock.after(weg ? 1 : Math.max(1, z.fragenNach * SEK), () => void sendeRueckfrage($, z, rf))
770 try {
771 const lokalFertig = lokal.then(res => ({ lokal: res }))
772 const sieger = await Promise.race([lokalFertig, perTelegram.then(antworten => ({ antworten }))])
773 if ('lokal' in sieger) {
774 const a = antwortenAus(sieger.lokal)
775 await schliesseRueckfrage($, z, rf, t => a[t.frage] ? `💻 Am Rechner beantwortet: ${a[t.frage]}` : '💻 Am Rechner erledigt.')
776 return sieger.lokal
777 }
778 lokalFertig.catch(() => { /* der Dialog wird verworfen */ })
779 await schliesseRueckfrage($, z, rf, t => `✅ ${t.antwort ?? ''}`)
780 $.ui.toast('Rückfrage per Telegram beantwortet')
781 return { result: { questions: e.questions, answers: sieger.antworten } }
782 } finally {
783 wecker.cancel()
784 if (!rf.geschlossen) await schliesseRueckfrage($, z, rf, () => '✖️ Abgebrochen.')
785 z.rueckfragen.delete(rf.id)
786 }
787 })
788
789 on('command.run', { command: 'telegram' }, async ($, e) => {
790 const [was = '', wert = ''] = String(e.args ?? '').trim().toLowerCase().split(/\s+/)
791 await zugangLesen($, z)
792 if (was === 'einrichten') return { text: await einrichten($, z) }
793 if (was === 'weg') {
794 const n = wert === 'aus' ? 0 : Number(wert)
795 if (!Number.isFinite(n) || n < 0 || (wert !== 'aus' && !wert)) return { text: 'Weg ab: Minuten ohne Maus und Tastatur (z. B. 3) oder aus?' }
796 z.wegAb = n
797 await $.store.set('wegAb', n)
798 return { text: n ? `Nach ${n} min ohne Maus und Tastatur giltst du als weg: dann kommt jeder Abschluss aufs Handy.` : 'Abwesenheit wird nicht mehr beachtet.' }
799 }
800 if (was === 'fertig') {
801 const n = wert === 'aus' ? 0 : Number(wert)
802 if (!Number.isFinite(n) || n < 0 || (wert !== 'aus' && !wert)) return { text: 'Fertig-Meldung: Minuten (z. B. 10) oder aus?' }
803 z.fertigAb = n
804 await $.store.set('fertigAb', n)
805 return { text: n ? `Zusätzlich meldet sich jeder Auftrag ab ${n} min Laufzeit, auch wenn du da bist.` : 'Nur noch melden, wenn du weg bist.' }
806 }
807 if (was === 'fragen') {
808 const n = wert === 'aus' ? -1 : wert === 'sofort' ? 0 : Number(wert)
809 if (!Number.isFinite(n) || (wert !== 'aus' && wert !== 'sofort' && !wert)) return { text: 'Rückfragen: Sekunden (z. B. 60), sofort oder aus?' }
810 z.fragenNach = n
811 await $.store.set('fragenNach', n)
812 return { text: n < 0 ? 'Rückfragen bleiben am Rechner.' : n === 0 ? 'Rückfragen gehen sofort aufs Handy.' : `Rückfragen gehen nach ${n} s ohne Antwort aufs Handy.` }
813 }
814 if (was === 'test') {
815 if (!z.zugang) return { text: ANLEITUNG }
816 await meldeFertig($, z, `🧪 Test · 📁 ${z.projekt || 'Claude Code'}`,
817 'So sieht eine Fertig-Meldung aus. Antworte auf diese Nachricht: dein Text landet als neue Nachricht in dieser Sitzung.')
818 return { text: z.tgFehler ? `Ging nicht: ${z.tgFehler}` : 'Testnachricht geschickt. Antworte in Telegram darauf.' }
819 }
820 const unbekannt = was && was !== 'status' && was !== 'hilfe' ? `„${String(e.args).trim()}“ kenne ich nicht.\n\n` : ''
821 return { text: unbekannt + karte(z, await leerlaufMs($)) }
822 })
823}
824