# Fertiger Code

Bausteine zum Kopieren: Client für Python und TypeScript, Webhook-Empfänger und Shell.

Bausteine zum Kopieren. Alle lesen den Schlüssel aus der Umgebungsvariable `SAMY_KEY`.

## Client für Python

Eine Datei `samy.py` mit zwei Funktionen: Nachricht schicken und die Seite einer App setzen.

```python
import os, requests  # pip install requests

API = "https://api.packlets.app"
HEADERS = {"Authorization": f"Bearer {os.environ['SAMY_KEY']}"}

def _call(method, path, body):
    res = requests.request(method, API + path, headers=HEADERS, json=body, timeout=10)
    if not res.ok:
        raise RuntimeError(f"Samy {res.status_code}: {res.text}")
    return res.json()

def push(text, title=None, url=None, image=None, buttons=None, elements=None, priority="normal"):
    """Nachricht schicken. Gibt die message_id zurück."""
    body = {"text": text, "title": title, "url": url, "image": image,
            "buttons": buttons or [], "elements": elements or [], "priority": priority}
    return _call("POST", "/v1/push", body)["message_id"]

def set_page(page):
    """App: die Seite setzen, die die App zeigt (https-Adresse)."""
    return _call("PUT", "/v1/ui", {"page": page})
```

So nutzt du sie:

```python
from samy import push, set_page

push("Backup von heute Nacht ist fertig.", title="Backup", priority="silent")

push("shop-api antwortet seit 9:38 Uhr nicht.", title="Server down", priority="high",
     buttons=[{"id": "restart", "label": "Neu starten"}])

push("23 Bestellungen, 1.204 € Umsatz.", title="Tagesbericht", elements=[
    {"type": "chart", "id": "umsatz", "label": "Umsatz der Woche", "unit": "€",
     "points": [{"label": "Mo", "value": 860}, {"label": "Di", "value": 1204}]},
])

set_page("https://dein-server.de/rechnungen")  # nur für ein Packlet der Art App
```

## Client für TypeScript

Eine Datei `samy.ts`, läuft mit Node.js 18 oder neuer ohne weitere Pakete.

```typescript
const API = "https://api.packlets.app";

async function call<T>(method: string, path: string, body: unknown): Promise<T> {
  const res = await fetch(API + path, {
    method,
    headers: { Authorization: `Bearer ${process.env.SAMY_KEY}`, "Content-Type": "application/json" },
    body: JSON.stringify(body),
  });
  if (!res.ok) throw new Error(`Samy ${res.status}: ${await res.text()}`);
  return (await res.json()) as T;
}

/** Nachricht schicken. Gibt die message_id zurück. */
export async function push(message: SamyMessage): Promise<string> {
  return (await call<{ message_id: string }>("POST", "/v1/push", message)).message_id;
}

/** App: die Seite setzen, die die App zeigt (https-Adresse) */
export const setPage = (page: string) => call<{ ok: true; page: string }>("PUT", "/v1/ui", { page });
```

## Typen für TypeScript

Gehören in dieselbe Datei. Damit prüft der Editor schon beim Schreiben, ob Nachrichten, Elemente und Ereignisse stimmen.

```typescript
export type SamyMessage = {
  text: string;
  title?: string;
  url?: string;
  image?: string;
  buttons?: { id: string; label: string; url?: string }[];
  elements?: SamyElement[];
  priority?: "normal" | "high" | "silent";
};

type Layout = {
  /** Pro Nachricht eindeutig: Buchstaben, Ziffern, _ und -, 1 bis 40 Zeichen */
  id: string;
  /** Spalten im Raster, Standard 6 (ganze Breite) */
  width?: 1 | 2 | 3 | 4 | 5 | 6;
  /** Mindesthöhe in Punkten */
  height?: number;
};

export type SamyElement =
  | (Omit<Layout, "height"> & { type: "text"; text: string; style?: "title" | "body" | "caption" })
  | (Layout & { type: "button"; label: string; style?: "primary" | "secondary" })
  | (Layout & { type: "toggle"; label: string; description?: string; value?: boolean })
  | (Layout & { type: "input"; label: string; placeholder?: string; button?: string; value?: string })
  | (Layout & {
      type: "chart";
      label: string;
      points?: { label: string; value: number }[];
      chart?: "bar" | "line";
      unit?: string;
      highlight?: number;
    })
  | (Layout & { type: "table"; columns: string[]; rows?: (string | number)[][]; label?: string });

/** Ereignisse am Webhook */
export type SamyEvent =
  | { type: "button"; packlet_id: string; message_id: string; button_id: string; pressed_at: string }
  | { type: "message"; packlet_id: string; message_id: string; text: string; sent_at: string }
  | {
      type: "ui";
      packlet_id: string;
      /** Die Nachricht, zu der das Element gehört */
      message_id: string;
      element_id: string;
      /** Schalter: true/false, Eingabefeld: Text, Button: null */
      value: boolean | string | null;
      changed_at: string;
    };
```

