Dein eigener Wind-Butler fĂŒr Telegram â mit dieser Anleitung baust du ihn ohne Technik-Vorwissen.
đ Keine Prognosen auf dieser Seite â der Butler meldet sich aktiv in Telegram.Gesamtaufwand: ca. 40 Minuten â in einem Zug durchfĂŒhrbar, alles im Browser. Die Zeitangabe steht zu jeder Etappe als Erstes.
Der Windbutler ist ein Telegram-Butler, der fĂŒr dich aufs Wind schaut. Nicht du musst Apps checken â er meldet sich bei dir, wenn an deinen Spots etwas geht.
Dauer: ca. 3 Minuten · Du brauchst nur Telegram auf deinem Handy oder Computer.
Bleibt auf deinem GerĂ€t gespeichert, nur fĂŒr dich â er macht die Telegram-Knöpfe dieser Anleitung klickbar.
Dauer: ca. 5 Minuten · Der Butler braucht ein KI-Gehirn, damit er dich versteht und persönlich schreibt. Du wĂ€hlst einen Anbieter und holst dir dort einen «API-Key» â das ist wie ein Bibliotheksausweis fĂŒrs Gehirn. Der Butler-Code spricht alle Anbieter, du musst keinen Code anpassen.
Dauer: ca. 3 Minuten · Der Butler braucht einen kleinen Computer im Internet, auf dem er wohnt und arbeitet. Cloudflare vermietet ihn gratis.
Dauer: ca. 5 Minuten · Jetzt baust du dem Butler sein Zuhause â ein «Worker». Das klingt technisch, ist aber: Text kopieren, einfĂŒgen, Knopf drĂŒcken.
Ein Klick auf den Knopf â dann in den Cloudflare-Editor wechseln und einfĂŒgen. Das war's.
/* âââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ
WINDBUTLER đŹïž â Der konversationelle Wind-Butler fĂŒr Telegram
LĂ€uft komplett auf der Cloudflare-Gratisstufe (0 CHF/Monat).
WAS ER TUT
ââ 3Ă tĂ€glich (07:00 / 12:00 / 17:00 Schweizer Zeit) schaut er in die
WINDPROGNOSEN (nicht den Live-Wind!) fĂŒr deine Spots.
ââ Gesucht werden Fenster von 07:00â22:00 Uhr an den nĂ€chsten
horizon-Tagen (Standard 3 = morgen, ĂŒbermorgen, Tag danach).
ââ Passt Windrichtung, StĂ€rke, Dauer, Regen und Bewölkung zu deinen
Regeln â schreibt dir PERSĂNLICH formuliert in Telegram.
ââ Deine freien Antworten («erinnere mich ĂŒbermorgen», «am Sihlsee
erst ab 12 kn») versteht er ĂŒber ein LLM-Gehirn deiner Wahl.
ââ TĂŒrsteher: Wer nicht Chef ist, kommt nur mit Einladungscode rein
(/invite als Admin). Ohne Code gibt es stattdessen den
Anleitung-Link â der Butler bleibt privat und 0-Franken-Sache.
EINBAU-ANLEITUNG (alles im Browser, kein Terminal, keine Creditcard)
âââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ
1. Telegram-Butler erstellen (einmalig):
In Telegram @BotFather suchen â /newbot senden â Anzeigename
z.B. «Windbutler» wĂ€hlen â Username, der auf _bot endet (z.B.
windbutler_marc_bot). Den TOKEN kopieren und wie ein Passwort
behandeln (niemals weitergeben).
2. Gehirn wĂ€hlen â einen API-Key eines LLM-Anbieters besorgen:
Mistral: console.mistral.ai â Sign up â API Keys â Create new key
Gemini: aistudio.google.com â Get API key â Create API key
Claude: console.anthropic.com â API Keys â Create Key
(Achtung: Claude hat KEIN Gratis-Contingent, nur
Pay-per-use â Mistral/Gemini sind gĂŒnstiger gratis.)
Diesen Code musst du NICHT anpassen: er spricht alle drei.
3. Cloudflare-Konto: dash.cloudflare.com â Sign up (gratis).
E-Mail bestÀtigen. Keine Kreditkarte nötig.
4. Worker bauen: Links «Compute (Workers)» â «Create application»
â «Create Worker» â Name «windbutler» â «Deploy».
Dann «Edit code» â den Beispiel-Inhalt im Editor LĂSCHEN und
DIESEN GESAMTEN CODE einfĂŒgen â «Deploy».
5. GedÀchtnis anlegen (KV speichert Spots & Einstellungen):
Links «Storage & Databases» â «KV» â «Create namespace» â
Name «windbutler». ZurĂŒck zum Worker: «Settings» â «Bindings» â
«Add» â «KV Namespace» â Variablenname GENAU «MEM» â Namespace
«windbutler» auswĂ€hlen â «Deploy».
6. Passwörter & Anbieter eintragen: Worker â «Settings» â
«Variables and Secrets» â «Add»:
âą Secret TELEGRAM_BOT_TOKEN = dein BotFather-Token (Schritt 1)
âą Secret LLM_API_KEY = dein Gehirn-Key (Schritt 2)
âą Text LLM_PROVIDER = "mistral" ODER "gemini" ODER
"anthropic"
âą (optional) Secret ADMIN_CHAT_ID â deine Chat-ID (steht in /status),
macht dich sofort zum Chef. Bleibt es leer, wird automatisch
Admin, wer als Erster /start sendet.
Danach «Deploy».
7. Wecker stellen: Worker â «Settings» â «Trigger Events» â
«Cron Triggers» â Add â Cron-Ausdruck: 5 * * * *
(= stĂŒndlich kurz nach der vollen Stunde weckt Cloudflare den
Butler; er schaut auf die Uhr und arbeitet nur um 07:00, 12:00
und 17:00 Schweizer Zeit. Sommer-/Winterzeit regelt er selbst.)
8. TĂŒrklingel verbinden (einmalig): im Browser deine Worker-Adresse
öffnen + /setup dahinter, also z.B.
https://windbutler.dein-name.workers.dev/setup
Es muss «â
Windbutler ist mit Telegram verbunden» stehen.
9. Testen: In Telegram deinen Bot öffnen â /start â /status senden.
Spots mit /spot add ⊠einrichten (Befehle baust du im
Spot-Werkzeug der Anleitung zusammen) â /alerts on â fertig. đ
TECHNISCH (falls du lieber wrangler/CLI nutzt â nicht nötig):
ââ wrangler.toml:
name = "windbutler"
main = "worker.js"
compatibility_date = "2025-01-01"
[[kv_namespaces]]
binding = "MEM"
id = "<deine-kv-namespace-id>"
[triggers]
crons = ["5 * * * *"]
[vars]
LLM_PROVIDER = "mistral"
ââ Secrets: wrangler secret put TELEGRAM_BOT_TOKEN / LLM_API_KEY
ââ Endpunkte: GET /setup (Webhook setzen), POST /tg (Telegram),
GET /tick (manueller Check zum Testen), GET /health (Lebenszeichen)
âââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ */
const CHECK_HOURS = [7, 12, 17]; // Schweizer Zeit â deine PrĂŒftage-Rhythmen
const DEFAULT_TZ = "Europe/Zurich";
const GUIDE_URL = "https://door.ch/windbutler/"; // Anleitung & Konfig-Tool â dieser Link steht in /help (eigene Seite? einfach Ă€ndern)
/* ---------- Cloudflare Eintrittspunkt ---------- */
export default {
async fetch(request, env, ctx) {
const url = new URL(request.url);
try {
if (url.pathname === "/setup") return await setupWebhook(request, env);
if (url.pathname === "/tg") {
if (request.method !== "POST") return txt("Webhook erwartet POST von Telegram.", 405);
if (request.headers.get("x-telegram-bot-api-secret-token") !== tgSecret(env))
return txt("Ich nehme nur echte Telegram-Nachrichten an. Zutritt verweigert.", 403);
const update = await request.json();
ctx.waitUntil(handleUpdate(update, env));
return txt("ok", 200);
}
if (url.pathname === "/tick") {
// Mini-Bremse: max. ein manueller Check pro 10 Minuten (schĂŒtzt vor Neugierigen und Crawlern)
const last = await env.MEM.get("meta:lastTick");
if (last && Date.now() - Number(last) < 600000)
return txt("Habe gerade schon getickt â frĂŒhestens in ein paar Minuten wieder. đŹïž", 429);
await env.MEM.put("meta:lastTick", String(Date.now()));
ctx.waitUntil(butlerTick(env));
return txt("Butler-Check gestartet â schau in Telegram. đŹïž", 200);
}
if (url.pathname === "/health") {
return txt("Windbutler lebt. đŹïž Gehirn: " + (env.LLM_PROVIDER || "mistral") +
(env.MEM ? " · GedĂ€chtnis OK" : " · â ïž KV-Binding MEM fehlt!"), 200);
}
return txt("Windbutler đŹïž â öffne einmal /setup zum Verbinden mit Telegram, /tick fĂŒr einen manuellen Check.", 200);
} catch (e) {
return txt("Fehler: " + e.message, 500);
}
},
async scheduled(event, env, ctx) {
// Wird stĂŒndlich von Cloudflare geweckt â butlerTick entscheidet selbst,
// ob es in der Schweiz gerade 07:00, 12:00 oder 17:00 ist.
ctx.waitUntil(butlerTick(env));
},
};
function txt(text, status) {
return new Response(text, { status, headers: { "content-type": "text/plain; charset=utf-8" } });
}
/* ---------- Telegram Webhook verbinden ---------- */
// Internes Webhook-Passwort: wird deterministisch aus dem Bot-Token abgeleitet.
// Vorteil: kein zusĂ€tzlicher Secret-Eintrag fĂŒr den Besitzer â /setup registriert
// es automatisch, /tg prĂŒft es bei jeder Nachricht (Telegram sendet es als Header).
function tgSecret(env) {
const t = env.TELEGRAM_BOT_TOKEN || "";
let h1 = 5381, h2 = 52711;
for (let i = 0; i < t.length; i++) {
const c = t.charCodeAt(i);
h1 = ((h1 * 33) ^ c) >>> 0;
h2 = ((h2 * 31) ^ c) >>> 0;
}
return "wb_" + h1.toString(36) + h2.toString(36) + "_" + t.length;
}
async function setupWebhook(request, env) {
if (!env.TELEGRAM_BOT_TOKEN) return txt("â TELEGRAM_BOT_TOKEN fehlt. Settings â Variables and Secrets prĂŒfen, dann Deploy.", 500);
const hook = new URL(request.url).origin + "/tg";
const r = await fetch("https://api.telegram.org/bot" + env.TELEGRAM_BOT_TOKEN + "/setWebhook", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ url: hook, secret_token: tgSecret(env), allowed_updates: ["message"], drop_pending_updates: true }),
});
const j = await r.json();
if (j.ok) return txt("â
Windbutler ist mit Telegram verbunden.\nWebhook: " + hook +
"\n\nJetzt im Telegram-Chat mit deinem Bot /start senden.\n(Solange du Spots hast und /alerts on aktiv ist, schreibt er von selbst.)");
return txt("â Verbinden fehlgeschlagen: " + JSON.stringify(j) +
"\nPrĂŒfe: Token korrekt als Secret? Worker deployed?", 500);
}
/* ---------- Telegram API ---------- */
async function tg(env, method, payload) {
const r = await fetch("https://api.telegram.org/bot" + env.TELEGRAM_BOT_TOKEN + "/" + method, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(payload),
});
const j = await r.json().catch(() => ({}));
if (j.ok) return j;
if (j.error_code === 429 && j.parameters && j.parameters.retry_after) {
await sleep(j.parameters.retry_after * 1000);
return tg(env, method, payload);
}
console.log("Telegram-Fehler:", JSON.stringify(j));
return j;
}
function send(env, chatId, text) {
if (!text) return Promise.resolve();
return tg(env, "sendMessage", { chat_id: chatId, text: text.slice(0, 3500) });
}
function sleep(ms) { return new Promise((r) => setTimeout(r, ms)); }
/* ---------- LLM-Gehirn (anbieter-neutral) ---------- */
const PROVIDERS = {
mistral: { url: "https://api.mistral.ai/v1/chat/completions", model: "mistral-small-latest" },
gemini: { url: "https://generativelanguage.googleapis.com/v1beta/openai/chat/completions", model: "gemini-2.5-flash" },
anthropic: { url: "https://api.anthropic.com/v1/messages", model: "claude-3-5-haiku-latest" },
openai: { url: "https://api.openai.com/v1/chat/completions", model: "gpt-4o-mini" },
};
async function llm(env, system, userMsg, maxTokens = 700) {
const provider = (env.LLM_PROVIDER || "mistral").toLowerCase();
const conf = PROVIDERS[provider] || PROVIDERS.mistral;
const model = env.LLM_MODEL || conf.model;
const headers = { "content-type": "application/json" };
const body = { model, max_tokens: maxTokens, messages: [
{ role: "user", content: system + "\n\n---\n" + userMsg },
]};
let extract;
if (provider === "anthropic") {
headers["x-api-key"] = env.LLM_API_KEY;
headers["anthropic-version"] = "2023-06-01";
body.system = system;
body.messages = [{ role: "user", content: userMsg }];
extract = (j) => (j.content && j.content[0] && j.content[0].text) || "";
} else {
headers["authorization"] = "Bearer " + env.LLM_API_KEY;
extract = (j) => (j.choices && j.choices[0] && j.choices[0].message && j.choices[0].message.content) || "";
}
const r = await fetch(conf.url, { method: "POST", headers, body: JSON.stringify(body) });
if (!r.ok) throw new Error("LLM " + provider + " antwortete HTTP " + r.status + ": " + (await r.text()).slice(0, 200));
return extract(await r.json()).trim();
}
function llmJson(raw) {
// Robustes JSON aus LLM-Antwort holen (auch mit ```json-ZĂ€unen drumrum)
let s = (raw || "").replace(/```json|```/gi, "").trim();
const a = s.indexOf("{"), b = s.lastIndexOf("}");
if (a >= 0 && b > a) s = s.slice(a, b + 1);
return JSON.parse(s);
}
/* ---------- Zeit-Helfer ---------- */
function localParts(date, tz) {
const parts = {};
for (const p of new Intl.DateTimeFormat("de-CH", {
timeZone: tz, hour12: false,
year: "numeric", month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit",
}).formatToParts(date)) parts[p.type] = p.value;
const h = (+parts.hour) % 24;
return { hour: h, minute: +parts.minute, hhmm: parts.hour + ":" + parts.minute, dateStr: parts.year + "-" + parts.month + "-" + parts.day };
}
const dayDiff = (a, b) => Math.round((Date.parse(b) - Date.parse(a)) / 86400000);
function dayLabel(dateStr) {
return new Date(dateStr + "T12:00:00Z")
.toLocaleDateString("de-CH", { weekday: "long", day: "numeric", month: "long", timeZone: "UTC" });
}
const COMPASS = ["N","NNO","NO","ONO","O","OSO","SO","SSO","S","SSW","SW","WSW","W","WNW","NW","NNW"];
const dirLabel = (deg) => COMPASS[Math.round(((deg % 360) + 360) % 360 / 22.5) % 16];
function inSector(deg, from, to) {
const d = ((deg % 360) + 360) % 360;
if (from <= to) return d >= from && d <= to;
return d >= from || d <= to; // Sektor ĂŒber Nord (z.B. 337.5â112.5)
}
// Richtungs-Mittelung vektoriell (ĂŒber Sinus/Kosinus): 350° + 10° = Nord, nicht SĂŒd!
function avgDir(degrees) {
let sinSum = 0, cosSum = 0;
for (const d of degrees) {
const rad = (d * Math.PI) / 180;
sinSum += Math.sin(rad);
cosSum += Math.cos(rad);
}
return ((Math.atan2(sinSum, cosSum) * 180) / Math.PI + 360) % 360;
}
function inQuiet(hhmm, quiet) {
if (!quiet || quiet === "off") return false;
const [a, b] = quiet.split("-");
if (!a || !b) return false;
if (a <= b) return hhmm >= a && hhmm < b;
return hhmm >= a || hhmm < b; // ĂŒber Mitternacht (z.B. 22:00â07:00)
}
const slug = (s) => s.toLowerCase().replace(/[^a-z0-9]+/g, "-").slice(0, 40);
/* ---------- KV-GedÀchtnis ---------- */
async function getUser(env, chatId) {
try { return JSON.parse((await env.MEM.get("u:" + chatId)) || "null"); } catch { return null; }
}
async function putUser(env, user) { await env.MEM.put("u:" + user.chatId, JSON.stringify(user)); }
async function allUsers(env) {
const users = [];
const list = await env.MEM.list({ prefix: "u:" });
for (const k of list.keys) {
const v = await env.MEM.get(k.name);
if (v) { try { users.push(JSON.parse(v)); } catch {} }
}
return users;
}
function extractInviteCode(text) {
const t = (text || "").trim().toUpperCase();
const m = t.match(/^\/START(?:@\w+)?\s+(WB-[A-Z0-9]{4,10})$/);
return m ? m[1] : (/^WB-[A-Z0-9]{4,10}$/.test(t) ? t : null);
}
async function getAdminId(env) {
if (env.ADMIN_CHAT_ID) return String(env.ADMIN_CHAT_ID);
return env.MEM ? await env.MEM.get("meta:admin") : null;
}
/* ---------- Eingehende Telegram-Nachrichten ---------- */
async function handleUpdate(update, env) {
try {
const msg = update.message;
if (!msg || !msg.text) return;
const chatId = String(msg.chat.id);
let user = await getUser(env, chatId);
if (!user) {
/* TĂŒrsteher: Ohne Einladungscode kommt nur der Chef rein. */
const chiefId = await getAdminId(env);
const inviteCode = extractInviteCode(msg.text);
const codeValid = inviteCode && (await env.MEM.get("meta:invite:" + inviteCode));
if (chiefId && chiefId !== chatId && !codeValid) {
await send(env, chatId,
"Ahoi! đ€” Dieser Butler ist privat â aber er ist ein Geschenk:\n\n" +
"Bau dir in 40 Minuten deinen eigenen Butler â Anleitung & Klick-Tool:\n" + GUIDE_URL + "\n\n" +
(inviteCode
? "Dein Code «" + inviteCode + "» ist leider ungĂŒltig oder wurde eingezogen."
: "Wenn du einen Einladungscode hast: schick mir /start <Code>."));
return;
}
// Noch kein Chef da: Nur ein echtes /start darf den ersten Admin
// beanspruchen â jedes andere Wort macht niemanden zum Chef (Bugfix).
if (!chiefId && !/^\/start(?:@\w+)?/i.test(msg.text.trim())) {
await send(env, chatId, "Sende /start zum Einrichten des Butlers.");
return;
}
user = {
chatId,
name: (msg.from && msg.from.first_name) || "Skipper",
tz: DEFAULT_TZ,
spots: [], alerts: true, quiet: "22:00-07:00",
instructions: [], reminders: [],
created: Date.now(),
};
if (!chiefId) {
await env.MEM.put("meta:admin", chatId);
}
await putUser(env, user);
await send(env, chatId,
"Ahoi " + user.name + "! đŹïž Ich bin dein Windbutler.\n\n" +
"Ich schaue 3Ă tĂ€glich (07:00, 12:00, 17:00) in die Windprognosen deiner Spots â " +
"Fenster von 07â22 Uhr, bis zu 3 Tage voraus. Passt etwas, melde ich mich von selbst.\n\n" +
"Richte Spots ein mit /spot add ⊠(Befehle baust du im Spot-Werkzeug der Anleitung zusammen), " +
"schalte Alarme mit /alerts on â oder schreib mir einfach frei: " +
"«erinnere mich morgen frĂŒh nochmals» oder «am Sihlsee erst ab 12 kn».\n/help zeigt alles.");
return;
}
const isAdmin = (await getAdminId(env)) === chatId;
const text = msg.text.trim();
if (text.startsWith("/")) await handleCommand(text, user, env, isAdmin);
else await handleFreeText(text, user, env);
} catch (e) {
console.log("handleUpdate-Fehler:", e.message);
}
}
async function handleCommand(text, user, env, isAdmin) {
const [raw, ...rest] = text.split(/\s+/);
const cmd = raw.split("@")[0].toLowerCase();
const args = rest.join(" ");
switch (cmd) {
case "/start":
await putUser(env, user);
await send(env, user.chatId, "Windbutler bereit. đŹïž\nSpots: " + user.spots.length +
" · Alarme: " + (user.alerts ? "an" : "aus") + " · Ruhezeit: " + user.quiet +
"\n/help fĂŒr alle Befehle. Und Frei-Schreiben geht auch: «erinnere mich in 2 Tagen».");
return;
case "/help":
await send(env, user.chatId,
"Ich verstehe Befehle UND normale Sprache:\n" +
"/spot add ⊠â Spot einrichten (Befehle aus dem Spot-Werkzeug der Anleitung kopieren)\n" +
"/spot list â Spots zeigen\n/spot remove <Name> â Spot löschen\n" +
"/check <Spot> â sofortige EinschĂ€tzung\n" +
"/alerts on / off â Alarme schalten\n/quiet 22:00-07:00 â Ruhezeit\n" +
"/status â wie stehe ich\n/pause & /resume â Butler pausieren/fortsetzen (Admin)\n/invite â Crew-Einladungscode erstellen (Admin)\n/invites â aktive Codes zeigen (Admin)\n/revoke <Code> â Code sperren (Admin)\n" +
"/users â alle registrierten Nutzer (Admin)\n\n" +
"đ Geschenk-Anleitung & Konfig-Tool (fertige Spot-Befehle zum Kopieren):\n" + GUIDE_URL + "\n\n" +
"Oder einfach schreiben: «erinnere mich ĂŒbermorgen», «am sihlsee erst ab 12 kn» âŠ");
return;
case "/spot": {
const [sub, ...subRest] = args.split(/\s+/);
if (sub === "add") return handleSpotAdd(subRest.join(" "), user, env);
if (sub === "list") {
if (!user.spots.length) return send(env, user.chatId, "Noch keine Spots. Baue einen im Spot-Werkzeug der Anleitung und schick mir den Befehl. đ");
await send(env, user.chatId, "Deine Spots:\n" + user.spots.map(spotLine).join("\n"));
return;
}
if (sub === "remove") {
const name = subRest.join(" ").toLowerCase();
const before = user.spots.length;
user.spots = user.spots.filter((s) => s.name.toLowerCase() !== name);
await putUser(env, user);
await send(env, user.chatId, user.spots.length < before
? "Weg damit. Von «" + subRest.join(" ") + "» sehe ich nichts mehr. đ"
: "Den Spot kenne ich nicht. /spot list zeigt, was ich habe.");
return;
}
await send(env, user.chatId, "Ich kenne /spot add, /spot list, /spot remove.");
return;
}
case "/check": {
const q = args.toLowerCase();
const spot = user.spots.find((s) => s.name.toLowerCase().includes(q)) || user.spots[0];
if (!spot) return send(env, user.chatId, "Richte zuerst einen Spot ein (Befehle: " + GUIDE_URL + ").");
return handleCheck(spot, user, env);
}
case "/alerts": {
const on = !/^off/i.test(args);
user.alerts = on;
await putUser(env, user);
await send(env, user.chatId, on ? "Alarme sind AN. Ich melde mich, wenn ein Fenster passt. đŹïž" : "Alarme sind AUS. /check geht trotzdem immer.");
return;
}
case "/quiet": {
if (/^off/i.test(args)) { user.quiet = "off"; }
else if (/^\d{1,2}:\d{2}-\d{1,2}:\d{2}/.test(args)) { user.quiet = args.trim(); }
else { await send(env, user.chatId, "Beispiel: /quiet 22:00-07:00 â oder /quiet off"); return; }
await putUser(env, user);
await send(env, user.chatId, "Ruhezeit gesetzt: " + user.quiet + " (Schweizer Zeit). Ausserhalb schreibe ich nur, wenn du mich fragst.");
return;
}
case "/status":
await send(env, user.chatId,
"Butler-Status:\n· Chat-ID: " + user.chatId + "\n· Spots: " + user.spots.length + (user.spots.length ? "\n" + user.spots.map(spotLine).join("\n") : "") +
"\n· Alarme: " + (user.alerts ? "an" : "aus") +
"\n· Ruhezeit: " + user.quiet +
"\n· PrĂŒft: 07:00, 12:00, 17:00 Uhr (Europe/Zurich), Prognose-Fenster 07â22 Uhr, bis zu " +
(user.spots.length ? Math.max(...user.spots.map((s) => s.horizon || 3)) : 3) + " Tage voraus" +
(user.instructions.length ? "\n· Deine Regeln: " + user.instructions.join(" · ") : "") +
(user.reminders.length ? "\n· Offene Erinnerungen: " + user.reminders.length : "") +
"\n· Gehirn: " + (env.LLM_PROVIDER || "mistral"));
return;
case "/users": {
if (!isAdmin) return send(env, user.chatId, "Das darf nur mein Chef. đ");
const users = await allUsers(env);
await send(env, user.chatId, "Registriert:\n" + users.map((u) => "· " + u.name + " (" + u.chatId + ") â " + u.spots.length + " Spots").join("\n"));
return;
}
case "/pause":
case "/resume": {
if (!isAdmin) return send(env, user.chatId, "Das darf nur mein Chef. đ");
await env.MEM.put("meta:paused", cmd === "/pause" ? "1" : "0");
await send(env, user.chatId, cmd === "/pause" ? "Ich mache Pause. /resume weckt mich." : "Wieder auf Sendung. đŹïž");
return;
}
case "/invite": {
if (!isAdmin) return send(env, user.chatId, "Das darf nur mein Chef. đ");
const code = "WB-" + crypto.randomUUID().replace(/-/g, "").slice(0, 6).toUpperCase();
await env.MEM.put("meta:invite:" + code, "1");
await send(env, user.chatId,
"đïž Einladungscode: " + code + "\n\nSchick ihn deiner Crew â die schickt mir /start " + code + ".\n" +
"/invites listet alle Codes, /revoke " + code + " zieht ihn ein.");
return;
}
case "/invites": {
if (!isAdmin) return send(env, user.chatId, "Das darf nur mein Chef. đ");
const list = await env.MEM.list({ prefix: "meta:invite:" });
await send(env, user.chatId, list.keys.length
? "Aktive Codes:\n" + list.keys.map((k) => "· " + k.name.slice("meta:invite:".length)).join("\n")
: "Keine aktiven Codes. /invite macht einen neuen. đïž");
return;
}
case "/revoke": {
if (!isAdmin) return send(env, user.chatId, "Das darf nur mein Chef. đ");
const code = args.trim().toUpperCase();
if (!code) return send(env, user.chatId, "Bitte Code anhÀngen: /revoke WB-XXXXXX");
await env.MEM.delete("meta:invite:" + code);
await send(env, user.chatId, "Code " + code + " ist eingezogen. R.I.P. đȘŠ");
return;
}
default:
await send(env, user.chatId, "Den Befehl kenne ich nicht â /help zeigt alles. Oder schreib mir einfach frei.");
}
}
function spotLine(s) {
return "· " + s.name + " â " + s.minKn + " kn+, Sektor " + s.sectorFrom + "â" + s.sectorTo + "° (" + dirLabel(s.sectorFrom) + "â" + dirLabel(s.sectorTo) + "), " +
s.minHours + " h am StĂŒck, " + (s.fromHour ?? 7) + "â" + (s.toHour ?? 22) + " Uhr, " + s.horizon + " Tage voraus" +
(s.maxCloud != null ? ", max. " + s.maxCloud + "% Wolken" : "");
}
function parseSpotArgs(args) {
const spot = {};
const re = /([a-zA-Z]+)\s*=\s*(?:"([^"]*)"|(\S+))/g;
// numMap: Keys kommen lowercased an (z.B. "minkn") und werden hier auf die
// echten Feldnamen gemappt â ohne diese Map fielen alle camelCase-Parameter
// stillschweigend auf die Defaults (Bugfix).
const numMap = {
minkn: "minKn", minhours: "minHours", maxprecip: "maxPrecip", horizon: "horizon",
fromhour: "fromHour", tohour: "toHour", maxcloud: "maxCloud",
};
let m;
while ((m = re.exec(args))) {
const key = m[1].toLowerCase();
const val = m[2] !== undefined ? m[2] : m[3];
if (key === "sector") {
const [a, b] = val.split("-").map(Number);
spot.sectorFrom = a; spot.sectorTo = b;
} else if (numMap[key]) {
spot[numMap[key]] = +val;
} else if (key === "name") spot.name = val;
else if (key === "lat" || key === "lon") spot[key] = +val;
}
return spot;
}
async function handleSpotAdd(args, user, env) {
const s = parseSpotArgs(args);
if (!s.name || s.lat == null || s.lon == null || s.sectorFrom == null) {
return send(env, user.chatId, "Da fehlt etwas (name, lat, lon, sector nötig). Nimm den fertigen Befehl aus dem Spot-Werkzeug der Anleitung: " + GUIDE_URL + " đ");
}
s.minKn = s.minKn ?? 6;
s.minHours = s.minHours ?? 2;
s.maxPrecip = s.maxPrecip ?? 0.5;
s.horizon = Math.min(7, s.horizon ?? 3);
const idx = user.spots.findIndex((x) => x.name.toLowerCase() === s.name.toLowerCase());
if (idx >= 0) user.spots[idx] = s; else user.spots.push(s);
await putUser(env, user);
await send(env, user.chatId, "Am Start! đ\n" + spotLine(s) + "\n\nIch prĂŒfe 3Ă tĂ€glich die Prognosen fĂŒr diesen Spot. Mit /check " +
s.name + " fragst du jederzeit direkt nach.");
}
/* ---------- Freie Sprache â das Gehirn versteht es ---------- */
async function handleFreeText(text, user, env) {
const now = new Date();
const prompt =
"Du bist das SprachverstÀndnis-Modul des Windbutler (Telegram-Bot).\n" +
"Jetzt (UTC): " + now.toISOString() + "\n" +
"Zeitzone des Users: " + (user.tz || DEFAULT_TZ) + "\n" +
"User heisst: " + user.name + "\n" +
"Seine Spots: " + (user.spots.length ? user.spots.map((s) => s.name).join(", ") : "(noch keine)") + "\n" +
"Seine bestehenden Regeln: " + (user.instructions.join("; ") || "(keine)") + "\n\n" +
"Der User schreibt: \"" + text + "\"\n\n" +
"Antworte AUSSCHLIESSLICH mit einem JSON-Objekt (kein Text davor/danach, keine Zollzeichen):\n" +
'{"action":"...","reply":"...","instruction":"","remindAt":"","spot":"","fields":{}}\n' +
"action ist genau einer von:\n" +
"- \"reminder\": User will spĂ€ter erinnert werden (z.B. «erinnere mich in 2 Tagen», «morgen frĂŒh nochmal»). remindAt = ISO-8601 UTC (YYYY-MM-DDTHH:MM:SSZ). reply = kurze BestĂ€tigung im Butler-Ton.\n" +
"- \"instruction\": dauerhafte Regel/PrÀferenz (z.B. «meld dich nur bei 12 kn oder mehr», «abends nicht vor 18 Uhr»). instruction = die Regel kurz & klar. reply = BestÀtigung im Butler-Ton.\n" +
"- \"spot_update\": User Ă€ndert am genannten Spot etwas (z.B. «am sihlsee erst ab 12 kn»). spot = Name des Spots (so gut wie möglich), fields = nur die geĂ€nderten Zahlen, z.B. {\"minKn\":12} â mögliche Felder: minKn, minHours, fromHour, toHour, maxCloud, horizon, maxPrecip.\n" +
"- \"alerts_on\" bzw. \"alerts_off\": User will Alarme an/aus.\n" +
"- \"help\": User fragt, was er machen kann.\n" +
"- \"chat\": alles andere. reply = Antwort im Butler-Ton: locker, warm, leicht schweizerisch, hilfsbereit, ohne Fachjargon.\n" +
"reply IMMER ausfĂŒllen, in der Sprache des Users (Deutsch), max. 2â3 SĂ€tze.";
let brain;
try {
brain = llmJson(await llm(env, prompt, text, 400));
} catch (e) {
return send(env, user.chatId, "Kurz gesagt: Ich hab's nicht ganz verstanden. Schreib's mir nochmal etwas anders. đ");
}
const reply = (brain.reply || "Verstanden.").trim();
switch (brain.action) {
case "reminder": {
const at = new Date(brain.remindAt);
if (!isNaN(at)) {
user.reminders.push({ at: at.getTime(), text: brain.reminderText || text });
await putUser(env, user);
}
break;
}
case "instruction": {
if (brain.instruction) {
user.instructions.push(String(brain.instruction).slice(0, 200));
await putUser(env, user);
}
break;
}
case "spot_update": {
const q = (brain.spot || "").toLowerCase();
const spot = user.spots.find((s) => s.name.toLowerCase().includes(q));
if (spot && brain.fields && typeof brain.fields === "object") {
for (const [k, v] of Object.entries(brain.fields)) {
if (["minKn", "minHours", "fromHour", "toHour", "maxCloud", "horizon", "maxPrecip"].includes(k) && !isNaN(+v)) spot[k] = +v;
}
await putUser(env, user);
}
break;
}
case "alerts_on": user.alerts = true; await putUser(env, user); break;
case "alerts_off": user.alerts = false; await putUser(env, user); break;
}
await send(env, user.chatId, reply);
}
/* ---------- /check â sofortige EinschĂ€tzung ---------- */
async function handleCheck(spot, user, env) {
await send(env, user.chatId, "Schaue nach ⊠đ");
const wins = await findWindows(env, spot, user.chatId, { minDay: 0 }); // heute inklusive
let cur = "";
try {
const r = await fetch(openMeteoUrl(spot, 2));
const d = await r.json();
if (d.current) {
cur = "JETZT am Spot: " + Math.round(d.current.wind_speed_10m) + " kn aus " + dirLabel(d.current.wind_direction_10m) +
", " + Math.round(d.current.cloud_cover ?? 0) + "% Wolken" + (d.current.precipitation ? ", " + d.current.precipitation + " mm Regen" : ", trocken.");
}
} catch {}
if (!wins.length) {
return send(env, user.chatId, cur + "\n\n" + spot.name + ": Auf " + spot.horizon + " Tage voraus sehe ich kein Fenster, das passt (" +
spot.minKn + " kn+, " + dirLabel(spot.sectorFrom) + "â" + dirLabel(spot.sectorTo) + ", " +
(spot.fromHour ?? 7) + "â" + (spot.toHour ?? 22) + " Uhr). Ich bleibe am Ball. đŹïž");
}
const msg = await butlerMessage(env, user, wins,
"Der User fragt per /check nach " + spot.name + ". Antworte mit einer sofort nĂŒtzlichen, persönlichen EinschĂ€tzung.");
await send(env, user.chatId, (cur ? cur + "\n\n" : "") + msg);
}
/* ---------- Der grosse Check â lĂ€uft um 07:00, 12:00, 17:00 ---------- */
async function butlerTick(env) {
if (!env.MEM) { console.log("KV-Binding MEM fehlt â Settings â Bindings prĂŒfen!"); return; }
if ((await env.MEM.get("meta:paused")) === "1") return;
const users = await allUsers(env);
for (const user of users) {
try {
const now = new Date();
const loc = localParts(now, user.tz || DEFAULT_TZ);
if (!CHECK_HOURS.includes(loc.hour)) continue; // nicht 07/12/17 Uhr â schlafen weiter
// 1. FĂ€llige Erinnerungen ausgeben (auch bei alerts off)
const due = (user.reminders || []).filter((r) => r.at <= now.getTime());
for (const r of due) {
if (!inQuiet(loc.hhmm, user.quiet)) await send(env, user.chatId, "â° " + r.text);
}
if (due.length) {
user.reminders = user.reminders.filter((r) => r.at > now.getTime());
await putUser(env, user);
}
// 2. Prognose-Fenster prĂŒfen
if (user.alerts === false) continue;
const fresh = [];
for (const s of user.spots) {
const wins = await findWindows(env, s, user.chatId, { minDay: 1 }); // erst ab MORGEN
for (const w of wins) if (!(await env.MEM.get(w.key))) fresh.push(w);
}
if (!fresh.length) continue;
if (inQuiet(loc.hhmm, user.quiet)) continue; // Ruhezeit: nÀchster Check meldet es
const msg = await butlerMessage(env, user, fresh, null);
await send(env, user.chatId, msg);
for (const w of fresh) {
await env.MEM.put(w.key, "1", { expirationTtl: (w.spot.horizon || 3) * 86400 + 172800 });
}
} catch (e) {
console.log("Tick-Fehler bei User " + user.chatId + ":", e.message);
}
}
}
/* ---------- Prognose-Fenster finden (Open-Meteo, gratis) ---------- */
function openMeteoUrl(spot, days) {
return "https://api.open-meteo.com/v1/forecast?latitude=" + spot.lat + "&longitude=" + spot.lon +
"&hourly=wind_speed_10m,wind_direction_10m,precipitation,cloud_cover¤t=wind_speed_10m,wind_direction_10m,precipitation,cloud_cover" +
"&wind_speed_unit=kn&timezone=auto&forecast_days=" + Math.min(7, days + 1);
}
async function findWindows(env, spot, chatId, { minDay = 1 } = {}) {
const horizon = spot.horizon || 3;
const r = await fetch(openMeteoUrl(spot, horizon));
if (!r.ok) throw new Error("Open-Meteo HTTP " + r.status);
const d = await r.json();
const h = d.hourly;
const off = d.utc_offset_seconds || 0;
const today = new Date(Date.now() + off * 1000).toISOString().slice(0, 10); // Spot-lokales Datum
const hourFrom = spot.fromHour ?? 7; // Standard-Fenster: 07â22 Uhr
const hourTo = spot.toHour ?? 22;
const maxPrecip = spot.maxPrecip ?? 0.5;
const windows = [];
let run = null;
const closeRun = () => {
const len = run.i1 - run.i0 + 1;
if (len >= (spot.minHours || 2)) {
const kns = h.wind_speed_10m.slice(run.i0, run.i1 + 1).map(Number);
const dirs = h.wind_direction_10m.slice(run.i0, run.i1 + 1).map(Number);
const clouds = h.cloud_cover.slice(run.i0, run.i1 + 1).map(Number);
const rains = h.precipitation.slice(run.i0, run.i1 + 1).map(Number);
windows.push({
spot, len,
start: h.time[run.i0],
endHour: (+h.time[run.i1].slice(11, 13)) + 1,
startHour: +h.time[run.i0].slice(11, 13),
day: h.time[run.i0].slice(0, 10),
knMin: Math.round(Math.min(...kns)), knMax: Math.round(Math.max(...kns)),
dir: dirLabel(avgDir(dirs)),
cloud: Math.round(Math.max(...clouds)),
rain: Math.round(Math.max(...rains) * 10) / 10,
key: "nf:" + chatId + ":" + slug(spot.name) + ":" + h.time[run.i0],
});
}
};
for (let i = 0; i < h.time.length; i++) {
const t = h.time[i];
const hour = +t.slice(11, 13);
const dd = dayDiff(today, t.slice(0, 10));
const ok =
dd >= minDay && dd <= horizon &&
hour >= hourFrom && hour <= hourTo &&
+h.wind_speed_10m[i] >= spot.minKn &&
inSector(+h.wind_direction_10m[i], spot.sectorFrom, spot.sectorTo) &&
+h.precipitation[i] <= maxPrecip &&
(spot.maxCloud == null || +h.cloud_cover[i] <= spot.maxCloud);
if (ok) { if (!run) run = { i0: i, i1: i }; else run.i1 = i; }
else if (run) { closeRun(); run = null; }
}
if (run) closeRun();
return windows;
}
/* ---------- Persönlich formulierte Butler-Nachricht ---------- */
const BUTLER_SYSTEM =
"Du bist «Windbutler», der persönliche Wind-Concierge eines Wind- und Kitesportlers. " +
"Ton: locker, warm, leicht schweizerisch gefĂ€rbt, persönlich, mit Trockenwitz â nie technisch, nie Marketing. " +
"Wie eine SMS von einem guten Freund mit Wind im Blut. Beispielduktus: " +
"«Sihlsee calling: Am Freitag 11â17 Uhr dreht's auf NNO mit 8â12 kn. Trocken, ein paar Quellwolken. " +
"Samstag sieht noch besser aus. Was soll ich als nÀchstes tun?» " +
"REGELN: Maximal ~700 Zeichen. Kein Markdown, keine Sternchen, keine AufzĂ€hlungszeichen. Max. 1â2 Emojis. " +
"Nenne pro Fenster: Tag, Zeitfenster, Windrichtung, Kn-Bereich, und wenn relevant Wolken/Regen. " +
"Beziehe die bestehenden Regeln des Users ein (z.B. «ab 12 kn erst melden»). " +
"Schliesse mit einer persönlichen Frage oder einem klaren nÀchsten Schritt. " +
"Antworte NUR mit der Nachricht an den User, sonst nichts.";
async function butlerMessage(env, user, windows, extraInstruction) {
const data = windows.map((w) => ({
spot: w.spot.name,
wann: dayLabel(w.day) + " (" + w.day + ")",
uhr: w.startHour + "â" + w.endHour + " Uhr",
wind: w.knMin + "â" + w.knMax + " kn aus " + w.dir,
wolken: w.cloud + "%",
regen: w.rain + " mm",
dauer: w.len + " h",
}));
const prompt = (extraInstruction ? extraInstruction + "\n\n" : "") +
"User: " + user.name + "\n" +
"Bestehende Regeln des Users: " + (user.instructions.join("; ") || "(keine)") + "\n" +
"Passende Prognose-Fenster (JSON):\n" + JSON.stringify(data, null, 1);
try {
const msg = await llm(env, BUTLER_SYSTEM.replace("eines Wind- und Kitesportlers", "von " + user.name), prompt, 700);
if (msg) return msg;
} catch (e) {
console.log("Butler-LLM-Fehler:", e.message);
}
// Notnagel ohne Gehirn â der Butler meldet sich trotzdem:
const w = windows[0];
return "đŹïž " + w.spot.name + ": " + dayLabel(w.day) + ", " + w.startHour + "â" + w.endHour + " Uhr, " +
w.knMin + "â" + w.knMax + " kn aus " + w.dir + ", " + w.cloud + "% Wolken. Ich bleibe dran.";
}
Dauer: ca. 5 Minuten · Damit sich der Butler merkt, welche Spots du hast, welche Regeln du ihm gegeben hast und was er dir schon gemeldet hat, braucht er ein Notizheft â das heisst bei Cloudflare «KV».
Dauer: ca. 5 Minuten · Jetzt teilst du dem Butler seine zwei Passwörter mit und sagst ihm, welches Gehirn er benutzt. Alles im gleichen MenĂŒ.
| Name | Typ | Wert | Woher? |
|---|---|---|---|
TELEGRAM_BOT_TOKEN | Secret | 123456789:ABC-Def⊠| Etappe 1 · BotFather |
LLM_API_KEY | Secret | dein API-Key | Etappe 2 · Gehirn-Anbieter |
LLM_PROVIDER | Text | mistral · gemini · anthropic · openai | deine Gehirn-Wahl |
Dauer: ca. 5 Minuten · Zwei letzte Schalter: damit der Butler 3Ă tĂ€glich aufwacht (Wecker) und damit Telegram weiss, wohin deine Nachrichten gehen (TĂŒrklingel).
Dauer: ca. 10 Minuten · Fast fertig â jetzt weckst du den Butler das erste Mal.
Username deines Telegram-Butlers (ohne @) â den hast du in Etappe 1 notiert. Er macht die Telegram-Knöpfe dieser Seite klickbar. Bleibt auf deinem GerĂ€t gespeichert, keine Verbindung zum Bot.
Alles von Hand und mit Klicks. Das Preset fĂŒllt die Felder nur vor â ĂŒberschreiben erlaubt.
𥠫Nur ab/bis» = Zeitfenster statt Standard 07â22 Uhr (fĂŒr Thermikspots), «Max. Bewölkung» = Schönwetter-Filter, «Prognose-Horizont» = wie viele Tage voraus (Standard 3 = morgen, ĂŒbermorgen, Tag danach) â leer lassen gilt immer.
Pro Spot: «Kopieren & Telegram öffnen» â der Befehl wandert in die Zwischenablage und dein Butler-Chat öffnet sich. EinfĂŒgen, senden â der Trigger ist sofort scharf.
Zum Steuern des Butlers direkt in Telegram.
/start | Butler starten, Chat-ID registrieren |
/spot add ⊠| Spot mit Trigger registrieren (aus der Liste kopieren) |
/spot list | Alle registrierten Spots zeigen |
/spot remove <Name> | Spot löschen |
/check <Spot> | Sofortige EinschĂ€tzung fĂŒr einen Spot |
/alerts on / /alerts off | Alarme global ein-/ausschalten |
/status | Zustand des Butlers (Spots, Alarme, letzte Checks) |
/quiet 22:00-07:00 | Ruhezeit â keine Meldungen in diesem Fenster |
/users | Friends & Family: registrierte Nutzer anzeigen (nur Admin) |
/pause / /resume | Butler pausieren / fortsetzen (nur Admin) |
/invite | Crew-Einladungscode erstellen â Friends & Family kommen nur noch damit rein (nur Admin) |
/invites | Aktive Einladungscodes anzeigen (nur Admin) |
/revoke <Code> | Einladungscode sperren (nur Admin) |
đŹ Und darĂŒber hinaus: einfach frei schreiben â «erinnere mich in 2 Tagen», «nur ab 12 kn», «schweig bis Montag» â das Gehirn versteht es.
Die hĂ€ufigsten Stolpersteine â in der Reihenfolge, in der sie vorkommen.
Das KV-GedÀchtnis ist nicht angekoppelt: Etappe 5 wiederholen. Der Variablenname muss exakt MEM sein (grossgeschrieben), und danach Deploy nicht vergessen.
Nach jeder Ănderung in Cloudflare (Variablen, Bindings, Code): Deploy drĂŒcken. Ohne Deploy lebt die alte Version weiter. Im Telegram-Chat wirken Spot-Ănderungen sofort.
Wahrscheinlich ist der LLM_API_KEY leer/abgelaufen oder das Gratis-Guthaben ist aufgebraucht. Neuen Key besorgen (Etappe 2), als LLM_API_KEY eintragen, Deploy. Bis dahin meldet er sich mit einer schlichten Kurzform â ganz ohne Gehirn geht also nie etwas verloren.
Cloudflare (Worker, Wecker, GedĂ€chtnis) und Open-Meteo (Prognosen) sind gratis â auch fĂŒr immer, ohne Zeitlimit. Das KI-Gehirn lĂ€uft im Gratis-Startguthaben von Mistral bzw. Google. Nur Claude (Anthropic) kostet von Anfang an pay-per-use â ein paar Rappen pro Monat beim Butler-Umfang. Wenn das Gratis-Guthaben des Gehirns mal aufgebraucht wĂ€re: einfach neuen Key bei einem anderen Anbieter besorgen (Etappe 2) und LLM_PROVIDER wechseln.
Ja. Der Butler-Code spricht Mistral, Gemini, Claude und OpenAI von Haus aus. Du Ă€nderst nur in Cloudflare (Settings â Variables and Secrets) zwei EintrĂ€ge: LLM_API_KEY (neuer SchlĂŒssel) und LLM_PROVIDER (das Wort mistral / gemini / anthropic / openai) â Deploy. Fertig. Deshalb funktioniert diese Anleitung auch fĂŒr Leute, die lieber ein anderes Gehirn einsetzen.
Sie ist komplett: Der gesamte Butler-Code steckt hier in Etappe 4 (kopierfertig), die Anleitung erklĂ€rt jeden Klick von Telegram bis Cloudflare, und das Konfig-Tool baut die Spot-Befehle. Du kannst diese eine Seite an beliebige Leute weitergeben â per Link oder als Datei. Sie brauchen sonst nichts: keine weiteren Dokumente, keinen Entwickler, kein Technik-Vorwissen.
Was du auf dieser Seite eintippst, bleibt nur in deinem Browser (localStorage) â es gibt keinen Server dahinter. Was dein Butler weiss (Spots, Regeln, Erinnerungen), liegt in deinem Cloudflare-KV, das nur dein Worker lesen kann. Und die beiden Passwörter (Token, API-Key) gibst du nur bei Cloudflare als Secret ein â in keine Webseite, nie in einen Chat.
Nein â bewusst nicht. Er prĂŒft 3Ă tĂ€glich (07/12/17 Uhr) die Prognosen und sucht Zeitfenster zwischen 07:00 und 22:00 Uhr an den nĂ€chsten Tagen (Standard 3 Tage, pro Spot einstellbar). Live-Wind von jetzt bringt dir nichts, wenn du erst morgen Zeit hast â dafĂŒr plant er dir voraus. FĂŒr «was geht JETZT» gibt es /check Spotname.
Schick ihnen einen Einladungscode: /invite in Telegram erzeugt einen Code (z. B. WB-A1B2C3), den du ihnen weiterleitest (z. B. per WhatsApp). Sie öffnen den Chat mit deinem Bot und senden /start WB-A1B2C3 â ab sofort sind sie freigeschaltet und schicken ihre eigenen /spot add-Befehle (Befehle baust du hier fĂŒr sie zusammen). Du siehst alle registrierten Leute mit /users â und mit /revoke ziehst du einen Code wieder ein, falls er in falsche HĂ€nde gerĂ€t. Jeder hat seine eigenen Spots; gemeldet wird in den eigenen Chat. Wer es ganz getrennt will, baut sich mit dieser Anleitung einfach einen eigenen Bot â sie ist bewusst so geschrieben, dass jede Person sie ohne Vorwissen durchfĂŒhren kann.
Ja: BotFather /deletebot senden â Bot auswĂ€hlen â zweimal bestĂ€tigen. Danach braucht es einen neuen Bot (neuer Token) und du wiederholst Etappe 6 und 7 mit dem neuen Token. Der Username bleibt eine Weile blockiert, nimm einfach einen neuen.