WINDBUTLER

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.

Was ist der Windbutler — und wie denkt er?

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.

  1. 3× tĂ€glich schaut er in die Prognosen: um 07:00, 12:00 und 17:00 Uhr (Schweizer Zeit) prĂŒft er die Windprognosen deiner Spots. Nicht den Live-Wind — sondern die Vorhersagen fĂŒr die nĂ€chsten Tage.
  2. Was er sucht: Zeitfenster von 07:00–22:00 Uhr am morgigen, ĂŒbermorgigen und darauffolgenden Tag (Standard: 3 Tage voraus — pro Spot einstellbar), in denen Windrichtung, StĂ€rke, Dauer, Regen und Bewölkung zu deinen Regeln passen.
  3. Wenn etwas passt, schreibt er dir persönlich — z.B. «Sihlsee calling: Am Freitag 11–17 Uhr dreht's auf NNO mit 8–12 kn 
 Was soll ich als nĂ€chstes tun?» Kein totes Alerter-Geschwafel.
  4. Du antwortest einfach frei: «erinnere mich ĂŒbermorgen nochmals», «am Sihlsee erst ab 12 kn» — er versteht das, weil er ein KI-Gehirn hat. Welches Gehirn (Mistral, Gemini, Claude 
), entscheidest du in Etappe 2.
  5. Kosten: 0 Franken im Grundbetrieb. Cloudflare (Rechner + Wecker) und Open-Meteo (Prognosen) sind gratis; das KI-Gehirn lÀuft im Gratis-Startguthaben.
💡 Diese Seite hier ist dein Konfigurations-Werkzeug: Sie erklĂ€rt jeden Einbau-Schritt und hilft dir, die Spot-Befehle zusammenzuklicken. Die Seite selbst speichert nichts auf einem Server — deine Eingaben bleiben in deinem Browser.

1Etappe 1 · Den Butler in Telegram anstellen

Dauer: ca. 3 Minuten · Du brauchst nur Telegram auf deinem Handy oder Computer.

  1. Telegram öffnen, BotFather suchen: Tippe oben in die Suche @BotFather und wĂ€hle ihn — oder klicke einfach auf den Knopf: 🔍 BotFather öffnen Wichtig: er hat einen blauen Haken und ĂŒber 1 Mio. Nutzer — es gibt FĂ€lschungen. BotFather ist die offizielle «Bot-Zentrale» von Telegram.
  2. Neuen Bot bestellen: Sende im Chat die Nachricht /newbot (SchrĂ€gstrich, dann newbot — wie eine SMS tippen und senden).
  3. Namen geben: BotFather fragt nach einem Namen — das ist der Anzeigename. Schreibe z.B. Windbutler und sende.
  4. Username geben: Jetzt verlangt er einen Username, der auf _bot enden muss. Nimm etwas Eigenes, z.B. windbutler_marc_bot oder marc_windbutler_bot. Ist er vergeben, probiere eine andere Kombination. Trag ihn gleich unten ins Feld ein — damit werden die Telegram-Knöpfe dieser Anleitung klickbar.
  5. Token kopieren: BotFather schickt dir eine lange kryptische Zeile (beginnt meist mit Zahlen, dann Doppelpunkt, dann Buchstabensalat). Das ist der Token — quasi der WohnungsschlĂŒssel deines Butlers. Kopiere ihn (Tippen → Kopieren) und bewahre ihn auf.
  6. Bot-Chat merken: BotFather zeigt dir den Link zu deinem Bot (t.me/
). Tippe ihn an, damit du den Chat spĂ€ter findest. /start sendest du erst in Etappe 8 — jetzt antwortet der Bot noch niemandem, das ist normal.

Bleibt auf deinem GerĂ€t gespeichert, nur fĂŒr dich — er macht die Telegram-Knöpfe dieser Anleitung klickbar.

🔒 Der Token ist ein Passwort: niemals jemandem zeigen, nie in diese Seite eintippen, nirgends posten. Kommt er in falsche HĂ€nde, kann jemand deinen Bot ĂŒbernehmen. Im Zweifel: bei BotFather /revoke senden — dann gibt's einen frischen Token.
đŸ—‘ïž Einen Bot löschen (falls je nötig): BotFather /deletebot senden → Bot auswĂ€hlen → zweimal bestĂ€tigen. Fertig.

