Compare commits

..

6 Commits

Author SHA1 Message Date
c0ff22394c Threema QR: on-demand modal + auto-open on first add
All checks were successful
Build and Push / build (push) Successful in 1m29s
2026-05-17 17:13:44 +02:00
395d2f43cc Threema: customer-friendly texts + QR setup component
All checks were successful
Build and Push / build (push) Successful in 1m27s
2026-05-17 16:50:23 +02:00
6f42b56ad5 Threema package: relay-managed channel users + provisioning endpoints
All checks were successful
Build and Push / build (push) Successful in 1m26s
2026-05-17 11:10:40 +02:00
85c4302f7a Threema Gateway
All checks were successful
Build and Push / build (push) Successful in 1m30s
2026-05-16 22:00:27 +02:00
726151d90b Add new TTS/STT Logic
Some checks failed
Build and Push / build (push) Failing after 45s
2026-05-16 19:55:51 +02:00
a13af83655 Adjust skills
All checks were successful
Build and Push / build (push) Successful in 1m31s
2026-05-11 21:25:09 +02:00
20 changed files with 1726 additions and 204 deletions

View File

@@ -17,3 +17,7 @@ LITELLM_MASTER_KEY=
# Portal Database (CloudNativePG) # Portal Database (CloudNativePG)
DATABASE_URL=postgresql://portal:${PORTAL_DB_PASSWORD}@portal-db-rw.pieced-system.svc:5432/portal DATABASE_URL=postgresql://portal:${PORTAL_DB_PASSWORD}@portal-db-rw.pieced-system.svc:5432/portal
# Threema relay (central pieced-threema-gateway)
THREEMA_RELAY_URL=http://pieced-threema-gateway.threema-gateway.svc:8080
THREEMA_RELAY_ADMIN_TOKEN=__from_openbao__

134
README.md
View File