## Webhook-Empfänger

Prüft die Signatur und verteilt die drei Ereignisse. Die Erklärung dazu steht unter [Webhook einrichten](https://packlets.app/docs/webhook.md).

```python
import hashlib, hmac, os
from flask import Flask, request  # pip install flask

app = Flask(__name__)
SECRET = hashlib.sha256(os.environ["SAMY_KEY"].encode()).hexdigest().encode()

@app.post("/samy")
def samy_webhook():
    expected = "sha256=" + hmac.new(SECRET, request.get_data(), hashlib.sha256).hexdigest()
    if not hmac.compare_digest(expected, request.headers.get("X-Samy-Signature", "")):
        return "", 401

    event = request.get_json()
    if event["type"] == "button":
        print("Knopf", event["button_id"], "in Nachricht", event["message_id"])
    elif event["type"] == "ui":
        print("Element", event["element_id"], "in Nachricht", event["message_id"], "ist jetzt", event["value"])
    elif event["type"] == "message":
        print("Chat:", event["text"])
    return "", 204

app.run(port=8080)
```

```typescript
import { createHash, createHmac, timingSafeEqual } from "node:crypto";
import { createServer } from "node:http";
import type { SamyEvent } from "./samy.ts";

const secret = createHash("sha256").update(process.env.SAMY_KEY ?? "").digest("hex");

function isFromSamy(rawBody: string, signature = ""): boolean {
  const expected = Buffer.from("sha256=" + createHmac("sha256", secret).update(rawBody, "utf8").digest("hex"));
  const given = Buffer.from(signature);
  return expected.length === given.length && timingSafeEqual(expected, given);
}

createServer(async (req, res) => {
  let body = "";
  for await (const chunk of req) body += chunk;
  if (!isFromSamy(body, req.headers["x-samy-signature"] as string | undefined)) {
    res.writeHead(401).end();
    return;
  }
  res.writeHead(204).end(); // erst antworten, dann arbeiten

  const event = JSON.parse(body) as SamyEvent;
  if (event.type === "button") console.log("Knopf", event.button_id, "in Nachricht", event.message_id);
  else if (event.type === "ui") console.log("Element", event.element_id, "in Nachricht", event.message_id, "ist jetzt", event.value);
  else if (event.type === "message") console.log("Chat:", event.text);
}).listen(8080);
```

## Shell

Für Cronjobs und Skripte. Die erste Fassung schickt JSON, die zweite ist die kürzeste, die geht:

```bash
curl -fsS https://api.packlets.app/v1/push \
  -H "Authorization: Bearer $SAMY_KEY" \
  -H "Content-Type: application/json" \
  -d '{"title":"Backup","text":"Backup von heute Nacht ist fertig.","priority":"silent"}'
```

```bash
curl -fsS https://api.packlets.app/v1/push -H "Authorization: Bearer $SAMY_KEY" -d text="Backup fertig"
```

Mit `-f` endet `curl` bei einem Fehler mit einem Status ungleich null. So merkt dein Skript, wenn etwas nicht geklappt hat.

Eine Meldung nur im Fehlerfall:

```bash
./backup.sh || curl -fsS https://api.packlets.app/v1/push \
  -H "Authorization: Bearer $SAMY_KEY" \
  -d title="Backup" -d text="Das Backup ist fehlgeschlagen." -d priority=high
```