2Etappe 2 · Das Gehirn wÀhlen (API-Key besorgen)

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.

  1. Konto erstellen: Öffne console.mistral.ai im Browser → klicke auf Sign up (E-Mail-Adresse + Passwort, oder Google-Konto) und bestĂ€tige die E-Mail.
  2. API-Key erzeugen: Nach dem Einloggen links im MenĂŒ API Keys (oder Usage → API Keys) → Button Create new key → als Name z.B. windbutler → bestĂ€tigen.
  3. Kopieren: Der SchlĂŒssel wird nur einmal angezeigt — jetzt sofort kopieren (Tippen → Copy). Er sieht aus wie ein langes Passwort.
  4. Notieren fĂŒr Etappe 6: Du trĂ€gst ihn spĂ€ter als LLM_API_KEY ein, und als Anbieter-Wort mistral.
✅ Gratis-Startguthaben ist eingebaut, und Mistral reicht locker fĂŒr den Butler (er macht nur wenige kurze Anfragen pro Tag). Empfehlung fĂŒr den Windbutler.
  1. Konto: Öffne aistudio.google.com und melde dich mit deinem Google-Konto an (ein normales Gmail oder Google-Konto genĂŒgt).
  2. API-Key erzeugen: Klicke auf Get API key (bzw. «API-SchlĂŒssel abrufen») → Create API key in new project / «API-Key in neuem Projekt erstellen».
  3. Kopieren: Der SchlĂŒssel erscheint (beginnt mit «AI ») — kopieren.
  4. Notieren fĂŒr Etappe 6: SpĂ€ter als LLM_API_KEY eintragen, Anbieter-Wort gemini.
✅ Der Butler nutzt das Flash-Modell — schnell, gĂŒnstig, im Gratis-Contingent von Google AI Studio mit dabei.
  1. Konto: Öffne console.anthropic.com → Sign up. Achtung: Anthropic verlangt Telefonnummer und Kreditkarte zur Verifizierung.
  2. Guthaben aufladen: Claude hat kein Gratis-Contingent — unter Billing/Billing & limits lĂ€dst du ein kleines Guthaben auf (z.B. 5 $, reichen monatelang fĂŒr einen Butler).
  3. API-Key erzeugen: Links im MenĂŒ API Keys (unter «Org settings») → Create Key → Name z.B. windbutler → kopieren.
  4. Notieren fĂŒr Etappe 6: SpĂ€ter als LLM_API_KEY eintragen, Anbieter-Wort anthropic.
💾 Claude ist ein tolles Gehirn, kostet aber pro Nutzung ein paar Rappen — fĂŒr den Windbutler nicht nötig. Er wĂ€hlt automatisch das gĂŒnstige Claude-Modell, wenn du ihn einsetzt.
  1. Konto: Öffne platform.openai.com → Sign up (E-Mail oder Google-Konto). Kreditkarte nötig, es gibt aber ein kleines Startguthaben.
  2. API-Key erzeugen: Links API Keys → Create new secret key → Name z.B. windbutler → kopieren.
  3. Notieren fĂŒr Etappe 6: SpĂ€ter als LLM_API_KEY eintragen, Anbieter-Wort openai.
💡 Egal fĂŒr wen du dich entscheidest: Du kannst spĂ€ter jederzeit wechseln, ohne den Code anzufassen — in Etappe 6 einfach das Anbieter-Wort Ă€ndern. Mehr dazu in den FAQ unten.

3Etappe 3 · Cloudflare-Konto (das Zuhause des Butlers)

Dauer: ca. 3 Minuten · Der Butler braucht einen kleinen Computer im Internet, auf dem er wohnt und arbeitet. Cloudflare vermietet ihn gratis.

  1. Registrieren: Öffne dash.cloudflare.com → klicke auf Sign up. Trage deine E-Mail-Adresse und ein Passwort ein (oder nutze Google-Anmeldung).
  2. E-Mail bestĂ€tigen: Du bekommst eine Mail von Cloudflare → Link darin anklicken. Fertig.
  3. Nichts weiter: Cloudflare fragt vielleicht, ob du eine Domain hinzufĂŒgen willst — ĂŒberspringe das (Skip). Wir brauchen keine Domain, kein Abo, keine Kreditkarte. Der Gratis-Tarif reicht fĂŒr den Windbutler völlig.