@@ -1,100 +1,66 @@
# PieCed Portal # Threema UX v2 — QR available on demand
Customer self-service portal for the PieCed IT multi-tenant OpenClaw platform. Replaces the previous attempt that had the QR rendering inline above
the channel help text. That placement didn't work for you in practice
because it wasn't visible when you actually wanted it.
## Stack ## What this does instead
| Layer | Choice | 1. **"Show QR" link** next to the channel title in the threema card —
|-------|--------| visible at all times, clickable any time you want the QR.
| Framework | Next.js 15 LTS (App Router, standalone output, Turbopack) |
| Auth | NextAuth v5 + ZITADEL OIDC (CODE flow) |
| Tenant mgmt | Direct K8s API → `PiecedTenant` CRs (Option A) |
| Usage data | LiteLLM `/team/info` + `/global/spend/logs` |
| i18n | next-intl 4.x (en/de) |
| Styling | Tailwind CSS 4 |
| Deployment | Container in `pieced-system`, exposed at `app.pieced.ch` |
## Setup 2. **Auto-opens the modal** the first time you focus the add-ID input
on the page (so a new user adding their first ID sees it without
needing to click "Show QR" themselves). Doesn't re-pop after
dismissal — the link covers re-opens.
### 1. ZITADEL Application 3. **Modal** with QR + 3-step instructions + "AIAGENT" label. Plain
`<img>` (no next/image), closes on ESC / overlay click / × button.
In ZITADEL console (`auth.pieced.ch`), project "OpenClaw Platform": The earlier `threema-setup.tsx` component file is removed — replaced by
`threema-qr-modal.tsx`.
1. Create Application → **PieCed Portal** → Web → Authentication Method: **CODE** ## Files
2. Redirect URI: `https://app.pieced.ch/api/auth/callback/zitadel`
3. Post-logout URI: `https://app.pieced.ch/login`
4. Note Client ID and Client Secret
### 2. OpenBao Secrets ```
src/lib/threema-gateway-config.ts # unchanged from before — central gateway constants
src/components/channel-users/threema-qr-modal.tsx # NEW — the modal
src/components/channel-users/channel-users.tsx # MODIFIED — Show QR button + focus auto-open + modal mount
deploy/patch-i18n-threema.mjs # adds threemaSetup.showQr label in 4 langs
public/threema/qr_code_AIAGENT.png # unchanged
```
## Apply
```bash ```bash
bao kv put pieced/portal/oidc \ cd /path/to/pieced-portal
client_id="<from step 1>" \
client_secret="<from step 1>" \ # Remove the old inline component if you applied the previous archive
nextauth_secret="$(openssl rand -base64 32)" rm -f src/components/channel-users/threema-setup.tsx
# Drop new + modified files
unzip -o /path/to/threema-ux-v2.zip
# Update messages
node deploy/patch-i18n-threema.mjs
# TS check
npx tsc --noEmit
git add -A
git commit -m "Threema QR: on-demand modal + auto-open on first add"
git push
``` ```
### 3. Build & Push After redeploy, the threema card under Authorized Users shows:
```bash
docker build -t registry.c5ai.ch/pieced/pieced-portal:0.1.0 .
docker push registry.c5ai.ch/pieced/pieced-portal:0.1.0
```
Update image tag in `pieced-gitops/apps/portal/deployment.yaml`, push, ArgoCD syncs.
### 4. DNS
Ensure `app.pieced.ch` A record → MetalLB ingress IP (or ExternalDNS handles it).
## Local Development
```bash
cp .env.example .env.local
# Fill in values — K8s client uses ~/.kube/config locally
npm install
npm run dev
```
## Project Structure
``` ```
src/ threema [Show QR] 0 users
├── app/ ────────────────────
│ ├── api/ <help text: 'Enter your own Threema ID...'>
├── auth/[...nextauth]/route.ts # NextAuth handler ────────────────────
├── tenants/route.ts # Tenant CRUD (K8s API) [ input: A8K2P3X7 ] [ Add ]
└── usage/route.ts # Usage stub ^^^ focusing this opens the QR modal the first time
│ ├── [locale]/
│ │ ├── layout.tsx # Locale layout + NavShell
│ │ ├── page.tsx # Redirect → /dashboard
│ │ ├── login/page.tsx # ZITADEL sign-in
│ │ ├── dashboard/page.tsx # Customer dashboard
│ │ └── admin/page.tsx # Platform admin tenant list
│ ├── layout.tsx # Root layout
│ └── globals.css # Tailwind 4 theme
├── components/
│ ├── layout/nav-shell.tsx # Header + navigation
│ └── ui/ # Reusable UI components
├── i18n/
│ ├── routing.ts # next-intl 4.x routing config
│ ├── navigation.ts # Localized Link, redirect, etc.
│ └── request.ts # Server-side i18n config
├── lib/
│ ├── auth.ts # NextAuth v5 + ZITADEL config
│ ├── k8s.ts # K8s client for PiecedTenant CRs
│ ├── litellm.ts # LiteLLM API client
│ └── session.ts # Session helpers
├── messages/
│ ├── en.json
│ └── de.json
└── types/index.ts # Shared TypeScript types
``` ```
## Session Roadmap Clicking "Show QR" or focusing the input → modal with QR + steps.
- **6.1** ← This session: scaffold, auth, basic pages
- **6.2**: Instance management, package config, usage display
- **6.3**: Onboarding flow (create ZITADEL org → PiecedTenant CR)
- **6.4**: Workspace editor (SOUL.md, AGENTS.md, TOOLS.md)
- **6.5**: Admin panel (tenant lifecycle, billing overview)

70
deploy/README-threema.md Normal file
View File

@@ -0,0 +1,70 @@
# Wiring Threema relay into the portal
Drop-in files in this archive:
```
src/lib/packages.ts # add 'threema' to catalog + customProvisioning flag
src/lib/threema-relay.ts # new — admin API client
src/app/api/tenants/[name]/threema/route.ts # new — POST provision / DELETE deprovision
src/app/api/tenants/[name]/threema/routes/route.ts # new — atomic add/remove of a single Threema ID
src/components/channel-users/channel-users.tsx # branch threema through relay-managed endpoint
src/components/packages/package-card.tsx # handle customProvisioning enable/disable
deploy/patch-i18n-threema.mjs # idempotent i18n key injection
```
## Manual steps after dropping in
1. `.env` (and `.env.example`) — add:
```
THREEMA_RELAY_URL=http://pieced-threema-gateway.threema-gateway.svc:8080
THREEMA_RELAY_ADMIN_TOKEN=__from_openbao__
```
The portal pod's OpenBao client should also read `secret/data/threema-gateway/admin` and surface `token` as this env var (existing ESO pattern in the portal's Helm chart).
2. Patch the message files (one-time):
```bash
node deploy/patch-i18n-threema.mjs
```
3. Re-export `CHANNEL_PACKAGE_IDS` is unchanged in source; verify
`tenants/[name]/page.tsx` still derives the enabled-channels list
from it — it should now include `threema` automatically once a
tenant has it in `spec.packages`.
4. Type-check:
```bash
npx tsc --noEmit
```
## Flow summary
### Enabling Threema for a tenant
1. Customer toggles the threema package card.
2. PackageCard sees `customProvisioning: true` → POSTs `/api/tenants/<name>/threema`.
3. Handler calls relay `POST /admin/tokens` → gets `{token, hmacSecret}`.
4. Handler writes them to OpenBao at `secret/data/tenants/tenant-<name>/threema-relay`.
5. PackageCard then PATCHes `tenant.spec.packages` to include `threema`.
6. Operator reconciles: ExternalSecret syncs OpenBao → Secret; OpenClaw pod restarts with `THREEMA_RELAY_*` env vars; plugin registers `threema` channel.
### Customer adds a Threema ID
1. UI calls `POST /api/tenants/<name>/threema/routes` with `{threemaId}`.
2. Handler calls relay `POST /admin/routes` (uniqueness enforced at PK).
3. On 201 or 409-from-same-tenant: handler patches K8s `spec.channelUsers.threema`.
4. On 409-from-other-tenant: 409 to client with explanation.
5. On K8s patch failure after relay success: handler compensates by `DELETE /admin/routes/...` at the relay.
### Customer removes a Threema ID
1. UI calls `DELETE /api/tenants/<name>/threema/routes?threemaId=...`.
2. Handler patches K8s `spec.channelUsers.threema` to drop the ID.
3. Handler calls relay `DELETE /admin/routes/...` (404 = idempotent OK).
4. If relay drop fails: K8s already updated, surface warning but treat as success — relay deletes are idempotent on retry.
### Disabling Threema for a tenant
1. Customer disables the threema card.
2. PackageCard DELETEs `/api/tenants/<name>/threema`.
3. Handler calls relay `DELETE /admin/tokens/<name>` (cascades to all routes for this tenant).
4. Handler deletes OpenBao secret at `secret/data/tenants/tenant-<name>/threema-relay`.
5. PackageCard then PATCHes `tenant.spec.packages` to drop `threema`.
6. Operator reconciles: ExternalSecret targets a missing OpenBao path → Secret deleted → OpenClaw pod restarts without `threema` channel.
There's a small window (between step 4 and the operator's reconcile) where the pod still thinks it has a relay token but the relay has revoked it. Outbound during that window returns 401 from the relay; inbound is blackholed at the relay because routes are gone. Both are graceful failures.

View File

@@ -0,0 +1,122 @@
#!/usr/bin/env node
/**
* Run: node deploy/patch-i18n-threema.mjs
*
* Idempotently injects (or overwrites) customer-facing Threema texts:
* - packages.threema.{description, instructions, disclaimer}
* - channelUsers.threemaIdHelp
* - channelUsers.threemaSetup.{title, step1, step2, step3, qrAlt}
*
* Replaces the earlier version of this script entirely. The new texts:
* - Drop "Gateway account" jargon (customers don't know it)
* - Drop asterisk-prefix references (customers don't see / type it)
* - Tell the customer to add their OWN Threema ID, not someone else's
* - Disclose that Threema charges per message via the gateway
* - Walk through the QR-scan + add-your-ID flow explicitly
*
* Re-running is safe — keys are set, not merged, so this is the
* source of truth for the values it touches.
*/
import { readFileSync, writeFileSync } from "fs";
const i18n = {
en: {
pkg: {
description:
"Send and receive messages through Threema. Each inbound and outbound message uses the shared PieCed messaging service and incurs a per-message charge from Threema — a third-party cost, separate from your PieCed subscription.",
instructions:
"1. Enable this package.\n2. Open Threema on your phone, scan the QR code shown under Authorized Users → threema, and accept the contact.\n3. Add your own Threema ID under Authorized Users → threema so the assistant recognises your messages.\n4. Send a message from Threema to start chatting with the assistant.",
disclaimer:
"Messages between Threema and PieCed are end-to-end encrypted up to PieCed's messaging service, where they are decrypted to be routed to your assistant. Each message sent or received is counted toward Threema's per-message billing — see your plan for current rates.",
},
channelHelp:
"Enter your own Threema ID — the 8 characters shown in your Threema app under Settings → My Threema ID. Once added, you'll be able to chat with the assistant directly from Threema.",
setup: {
title: "Add the assistant to your Threema",
step1: "Open Threema on your phone.",
step2: "Tap the scan icon and scan this QR code to add the assistant as a contact.",
step3: "Then add your own Threema ID below.",
qrAlt: "QR code to add {gateway} as a Threema contact",
showQr: "Show QR",
},
},
de: {
pkg: {
description:
"Senden und empfangen Sie Nachrichten über Threema. Jede eingehende und ausgehende Nachricht läuft über den gemeinsamen PieCed-Messaging-Dienst und verursacht eine Gebühr pro Nachricht bei Threema — eine Drittanbieter-Kostenposition, unabhängig von Ihrem PieCed-Abonnement.",
instructions:
"1. Aktivieren Sie dieses Paket.\n2. Öffnen Sie Threema auf Ihrem Telefon, scannen Sie den QR-Code unter Autorisierte Benutzer → threema und akzeptieren Sie den Kontakt.\n3. Tragen Sie Ihre eigene Threema-ID unter Autorisierte Benutzer → threema ein, damit der Assistent Ihre Nachrichten erkennt.\n4. Schreiben Sie eine Nachricht aus Threema, um das Gespräch zu beginnen.",
disclaimer:
"Nachrichten zwischen Threema und PieCed werden Ende-zu-Ende verschlüsselt bis zum PieCed-Messaging-Dienst, wo sie entschlüsselt und an Ihren Assistenten weitergeleitet werden. Jede gesendete oder empfangene Nachricht wird gemäss Threema-Tarif pro Nachricht abgerechnet — die aktuellen Preise finden Sie in Ihrem Plan.",
},
channelHelp:
"Geben Sie Ihre eigene Threema-ID ein — die 8 Zeichen, die in Ihrer Threema-App unter Einstellungen → Meine Threema-ID angezeigt werden. Anschliessend können Sie direkt aus Threema mit dem Assistenten chatten.",
setup: {
title: "Assistenten zu Threema hinzufügen",
step1: "Öffnen Sie Threema auf Ihrem Telefon.",
step2: "Tippen Sie auf das Scan-Symbol und scannen Sie diesen QR-Code, um den Assistenten als Kontakt hinzuzufügen.",
step3: "Fügen Sie anschliessend unten Ihre eigene Threema-ID hinzu.",
qrAlt: "QR-Code, um {gateway} als Threema-Kontakt hinzuzufügen",
showQr: "QR anzeigen",
},
},
fr: {
pkg: {
description:
"Envoyez et recevez des messages via Threema. Chaque message entrant ou sortant transite par le service de messagerie PieCed partagé et entraîne des frais par message facturés par Threema — un coût tiers, distinct de votre abonnement PieCed.",
instructions:
"1. Activez ce package.\n2. Ouvrez Threema sur votre téléphone, scannez le QR code affiché dans Utilisateurs autorisés → threema, puis acceptez le contact.\n3. Ajoutez votre propre identifiant Threema sous Utilisateurs autorisés → threema afin que l'assistant reconnaisse vos messages.\n4. Envoyez un message depuis Threema pour commencer la conversation.",
disclaimer:
"Les messages entre Threema et PieCed sont chiffrés de bout en bout jusqu'au service de messagerie PieCed, où ils sont déchiffrés pour être acheminés vers votre assistant. Chaque message envoyé ou reçu est facturé par Threema selon son tarif par message — consultez votre plan pour les tarifs en vigueur.",
},
channelHelp:
"Saisissez votre propre identifiant Threema — les 8 caractères affichés dans votre application Threema sous Réglages → Mon identifiant Threema. Une fois ajouté, vous pourrez discuter directement avec l'assistant depuis Threema.",
setup: {
title: "Ajouter l'assistant à Threema",
step1: "Ouvrez Threema sur votre téléphone.",
step2: "Appuyez sur l'icône de scan et scannez ce QR code pour ajouter l'assistant comme contact.",
step3: "Puis ajoutez votre propre identifiant Threema ci-dessous.",
qrAlt: "QR code pour ajouter {gateway} comme contact Threema",
showQr: "Afficher le QR",
},
},
it: {
pkg: {
description:
"Invia e ricevi messaggi tramite Threema. Ogni messaggio in entrata e in uscita passa attraverso il servizio di messaggistica condiviso di PieCed e comporta un addebito per messaggio da parte di Threema — un costo di terzi, separato dall'abbonamento PieCed.",
instructions:
"1. Attiva questo pacchetto.\n2. Apri Threema sul tuo telefono, scansiona il QR code mostrato in Utenti autorizzati → threema e accetta il contatto.\n3. Aggiungi il tuo ID Threema sotto Utenti autorizzati → threema affinché l'assistente riconosca i tuoi messaggi.\n4. Invia un messaggio da Threema per iniziare la conversazione.",
disclaimer:
"I messaggi tra Threema e PieCed sono cifrati end-to-end fino al servizio di messaggistica PieCed, dove vengono decifrati per essere inoltrati al tuo assistente. Ogni messaggio inviato o ricevuto viene addebitato da Threema secondo la sua tariffa per messaggio — consulta il tuo piano per i prezzi attuali.",
},
channelHelp:
"Inserisci il tuo ID Threema — gli 8 caratteri mostrati nella tua app Threema sotto Impostazioni → Il mio ID Threema. Una volta aggiunto, potrai conversare con l'assistente direttamente da Threema.",
setup: {
title: "Aggiungi l'assistente a Threema",
step1: "Apri Threema sul tuo telefono.",
step2: "Tocca l'icona di scansione e scansiona questo QR code per aggiungere l'assistente ai contatti.",
step3: "Quindi aggiungi il tuo ID Threema qui sotto.",
qrAlt: "QR code per aggiungere {gateway} come contatto Threema",
showQr: "Mostra QR",
},
},
};
for (const [lang, entries] of Object.entries(i18n)) {
const path = `src/messages/${lang}.json`;
const json = JSON.parse(readFileSync(path, "utf8"));
json.packages = json.packages ?? {};
json.packages.threema = {
description: entries.pkg.description,
instructions: entries.pkg.instructions,
disclaimer: entries.pkg.disclaimer,
};
json.channelUsers = json.channelUsers ?? {};
json.channelUsers.threemaIdHelp = entries.channelHelp;
json.channelUsers.threemaSetup = entries.setup;
writeFileSync(path, JSON.stringify(json, null, 2) + "\n");
console.log(`Patched ${path}`);
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 56 KiB

View File

@@ -13,8 +13,15 @@ import { ChannelUsers } from "@/components/channel-users/channel-users";
import { AssignedUsersPanel } from "@/components/tenants/assigned-users-panel"; import { AssignedUsersPanel } from "@/components/tenants/assigned-users-panel";
import { SubscriptionToggle } from "@/components/tenants/subscription-toggle"; import { SubscriptionToggle } from "@/components/tenants/subscription-toggle";
import { formatDateTime, formatRelative } from "@/lib/format"; import { formatDateTime, formatRelative } from "@/lib/format";
import { CHANNEL_PACKAGE_IDS } from "@/lib/packages";
const CHANNEL_PACKAGES = ["telegram", "discord", "email"]; // CHANNEL_PACKAGES used to be a hardcoded literal here
// (`["telegram", "discord", "email"]`). It now derives from the
// portal-side catalog so adding a new channel anywhere only requires
// editing src/lib/packages.ts. The `email` channel was dropped as
// part of the Phase A package-model rework — IMAP/SMTP is now the
// `mail` skill instead.
const CHANNEL_PACKAGES = CHANNEL_PACKAGE_IDS;
export default async function TenantDetailPage({ export default async function TenantDetailPage({
params, params,

View File

@@ -0,0 +1,168 @@
import { NextRequest, NextResponse } from "next/server";
import { getSessionUser, canMutate } from "@/lib/session";
import { getTenant } from "@/lib/k8s";
import {
writePackageSecrets,
deletePackageSecrets,
} from "@/lib/openbao";
import { mintToken, revokeToken } from "@/lib/threema-relay";
import { safeError } from "@/lib/errors";
/**
* Threema package provisioning — special-cased because the credentials
* are platform-issued (relay mints them), not customer-supplied.
*
* POST /api/tenants/:name/threema
* - Mints a per-tenant bearer + HMAC secret from the central relay.
* - Writes both to OpenBao under
* secret/data/tenants/<tenant-{name}>/threema-relay so the
* operator's ExternalSecret can sync them into the tenant
* namespace alongside other channel secrets.
* - Returns 200 on success. The caller (PackageCard) then PATCHes
* tenant.spec.packages to add "threema".
*
* DELETE /api/tenants/:name/threema
* - Revokes the per-tenant token at the relay (cascades to all
* routes — the relay's tokens.deleteToken also deletes routes).
* - Deletes the OpenBao secret so the ExternalSecret/operator can
* converge cleanly.
* - Returns 200 on success even if no token existed (idempotent).
*
* Failure semantics
* -----------------
* On POST: if minting succeeds but the OpenBao write fails, we attempt
* to revoke the just-minted token before returning the error. That way
* the relay doesn't keep an orphan token row that nothing can use.
* Best-effort cleanup; if the revoke also fails, the relay admin can
* use DELETE /admin/tokens/<name> manually.
*
* On DELETE: we revoke FIRST, then delete OpenBao. If revoke fails we
* return the error and stop — leaving OpenBao alone means the pod's
* still-mounted secret keeps working in the brief window between
* "customer hits disable" and "operator reconciles spec without threema",
* which is more graceful than yanking the secret out from under a
* running pod.
*/
const VAULT_SUFFIX = "threema-relay";
export async function POST(
_req: NextRequest,
{ params }: { params: Promise<{ name: string }> },
) {
const user = await getSessionUser();
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
if (!canMutate(user))
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
const { name } = await params;
try {
const tenant = await getTenant(name);
if (!tenant)
return NextResponse.json({ error: "Not found" }, { status: 404 });
if (
!user.isPlatform &&
tenant.metadata.labels?.["pieced.ch/zitadel-org-id"] !== user.orgId
) {
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
}
const minted = await mintToken(name);
if (!minted.ok) {
return NextResponse.json(
{ error: `Relay mint failed: ${minted.message}` },
{ status: minted.kind === "http" ? 502 : 503 },
);
}
try {
await writePackageSecrets(`tenant-${name}`, VAULT_SUFFIX, {
token: minted.token,
"hmac-secret": minted.hmacSecret,
});
} catch (e) {
// Compensate: revoke the just-minted token so the relay doesn't
// hold an orphan. Best-effort — log and continue surfacing the
// original error.
const revoke = await revokeToken(name);
if (!revoke.ok) {
console.error(
`[threema/provision] Compensating revoke failed for ${name}: ${revoke.message}`,
);
}
return NextResponse.json(
{ error: `OpenBao write failed: ${safeError(e, "secret store unavailable")}` },
{ status: 503 },
);
}
return NextResponse.json({ ok: true });
} catch (e) {
console.error("[threema/provision]", e);
return NextResponse.json(
{ error: safeError(e, "Provisioning failed") },
{ status: 500 },
);
}
}
export async function DELETE(
_req: NextRequest,
{ params }: { params: Promise<{ name: string }> },
) {
const user = await getSessionUser();
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
if (!canMutate(user))
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
const { name } = await params;
try {
const tenant = await getTenant(name);
if (!tenant)
return NextResponse.json({ error: "Not found" }, { status: 404 });
if (
!user.isPlatform &&
tenant.metadata.labels?.["pieced.ch/zitadel-org-id"] !== user.orgId
) {
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
}
const revoke = await revokeToken(name);
// 404 from relay = nothing to revoke = idempotent success.
if (!revoke.ok && !(revoke.kind === "http" && revoke.status === 404)) {
return NextResponse.json(
{ error: `Relay revoke failed: ${revoke.message}` },
{ status: revoke.kind === "http" ? 502 : 503 },
);
}
// Delete the OpenBao secret. Idempotent — deletePackageSecrets
// tolerates 404.
try {
await deletePackageSecrets(`tenant-${name}`, VAULT_SUFFIX);
} catch (e) {
// Already revoked at the relay — surface the openbao failure
// but keep the partial-success state visible.
return NextResponse.json(
{
error: `Token revoked, but OpenBao delete failed: ${safeError(e, "secret store unavailable")}`,
partial: true,
},
{ status: 503 },
);
}
return NextResponse.json({
ok: true,
deletedRoutes: revoke.ok ? revoke.deletedRoutes : 0,
});
} catch (e) {
console.error("[threema/deprovision]", e);
return NextResponse.json(
{ error: safeError(e, "Deprovisioning failed") },
{ status: 500 },
);
}
}

View File

@@ -0,0 +1,217 @@
import { NextRequest, NextResponse } from "next/server";
import { z } from "zod";
import { getSessionUser, canMutate } from "@/lib/session";
import { getTenant, patchTenantSpec } from "@/lib/k8s";
import {
createRoute,
deleteRoute,
isRouteConflictForOtherTenant,
isRouteConflictForSameTenant,
listRoutes,
} from "@/lib/threema-relay";
import { safeError } from "@/lib/errors";
/**
* Threema route management — keeps three places in sync:
*
* 1. Relay DB (`routes` table) — source of truth for uniqueness
* 2. K8s spec.channelUsers.threema — what the operator sees
* 3. Customer UI — derived from (2)
*
* Add (POST) order: relay first (to claim uniqueness atomically), then
* K8s. On K8s failure we compensate by deleting the relay route.
*
* Remove (DELETE) order: K8s first (UI shows it gone immediately, which
* is the customer-facing semantic that matters), then relay. On relay
* failure we DO NOT rollback K8s — the customer wanted it gone, and
* the relay's stale route will be cleaned up on the next retry (deletes
* are idempotent at the relay).
*
* Read-modify-write race: patchTenantSpec uses K8s merge-patch on
* spec.channelUsers.threema, which REPLACES the entire array. We GET
* the latest array, mutate it, then PATCH. Two concurrent adds from the
* same customer's tabs can lose one of them. Acceptable at pilot scale
* (single-digit customers, low concurrency); revisit with SSA + field
* managers if it ever bites.
*/
const ROUTE_BODY = z.object({
threemaId: z
.string()
.regex(/^[A-Z0-9]{8}$/, "Threema ID must be 8 uppercase alphanumeric chars (no asterisk)"),
});
// ---- helpers --------------------------------------------------------------
async function loadTenantOrError(name: string, user: Awaited<ReturnType<typeof getSessionUser>>) {
if (!user) return { error: NextResponse.json({ error: "Unauthorized" }, { status: 401 }) };
if (!canMutate(user))
return { error: NextResponse.json({ error: "Forbidden" }, { status: 403 }) };
const tenant = await getTenant(name);
if (!tenant)
return { error: NextResponse.json({ error: "Not found" }, { status: 404 }) };
if (
!user.isPlatform &&
tenant.metadata.labels?.["pieced.ch/zitadel-org-id"] !== user.orgId
) {
return { error: NextResponse.json({ error: "Forbidden" }, { status: 403 }) };
}
return { tenant };
}
function currentThreemaIds(tenantSpec: any): string[] {
const cu = tenantSpec?.channelUsers ?? {};
const ids = cu.threema;
return Array.isArray(ids) ? ids.filter((x) => typeof x === "string") : [];
}
// ---- GET ------------------------------------------------------------------
export async function GET(
_req: NextRequest,
{ params }: { params: Promise<{ name: string }> },
) {
const user = await getSessionUser();
const { name } = await params;
const loaded = await loadTenantOrError(name, user);
if (loaded.error) return loaded.error;
const res = await listRoutes(name);
if (!res.ok) {
return NextResponse.json(
{ error: `Relay list failed: ${res.message}` },
{ status: res.kind === "http" ? 502 : 503 },
);
}
return NextResponse.json({ routes: res.routes });
}
// ---- POST -----------------------------------------------------------------
export async function POST(
req: NextRequest,
{ params }: { params: Promise<{ name: string }> },
) {
const user = await getSessionUser();
const { name } = await params;
const loaded = await loadTenantOrError(name, user);
if (loaded.error) return loaded.error;
const tenant = loaded.tenant;
const body = await req.json().catch(() => null);
const parsed = ROUTE_BODY.safeParse(body);
if (!parsed.success) {
return NextResponse.json(
{ error: "Invalid threemaId", details: parsed.error.flatten() },
{ status: 400 },
);
}
const threemaId = parsed.data.threemaId;
// Step 1: claim at the relay. Uniqueness is enforced here.
const claim = await createRoute(name, threemaId);
if (!claim.ok) {
if (isRouteConflictForOtherTenant(claim, name)) {
return NextResponse.json(
{ error: "This Threema ID is already registered to another tenant" },
{ status: 409 },
);
}
if (!isRouteConflictForSameTenant(claim, name)) {
// Genuine non-409 failure
return NextResponse.json(
{ error: `Relay create failed: ${claim.message}` },
{ status: claim.kind === "http" ? claim.status : 503 },
);
}
// Idempotent self-claim — continue to step 2 to ensure K8s mirrors it.
}
// Step 2: add to K8s spec.channelUsers.threema (idempotent).
const existing = currentThreemaIds(tenant!.spec);
if (!existing.includes(threemaId)) {
const next = [...existing, threemaId];
try {
await patchTenantSpec(name, {
channelUsers: {
...(tenant!.spec?.channelUsers ?? {}),
threema: next,
} as Record<string, string[]>,
});
} catch (e) {
// Compensate: drop the relay route so we don't leave an orphan.
const compensate = await deleteRoute(name, threemaId);
if (!compensate.ok) {
console.error(
`[threema/routes] Compensating route delete failed for ${name}/${threemaId}: ${compensate.message}`,
);
}
return NextResponse.json(
{ error: `K8s patch failed: ${safeError(e, "patch failed")}` },
{ status: 500 },
);
}
}
return NextResponse.json({ ok: true, threemaId });
}
// ---- DELETE ---------------------------------------------------------------
export async function DELETE(
req: NextRequest,
{ params }: { params: Promise<{ name: string }> },
) {
const user = await getSessionUser();
const { name } = await params;
const loaded = await loadTenantOrError(name, user);
if (loaded.error) return loaded.error;
const tenant = loaded.tenant;
// threemaId arrives as ?threemaId=... since DELETE bodies are uneven across clients.
const threemaId = new URL(req.url).searchParams.get("threemaId") ?? "";
const parsed = ROUTE_BODY.safeParse({ threemaId });
if (!parsed.success) {
return NextResponse.json(
{ error: "Invalid threemaId", details: parsed.error.flatten() },
{ status: 400 },
);
}
// Step 1: drop from K8s (idempotent).
const existing = currentThreemaIds(tenant!.spec);
if (existing.includes(parsed.data.threemaId)) {
const next = existing.filter((id) => id !== parsed.data.threemaId);
try {
await patchTenantSpec(name, {
channelUsers: {
...(tenant!.spec?.channelUsers ?? {}),
threema: next,
} as Record<string, string[]>,
});
} catch (e) {
return NextResponse.json(
{ error: `K8s patch failed: ${safeError(e, "patch failed")}` },
{ status: 500 },
);
}
}
// Step 2: drop at relay (also idempotent — 404 is fine).
const dropped = await deleteRoute(name, parsed.data.threemaId);
if (!dropped.ok && !(dropped.kind === "http" && dropped.status === 404)) {
// K8s is already updated; surface but don't rollback. Next time the
// user toggles, both will converge.
return NextResponse.json(
{
ok: true,
threemaId: parsed.data.threemaId,
warning: `Removed from K8s but relay drop failed: ${dropped.message}`,
},
{ status: 200 },
);
}
return NextResponse.json({ ok: true, threemaId: parsed.data.threemaId });
}

View File

@@ -3,14 +3,32 @@
import { useState, useCallback } from "react"; import { useState, useCallback } from "react";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { ThreemaQrModal } from "./threema-qr-modal";
/** Maps channel IDs to the instructions for finding the user ID. */ /** Maps channel IDs to the instructions for finding the user ID. */
const CHANNEL_ID_HELP: Record<string, string> = { const CHANNEL_ID_HELP: Record<string, string> = {
telegram: "telegramIdHelp", telegram: "telegramIdHelp",
discord: "discordIdHelp", discord: "discordIdHelp",
email: "emailIdHelp", threema: "threemaIdHelp",
// email entry dropped in the Phase A rework — IMAP/SMTP is handled by
// the `mail` skill (category=skill, not channel), so it never appears
// in `enabledChannels`. If a future channel is added to the catalog,
// give it an entry here so the help blurb renders.
}; };
/**
* Channels whose user list is managed through a dedicated endpoint
* instead of the generic PATCH /api/tenants/:name flow.
*
* Threema is the only one today — adding/removing a Threema ID has to
* synchronise with the central pieced-threema-gateway relay's `routes`
* table (uniqueness enforced there, not in K8s). The
* /api/tenants/:name/threema/routes endpoint owns that two-step
* coordination (relay first, then K8s, with compensation on K8s
* failure). Other channels just patch the K8s spec directly.
*/
const RELAY_MANAGED_CHANNELS = new Set(["threema"]);
interface ChannelUsersProps { interface ChannelUsersProps {
tenantName: string; tenantName: string;
/** Currently enabled channel packages (e.g. ["telegram", "discord"]) */ /** Currently enabled channel packages (e.g. ["telegram", "discord"]) */
@@ -34,6 +52,14 @@ export function ChannelUsers({
const [inputValues, setInputValues] = useState<Record<string, string>>({}); const [inputValues, setInputValues] = useState<Record<string, string>>({});
const [channelUsers, setChannelUsers] = const [channelUsers, setChannelUsers] =
useState<Record<string, string[]>>(initialChannelUsers); useState<Record<string, string[]>>(initialChannelUsers);
/** Which channel's QR helper modal is open, if any. */
const [showQrFor, setShowQrFor] = useState<string | null>(null);
/**
* Tracks channels for which we've already auto-opened the helper
* modal on this page load. Prevents the modal from re-popping every
* time the user refocuses the input after dismissing it.
*/
const [autoOpened, setAutoOpened] = useState<Set<string>>(() => new Set());
const updateChannelUsers = useCallback( const updateChannelUsers = useCallback(
async (updated: Record<string, string[]>) => { async (updated: Record<string, string[]>) => {
@@ -60,6 +86,70 @@ export function ChannelUsers({
[tenantName, router] [tenantName, router]
); );
/**
* Threema (and any future relay-managed channel) uses a dedicated
* endpoint that synchronises the central relay's routes table with
* the K8s spec atomically. We call it per-ID rather than sending the
* whole array because uniqueness is enforced ID-by-ID at the relay,
* and the error UX of "this ID is taken" is per-add.
*/
const addToRelayChannel = useCallback(
async (channel: string, threemaId: string) => {
setSaving(true);
setError("");
try {
const res = await fetch(
`/api/tenants/${tenantName}/${channel}/routes`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ threemaId }),
}
);
if (!res.ok) {
const data = await res.json().catch(() => ({}));
throw new Error(data.error || `Add failed (HTTP ${res.status})`);
}
setChannelUsers((prev) => {
const current = prev[channel] ?? [];
if (current.includes(threemaId)) return prev;
return { ...prev, [channel]: [...current, threemaId] };
});
router.refresh();
} catch (e: any) {
setError(e.message);
} finally {
setSaving(false);
}
},
[tenantName, router]
);
const removeFromRelayChannel = useCallback(
async (channel: string, threemaId: string) => {
setSaving(true);
setError("");
try {
const url = `/api/tenants/${tenantName}/${channel}/routes?threemaId=${encodeURIComponent(threemaId)}`;
const res = await fetch(url, { method: "DELETE" });
if (!res.ok) {
const data = await res.json().catch(() => ({}));
throw new Error(data.error || `Remove failed (HTTP ${res.status})`);
}
setChannelUsers((prev) => {
const current = prev[channel] ?? [];
return { ...prev, [channel]: current.filter((id) => id !== threemaId) };
});
router.refresh();
} catch (e: any) {
setError(e.message);
} finally {
setSaving(false);
}
},
[tenantName, router]
);
const handleAdd = useCallback( const handleAdd = useCallback(
(channel: string) => { (channel: string) => {
const userId = inputValues[channel]?.trim(); const userId = inputValues[channel]?.trim();
@@ -71,18 +161,27 @@ export function ChannelUsers({
return; return;
} }
const updated = {
...channelUsers,
[channel]: [...current, userId],
};
setInputValues((prev) => ({ ...prev, [channel]: "" })); setInputValues((prev) => ({ ...prev, [channel]: "" }));
updateChannelUsers(updated);
if (RELAY_MANAGED_CHANNELS.has(channel)) {
void addToRelayChannel(channel, userId);
} else {
const updated = {
...channelUsers,
[channel]: [...current, userId],
};
updateChannelUsers(updated);
}
}, },
[channelUsers, inputValues, updateChannelUsers, t] [channelUsers, inputValues, updateChannelUsers, addToRelayChannel, t]
); );
const handleRemove = useCallback( const handleRemove = useCallback(
(channel: string, userId: string) => { (channel: string, userId: string) => {
if (RELAY_MANAGED_CHANNELS.has(channel)) {
void removeFromRelayChannel(channel, userId);
return;
}
const current = channelUsers[channel] || []; const current = channelUsers[channel] || [];
const updated = { const updated = {
...channelUsers, ...channelUsers,
@@ -90,7 +189,7 @@ export function ChannelUsers({
}; };
updateChannelUsers(updated); updateChannelUsers(updated);
}, },
[channelUsers, updateChannelUsers] [channelUsers, updateChannelUsers, removeFromRelayChannel]
); );
if (enabledChannels.length === 0) return null; if (enabledChannels.length === 0) return null;
@@ -126,9 +225,19 @@ export function ChannelUsers({
className="bg-surface-2 border border-border rounded-lg p-4" className="bg-surface-2 border border-border rounded-lg p-4"
> >
<div className="flex items-center justify-between mb-3"> <div className="flex items-center justify-between mb-3">
<h4 className="text-sm font-medium text-text-primary capitalize"> <div className="flex items-center gap-2">
{channel} <h4 className="text-sm font-medium text-text-primary capitalize">
</h4> {channel}
</h4>
{channel === "threema" && (
<button
onClick={() => setShowQrFor("threema")}
className="text-xs font-medium text-accent hover:text-accent-dim cursor-pointer underline underline-offset-2"
>
{t("threemaSetup.showQr")}
</button>
)}
</div>
<span className="text-xs text-text-muted tabular-nums"> <span className="text-xs text-text-muted tabular-nums">
{users.length} {t("users")} {users.length} {t("users")}
</span> </span>
@@ -176,6 +285,17 @@ export function ChannelUsers({
[channel]: e.target.value, [channel]: e.target.value,
})) }))
} }
onFocus={() => {
// For threema specifically, open the QR helper the
// first time the user clicks into the input on this
// page load. We don't repeat after dismiss — the
// "Show QR" button next to the channel name covers
// re-opens on demand.
if (channel === "threema" && !autoOpened.has("threema")) {
setShowQrFor("threema");
setAutoOpened((prev) => new Set(prev).add("threema"));
}
}}
onKeyDown={(e) => { onKeyDown={(e) => {
if (e.key === "Enter") handleAdd(channel); if (e.key === "Enter") handleAdd(channel);
}} }}
@@ -194,6 +314,11 @@ export function ChannelUsers({
</div> </div>
); );
})} })}
<ThreemaQrModal
open={showQrFor === "threema"}
onClose={() => setShowQrFor(null)}
/>
</div> </div>
); );
} }

View File

@@ -0,0 +1,82 @@
"use client";
import { useTranslations } from "next-intl";
import { useEffect } from "react";
import { THREEMA_GATEWAY } from "@/lib/threema-gateway-config";
interface ThreemaQrModalProps {
open: boolean;
onClose: () => void;
}
/**
* On-demand modal showing the QR for adding the assistant on Threema.
* Triggered by the "Show QR" button in the threema channel card and
* closes on overlay click, ESC, or the close button.
*
* Uses a plain <img> not next/image — image optimization adds nothing
* for a 57KB static PNG and removes a potential source of rendering
* bugs in the Next.js standalone build.
*/
export function ThreemaQrModal({ open, onClose }: ThreemaQrModalProps) {
const t = useTranslations("channelUsers.threemaSetup");
useEffect(() => {
if (!open) return;
function onKey(e: KeyboardEvent) {
if (e.key === "Escape") onClose();
}
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [open, onClose]);
if (!open) return null;
return (
<div
onClick={onClose}
className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm p-4"
>
<div
onClick={(e) => e.stopPropagation()}
className="w-full max-w-md bg-surface-1 border border-border rounded-2xl p-6 shadow-2xl shadow-black/40 space-y-4"
>
<div className="flex items-start justify-between">
<h3 className="text-base font-semibold text-text-primary">
{t("title")}
</h3>
<button
onClick={onClose}
className="text-text-muted hover:text-text-primary text-xl leading-none"
aria-label="Close"
>
×
</button>
</div>
<div className="flex justify-center">
<div className="bg-white p-3 rounded-md">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={THREEMA_GATEWAY.qrCodePath}
alt={t("qrAlt", { gateway: THREEMA_GATEWAY.displayName })}
width={220}
height={220}
style={{ display: "block" }}
/>
</div>
</div>
<p className="text-center text-xs text-text-muted font-mono">
{THREEMA_GATEWAY.displayName}
</p>
<ol className="list-decimal list-inside text-xs text-text-secondary space-y-1.5">
<li>{t("step1")}</li>
<li>{t("step2")}</li>
<li>{t("step3")}</li>
</ol>
</div>
</div>
);
}

View File

@@ -3,7 +3,7 @@
import { useState, useCallback, useEffect, useRef } from "react"; import { useState, useCallback, useEffect, useRef } from "react";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import { Card } from "@/components/ui/card"; import { Card } from "@/components/ui/card";
import { PACKAGE_CATALOG, type PackageDef } from "@/lib/packages"; import { PACKAGE_CATALOG, DEFAULT_PACKAGE_IDS, type PackageDef } from "@/lib/packages";
import { isPersonalOrgName, displayOrgNameFor } from "@/lib/personal-org"; import { isPersonalOrgName, displayOrgNameFor } from "@/lib/personal-org";
import { import {
configureStepSchema, configureStepSchema,
@@ -69,6 +69,7 @@ translation, and general question answering.
`; `;
const CATEGORIES = [ const CATEGORIES = [
{ key: "core" as const, labelKey: "categories.core" },
{ key: "channel" as const, labelKey: "categories.channels" }, { key: "channel" as const, labelKey: "categories.channels" },
{ key: "skill" as const, labelKey: "categories.skills" }, { key: "skill" as const, labelKey: "categories.skills" },
] as const; ] as const;
@@ -198,7 +199,13 @@ export function OnboardingWizard({
agentName: "Assistant", agentName: "Assistant",
soulMd: FALLBACK_SOUL.replace("{company}", displayOrgName), soulMd: FALLBACK_SOUL.replace("{company}", displayOrgName),
agentsMd: FALLBACK_AGENTS, agentsMd: FALLBACK_AGENTS,
packages: [] as string[], // CORE defaults: heartbeat + cron + active-memory pre-selected so
// the assistant can be proactive, run scheduled tasks, and recall
// stable context out of the box. Customers can untoggle any of
// them before submitting. core-voice is fully wired (Phase B)
// but stays unselected — opt-in keeps audio spend predictable
// for tenants who don't intend to use voice channels.
packages: [...DEFAULT_PACKAGE_IDS] as string[],
billingAddress: { billingAddress: {
// For personal accounts, leave the company field empty — it'll // For personal accounts, leave the company field empty — it'll
// appear on invoices. The user can still type something if they // appear on invoices. The user can still type something if they
@@ -691,7 +698,7 @@ export function OnboardingWizard({
<button <button
type="button" type="button"
onClick={() => togglePackage(pkg.id)} onClick={() => togglePackage(pkg.id)}
className="w-full flex items-center justify-between px-3 py-2.5 cursor-pointer hover:bg-surface-3/30 transition-colors" className="w-full flex items-center justify-between px-3 py-2.5 transition-colors cursor-pointer hover:bg-surface-3/30"
> >
<div className="text-left"> <div className="text-left">
<span <span

View File

@@ -30,6 +30,26 @@ export function PackageCard({
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
async function handleEnable() { async function handleEnable() {
if (pkg.customProvisioning) {
// Platform-side provisioning, then add to packages list.
setSaving(true);
setError(null);
try {
const provRes = await fetch(`/api/tenants/${tenantName}/${pkg.id}`, {
method: "POST",
});
if (!provRes.ok) {
const err = await provRes.json().catch(() => ({}));
throw new Error(err.error || `Provisioning failed (HTTP ${provRes.status})`);
}
await togglePackage(true);
} catch (e: any) {
setError(e.message);
} finally {
setSaving(false);
}
return;
}
if (pkg.requiresSecrets) { if (pkg.requiresSecrets) {
setShowModal(true); setShowModal(true);
setSecrets({}); setSecrets({});
@@ -40,6 +60,34 @@ export function PackageCard({
await togglePackage(true); await togglePackage(true);
} }
async function handleDisable() {
setSaving(true);
setError(null);
try {
if (pkg.customProvisioning) {
// Revoke platform-side credentials FIRST so the relay drops
// routes before the operator removes the channel from the
// OpenClaw config. Partial-success (token revoked, OpenBao
// delete failed) returns 503 with partial=true and we surface
// the error rather than continuing — the secret may still be
// valid in OpenBao and rolling back the relay revoke isn't
// possible (it cascaded to routes).
const deprovRes = await fetch(`/api/tenants/${tenantName}/${pkg.id}`, {
method: "DELETE",
});
if (!deprovRes.ok) {
const err = await deprovRes.json().catch(() => ({}));
throw new Error(err.error || `Deprovisioning failed (HTTP ${deprovRes.status})`);
}
}
await togglePackage(false);
} catch (e: any) {
setError(e.message);
} finally {
setSaving(false);
}
}
async function togglePackage(enable: boolean) { async function togglePackage(enable: boolean) {
setSaving(true); setSaving(true);
try { try {
@@ -124,7 +172,7 @@ export function PackageCard({
)} )}
{canEdit ? ( {canEdit ? (
<button <button
onClick={enabled ? () => togglePackage(false) : handleEnable} onClick={enabled ? handleDisable : handleEnable}
disabled={saving} disabled={saving}
className={`ml-auto rounded-lg px-3 py-1.5 text-xs font-medium transition-all cursor-pointer ${ className={`ml-auto rounded-lg px-3 py-1.5 text-xs font-medium transition-all cursor-pointer ${
enabled enabled

View File

@@ -15,6 +15,7 @@ interface Props {
} }
const CATEGORIES = [ const CATEGORIES = [
{ key: "core" as const, labelKey: "categories.core" },
{ key: "channel" as const, labelKey: "categories.channels" }, { key: "channel" as const, labelKey: "categories.channels" },
{ key: "skill" as const, labelKey: "categories.skills" }, { key: "skill" as const, labelKey: "categories.skills" },
] as const; ] as const;

View File

@@ -1,9 +1,45 @@
/**
* Portal-side package catalog. Hardcoded mirror of the operator-side
* catalog ConfigMap (deploy/helm/pieced-operator/templates/catalog-cm.yaml).
*
* The two have to stay in sync:
* - `id` here must match the catalog key in the ConfigMap.
* - `secrets[].key` here must match the catalog's env_vars[].secret_key
* so that POST /api/tenants/:name/secrets writes to the same OpenBao
* path the operator's ExternalSecret reads from.
* - `requiresSecrets` is true when the catalog declares any env_var
* that is a secret (vault_path_suffix set, no default value).
*
* Category model (Phase A rework):
* - core — platform-behaviour toggles (heartbeat, cron,
* active-memory, voice). Mostly no secrets. core-voice is
* fully wired (Phase B): toggling installs the STT / TTS /
* Talk surface via the operator's config_patch, routed
* through LiteLLM (pieced-stt, pieced-tts-inbound,
* pieced-tts-talk).
* - channel — messaging integration.
* - skill — ClawHub skill install.
*
* Custom provisioning (Threema):
* The `threema` channel sets `requiresSecrets: false` because its
* credentials are platform-issued, not customer-entered. Enabling
* threema goes through a dedicated endpoint
* (/api/tenants/:name/threema) that mints token + HMAC secret from
* the central pieced-threema-gateway relay and writes them to OpenBao
* at secret/data/tenants/<name>/threema-relay before the package is
* added to spec.packages. Disabling reverses both steps. The
* `customProvisioning` flag here tells the package-card UI to use
* that endpoint instead of the standard /secrets+PATCH dance.
*/
export interface PackageSecretField { export interface PackageSecretField {
key: string; key: string;
labelKey: string; labelKey: string;
placeholderKey: string; placeholderKey: string;
} }
export type PackageCategory = "core" | "channel" | "skill";
export interface PackageDef { export interface PackageDef {
id: string; id: string;
name: string; name: string;
@@ -12,10 +48,53 @@ export interface PackageDef {
secrets?: PackageSecretField[]; secrets?: PackageSecretField[];
instructionsKey?: string; instructionsKey?: string;
disclaimerKey?: string; disclaimerKey?: string;
category: "channel" | "skill"; category: PackageCategory;
/**
* When true, enabling/disabling this package goes through
* /api/tenants/:name/<id> (POST/DELETE) instead of the generic
* /secrets+PATCH flow. The handler at that path does platform-side
* provisioning (mint credentials, register with sibling services, etc.)
* that the customer is not aware of.
*/
customProvisioning?: boolean;
} }
export const PACKAGE_CATALOG: PackageDef[] = [ export const PACKAGE_CATALOG: PackageDef[] = [
// -------------------------------------------------------------------------
// CORE
// -------------------------------------------------------------------------
{
id: "core-heartbeat",
name: "Heartbeat (Proactive Checks)",
descriptionKey: "packages.coreHeartbeat.description",
requiresSecrets: false,
category: "core",
},
{
id: "core-cron",
name: "Scheduled Tasks (Cron)",
descriptionKey: "packages.coreCron.description",
requiresSecrets: false,
category: "core",
},
{
id: "core-active-memory",
name: "Active Memory",
descriptionKey: "packages.coreActiveMemory.description",
requiresSecrets: false,
category: "core",
},
{
id: "core-voice",
name: "Voice Interaction",
descriptionKey: "packages.coreVoice.description",
requiresSecrets: false,
category: "core",
},
// -------------------------------------------------------------------------
// CHANNELS
// -------------------------------------------------------------------------
{ {
id: "telegram", id: "telegram",
name: "Telegram", name: "Telegram",
@@ -43,42 +122,201 @@ export const PACKAGE_CATALOG: PackageDef[] = [
labelKey: "packages.discord.botTokenLabel", labelKey: "packages.discord.botTokenLabel",
placeholderKey: "packages.discord.botTokenPlaceholder", placeholderKey: "packages.discord.botTokenPlaceholder",
}, },
// app-id was missing from the portal catalog historically while the
// operator catalog declared DISCORD_APP_ID as a required env var.
// Tenants who enabled Discord ended up with the env var blank
// because the secrets POST never wrote an `app-id` key to OpenBao
// and the operator's ExternalSecret couldn't populate it. Added
// here as part of the Phase A rework to close the alignment gap;
// not strictly secret (the application ID is visible in the bot's
// profile URL) but stored alongside the bot token for convenience.
{
key: "app-id",
labelKey: "packages.discord.appIdLabel",
placeholderKey: "packages.discord.appIdPlaceholder",
},
], ],
instructionsKey: "packages.discord.instructions", instructionsKey: "packages.discord.instructions",
disclaimerKey: "packages.discord.disclaimer", disclaimerKey: "packages.discord.disclaimer",
category: "channel", category: "channel",
}, },
{ {
id: "email", id: "threema",
name: "Email", name: "Threema",
descriptionKey: "packages.email.description", descriptionKey: "packages.threema.description",
requiresSecrets: true, // No customer-entered secrets. The token + hmac secret are minted
secrets: [ // server-side by the relay's /admin/tokens endpoint when the
{ key: "smtp-host", labelKey: "packages.email.smtpHostLabel", placeholderKey: "packages.email.smtpHostPlaceholder" }, // package is enabled, and stored in OpenBao by the portal. The
{ key: "smtp-user", labelKey: "packages.email.smtpUserLabel", placeholderKey: "packages.email.smtpUserPlaceholder" }, // `customProvisioning` flag steers the PackageCard UI through the
{ key: "smtp-password", labelKey: "packages.email.smtpPasswordLabel", placeholderKey: "packages.email.smtpPasswordPlaceholder" }, // dedicated /api/tenants/:name/threema endpoint instead.
{ key: "imap-host", labelKey: "packages.email.imapHostLabel", placeholderKey: "packages.email.imapHostPlaceholder" }, requiresSecrets: false,
], customProvisioning: true,
instructionsKey: "packages.email.instructions", instructionsKey: "packages.threema.instructions",
disclaimerKey: "packages.email.disclaimer", disclaimerKey: "packages.threema.disclaimer",
category: "channel", category: "channel",
}, },
// -------------------------------------------------------------------------
// SKILLS
// -------------------------------------------------------------------------
{ {
id: "web-search", id: "git-cli",
name: "Web Search", name: "Git CLI",
descriptionKey: "packages.webSearch.description", descriptionKey: "packages.gitCli.description",
requiresSecrets: false, requiresSecrets: false,
category: "skill", category: "skill",
}, },
{ {
id: "document-processing", id: "github",
name: "Document Processing", name: "GitHub (gh CLI)",
descriptionKey: "packages.documentProcessing.description", descriptionKey: "packages.github.description",
requiresSecrets: true,
secrets: [
{
key: "token",
labelKey: "packages.github.tokenLabel",
placeholderKey: "packages.github.tokenPlaceholder",
},
],
instructionsKey: "packages.github.instructions",
category: "skill",
},
{
id: "gitea",
name: "Gitea",
descriptionKey: "packages.gitea.description",
requiresSecrets: true,
secrets: [
{
key: "token",
labelKey: "packages.gitea.tokenLabel",
placeholderKey: "packages.gitea.tokenPlaceholder",
},
],
instructionsKey: "packages.gitea.instructions",
category: "skill",
},
{
id: "whisper-self-hosted",
name: "Whisper (Self-Hosted Transcription)",
descriptionKey: "packages.whisperSelfHosted.description",
requiresSecrets: false, requiresSecrets: false,
category: "skill", category: "skill",
}, },
{
id: "searxng-local-search",
name: "Web Search (SearXNG)",
descriptionKey: "packages.searxngLocalSearch.description",
requiresSecrets: false,
category: "skill",
},
{
id: "gog",
name: "Google Workspace (Gog)",
descriptionKey: "packages.gog.description",
requiresSecrets: true,
secrets: [
{
key: "client-id",
labelKey: "packages.gog.clientIdLabel",
placeholderKey: "packages.gog.clientIdPlaceholder",
},
{
key: "client-secret",
labelKey: "packages.gog.clientSecretLabel",
placeholderKey: "packages.gog.clientSecretPlaceholder",
},
{
key: "refresh-token",
labelKey: "packages.gog.refreshTokenLabel",
placeholderKey: "packages.gog.refreshTokenPlaceholder",
},
],
instructionsKey: "packages.gog.instructions",
disclaimerKey: "packages.gog.disclaimer",
category: "skill",
},
{
id: "mail",
name: "Email (IMAP / SMTP)",
descriptionKey: "packages.mail.description",
requiresSecrets: true,
secrets: [
{
key: "imap-host",
labelKey: "packages.mail.imapHostLabel",
placeholderKey: "packages.mail.imapHostPlaceholder",
},
{
key: "imap-user",
labelKey: "packages.mail.imapUserLabel",
placeholderKey: "packages.mail.imapUserPlaceholder",
},
{
key: "imap-pass",
labelKey: "packages.mail.imapPassLabel",
placeholderKey: "packages.mail.imapPassPlaceholder",
},
{
key: "smtp-host",
labelKey: "packages.mail.smtpHostLabel",
placeholderKey: "packages.mail.smtpHostPlaceholder",
},
{
key: "smtp-user",
labelKey: "packages.mail.smtpUserLabel",
placeholderKey: "packages.mail.smtpUserPlaceholder",
},
{
key: "smtp-pass",
labelKey: "packages.mail.smtpPassLabel",
placeholderKey: "packages.mail.smtpPassPlaceholder",
},
],
instructionsKey: "packages.mail.instructions",
disclaimerKey: "packages.mail.disclaimer",
category: "skill",
},
]; ];
export function getPackageDef(id: string): PackageDef | undefined { export function getPackageDef(id: string): PackageDef | undefined {
return PACKAGE_CATALOG.find((p) => p.id === id); return PACKAGE_CATALOG.find((p) => p.id === id);
} }
/**
* IDs of channel-category packages. Derived from the catalog so it
* cannot drift from the source of truth (previously hardcoded as
* `["telegram", "discord", "email"]` in tenants/[name]/page.tsx —
* removed as part of the Phase A package-model rework).
*
* Consumers: tenant detail page (filter spec.packages to channel set
* before rendering the channel-users panel).
*/
export const CHANNEL_PACKAGE_IDS: string[] = PACKAGE_CATALOG
.filter((p) => p.category === "channel")
.map((p) => p.id);
/**
* Default packages selected when the wizard opens a fresh onboarding
* request. The three CORE behaviours that make the assistant feel
* "smart out of the box":
* - heartbeat: proactive checks (otherwise the assistant is purely
* reactive).
* - cron: scheduled tasks (daily briefings, reminders).
* - active-memory: long-term recall of stable preferences and habits.
*
* Each adds some token cost — active-memory the most (one extra
* sub-agent turn per inbound message) — so customers can untoggle any
* of them before submitting.
*
* core-voice is intentionally NOT a default. It is fully wired (Phase B)
* and customers can enable it from the wizard, but it incurs separate
* audio spend on every inbound voice note (Whisper STT) and every
* outbound reply (kani-tts / kokoro-fastapi via LiteLLM). Opt-in keeps
* cost predictable for tenants who don't intend to use voice channels.
*/
export const DEFAULT_PACKAGE_IDS: string[] = [
"core-heartbeat",
"core-cron",
"core-active-memory",
];

View File

@@ -0,0 +1,33 @@
/**
* Threema central gateway info shown to customers.
*
* Today PieCed runs exactly one Threema Gateway account (*AIAGENT) and
* every tenant talks to that. The constants below are hardcoded for
* that account. The values are intentionally kept here (and not split
* across i18n / env / runtime config) so when we move to multiple
* gateway accounts there's a single file to refactor.
*
* To go dynamic (future):
* 1. Replace `THREEMA_GATEWAY` constant with a runtime lookup —
* either per-tenant from the relay's admin API, or from an
* env var that lists the active account.
* 2. Move the QR PNG into a server-rendered route that takes a
* gateway ID query param.
* 3. Update consumers (today only ThreemaSetup) to accept the
* gateway info as a prop and pass it from a server component.
*
* In display contexts we strip the leading asterisk from the Threema
* ID — customers don't understand the `*X` prefix convention used for
* Gateway accounts, and the QR code carries the real value anyway. We
* keep the asterisk only for places where the technical value matters
* (server-side message routing, debug logs).
*/
export const THREEMA_GATEWAY = {
/** Technical Threema Gateway ID, with leading asterisk. */
id: "*AIAGENT",
/** Display name shown to customers (no asterisk). */
displayName: "AIAGENT",
/** Public path to the QR code PNG served from `public/`. */
qrCodePath: "/threema/qr_code_AIAGENT.png",
} as const;

202
src/lib/threema-relay.ts Normal file
View File

@@ -0,0 +1,202 @@
/**
* Admin client for the central pieced-threema-gateway relay.
*
* All calls authenticate with a static admin bearer (THREEMA_RELAY_ADMIN_TOKEN
* from env, sourced from OpenBao at secret/data/threema-gateway/admin). The
* portal is the only caller; never expose these functions through a public
* HTTP surface.
*
* Resilience: every method returns a strongly-typed result rather than
* throwing. Network errors surface as `{ ok: false, kind: 'network' }`,
* 4xx/5xx as `{ ok: false, kind: 'http', status, ... }`. Callers that need
* to compensate (e.g. delete a route after a K8s patch failure) can
* inspect `kind` to decide whether retry is sensible.
*/
const RELAY_URL = (process.env.THREEMA_RELAY_URL ?? "").replace(/\/+$/, "");
const ADMIN_TOKEN = process.env.THREEMA_RELAY_ADMIN_TOKEN ?? "";
function assertConfigured(): void {
if (!RELAY_URL) throw new Error("THREEMA_RELAY_URL not set");
if (!ADMIN_TOKEN) throw new Error("THREEMA_RELAY_ADMIN_TOKEN not set");
}
type RelayError =
| { ok: false; kind: "network"; message: string }
| { ok: false; kind: "http"; status: number; message: string; body?: unknown };
export type RelayResult<T> = ({ ok: true } & T) | RelayError;
async function call<T>(
path: string,
init: RequestInit,
): Promise<RelayResult<T>> {
assertConfigured();
try {
const res = await fetch(`${RELAY_URL}${path}`, {
...init,
headers: {
...(init.headers ?? {}),
Authorization: `Bearer ${ADMIN_TOKEN}`,
"Content-Type": "application/json",
},
});
let body: unknown = null;
const text = await res.text().catch(() => "");
if (text) {
try {
body = JSON.parse(text);
} catch {
body = text;
}
}
if (!res.ok) {
const message =
(body && typeof body === "object" && "error" in body && typeof (body as Record<string, unknown>).error === "string"
? ((body as Record<string, unknown>).error as string)
: `HTTP ${res.status}`);
return { ok: false, kind: "http", status: res.status, message, body };
}
return { ok: true, ...(body as Record<string, unknown>) } as RelayResult<T>;
} catch (e) {
return { ok: false, kind: "network", message: (e as Error).message };
}
}
// ---------------------------------------------------------------------------
// Tokens
// ---------------------------------------------------------------------------
/** Mint (or rotate) per-tenant bearer + HMAC secret. */
export function mintToken(
tenantName: string,
): Promise<RelayResult<{ token: string; hmacSecret: string }>> {
return call("/admin/tokens", {
method: "POST",
body: JSON.stringify({ tenantName }),
});
}
/**
* Revoke per-tenant token. Cascades — relay also deletes all routes
* owned by this tenant.
*/
export function revokeToken(
tenantName: string,
): Promise<RelayResult<{ deletedRoutes: number }>> {
return call(`/admin/tokens/${encodeURIComponent(tenantName)}`, {
method: "DELETE",
});
}
export function tokenExists(
tenantName: string,
): Promise<RelayResult<{ exists: true }>> {
return call(`/admin/tokens/${encodeURIComponent(tenantName)}`, {
method: "GET",
});
}
// ---------------------------------------------------------------------------
// Routes
// ---------------------------------------------------------------------------
export interface RouteRow {
threema_id: string;
tenant_name: string;
created_at: string;
created_by: string;
}
export function listRoutes(
tenantName: string,
): Promise<RelayResult<{ routes: RouteRow[] }>> {
return call(`/admin/routes?tenant=${encodeURIComponent(tenantName)}`, {
method: "GET",
});
}
/**
* Create a route. The relay enforces uniqueness — on conflict returns
* status 409 with body `{ ownedBy: <tenantName> | null }`. Callers
* should inspect `kind === 'http'` + status 409 + body.ownedBy to
* distinguish idempotent self-claim from real conflict.
*/
export function createRoute(
tenantName: string,
threemaId: string,
): Promise<RelayResult<{ ok: true }>> {
return call("/admin/routes", {
method: "POST",
body: JSON.stringify({ tenantName, threemaId }),
});
}
export function deleteRoute(
tenantName: string,
threemaId: string,
): Promise<RelayResult<{ ok: true }>> {
return call(
`/admin/routes/${encodeURIComponent(threemaId)}?tenant=${encodeURIComponent(tenantName)}`,
{ method: "DELETE" },
);
}
// ---------------------------------------------------------------------------
// Usage
// ---------------------------------------------------------------------------
export interface UsageBreakdown {
tenant: string;
from: string;
to: string;
totals: { in: number; out: number };
daily: Array<{ day: string; direction: "in" | "out"; count: number }>;
}
export function getUsage(
tenantName: string,
from?: Date,
to?: Date,
): Promise<RelayResult<UsageBreakdown>> {
const qs = new URLSearchParams({ tenant: tenantName });
if (from) qs.set("from", from.toISOString());
if (to) qs.set("to", to.toISOString());
return call(`/admin/usage?${qs.toString()}`, { method: "GET" });
}
// ---------------------------------------------------------------------------
// Health
// ---------------------------------------------------------------------------
export function health(): Promise<RelayResult<{ credits: number }>> {
return call("/admin/health", { method: "GET" });
}
// ---------------------------------------------------------------------------
// Helpers for caller code
// ---------------------------------------------------------------------------
/**
* Type guard: did this route-create fail because the ID is owned by
* someone else? Distinguishes from "owned by SAME tenant" (idempotent).
*/
export function isRouteConflictForOtherTenant(
result: RelayResult<{ ok: true }>,
tenantName: string,
): boolean {
if (result.ok) return false;
if (result.kind !== "http" || result.status !== 409) return false;
const body = result.body as { ownedBy?: string | null } | undefined;
return !!body?.ownedBy && body.ownedBy !== tenantName;
}
export function isRouteConflictForSameTenant(
result: RelayResult<{ ok: true }>,
tenantName: string,
): boolean {
if (result.ok) return false;
if (result.kind !== "http" || result.status !== 409) return false;
const body = result.body as { ownedBy?: string | null } | undefined;
return body?.ownedBy === tenantName;
}

View File

@@ -214,7 +214,8 @@
"packages": { "packages": {
"categories": { "categories": {
"channels": "Kanäle", "channels": "Kanäle",
"skills": "Fähigkeiten" "skills": "Fähigkeiten",
"core": "Kern"
}, },
"enable": "Aktivieren", "enable": "Aktivieren",
"disable": "Deaktivieren", "disable": "Deaktivieren",
@@ -239,29 +240,78 @@
"botTokenLabel": "Discord Bot Token", "botTokenLabel": "Discord Bot Token",
"botTokenPlaceholder": "MTAxNjQ0OTk2NjAz...", "botTokenPlaceholder": "MTAxNjQ0OTk2NjAz...",
"instructions": "1. Gehen Sie zu discord.com/developers/applications\n2. Erstellen Sie eine neue Anwendung und fügen Sie einen Bot hinzu\n3. Kopieren Sie den Bot-Token", "instructions": "1. Gehen Sie zu discord.com/developers/applications\n2. Erstellen Sie eine neue Anwendung und fügen Sie einen Bot hinzu\n3. Kopieren Sie den Bot-Token",
"disclaimer": "Ich bestätige, dass ich diesen Discord-Bot besitze und PieCed IT autorisiere, ihn mit meinem KI-Assistenten zu verbinden." "disclaimer": "Ich bestätige, dass ich diesen Discord-Bot besitze und PieCed IT autorisiere, ihn mit meinem KI-Assistenten zu verbinden.",
}, "appIdLabel": "Discord-Anwendungs-ID",
"email": { "appIdPlaceholder": "1819-stellige numerische ID aus dem Developer Portal"
"description": "Ermöglichen Sie Ihrem KI-Assistenten, E-Mails zu senden und zu empfangen.",
"smtpHostLabel": "SMTP Host",
"smtpHostPlaceholder": "smtp.example.com",
"smtpUserLabel": "SMTP Benutzername",
"smtpUserPlaceholder": "user@example.com",
"smtpPasswordLabel": "SMTP Passwort",
"smtpPasswordPlaceholder": "••••••••",
"imapHostLabel": "IMAP Host",
"imapHostPlaceholder": "imap.example.com",
"instructions": "Geben Sie SMTP- und IMAP-Zugangsdaten an. Der Assistent nutzt diese zum Senden und Empfangen von Nachrichten.",
"disclaimer": "Ich bestätige, dass ich berechtigt bin, diese E-Mail-Zugangsdaten zu verwenden und dass PieCed IT auf dieses Postfach zugreifen darf."
},
"webSearch": {
"description": "Geben Sie Ihrem KI-Assistenten die Möglichkeit, im Web zu suchen."
},
"documentProcessing": {
"description": "Aktivieren Sie Dokumentenverarbeitung, Zusammenfassung und Extraktion."
}, },
"statusEnabled": "aktiviert", "statusEnabled": "aktiviert",
"statusDisabled": "deaktiviert" "statusDisabled": "deaktiviert",
"coreHeartbeat": {
"description": "Periodischer Agentenlauf alle 30 Minuten, der es dem Assistenten erlaubt, Posteingang, Kalender und andere konfigurierte Quellen zu prüfen und proaktiv Bescheid zu geben, wenn etwas Aufmerksamkeit braucht. Ohne diese Option reagiert der Assistent nur, wenn Sie ihn ansprechen."
},
"coreCron": {
"description": "Erlaubt dem Assistenten, geplante Aufgaben auszuführen (tägliche Briefings, wiederkehrende Erinnerungen, periodische Berichte). Standardmässig deaktiviert. Bei Deaktivierung bleibt das Cron-Werkzeug verfügbar, aber keine geplante Aufgabe wird ausgeführt."
},
"coreActiveMemory": {
"description": "Erlaubt dem Assistenten, stabile Präferenzen, wiederkehrende Gewohnheiten und langfristigen Kontext aus früheren Gesprächen abzurufen. Nutzt einen zusätzlichen Sub-Agent-Lauf pro eingehender Nachricht, um den Memory-Store abzufragen. Nur Direktnachrichten. Kleiner Mehraufwand an Tokens im Tausch gegen Kontinuität und Personalisierung."
},
"coreVoice": {
"description": "Spracherkennung für eingehende Sprachnachrichten und Sprachsynthese für Antworten, über das PieCed-LiteLLM-Gateway, damit Audiokosten pro Mandant erfasst werden. Die Laufzeit-Integration kommt im nächsten Plattform-Release; das Umschalten speichert die Auswahl für diese Auslieferung."
},
"gitCli": {
"description": "Eigenständige Git-Kommandozeilenoperationen (clone, commit, branch, diff, log, status). Für private Repositories konfigurieren Sie die Zugangsdaten in Ihrem Workspace."
},
"github": {
"description": "Interaktion mit GitHub-Repositories über die gh-CLI — Issues, Pull Requests, CI-Läufe, Releases, Gists. Erfordert ein persönliches Zugriffstoken.",
"tokenLabel": "GitHub Persönliches Zugriffstoken",
"tokenPlaceholder": "ghp_… oder github_pat_…",
"instructions": "1. Öffnen Sie https://github.com/settings/tokens\n2. Erstellen Sie ein fein abgestimmtes persönliches Zugriffstoken mit den gewünschten Repo-Berechtigungen\n3. Kopieren Sie das Token (es wird nur einmal angezeigt)"
},
"gitea": {
"description": "Interaktion mit einer Gitea-Instanz — Repositories, Issues, Pull Requests, Releases. Standardmässig die PieCed-Plattform-Gitea unter git.c5ai.ch.",
"tokenLabel": "Gitea-Zugriffstoken",
"tokenPlaceholder": "Erstellt unter Einstellungen → Anwendungen",
"instructions": "1. Melden Sie sich bei Ihrer Gitea-Instanz an (Standard https://git.c5ai.ch)\n2. Gehen Sie zu Einstellungen → Anwendungen → Neues Token erstellen\n3. Vergeben Sie die gewünschten Berechtigungen (repo, issue, user)\n4. Kopieren Sie das Token"
},
"whisperSelfHosted": {
"description": "Transkribieren Sie Audiodateien über die plattformeigene Whisper-Instanz. Nützlich für Ad-hoc-Transkriptionsaufgaben aus dem Chat heraus."
},
"searxngLocalSearch": {
"description": "Datenschutzfreundliche Web-Suche über die interne SearXNG-Instanz der Plattform. Durchsuchen Sie Web, Bilder und News ohne externe API-Aufrufe oder Tracker."
},
"gog": {
"description": "Gebündelter Zugriff auf Gmail, Kalender, Drive, Docs, Sheets und Kontakte via Google OAuth. Setup erfordert ein Google-Cloud-Projekt — wenden Sie sich an den PieCed-Support für die Einrichtung.",
"clientIdLabel": "Google OAuth Client-ID",
"clientIdPlaceholder": "xxxxxxxxxxx.apps.googleusercontent.com",
"clientSecretLabel": "Google OAuth Client-Secret",
"clientSecretPlaceholder": "GOCSPX-…",
"refreshTokenLabel": "Google OAuth Refresh-Token",
"refreshTokenPlaceholder": "1//0g…",
"instructions": "Die Google-Workspace-Integration verwendet OAuth und erfordert derzeit manuelles Onboarding. Bitte eröffnen Sie ein Support-Ticket, um den Setup-Prozess zu starten — wir tauschen die Client-Zugangsdaten und ein Refresh-Token offline aus und aktivieren dann dieses Paket für Ihren Mandanten.",
"disclaimer": "Mit der Aktivierung der Google-Workspace-Integration autorisieren Sie PieCed, in Ihrem Namen auf Gmail, Kalender, Drive, Docs, Sheets und Kontakte zuzugreifen. Daten fliessen über die Google-APIs, vorbehaltlich der Google-Bedingungen."
},
"mail": {
"description": "E-Mails über IMAP lesen, suchen und verwalten; senden über SMTP. Funktioniert mit Gmail (mit App-Passwort), Outlook, Fastmail und jedem standardkonformen IMAP/SMTP-Host.",
"imapHostLabel": "IMAP-Host",
"imapHostPlaceholder": "imap.example.com",
"imapUserLabel": "IMAP-Benutzername",
"imapUserPlaceholder": "benutzer@example.com",
"imapPassLabel": "IMAP-Passwort",
"imapPassPlaceholder": "••••••••",
"smtpHostLabel": "SMTP-Host",
"smtpHostPlaceholder": "smtp.example.com",
"smtpUserLabel": "SMTP-Benutzername",
"smtpUserPlaceholder": "benutzer@example.com",
"smtpPassLabel": "SMTP-Passwort",
"smtpPassPlaceholder": "••••••••",
"instructions": "1. Für Gmail: Aktivieren Sie die 2-Faktor-Authentifizierung, erstellen Sie dann unter https://myaccount.google.com/apppasswords ein App-Passwort und verwenden Sie es als IMAP- und SMTP-Passwort.\n2. Für Outlook / Microsoft 365 mit MFA: Generieren Sie ein App-Passwort in den Sicherheitseinstellungen Ihres Kontos.\n3. Für andere Anbieter: Konsultieren Sie deren IMAP/SMTP-Dokumentation für Hostnamen und Ports.\n4. Typische IMAP-Hosts: imap.gmail.com, outlook.office365.com.\n5. Typische SMTP-Hosts: smtp.gmail.com, smtp.office365.com.",
"disclaimer": "Der Assistent erhält Lese- und Schreibzugriff auf das von Ihnen konfigurierte Postfach. Verwenden Sie eine dedizierte Adresse anstelle eines persönlichen Postfachs, wenn Sie den Umfang einschränken möchten."
},
"threema": {
"description": "Senden und empfangen Sie Nachrichten über Threema. Jede eingehende und ausgehende Nachricht läuft über den gemeinsamen PieCed-Messaging-Dienst und verursacht eine Gebühr pro Nachricht bei Threema — eine Drittanbieter-Kostenposition, unabhängig von Ihrem PieCed-Abonnement.",
"instructions": "1. Aktivieren Sie dieses Paket.\n2. Öffnen Sie Threema auf Ihrem Telefon, scannen Sie den QR-Code unter Autorisierte Benutzer → threema und akzeptieren Sie den Kontakt.\n3. Tragen Sie Ihre eigene Threema-ID unter Autorisierte Benutzer → threema ein, damit der Assistent Ihre Nachrichten erkennt.\n4. Schreiben Sie eine Nachricht aus Threema, um das Gespräch zu beginnen.",
"disclaimer": "Nachrichten zwischen Threema und PieCed werden Ende-zu-Ende verschlüsselt bis zum PieCed-Messaging-Dienst, wo sie entschlüsselt und an Ihren Assistenten weitergeleitet werden. Jede gesendete oder empfangene Nachricht wird gemäss Threema-Tarif pro Nachricht abgerechnet — die aktuellen Preise finden Sie in Ihrem Plan."
}
}, },
"admin": { "admin": {
"title": "Plattform-Admin", "title": "Plattform-Admin",
@@ -346,7 +396,15 @@
"alreadyAdded": "Diese Benutzer-ID ist bereits autorisiert.", "alreadyAdded": "Diese Benutzer-ID ist bereits autorisiert.",
"telegramIdHelp": "So finden Sie Ihre Telegram-Benutzer-ID:\n1. Öffnen Sie Telegram und schreiben Sie @userinfobot\n2. Der Bot antwortet sofort mit Ihrer numerischen ID\n3. Geben Sie diese Nummer hier ein", "telegramIdHelp": "So finden Sie Ihre Telegram-Benutzer-ID:\n1. Öffnen Sie Telegram und schreiben Sie @userinfobot\n2. Der Bot antwortet sofort mit Ihrer numerischen ID\n3. Geben Sie diese Nummer hier ein",
"discordIdHelp": "So finden Sie Ihre Discord-Benutzer-ID:\n1. Aktivieren Sie den Entwicklermodus in den Discord-Einstellungen (Erweitert)\n2. Rechtsklick auf Ihren Namen → Benutzer-ID kopieren\n3. Geben Sie diese Nummer hier ein", "discordIdHelp": "So finden Sie Ihre Discord-Benutzer-ID:\n1. Aktivieren Sie den Entwicklermodus in den Discord-Einstellungen (Erweitert)\n2. Rechtsklick auf Ihren Namen → Benutzer-ID kopieren\n3. Geben Sie diese Nummer hier ein",
"emailIdHelp": "Geben Sie die E-Mail-Adresse ein, die zur Interaktion mit dem Assistenten autorisiert werden soll." "threemaIdHelp": "Geben Sie Ihre eigene Threema-ID ein — die 8 Zeichen, die in Ihrer Threema-App unter Einstellungen → Meine Threema-ID angezeigt werden. Anschliessend können Sie direkt aus Threema mit dem Assistenten chatten.",
"threemaSetup": {
"title": "Assistenten zu Threema hinzufügen",
"step1": "Öffnen Sie Threema auf Ihrem Telefon.",
"step2": "Tippen Sie auf das Scan-Symbol und scannen Sie diesen QR-Code, um den Assistenten als Kontakt hinzuzufügen.",
"step3": "Fügen Sie anschliessend unten Ihre eigene Threema-ID hinzu.",
"qrAlt": "QR-Code, um {gateway} als Threema-Kontakt hinzuzufügen",
"showQr": "QR anzeigen"
}
}, },
"team": { "team": {
"title": "Team", "title": "Team",

View File

@@ -214,7 +214,8 @@
"packages": { "packages": {
"categories": { "categories": {
"channels": "Channels", "channels": "Channels",
"skills": "Skills" "skills": "Skills",
"core": "Core"
}, },
"enable": "Enable", "enable": "Enable",
"disable": "Disable", "disable": "Disable",
@@ -239,29 +240,78 @@
"botTokenLabel": "Discord Bot Token", "botTokenLabel": "Discord Bot Token",
"botTokenPlaceholder": "MTAxNjQ0OTk2NjAz...", "botTokenPlaceholder": "MTAxNjQ0OTk2NjAz...",
"instructions": "1. Go to discord.com/developers/applications\n2. Create a new application and add a bot\n3. Copy the bot token", "instructions": "1. Go to discord.com/developers/applications\n2. Create a new application and add a bot\n3. Copy the bot token",
"disclaimer": "I confirm I own this Discord bot and authorize PieCed IT to connect it to my AI assistant." "disclaimer": "I confirm I own this Discord bot and authorize PieCed IT to connect it to my AI assistant.",
"appIdLabel": "Discord Application ID",
"appIdPlaceholder": "18-19 digit numeric ID from Developer Portal"
}, },
"email": { "statusEnabled": "enabled",
"description": "Enable your AI assistant to send and receive email.", "statusDisabled": "disabled",
"coreHeartbeat": {
"description": "Periodic agent run every 30 minutes that lets your assistant check inbox, calendar, and other configured sources and message you proactively when something needs attention. Without this, the assistant only responds when you message it first."
},
"coreCron": {
"description": "Allow the assistant to run scheduled tasks (daily briefings, recurring reminders, periodic reports). Off by default. When off, the agent's cron tool stays available but no scheduled job ever fires."
},
"coreActiveMemory": {
"description": "Lets the assistant recall stable preferences, recurring habits, and long-term context from past conversations during a chat. Uses an extra sub-agent turn per inbound message to query the memory store. Direct-message sessions only. Adds a small token cost in exchange for continuity and personalisation."
},
"coreVoice": {
"description": "Speech-to-text on incoming voice notes and text-to-speech on replies, routed through the PieCed LiteLLM gateway so audio cost is tracked per tenant alongside chat. Runtime wiring lands in the next platform release; toggling now stores the preference for that rollout."
},
"gitCli": {
"description": "Standalone git command-line operations (clone, commit, branch, diff, log, status). For private repositories, configure credentials in your workspace."
},
"github": {
"description": "Interact with GitHub repositories via the gh CLI — issues, pull requests, CI runs, releases, gists. Requires a personal access token.",
"tokenLabel": "GitHub Personal Access Token",
"tokenPlaceholder": "ghp_… or github_pat_…",
"instructions": "1. Open https://github.com/settings/tokens\n2. Generate a fine-grained personal access token with the repo scopes you want the assistant to use\n3. Copy the token (it's shown only once)"
},
"gitea": {
"description": "Interact with a Gitea instance — repositories, issues, pull requests, releases. Defaults to the PieCed-platform Gitea at git.c5ai.ch.",
"tokenLabel": "Gitea Access Token",
"tokenPlaceholder": "Generated under Settings → Applications",
"instructions": "1. Log in to your Gitea instance (default https://git.c5ai.ch)\n2. Go to Settings → Applications → Generate New Token\n3. Grant the scopes you want the assistant to use (repo, issue, user)\n4. Copy the token"
},
"whisperSelfHosted": {
"description": "Transcribe audio files via the platform's self-hosted Whisper instance. Useful for ad-hoc transcription tasks initiated from chat."
},
"searxngLocalSearch": {
"description": "Privacy-respecting web search via the platform's internal SearXNG instance. Search the web, images, and news without external API calls or trackers."
},
"gog": {
"description": "Bundled access to Gmail, Calendar, Drive, Docs, Sheets, and Contacts via Google OAuth. Setup requires a Google Cloud project — contact PieCed support to onboard.",
"clientIdLabel": "Google OAuth Client ID",
"clientIdPlaceholder": "xxxxxxxxxxx.apps.googleusercontent.com",
"clientSecretLabel": "Google OAuth Client Secret",
"clientSecretPlaceholder": "GOCSPX-…",
"refreshTokenLabel": "Google OAuth Refresh Token",
"refreshTokenPlaceholder": "1//0g…",
"instructions": "Google Workspace integration uses OAuth and requires manual onboarding for now. Please open a support ticket to start the setup — we'll exchange the client credentials and a refresh token offline, then enable this package on your tenant.",
"disclaimer": "By enabling Google Workspace integration you authorize PieCed to access Gmail, Calendar, Drive, Docs, Sheets, and Contacts on your behalf. Data flows through Google's APIs subject to Google's terms."
},
"mail": {
"description": "Read, search, and manage email via IMAP; send via SMTP. Works with Gmail (with an app password), Outlook, Fastmail, and any standard IMAP/SMTP host.",
"imapHostLabel": "IMAP Host",
"imapHostPlaceholder": "imap.example.com",
"imapUserLabel": "IMAP Username",
"imapUserPlaceholder": "user@example.com",
"imapPassLabel": "IMAP Password",
"imapPassPlaceholder": "••••••••",
"smtpHostLabel": "SMTP Host", "smtpHostLabel": "SMTP Host",
"smtpHostPlaceholder": "smtp.example.com", "smtpHostPlaceholder": "smtp.example.com",
"smtpUserLabel": "SMTP Username", "smtpUserLabel": "SMTP Username",
"smtpUserPlaceholder": "user@example.com", "smtpUserPlaceholder": "user@example.com",
"smtpPasswordLabel": "SMTP Password", "smtpPassLabel": "SMTP Password",
"smtpPasswordPlaceholder": "••••••••", "smtpPassPlaceholder": "••••••••",
"imapHostLabel": "IMAP Host", "instructions": "1. For Gmail: enable 2-Step Verification, then create an App Password at https://myaccount.google.com/apppasswords and use it as both IMAP and SMTP password.\n2. For Outlook / Microsoft 365 with MFA: generate an app password in your account's security settings.\n3. For other providers: refer to their IMAP/SMTP documentation for host names and ports.\n4. Typical IMAP hosts: imap.gmail.com, outlook.office365.com.\n5. Typical SMTP hosts: smtp.gmail.com, smtp.office365.com.",
"imapHostPlaceholder": "imap.example.com", "disclaimer": "The assistant gains read/write access to the mailbox you configure. Consider using a dedicated address rather than a personal inbox if you want to limit scope."
"instructions": "Provide SMTP and IMAP credentials. The assistant uses these to send and monitor messages.",
"disclaimer": "I confirm I am authorized to use these email credentials and that PieCed IT may access this mailbox."
}, },
"webSearch": { "threema": {
"description": "Give your AI assistant the ability to search the web." "description": "Send and receive messages through Threema. Each inbound and outbound message uses the shared PieCed messaging service and incurs a per-message charge from Threema — a third-party cost, separate from your PieCed subscription.",
}, "instructions": "1. Enable this package.\n2. Open Threema on your phone, scan the QR code shown under Authorized Users → threema, and accept the contact.\n3. Add your own Threema ID under Authorized Users → threema so the assistant recognises your messages.\n4. Send a message from Threema to start chatting with the assistant.",
"documentProcessing": { "disclaimer": "Messages between Threema and PieCed are end-to-end encrypted up to PieCed's messaging service, where they are decrypted to be routed to your assistant. Each message sent or received is counted toward Threema's per-message billing — see your plan for current rates."
"description": "Enable document parsing, summarization, and extraction." }
},
"statusEnabled": "enabled",
"statusDisabled": "disabled"
}, },
"admin": { "admin": {
"title": "Platform Admin", "title": "Platform Admin",
@@ -346,7 +396,15 @@
"alreadyAdded": "This user ID is already authorized.", "alreadyAdded": "This user ID is already authorized.",
"telegramIdHelp": "To find your Telegram user ID:\n1. Open Telegram and message @userinfobot\n2. It instantly replies with your numeric ID\n3. Enter that number here", "telegramIdHelp": "To find your Telegram user ID:\n1. Open Telegram and message @userinfobot\n2. It instantly replies with your numeric ID\n3. Enter that number here",
"discordIdHelp": "To find your Discord user ID:\n1. Enable Developer Mode in Discord settings (Advanced)\n2. Right-click your name → Copy User ID\n3. Enter that number here", "discordIdHelp": "To find your Discord user ID:\n1. Enable Developer Mode in Discord settings (Advanced)\n2. Right-click your name → Copy User ID\n3. Enter that number here",
"emailIdHelp": "Enter the email address that should be authorized to interact with the assistant." "threemaIdHelp": "Enter your own Threema ID — the 8 characters shown in your Threema app under Settings → My Threema ID. Once added, you'll be able to chat with the assistant directly from Threema.",
"threemaSetup": {
"title": "Add the assistant to your Threema",
"step1": "Open Threema on your phone.",
"step2": "Tap the scan icon and scan this QR code to add the assistant as a contact.",
"step3": "Then add your own Threema ID below.",
"qrAlt": "QR code to add {gateway} as a Threema contact",
"showQr": "Show QR"
}
}, },
"team": { "team": {
"title": "Team", "title": "Team",

View File

@@ -214,7 +214,8 @@
"packages": { "packages": {
"categories": { "categories": {
"channels": "Canaux", "channels": "Canaux",
"skills": "Compétences" "skills": "Compétences",
"core": "Cœur"
}, },
"enable": "Activer", "enable": "Activer",
"disable": "Désactiver", "disable": "Désactiver",
@@ -239,29 +240,78 @@
"botTokenLabel": "Token du bot Discord", "botTokenLabel": "Token du bot Discord",
"botTokenPlaceholder": "MTAxNjQ0OTk2NjAz...", "botTokenPlaceholder": "MTAxNjQ0OTk2NjAz...",
"instructions": "1. Allez sur discord.com/developers/applications\n2. Créez une nouvelle application et ajoutez un bot\n3. Copiez le token du bot", "instructions": "1. Allez sur discord.com/developers/applications\n2. Créez une nouvelle application et ajoutez un bot\n3. Copiez le token du bot",
"disclaimer": "Je confirme que je possède ce bot Discord et autorise PieCed IT à le connecter à mon assistant IA." "disclaimer": "Je confirme que je possède ce bot Discord et autorise PieCed IT à le connecter à mon assistant IA.",
"appIdLabel": "ID d'application Discord",
"appIdPlaceholder": "ID numérique de 1819 chiffres depuis le Developer Portal"
}, },
"email": { "statusEnabled": "activé",
"description": "Permettez à votre assistant IA d'envoyer et de recevoir des e-mails.", "statusDisabled": "désactivé",
"coreHeartbeat": {
"description": "Exécution périodique de l'agent toutes les 30 minutes pour vérifier votre boîte mail, votre agenda et d'autres sources configurées, et vous notifier de manière proactive lorsqu'une attention est requise. Sans cette option, l'assistant ne répond que lorsque vous lui écrivez."
},
"coreCron": {
"description": "Permet à l'assistant d'exécuter des tâches programmées (briefings quotidiens, rappels récurrents, rapports périodiques). Désactivé par défaut. Lorsqu'il est désactivé, l'outil cron reste disponible mais aucune tâche planifiée ne s'exécute."
},
"coreActiveMemory": {
"description": "Permet à l'assistant de se rappeler des préférences stables, des habitudes récurrentes et du contexte à long terme issu de conversations passées. Utilise un tour de sous-agent supplémentaire par message entrant pour interroger la mémoire. Uniquement en messages directs. Légère consommation de tokens supplémentaire en échange de continuité et de personnalisation."
},
"coreVoice": {
"description": "Reconnaissance vocale sur les notes vocales entrantes et synthèse vocale sur les réponses, via la passerelle PieCed LiteLLM pour un suivi du coût audio par tenant. L'intégration runtime arrive dans la prochaine version de la plateforme ; basculer le commutateur enregistre dès maintenant la préférence."
},
"gitCli": {
"description": "Opérations git en ligne de commande autonomes (clone, commit, branch, diff, log, status). Pour les dépôts privés, configurez les identifiants dans votre espace de travail."
},
"github": {
"description": "Interagissez avec les dépôts GitHub via la CLI gh — issues, pull requests, exécutions CI, releases, gists. Nécessite un jeton d'accès personnel.",
"tokenLabel": "Jeton d'accès personnel GitHub",
"tokenPlaceholder": "ghp_… ou github_pat_…",
"instructions": "1. Ouvrez https://github.com/settings/tokens\n2. Générez un jeton d'accès personnel fin avec les portées de dépôt souhaitées\n3. Copiez le jeton (il n'est affiché qu'une fois)"
},
"gitea": {
"description": "Interagissez avec une instance Gitea — dépôts, issues, pull requests, releases. Par défaut, l'instance Gitea PieCed à git.c5ai.ch.",
"tokenLabel": "Jeton d'accès Gitea",
"tokenPlaceholder": "Généré sous Paramètres → Applications",
"instructions": "1. Connectez-vous à votre instance Gitea (par défaut https://git.c5ai.ch)\n2. Allez dans Paramètres → Applications → Générer un nouveau jeton\n3. Accordez les portées souhaitées (repo, issue, user)\n4. Copiez le jeton"
},
"whisperSelfHosted": {
"description": "Transcrivez des fichiers audio via l'instance Whisper auto-hébergée de la plateforme. Utile pour les transcriptions ad hoc initiées depuis le chat."
},
"searxngLocalSearch": {
"description": "Recherche web respectueuse de la vie privée via l'instance SearXNG interne de la plateforme. Recherchez le web, les images et les actualités sans appels d'API externes ni traqueurs."
},
"gog": {
"description": "Accès groupé à Gmail, Agenda, Drive, Docs, Sheets et Contacts via Google OAuth. La configuration nécessite un projet Google Cloud — contactez le support PieCed pour l'intégration.",
"clientIdLabel": "ID client Google OAuth",
"clientIdPlaceholder": "xxxxxxxxxxx.apps.googleusercontent.com",
"clientSecretLabel": "Secret client Google OAuth",
"clientSecretPlaceholder": "GOCSPX-…",
"refreshTokenLabel": "Jeton de rafraîchissement Google OAuth",
"refreshTokenPlaceholder": "1//0g…",
"instructions": "L'intégration de Google Workspace utilise OAuth et nécessite actuellement une intégration manuelle. Veuillez ouvrir un ticket de support pour démarrer la configuration — nous échangerons hors ligne les identifiants client et un jeton de rafraîchissement, puis activerons ce package sur votre tenant.",
"disclaimer": "En activant l'intégration de Google Workspace, vous autorisez PieCed à accéder à Gmail, Agenda, Drive, Docs, Sheets et Contacts en votre nom. Les données transitent par les API de Google, soumises aux conditions de Google."
},
"mail": {
"description": "Lisez, recherchez et gérez vos e-mails via IMAP ; envoyez via SMTP. Compatible avec Gmail (avec un mot de passe d'application), Outlook, Fastmail et tout hôte IMAP/SMTP standard.",
"imapHostLabel": "Hôte IMAP",
"imapHostPlaceholder": "imap.example.com",
"imapUserLabel": "Nom d'utilisateur IMAP",
"imapUserPlaceholder": "utilisateur@example.com",
"imapPassLabel": "Mot de passe IMAP",
"imapPassPlaceholder": "••••••••",
"smtpHostLabel": "Hôte SMTP", "smtpHostLabel": "Hôte SMTP",
"smtpHostPlaceholder": "smtp.example.com", "smtpHostPlaceholder": "smtp.example.com",
"smtpUserLabel": "Nom d'utilisateur SMTP", "smtpUserLabel": "Nom d'utilisateur SMTP",
"smtpUserPlaceholder": "user@example.com", "smtpUserPlaceholder": "utilisateur@example.com",
"smtpPasswordLabel": "Mot de passe SMTP", "smtpPassLabel": "Mot de passe SMTP",
"smtpPasswordPlaceholder": "••••••••", "smtpPassPlaceholder": "••••••••",
"imapHostLabel": "Hôte IMAP", "instructions": "1. Pour Gmail : activez la validation en deux étapes, puis créez un mot de passe d'application sur https://myaccount.google.com/apppasswords et utilisez-le comme mot de passe IMAP et SMTP.\n2. Pour Outlook / Microsoft 365 avec MFA : générez un mot de passe d'application dans les paramètres de sécurité de votre compte.\n3. Pour les autres fournisseurs : consultez leur documentation IMAP/SMTP pour les noms d'hôte et les ports.\n4. Hôtes IMAP typiques : imap.gmail.com, outlook.office365.com.\n5. Hôtes SMTP typiques : smtp.gmail.com, smtp.office365.com.",
"imapHostPlaceholder": "imap.example.com", "disclaimer": "L'assistant obtient un accès en lecture/écriture à la boîte aux lettres que vous configurez. Envisagez d'utiliser une adresse dédiée plutôt qu'une boîte personnelle si vous souhaitez limiter la portée."
"instructions": "Fournissez les identifiants SMTP et IMAP. L'assistant les utilise pour envoyer et surveiller les messages.",
"disclaimer": "Je confirme que je suis autorisé à utiliser ces identifiants e-mail et que PieCed IT peut accéder à cette boîte mail."
}, },
"webSearch": { "threema": {
"description": "Donnez à votre assistant IA la capacité de rechercher sur le web." "description": "Envoyez et recevez des messages via Threema. Chaque message entrant ou sortant transite par le service de messagerie PieCed partagé et entraîne des frais par message facturés par Threema — un coût tiers, distinct de votre abonnement PieCed.",
}, "instructions": "1. Activez ce package.\n2. Ouvrez Threema sur votre téléphone, scannez le QR code affiché dans Utilisateurs autorisés → threema, puis acceptez le contact.\n3. Ajoutez votre propre identifiant Threema sous Utilisateurs autorisés → threema afin que l'assistant reconnaisse vos messages.\n4. Envoyez un message depuis Threema pour commencer la conversation.",
"documentProcessing": { "disclaimer": "Les messages entre Threema et PieCed sont chiffrés de bout en bout jusqu'au service de messagerie PieCed, où ils sont déchiffrés pour être acheminés vers votre assistant. Chaque message envoyé ou reçu est facturé par Threema selon son tarif par message — consultez votre plan pour les tarifs en vigueur."
"description": "Activez l'analyse, le résumé et l'extraction de documents." }
},
"statusEnabled": "activé",
"statusDisabled": "désactivé"
}, },
"admin": { "admin": {
"title": "Admin plateforme", "title": "Admin plateforme",
@@ -346,7 +396,15 @@
"alreadyAdded": "Cet identifiant est déjà autorisé.", "alreadyAdded": "Cet identifiant est déjà autorisé.",
"telegramIdHelp": "Pour trouver votre identifiant Telegram :\n1. Ouvrez Telegram et envoyez un message à @userinfobot\n2. Il répond instantanément avec votre identifiant numérique\n3. Entrez ce numéro ici", "telegramIdHelp": "Pour trouver votre identifiant Telegram :\n1. Ouvrez Telegram et envoyez un message à @userinfobot\n2. Il répond instantanément avec votre identifiant numérique\n3. Entrez ce numéro ici",
"discordIdHelp": "Pour trouver votre identifiant Discord :\n1. Activez le mode développeur dans les paramètres Discord (Avancé)\n2. Clic droit sur votre nom → Copier l'identifiant\n3. Entrez ce numéro ici", "discordIdHelp": "Pour trouver votre identifiant Discord :\n1. Activez le mode développeur dans les paramètres Discord (Avancé)\n2. Clic droit sur votre nom → Copier l'identifiant\n3. Entrez ce numéro ici",
"emailIdHelp": "Entrez l'adresse e-mail qui doit être autorisée à interagir avec l'assistant." "threemaIdHelp": "Saisissez votre propre identifiant Threema — les 8 caractères affichés dans votre application Threema sous Réglages → Mon identifiant Threema. Une fois ajouté, vous pourrez discuter directement avec l'assistant depuis Threema.",
"threemaSetup": {
"title": "Ajouter l'assistant à Threema",
"step1": "Ouvrez Threema sur votre téléphone.",
"step2": "Appuyez sur l'icône de scan et scannez ce QR code pour ajouter l'assistant comme contact.",
"step3": "Puis ajoutez votre propre identifiant Threema ci-dessous.",
"qrAlt": "QR code pour ajouter {gateway} comme contact Threema",
"showQr": "Afficher le QR"
}
}, },
"team": { "team": {
"title": "Équipe", "title": "Équipe",

View File

@@ -214,7 +214,8 @@
"packages": { "packages": {
"categories": { "categories": {
"channels": "Canali", "channels": "Canali",
"skills": "Capacità" "skills": "Capacità",
"core": "Core"
}, },
"enable": "Attiva", "enable": "Attiva",
"disable": "Disattiva", "disable": "Disattiva",
@@ -239,29 +240,78 @@
"botTokenLabel": "Token bot Discord", "botTokenLabel": "Token bot Discord",
"botTokenPlaceholder": "MTAxNjQ0OTk2NjAz...", "botTokenPlaceholder": "MTAxNjQ0OTk2NjAz...",
"instructions": "1. Vai su discord.com/developers/applications\n2. Crea una nuova applicazione e aggiungi un bot\n3. Copia il token del bot", "instructions": "1. Vai su discord.com/developers/applications\n2. Crea una nuova applicazione e aggiungi un bot\n3. Copia il token del bot",
"disclaimer": "Confermo di possedere questo bot Discord e autorizzo PieCed IT a collegarlo al mio assistente IA." "disclaimer": "Confermo di possedere questo bot Discord e autorizzo PieCed IT a collegarlo al mio assistente IA.",
}, "appIdLabel": "ID applicazione Discord",
"email": { "appIdPlaceholder": "ID numerico di 1819 cifre dal Developer Portal"
"description": "Permetti al tuo assistente IA di inviare e ricevere e-mail.",
"smtpHostLabel": "Host SMTP",
"smtpHostPlaceholder": "smtp.example.com",
"smtpUserLabel": "Nome utente SMTP",
"smtpUserPlaceholder": "user@example.com",
"smtpPasswordLabel": "Password SMTP",
"smtpPasswordPlaceholder": "••••••••",
"imapHostLabel": "Host IMAP",
"imapHostPlaceholder": "imap.example.com",
"instructions": "Fornisci le credenziali SMTP e IMAP. L'assistente le usa per inviare e monitorare i messaggi.",
"disclaimer": "Confermo di essere autorizzato a utilizzare queste credenziali e-mail e che PieCed IT può accedere a questa casella di posta."
},
"webSearch": {
"description": "Dai al tuo assistente IA la capacità di cercare nel web."
},
"documentProcessing": {
"description": "Attiva l'analisi, il riassunto e l'estrazione di documenti."
}, },
"statusEnabled": "abilitato", "statusEnabled": "abilitato",
"statusDisabled": "disabilitato" "statusDisabled": "disabilitato",
"coreHeartbeat": {
"description": "Esecuzione periodica dell'agente ogni 30 minuti che consente all'assistente di controllare posta, calendario e altre fonti configurate e di avvisarti proattivamente quando serve attenzione. Senza questa opzione, l'assistente risponde solo quando lo contatti."
},
"coreCron": {
"description": "Consente all'assistente di eseguire attività pianificate (briefing giornalieri, promemoria ricorrenti, report periodici). Disattivato per impostazione predefinita. Quando è disattivato, lo strumento cron resta disponibile ma nessuna attività pianificata viene eseguita."
},
"coreActiveMemory": {
"description": "Consente all'assistente di richiamare preferenze stabili, abitudini ricorrenti e contesto a lungo termine dalle conversazioni precedenti. Utilizza un turno extra di sub-agente per ogni messaggio in entrata per interrogare lo store di memoria. Solo messaggi diretti. Aggiunge un piccolo costo in token in cambio di continuità e personalizzazione."
},
"coreVoice": {
"description": "Riconoscimento vocale sui messaggi audio in entrata e sintesi vocale sulle risposte, instradati attraverso il gateway PieCed LiteLLM per tracciare il costo audio per tenant. L'integrazione runtime arriverà nel prossimo rilascio della piattaforma; attivare ora salva la preferenza per quel rilascio."
},
"gitCli": {
"description": "Operazioni git da riga di comando autonome (clone, commit, branch, diff, log, status). Per i repository privati, configura le credenziali nel tuo workspace."
},
"github": {
"description": "Interagisci con repository GitHub tramite la CLI gh — issue, pull request, esecuzioni CI, release, gist. Richiede un token di accesso personale.",
"tokenLabel": "Token di accesso personale GitHub",
"tokenPlaceholder": "ghp_… o github_pat_…",
"instructions": "1. Apri https://github.com/settings/tokens\n2. Genera un token di accesso personale fine con gli ambiti repo desiderati\n3. Copia il token (viene mostrato una sola volta)"
},
"gitea": {
"description": "Interagisci con un'istanza Gitea — repository, issue, pull request, release. Per impostazione predefinita, l'istanza Gitea PieCed su git.c5ai.ch.",
"tokenLabel": "Token di accesso Gitea",
"tokenPlaceholder": "Generato in Impostazioni → Applicazioni",
"instructions": "1. Accedi alla tua istanza Gitea (predefinito https://git.c5ai.ch)\n2. Vai a Impostazioni → Applicazioni → Genera nuovo token\n3. Concedi gli ambiti desiderati (repo, issue, user)\n4. Copia il token"
},
"whisperSelfHosted": {
"description": "Trascrivi file audio tramite l'istanza Whisper auto-ospitata della piattaforma. Utile per attività di trascrizione ad hoc avviate dalla chat."
},
"searxngLocalSearch": {
"description": "Ricerca web rispettosa della privacy tramite l'istanza SearXNG interna della piattaforma. Cerca sul web, nelle immagini e nelle notizie senza chiamate ad API esterne né tracker."
},
"gog": {
"description": "Accesso integrato a Gmail, Calendar, Drive, Docs, Sheets e Contatti tramite Google OAuth. La configurazione richiede un progetto Google Cloud — contatta il supporto PieCed per l'onboarding.",
"clientIdLabel": "ID client Google OAuth",
"clientIdPlaceholder": "xxxxxxxxxxx.apps.googleusercontent.com",
"clientSecretLabel": "Client secret Google OAuth",
"clientSecretPlaceholder": "GOCSPX-…",
"refreshTokenLabel": "Token di refresh Google OAuth",
"refreshTokenPlaceholder": "1//0g…",
"instructions": "L'integrazione con Google Workspace utilizza OAuth e richiede attualmente un onboarding manuale. Apri un ticket di supporto per avviare la configurazione — scambieremo le credenziali del client e un token di refresh offline, quindi abiliteremo questo pacchetto sul tuo tenant.",
"disclaimer": "Abilitando l'integrazione con Google Workspace autorizzi PieCed ad accedere per tuo conto a Gmail, Calendar, Drive, Docs, Sheets e Contatti. I dati transitano attraverso le API di Google, soggetti ai termini di Google."
},
"mail": {
"description": "Leggi, cerca e gestisci le e-mail via IMAP; invia tramite SMTP. Funziona con Gmail (con una password per app), Outlook, Fastmail e qualsiasi host IMAP/SMTP standard.",
"imapHostLabel": "Host IMAP",
"imapHostPlaceholder": "imap.example.com",
"imapUserLabel": "Username IMAP",
"imapUserPlaceholder": "utente@example.com",
"imapPassLabel": "Password IMAP",
"imapPassPlaceholder": "••••••••",
"smtpHostLabel": "Host SMTP",
"smtpHostPlaceholder": "smtp.example.com",
"smtpUserLabel": "Username SMTP",
"smtpUserPlaceholder": "utente@example.com",
"smtpPassLabel": "Password SMTP",
"smtpPassPlaceholder": "••••••••",
"instructions": "1. Per Gmail: abilita la verifica in due passaggi, quindi crea una password per app su https://myaccount.google.com/apppasswords e usala come password IMAP e SMTP.\n2. Per Outlook / Microsoft 365 con MFA: genera una password per app nelle impostazioni di sicurezza del tuo account.\n3. Per altri provider: consulta la loro documentazione IMAP/SMTP per nomi host e porte.\n4. Host IMAP tipici: imap.gmail.com, outlook.office365.com.\n5. Host SMTP tipici: smtp.gmail.com, smtp.office365.com.",
"disclaimer": "L'assistente ottiene accesso in lettura/scrittura alla casella di posta che configuri. Valuta l'uso di un indirizzo dedicato anziché di una casella personale se vuoi limitare la portata."
},
"threema": {
"description": "Invia e ricevi messaggi tramite Threema. Ogni messaggio in entrata e in uscita passa attraverso il servizio di messaggistica condiviso di PieCed e comporta un addebito per messaggio da parte di Threema — un costo di terzi, separato dall'abbonamento PieCed.",
"instructions": "1. Attiva questo pacchetto.\n2. Apri Threema sul tuo telefono, scansiona il QR code mostrato in Utenti autorizzati → threema e accetta il contatto.\n3. Aggiungi il tuo ID Threema sotto Utenti autorizzati → threema affinché l'assistente riconosca i tuoi messaggi.\n4. Invia un messaggio da Threema per iniziare la conversazione.",
"disclaimer": "I messaggi tra Threema e PieCed sono cifrati end-to-end fino al servizio di messaggistica PieCed, dove vengono decifrati per essere inoltrati al tuo assistente. Ogni messaggio inviato o ricevuto viene addebitato da Threema secondo la sua tariffa per messaggio — consulta il tuo piano per i prezzi attuali."
}
}, },
"admin": { "admin": {
"title": "Admin piattaforma", "title": "Admin piattaforma",
@@ -346,7 +396,15 @@
"alreadyAdded": "Questo ID utente è già autorizzato.", "alreadyAdded": "Questo ID utente è già autorizzato.",
"telegramIdHelp": "Per trovare il tuo ID Telegram:\n1. Apri Telegram e invia un messaggio a @userinfobot\n2. Risponde istantaneamente con il tuo ID numerico\n3. Inserisci quel numero qui", "telegramIdHelp": "Per trovare il tuo ID Telegram:\n1. Apri Telegram e invia un messaggio a @userinfobot\n2. Risponde istantaneamente con il tuo ID numerico\n3. Inserisci quel numero qui",
"discordIdHelp": "Per trovare il tuo ID Discord:\n1. Attiva la Modalità sviluppatore nelle impostazioni Discord (Avanzate)\n2. Clic destro sul tuo nome → Copia ID utente\n3. Inserisci quel numero qui", "discordIdHelp": "Per trovare il tuo ID Discord:\n1. Attiva la Modalità sviluppatore nelle impostazioni Discord (Avanzate)\n2. Clic destro sul tuo nome → Copia ID utente\n3. Inserisci quel numero qui",
"emailIdHelp": "Inserisci l'indirizzo e-mail che deve essere autorizzato a interagire con l'assistente." "threemaIdHelp": "Inserisci il tuo ID Threema — gli 8 caratteri mostrati nella tua app Threema sotto Impostazioni → Il mio ID Threema. Una volta aggiunto, potrai conversare con l'assistente direttamente da Threema.",
"threemaSetup": {
"title": "Aggiungi l'assistente a Threema",
"step1": "Apri Threema sul tuo telefono.",
"step2": "Tocca l'icona di scansione e scansiona questo QR code per aggiungere l'assistente ai contatti.",
"step3": "Quindi aggiungi il tuo ID Threema qui sotto.",
"qrAlt": "QR code per aggiungere {gateway} come contatto Threema",
"showQr": "Mostra QR"
}
}, },
"team": { "team": {
"title": "Team", "title": "Team",