4Etappe 4 · Den Butler einziehen lassen (Code einsetzen)

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.

  1. Zu den Workers: Nach dem Login in Cloudflare links im MenĂŒ Compute (Workers) anklicken. (Falls dein MenĂŒ anders aussieht: such den Eintrag Workers & Pages.)
  2. Erstellen: Klicke Create bzw. Create application → wĂ€hle Create Worker (Start with / Hello World).
  3. Name: Gib dem Worker den Namen windbutler → klicke Deploy (bzw. «Bereitstellen»). Cloudflare legt jetzt einen Mini-Computer mit Beispielcode an.
  4. Code-Editor öffnen: Klicke danach auf Edit code (die SchaltflÀche mit dem </>-Zeichen). Es öffnet sich ein Editor mit einfachem Beispieltext.
  5. Beispielcode löschen: Klicke in den Text, markiere alles (am Computer: Strg+A bzw. Cmd+A) und lösche es.
  6. Windbutler-Code einfĂŒgen: Der komplette Code steckt auf dieser Seite — im grossen Kasten direkt unter diesen Schritten. Klicke auf «Windbutler-Code kopieren», wechsle zum Cloudflare-Editor und fĂŒge ihn ein (Strg+V bzw. Cmd+V). Es ist egal, dass oben deutsche Kommentare stehen — die gehören dazu.
  7. Speichern: Oben rechts auf Deploy klicken. Der Butler wohnt jetzt online.
  8. Adresse anschauen: Unter dem Worker-Namen steht seine Adresse, sie sieht so aus: https://windbutler.dein-name.workers.dev — der Teil zwischen windbutler. und .workers.dev ist dein Cloudflare-Name. Trag ihn gleich im Kasten unten ein, dann sind alle Adressen dieser Anleitung automatisch klickbar.
Sobald du deinen Namen eintippst, erscheint hier deine Butler-Adresse — und alle Adressen dieser Anleitung werden klickbar und tragen deinen Namen.
💡 Der Code ist anbieter-neutral geschrieben: Ob Mistral, Gemini, Claude oder OpenAI — er spricht alle. Am Code musst du nichts Ă€ndern, auch nicht spĂ€ter beim Gehirn-Wechsel.

📋 Der komplette Windbutler-Code (fĂŒr Schritt 6)

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&current=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.";
}

5Etappe 5 · Dem Butler ein GedÀchtnis geben (KV)

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».

  1. Notizheft kaufen (gratis): Links im MenĂŒ Storage & Databases (Speicher) → KV anklicken.
  2. Notizheft erstellen und speichern: Klicke Create namespace (bzw. «Namespace erstellen») → als Name windbutler eintippen → Add / Create klicken. Erst mit diesem Klick ist es gespeichert und erscheint in der Liste — dieser Schritt kommt VOR der Benennung der Variable.
  3. Zusammenkoppeln: Gehe zurĂŒck zu deinem Worker: links MenĂŒ Compute (Workers) → auf windbutler klicken → Reiter Settings (Einstellungen) → Bindings → Add binding (bzw. «Bindung hinzufĂŒgen»).
  4. Bindungstyp wĂ€hlen und bestĂ€tigen: In der Liste links KV-Namespace anklicken — dann rechts unten auf den blauen Knopf «Bindung hinzufĂŒgen» klicken. ⚠ Wichtig: Die Eingabefelder erscheinen erst nach diesem Klick. Vorher zeigt das Fenster nur eine Beschreibung mit Beispiel-Code — dort kannst und musst du noch nichts eintragen.
  5. Jetzt die Felder ausfĂŒllen: Als Variablenname GENAU MEM eintippen (drei Buchstaben, grossgeschrieben) — darunter im AufklappmenĂŒ deinen gespeicherten Namespace windbutler auswĂ€hlen. (Ist das MenĂŒ leer, fehlt Schritt 1–2.)
  6. Übernehmen: Save / Deploy klicken — ohne diesen Klick wird die Bindung nicht ĂŒbernommen.
📩 Wenn nach dem Speichern eine gelbe Warnung erscheint («Aktualisieren Sie Ihre Wrangler-Konfiguration 
 um Deployments synchron zu halten», mit Code-Kasten und «Copy»-Knopf): Das ist kein Fehler, sondern Cloudflares Hinweis: «Die Bindung ist gespeichert — hier ist der zugehörige Eintrag fĂŒr die Konfigurationsdatei, falls spĂ€ter im Editor neu deployed wird.» So gehst du damit um: (1) Reiter wrangler.jsonc anklicken → Copy. (2) Worker → Edit code öffnen und links in der Dateiliste nach wrangler.jsonc schauen. Gibt es die Datei: öffnen, die kopierten Zeilen innerhalb der Ă€ussersten geschweiften Klammer einfĂŒgen (z.B. direkt nach der Zeile mit «compatibility_date» — Komma nach der Zeile davor nicht vergessen) → Deploy. Gibt es die Datei nicht: Warnung getrost ignorieren — dein Worker lĂ€uft komplett ĂŒbers Dashboard, die Bindung bleibt aktiv.
✅ So prĂŒfst du, ob das GedĂ€chtnis sitzt: Öffne die Adresse deines Butlers mit /health dahinter — klickbar, sobald dein Cloudflare-Name in Etappe 4 eingetragen ist, ansonsten abtippen: https://windbutler.dein-name.workers.dev/health — es muss «Windbutler lebt · GedĂ€chtnis OK» stehen. Steht dort «⚠ KV-Binding MEM fehlt», stimmt der Variablenname oder der Namespace nicht (Schritte 4–6 nochmals).
⚠ Heisst das Binding nicht exakt MEM, findet der Butler sein GedĂ€chtnis nicht — er meldet das dann mit einer Warnung auf der /health-Seite. Also lieber zweimal kontrollieren.

6Etappe 6 · SchlĂŒssel und Gehirn einschreiben (Variablen & Secrets)

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ĂŒ.

  1. Ort: Worker windbutler öffnen → Settings → Variables and Secrets (Variablen und Geheimnisse).
  2. Erster Eintrag — Telegram-SchlĂŒssel: Klicke Add → wĂ€hle als Typ Secret (Geheimnis — wird danach unsichtbar gespeichert). Als Name: TELEGRAM_BOT_TOKEN. Als Wert: deinen Token aus Etappe 1 (der lange Buchstabensalat vom BotFather) → Save.
  3. Zweiter Eintrag — Gehirn-SchlĂŒssel: Nochmal Add → Typ Secret. Name: LLM_API_KEY. Wert: dein API-Key aus Etappe 2 → Save.
  4. Dritter Eintrag — Gehirn-Auswahl: Nochmal Add → diesmal Typ Text (Variable). Name: LLM_PROVIDER. Wert: genau eines dieser Wörter, je nach Etappe 2 — am einfachsten per Knopfdruck kopieren und ins Wert-Feld einfĂŒgen (Strg+V bzw. Cmd+V):
    (klein geschrieben!) → Save.
  5. Veröffentlichen: Danach unbedingt oben rechts (oder unten) Deploy klicken — ohne Deploy gilt nichts!
NameTypWertWoher?
TELEGRAM_BOT_TOKENSecret123456789:ABC-Def
Etappe 1 · BotFather
LLM_API_KEYSecretdein API-KeyEtappe 2 · Gehirn-Anbieter
LLM_PROVIDERTextmistral · gemini · anthropic · openaideine Gehirn-Wahl
💡 Gehirn wechseln? SpĂ€ter einfach einen neuen API-Key als LLM_API_KEY eintragen und bei LLM_PROVIDER das Wort Ă€ndern → Deploy. Fertig — der Code bleibt unverĂ€ndert.

7Etappe 7 · Wecker stellen und TĂŒrklingel anschliessen

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).

🛌 Der Wecker (Cron Trigger)

  1. Worker windbutler → Settings → Trigger Events → bei Cron Triggers auf Add klicken.
  2. In das Feld den Ausdruck 5 * * * * einfĂŒgen — am einfachsten per Knopf kopieren und ins Feld einfĂŒgen (Strg+V bzw. Cmd+V): — genau so, mit LeerschlĂ€gen zwischen den Sternchen — → Save. Die Liste «GeschĂ€tzte zukĂŒnftige Ereignisse (UTC)» zeigt dir danach stĂŒndliche Weckungen an: das ist korrekt und so gewollt.
  3. Warum steht da nicht einfach 07:00, 12:00, 17:00? Weil das nicht möglich ist: Der Wecker von Cloudflare tickt nach Weltzeit (UTC) — erkennbar am KĂŒrzel «(UTC)» in der Ereignis-Liste im Dialog. Die Schweizer Zeit aber springt zweimal pro Jahr (Sommer-/Winterzeit). Ein fester Ausdruck wie «07:00» wĂŒrde deshalb je nach Jahreszeit eine Stunde daneben wecken — und nach jeder Zeitumstellung mĂŒsste man ihn von Hand korrigieren.
  4. Die Lösung: Der Ausdruck bedeutet «Weck mich jede Stunde, kurz nach der vollen Stunde». Der Butler schaut bei jedem Weckruf selbst auf die Schweizer Uhr und arbeitet nur um 07:00, 12:00 und 17:00 — bei den anderen 21 Weckungen dreht er sich um und schlĂ€ft weiter. Genau das, was die Einleitung verspricht: 3× tĂ€glich.
  5. Wo die Zeiten stehen (falls du sie je Ă€ndern willst): im Code ganz oben, eine einzige Zeile: const CHECK_HOURS = [7, 12, 17]; — Ă€ndern, Deployen, fertig. Sommer-/Winterzeit regelt sich in jedem Fall von selbst.

🔔 Die TĂŒrklingel (Webhook)

  1. Öffne die Adresse deines Workers mit /setup dahinter — klickbar, sobald dein Cloudflare-Name in Etappe 4 eingetragen ist, ansonsten abtippen: https://windbutler.dein-name.workers.dev/setup
  2. Es muss stehen: «✅ Windbutler ist mit Telegram verbunden». Das war's — ab jetzt weiss Telegram, dass jede Nachricht an deinen Bot an den Butler geht.
  3. ⚙ FĂŒr spĂ€ter merken: Jedes Mal, wenn du den Butler-Code je neu einfĂŒgst und deployst (z. B. bei einem Update): /setup danach immer einmal neu öffnen — erst dann nimmt Telegram wieder Nachrichten an.
  4. Falls eine Fehlermeldung kommt: Token in Etappe 6 falsch abgetippt? Das ist der hĂ€ufigste Grund. Korrigieren → Deploy → Seite neu laden.

8Etappe 8 · Erster Start & Spots einrichten

Dauer: ca. 10 Minuten · Fast fertig — jetzt weckst du den Butler das erste Mal.

  1. In Telegram: öffne den Chat mit deinem Bot und sende /start. Der Butler begrĂŒsst dich — er ist live! 🎉 đŸ€– Butler-Chat öffnen (der Knopf wird scharf, sobald dein Bot-Username aus Etappe 1 eingetragen ist)
  2. Gesundheitscheck: Sende /status. Er antwortet mit seinen Einstellungen (noch 0 Spots, das Àndert sich gleich).
  3. Spots einrichten: Scrolle hier auf der Seite nach unten zum Spot-Werkzeug, klicke ein Preset an (oder fĂŒlle frei), klicke «Auf die Spot-Liste» — und dort pro Spot auf «Kopieren & Telegram öffnen»: der Befehl wandert in deine Zwischenablage und der Chat mit deinem Butler öffnet sich. Befehl einfĂŒgen und senden — der Butler bestĂ€tigt mit «Am Start! đŸ„Â».
  4. Alarme scharf: Sende /alerts on. Ab jetzt prĂŒft er automatisch um 07:00, 12:00 und 17:00 Uhr.
  5. Testfahrt ohne Wartezeit: Sende /check Sihlsee (oder deinen Spotnamen) — er antwortet sofort mit einer EinschĂ€tzung. Und fĂŒr einen kompletten Probelauf kannst du im Browser https://windbutler.dein-name.workers.dev/tick öffnen — das ist wie 12:00 Uhr mittags, nur sofort. (Klickbar mit deinem Cloudflare-Name aus Etappe 4.) Kleine Bremse: max. ein Probelauf alle 10 Minuten — das schĂŒtzt dich vor unnötigen Kosten durch neugierige Klicks.
  6. Frei schreiben: Probiere es aus: «erinnere mich morgen Vormittag nochmals» oder «am sihlsee erst ab 12 kn melden». Er versteht dich — das ist sein Gehirn aus Etappe 2.
🏁 Das war's! Der Butler ist im Dienst. Wenn ein Fenster passt (z.B. Freitag 11–17 Uhr, NNO, 9–13 kn, trocken), schreibt er dir von selbst — persönlich formuliert. Viel Spass auf dem Wasser! 🏄

AButler-Bot (fĂŒr die Befehle)

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.

BSpot hinzufĂŒgen / bearbeiten

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.

CVerwaltete Spots

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.

DAdmin-Kommandos

Zum Steuern des Butlers direkt in Telegram.

/startButler starten, Chat-ID registrieren
/spot add 
Spot mit Trigger registrieren (aus der Liste kopieren)
/spot listAlle registrierten Spots zeigen
/spot remove <Name>Spot löschen
/check <Spot>Sofortige EinschĂ€tzung fĂŒr einen Spot
/alerts on / /alerts offAlarme global ein-/ausschalten
/statusZustand des Butlers (Spots, Alarme, letzte Checks)
/quiet 22:00-07:00Ruhezeit — keine Meldungen in diesem Fenster
/usersFriends & Family: registrierte Nutzer anzeigen (nur Admin)
/pause / /resumeButler pausieren / fortsetzen (nur Admin)
/inviteCrew-Einladungscode erstellen — Friends & Family kommen nur noch damit rein (nur Admin)
/invitesAktive 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.

Wenn's hakt: Erste-Hilfe-Kiste 🔧

Die hĂ€ufigsten Stolpersteine — in der Reihenfolge, in der sie vorkommen.

Der Bot antwortet in Telegram ĂŒberhaupt nicht
  1. Hast du in Etappe 7 die /setup-Adresse im Browser geöffnet und «✅ 
 verbunden» gesehen? Wenn nein: nachholen. (Das gilt besonders nach jedem neuen Deploy des Codes: /setup danach immer einmal neu öffnen.)
  2. Kam bei /setup eine Fehlermeldung? Dann stimmt sehr wahrscheinlich der TELEGRAM_BOT_TOKEN nicht (Etappe 6) — abtippen ist haarig, Tippfehler sind die hĂ€ufigste Ursache. Token neu kopieren, einfĂŒgen, Deploy, /setup erneut öffnen.
  3. Hast du den Code in Etappe 4 wirklich eingefĂŒgt und deployed? Öffne einmal https://windbutler.dein-name.workers.dev — es muss «Windbutler đŸŒŹïž  » stehen.
  4. Bist du im richtigen Chat? Es muss der Chat mit deinem Bot sein (t.me/dein_name_bot), nicht BotFather.
Der Butler meldet «GedÀchtnis fehlt» (MEM-Warnung auf /health)

Das KV-GedÀchtnis ist nicht angekoppelt: Etappe 5 wiederholen. Der Variablenname muss exakt MEM sein (grossgeschrieben), und danach Deploy nicht vergessen.

Der Butler lÀuft, schreibt aber nie von selbst
  • /alerts on geschickt? /status zeigt es.
  • Sitzt du in der Ruhezeit? Standard ist 22:00–07:00 — stell sie mit /quiet off oder anders ein.
  • Sind deine Regeln realistisch? Sektor 337.5–112.5 heisst N–O — ein Westwind löst nie aus (absichtlich!). Teste mit /check Spotname: Dort siehst du sofort, was er sieht.
  • Er meldet nur Fenster 07–22 Uhr an den nĂ€chsten Tagen — nicht den Live-Wind von jetzt, und nicht heute Abend. Das ist so gewollt.
  • Probelauf ohne Wartezeit: einmal 
/tick öffnen.
Ich habe etwas geĂ€ndert (Spot, Secret, Cron) — nichts passiert

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.

Der Butler schreibt wirres Zeug oder gar nichts mehr (Gehirn-Problem)

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.

HĂ€ufige Fragen

Was kostet der Windbutler wirklich?

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.

Kann ich das Gehirn wechseln, ohne am Code zu schrauben?

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.

Ist diese Anleitung komplett — oder brauche ich noch etwas?

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.

Wird das hier alles irgendwo gespeichert?

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.

Schreibt der Butler den Live-Wind?

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.

Wie geben Friends & Family eigene Spots ein?

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.

Kann ich einen bestehenden Bot löschen und neu beginnen?

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.