Documentación abierta de mini-apps de TutuHai
Integración del SDK · API de capacidades · Datos en la nube · Autorización · Publicación
Las mini-apps de TutuHai son aplicaciones ligeras que se ejecutan dentro de TutuHai. Los desarrolladores suben solo un paquete de código frontend, alojado por la plataforma; las capacidades se comunican con TutuHai a través del SDK window.tt, sin backend propio (los datos de negocio pasan por los datos en la nube de TutuHai). Integración del SDK · APIs de capacidades · datos en la nube · autorización · publicación — todo en una sola página, con ejemplos que puedes copiar y ejecutar.
Contenido · 26 temas
Primeros pasos
Capacidades de datos
Conversaciones y multijugador
Archivos y unidad en la nube
Interacción entre apps
UI · ventana · host
Referencia
Primeros pasos
Introducción
Las mini-apps de TutuHai son aplicaciones ligeras que se ejecutan dentro de TutuHai. Los desarrolladores suben solo un paquete de código frontend, alojado por la plataforma; las capacidades se comunican con TutuHai a través del SDK window.tt, sin backend propio (los datos de negocio pasan por los datos en la nube de TutuHai).
Aislamiento y seguridad: las mini-apps se ejecutan en un iframe aislado (sandbox) en un origen aislado; la sesión de inicio de sesión del host nunca entra en la mini-app. Cada llamada recibe del host un token de vida corta y restringido, y el backend revalida por capacidad (scope).
Inicio rápido
- En la Consola de mini-apps (
/applets), haz clic en "Nuevo" para crear una mini-app (nombre + slug único). - Escribe un HTML de un solo archivo (incluye el SDK, llama a las capacidades vía
window.tt.*). - Crea una versión → completa las capacidades solicitadas → sube el paquete de código (HTML de un solo archivo).
- Envía para revisión → el administrador aprueba → publica en producción con un solo clic.
- Los usuarios la encuentran/abren mediante la búsqueda de "Descubrir", o compartes una tarjeta / copias un enlace para acceso directo.
Especificación de empaquetado
Las mini-apps admiten dos formas de subida: ① un paquete HTML de un solo archivo (autocontenido, entrada = raíz, el más simple); ② un zip con la salida de compilación de un framework real (el dist/ de npm run build, con index.html + assets repartidos en varios archivos — ver "Salida de compilación de framework"). La plataforma ejecuta una comprobación de especificación y una optimización antes de la subida.
Estructura del paquete (salida de compilación)
your-applet/ # dev directory (any structure: src, components, assets…)
├─ src/ … # your source (React / Vue / Svelte / vanilla)
└─ dist/index.html # ★build output: single-file HTML (← upload this)
# inlined CSS/JS, or referencing whitelisted CDNs; self-contained, no server
El paquete de código debe cumplir (comprobado automáticamente al subir):
- Entrada: un único archivo HTML con
<!doctype html>y un<html>raíz. - Adaptación móvil: debe incluir
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">. - SDK: incluye
<script src="/applet-sdk.js">(la plataforma lo reescribe a la dirección absoluta del host). - Autocontenido: CSS/JS en línea; si necesitas scripts externos, solo se permiten el SDK de la plataforma + CDNs de frameworks conocidos (unpkg / jsdelivr / cdnjs / esm.sh) — los scripts remotos arbitrarios están prohibidos (seguridad). Las imágenes y otros medios pasan por
tt.uploadImageo un CDN. - Tamaño: HTML de un solo archivo ≤ 1MB; zip multiarchivo ≤ 8MB en total, ≤ 1MB por archivo, ≤ 100 archivos; imágenes subidas ≤ 4MB.
- Datos: sin backend propio — los datos de negocio pasan por los datos en la nube
tt.cloud/tt.*storage.
📱🖥 Universal móvil / escritorio (una sola base de código, ambas superficies)
El mismo paquete se ejecuta en un iframe aislado dentro de TutuHai; el host lo lleva tanto en móvil (pantalla completa) como en escritorio (panel / puede pasar a pantalla completa). Escribe una única base de código universal con un diseño responsivo: ① viewport-fit=cover + áreas seguras env(safe-area-inset-*); ② superposiciones como hojas inferiores (móvil) ↔ centradas (escritorio, @media(min-width:480px)); ③ objetivos táctiles ≥ 44px; ④ sigue el modo oscuro/claro del host (tt.onThemeChange / [data-theme]); ⑤ DOM puro, sin anchos codificados a mano. Esto mantiene una experiencia coherente tanto en teléfono como en ordenador.
Salida de compilación de framework (dist.zip)
Además del HTML de un solo archivo, también puedes subir la salida de compilación de un framework real — usa React / Vue / Svelte / Angular / Solid / Astro / Next (exportación estática) / vanilla… el npm run build de cualquier cadena de herramientas, comprime el dist/ (con index.html + assets/*.js/css + fuentes/imágenes) y súbelo para alojarlo.
Independiente del framework: la plataforma solo reconoce un "contrato de paquete estático universal" — entrada
index.html+ referencias de assets relativas + SDK. Cualquier framework capaz de producir undistestático que cumpla ese contrato es compatible; los andamiajes de abajo son solo atajos seleccionados, no el límite del soporte.
estructura del zip (salida de compilación, raíz del zip = raíz del paquete)
myapp.zip
├─ index.html # ★entry (zip root)
├─ manifest.json # declares slug/name/version/scopes (see below)
└─ assets/
├─ index-*.js # built JS (relative references)
├─ index-*.css
└─ font/img…
Adaptación en tres pasos (funciona con cualquier framework):
- Establece una base relativa (recomendado, lo más seguro) — haz que la salida referencie los assets por ruta relativa (
./assets/x.jsen lugar de la ruta raíz-absoluta/assets/x.js), para que alojar bajo/<slug>/sea infalible. (La base por defecto también funciona: la plataforma reescribe automáticamente las referencias estáticas raíz-absolutas en HTML/CSS y usa un respaldo por Referer para los assets raíz-absolutos generados en tiempo de ejecución — como las precargas de CSS con code-split; pero bajo casos límite deReferrer-Policyestricta / precarga offline el Referer puede faltar y el respaldo falla, así que una base relativa es lo más seguro.) - Añade un manifest — coloca
manifest.jsonen el directorio estático (p. ej. elstatic/de Vite/SvelteKit, elpublic/de la mayoría de frameworks) para que quede en la raíz dedisttras la compilación; u omite el manifest y añade<meta name="tt:slug" content="…">(mástt:name / tt:version / tt:scopes) enindex.htmlcomo respaldo. - Incluye el SDK — dos formas: ① instalación por npm (recomendada, ideal para andamiajes reales): tras
npm i @tutuhai/applet-sdk,import { tt } from '@tutuhai/applet-sdk'— se empaqueta en la salida en tiempo de compilación, con tipos de TypeScript, sin editarindex.html; ② o escribe<script src="/applet-sdk.js">enindex.html(la plataforma lo reescribe a la dirección absoluta del host) y usa el globalwindow.tt.
📦 SDK de npm (probado con los andamiajes oficiales de React / Vue / Svelte)
Crea un proyecto con npm create vite@latest -- --template react-ts | vue-ts | svelte-ts, instala el mismo @tutuhai/applet-sdk, e importalo — código fuente multiarchivo real, npm run build produce varios chunks + una entrada index.html; comprímelo y súbelo.
# 1) Create a project with an official scaffold
npm create vite@latest my-applet -- --template react-ts # or vue-ts / svelte-ts
# 2) Install the SDK (same package for all three frameworks)
npm i @tutuhai/applet-sdk
# 3) import and use in your source (typed)
# src/App.tsx / App.vue / App.svelte
import { tt } from '@tutuhai/applet-sdk';
tt.ready((ctx) => {
tt.getProfile().then((me) => console.log('hi', me?.nickname));
});
await tt.cloud.add('notes', { text: 'hello' }); // cloud data, no backend of your own
# 4) vite.config: relative base; public/manifest.json declares slug/name/scopes
# export default { base: './', plugins: [react()] }
# 5) official build → zip dist → upload
npm run build && cd dist && zip -r ../my-applet.zip .
Ejemplos ejecutables en el repositorio: applets/frameworks/{react,vue,svelte} (tres proyectos reales con andamiaje oficial, todos importando el mismo paquete del SDK). Código fuente del paquete del SDK: applet-sdk/.
Campos de manifest.json
{
"slug": "myapp", // ★required, globally unique, determines hosting path /myapp/
"name": "My Mini-App", // ★required, display name
"version": "1.0.0", // ★required, must increment on every upload
"scopes": ["user.profile"], // requested capabilities (see "Authorization model")
"description": "One-line summary", // optional, shown on discover/detail
"icon": "icon.png", // optional, relative path in the bundle (or change it in the console after upload)
"display": "fullscreen", // optional, default open mode: window (floating, default) | fullscreen
"fileHandlers": [ // optional, declares "Open with" — which file kinds you can handle from a chat
{ "kinds": ["image"], "role": "editor", "label": "TutuEdit · Retouch" }
]
}
- display: declara la forma de apertura en escritorio — para apps de lienzo/pizarra/editor prefiere
fullscreen, para tarjetas/formularios ligeros usa elwindowpor defecto. Es solo un valor inicial; tras publicar puedes cambiar el "Modo de apertura" en cualquier momento en la consola (la consola manda). En móvil siempre es pantalla completa, sin verse afectado por este campo. Cuando se omite el manifest,<meta name="tt:display" content="fullscreen">funciona como respaldo. - fileHandlers: declara qué tipos de archivo puede manejar tu mini-app desde un chat — cuando un usuario toca "Abrir con" en un archivo del chat, las mini-apps que declararon un tipo coincidente aparecen como candidatas; al tocar una, ese archivo se envía directamente a tu mini-app (ver "Manejo de archivos del chat"). Cada elemento:
kindses uno deimage / video / audio / pdf / office / text / any(se permiten varios,any= cualquier archivo);roleeseditor(abrir en un editor) oviewer(previsualización);labelopcional (≤20 caracteres, el nombre a mostrar del candidato). Hasta 8 elementos. El control de acceso coincide con la visibilidad:private/self-usefunciona sin revisión (aparece solo en tu propio "Abrir con");publicrequiere aprobación del administrador antes de surtir efecto para todos.
Configuración de "base relativa" en una línea por framework:
// Vite (React/Vue/Svelte/Solid/Preact/Lit…)
export default { base: './' }
// SvelteKit (client-side routing → base must = slug) — static export + base set to your slug (else routes 404)
import adapter from '@sveltejs/adapter-static';
export default { kit: {
adapter: adapter({ fallback: 'index.html' }),
paths: { base: '/your-slug', relative: true }
} };
// Astro — astro.config.mjs
export default { base: './', build: { assets: 'assets' } }
# Angular — set a relative base href at build time
ng build --base-href ./ --output-path dist
// Next.js (static export) — next.config.js
module.exports = { output: 'export', images: { unoptimized: true }, assetPrefix: './' }
// Nuxt 3 (static) — nuxt.config.ts
export default defineNuxtConfig({ app: { baseURL: './', cdnURL: './' }, ssr: false })
// Vue CLI / webpack — vue.config.js (or webpack output.publicPath)
module.exports = { publicPath: './' }
<!-- Vanilla / no build: just use relative paths -->
<script src="./app.js"></script>
<link rel="stylesheet" href="./style.css">
⚠ SPAs con enrutamiento del lado del cliente (SvelteKit / React Router / Vue Router / Angular) Las mini-apps se alojan bajo la subruta
/<slug>/. Una SPA con enrutamiento del lado del cliente debe establecer su "base de router" a tu slug, de lo contrario el router del framework no puede coincidir con la ruta actual → 404 de toda la página (los assets cargan, pero el enrutamiento reporta no encontrado). Establecer solo una base relativa de assets no es suficiente — eso solo arregla las URLs de assets, no el enrutamiento. Por framework: SvelteKitkit.paths.base='/<slug>'; React Router<BrowserRouter basename="/<slug>">; Vue RoutercreateWebHistory('/<slug>/'); AngularAPP_BASE_HREF='/<slug>/'. (Las apps sin enrutamiento del lado del cliente — renderizado puro / un React de una sola página sin Router / vanilla — no se ven afectadas.)
⚠ Comprobación al subir Al subir, la plataforma ejecuta una "comprobación de contrato" sobre el dist: entrada / rutas relativas / manifest / referencia del SDK / límites / MIME se validan cada uno con pistas en línea. Total ≤ 8MB, ≤ 1MB por archivo, ≤ 100 archivos, solo MIME de la lista blanca (html/css/js/json/imágenes/fuentes/map/wasm). Comprime y crea subconjuntos de fuentes para mantener el tamaño bajo. Para salidas de framework pesadas (p. ej. tldraw / excalidraw con un único chunk >1MB) que excedan los límites por defecto por archivo/totales, pide al equipo de operaciones que suba los límites de "bytes por archivo" / "total sin comprimir" en el panel de administración (modificable en tiempo de ejecución, efectivo de inmediato); dividir
manualChunkstambién puede dejar el vendor por debajo del límite.
Ejemplo mínimo
Una mini-app completa y ejecutable — incluye el SDK, lee el apodo del usuario:
<!doctype html>
<html>
<body>
<div id="who">Loading…</div>
<!-- Relative path — maintenance-free: survives domain changes/blocks (platform rewrites to the host's absolute URL) -->
<script src="/applet-sdk.js"></script>
<script>
window.tt.ready(function () {
window.tt.getProfile().then(function (me) {
document.getElementById('who').textContent = 'Hi, ' + me.nickname;
});
});
</script>
</body>
</html>
Integración del SDK
Incluye el script del SDK en el HTML de tu mini-app, y luego usa window.tt:
<!-- Include the SDK in your mini-app HTML. Use a relative path — don't hardcode a domain -->
<script src="/applet-sdk.js"></script>
Nunca codifiques el dominio del host en tu mini-app. Escribe la ruta relativa
/applet-sdk.js(o cualquier origen marcador de posición) — la plataforma reescribe la URL del script del SDK al host actual en el momento de servir, cuando tu app se ejecuta en su iframe. Así, incluso si TutuHai cambia su dominio, o un dominio es bloqueado, cada mini-app publicada sigue funcionando sin cambio de código y sin republicar — un operador cambia un único valor de configuración.
Callback de listo, contexto y tema/idioma:
// After ready you get the context (appId / granted scopes / deep-link path / query / theme / locale / whether inline)
window.tt.ready(function (ctx) {
console.log(ctx.appId, ctx.scopes, ctx.path, ctx.query, ctx.theme, ctx.colorScheme, ctx.locale, ctx.inline);
});
window.tt.context(); // get the current context snapshot anytime (same as ready's ctx)
// Theme switching (fires live when the host toggles light/dark)
window.tt.onThemeChange(function (theme) {
document.documentElement.setAttribute('data-theme', theme);
});
// Locale switching (synced with the host's i18n; fires live when the host changes language — same mechanism as theme)
window.tt.onLocaleChange(function (locale) { // e.g. 'zh-CN' / 'en-US'
document.documentElement.setAttribute('lang', locale); // the SDK sets it already; you can also localize your own copy
});
Ejemplos por framework
window.tt es independiente del framework y funciona directamente en todos los frameworks principales (un solo archivo, sin compilación). Cada ejemplo incluye el manejo tanto de éxito ✅ como de fallo ❌ (autorización denegada / error de red):
Vanilla JS
// No framework — vanilla DOM
window.tt.ready(function () {
window.tt.getProfile()
.then(function (me) { // ✅ success
document.getElementById('who').textContent = 'Hi, ' + me.nickname;
})
.catch(function (err) { // ❌ failure (user denied authorization / network error)
document.getElementById('who').textContent = 'Failed: ' + err.message;
});
});
React
// React 18 + htm (no build)
const { useState, useEffect } = React;
function App() {
const [me, setMe] = useState(null);
const [err, setErr] = useState('');
useEffect(() => {
window.tt.ready(() =>
window.tt.getProfile().then(setMe).catch((e) => setErr(e.message))
);
}, []);
if (err) return html`<div>Failed: ${err}</div>`; // ❌
return html`<div>Hi ${me ? me.nickname : '…'}</div>`; // ✅
}
Preact
// Preact + htm (no build)
const { useState, useEffect } = preactHooks;
function App() {
const [me, setMe] = useState(null), [err, setErr] = useState('');
useEffect(() => {
window.tt.ready(() =>
window.tt.getProfile().then(setMe).catch((e) => setErr(e.message))
);
}, []);
return html`<div>${err ? 'Failed: ' + err : 'Hi ' + (me ? me.nickname : '…')}</div>`;
}
Vue 3
// Vue 3 (CDN)
const { createApp, ref, onMounted } = Vue;
createApp({
setup() {
const me = ref(null), err = ref('');
onMounted(() => window.tt.ready(() =>
window.tt.getProfile()
.then((p) => (me.value = p)) // ✅
.catch((e) => (err.value = e.message)) // ❌
));
return { me, err };
},
template: `<div>{{ err ? 'Failed: ' + err : 'Hi ' + (me?.nickname ?? '…') }}</div>`
}).mount('#app');
Svelte
// Svelte (runtime compile)
let me = $state(null), err = $state('');
window.tt.ready(() =>
window.tt.getProfile()
.then((p) => (me = p)) // ✅
.catch((e) => (err = e.message)) // ❌
);
// template: <div>{err ? 'Failed: ' + err : 'Hi ' + (me?.nickname ?? '…')}</div>
Solid
// SolidJS
import { createSignal, onMount } from 'solid-js';
function App() {
const [me, setMe] = createSignal(null), [err, setErr] = createSignal('');
onMount(() => window.tt.ready(() =>
window.tt.getProfile().then(setMe).catch((e) => setErr(e.message))
));
return <div>{err() ? 'Failed: ' + err() : 'Hi ' + (me()?.nickname ?? '…')}</div>;
}
Alpine.js
<!-- Alpine.js: declarative in HTML, zero build -->
<div x-data="{ me: null, err: '' }"
x-init="window.tt.ready(() =>
window.tt.getProfile()
.then(p => me = p) /* ✅ */
.catch(e => err = e.message))"> <!-- ❌ -->
<span x-text="err ? 'Failed: ' + err : 'Hi ' + (me?.nickname ?? '…')"></span>
</div>
Lit
// Lit (Web Components)
import { LitElement, html } from 'lit';
class MyApp extends LitElement {
static properties = { me: {}, err: {} };
connectedCallback() {
super.connectedCallback();
window.tt.ready(() =>
window.tt.getProfile()
.then((p) => (this.me = p)) // ✅
.catch((e) => (this.err = e.message)) // ❌
);
}
render() {
return html`<div>${this.err ? 'Failed: ' + this.err : 'Hi ' + (this.me?.nickname ?? '…')}</div>`;
}
}
customElements.define('my-app', MyApp);
jQuery
// jQuery
$(function () {
window.tt.ready(function () {
window.tt.getProfile()
.then(function (me) { $('#who').text('Hi, ' + me.nickname); }) // ✅
.catch(function (err) { $('#who').text('Failed: ' + err.message); }); // ❌
});
});
Angular
// Angular (component)
@Component({ selector: 'app-root', template: `<div>{{ msg }}</div>` })
export class AppComponent implements OnInit {
msg = 'Loading…';
ngOnInit() {
const tt = (window as any).tt;
tt.ready(() =>
tt.getProfile()
.then((me: any) => (this.msg = 'Hi, ' + me.nickname)) // ✅
.catch((e: any) => (this.msg = 'Failed: ' + e.message)) // ❌
);
}
}
Los ejemplos completos y ejecutables están en el repositorio: applet-platform/samples/demo-react.html, demo-svelte.html, demo-vue.html.
Capacidades de datos
Perfil de usuario · user.profile
// Get the current user's profile (needs user.profile; on first call the host prompts for authorization as needed)
try {
const me = await window.tt.getProfile(); // ✅ success
console.log(me.userId, me.nickname, me.avatarUrl);
} catch (err) { // ❌ failure
// err.message: "User denied authorization" (tapped deny) / network error
console.warn('Failed to get profile:', err.message);
}
// Profile changes (you or someone in the room changed nickname/avatar) → re-fetch and refresh display
window.tt.onProfileChange(() => refreshWhoUI());
Datos en la nube · cloud.data
Colecciones estructuradas alojadas que permiten a una mini-app persistir datos de negocio sin backend propio. Tres niveles de visibilidad:
mine: lee/escribe solo tus propios documentos (por defecto).all: lee/escribe todo, solo el desarrollador de la mini-app (propietario) — para una "consola de comerciante" que vea todos los pedidos/tickets.public: cualquier usuario con sesión iniciada puede leer todo, el nombre de la colección debe empezar porpub_— para comunidad/marketplace/foro; las escrituras y ediciones siguen limitadas al autor.
// Cloud data: no backend of your own, business data hosted by the TutuHai platform. All calls return a Promise — always handle failure.
try {
// Create a document (owned by the current user)
const { id } = await window.tt.cloud.add('orders', { items: cart, total: 68, status: 'pending' });
// Idempotent upsert: create or update by docKey (most common for "one vote per person" / one record per user, avoids fetch-id-then-update)
await window.tt.cloud.put('votes', me.userId, { choice: 'A' });
// My documents
const mine = await window.tt.cloud.list('orders', { scope: 'mine' });
// Read one (by id; returns null if not found)
const doc = await window.tt.cloud.get('orders', id);
// All documents (developer/owner only, for the merchant console; regular users → 403)
const all = await window.tt.cloud.list('orders', { scope: 'all' });
// Public collection: name starts with pub_ → any logged-in user can read everything (community/marketplace)
const posts = await window.tt.cloud.list('pub_posts', { scope: 'public' });
// where equality filter (server filters on a single data field; on large collections it narrows by parent key to avoid child docs being cut off by the 200 cap)
const votes = await window.tt.cloud.list('pub_votes', { scope: 'public', where: { pollId: id } });
// Beyond 200 rows: listPage cursor pagination (mine/all; can take where), returns { docs, nextCursor }
const pg = await window.tt.cloud.listPage('orders', { scope: 'mine', limit: 100, before: cursor });
// Field-level update (owner or developer; patch merges with the original data)
await window.tt.cloud.update('orders', id, { status: 'done' });
// Delete a document (owner or developer; idempotent) — completes CRUD, no need to pile up soft-delete flags
await window.tt.cloud.delete('orders', id);
} catch (err) { // ❌ failure
// Insufficient permission (403) / using public on a non-pub_ collection (400) / quota exceeded / network
console.warn('Cloud data error:', err.message);
}
Cada fila se lee de vuelta como { id, ownerId, mine, data:{…your fields}, createdAt, updatedAt } — tus campos están todos en data (p. ej. row.data.title).
Almacenamiento KV · storage.kv
Clave-valor aislado por (mini-app, usuario), bueno para estados privados pequeños como recuentos de fichaje, borradores, etc. tt.cloudStorage (setItem/getItem/getKeys/removeItem) es su alias al estilo de Telegram.
// Hosted KV (needs storage.kv): isolated per (mini-app, user), stores private state
try {
await window.tt.setStorage('count', 3);
const n = await window.tt.getStorage('count'); // 3 (returns null if absent)
await window.tt.removeStorage('count');
const keys = await window.tt.getStorageKeys();
} catch (err) { // ❌ quota (≤64 keys / 8KB) / network
console.warn('Storage failed:', err.message);
}
// Telegram-style alias (same as above, needs storage.kv):
await window.tt.cloudStorage.setItem('draft', 'unsent content');
const draft = await window.tt.cloudStorage.getItem('draft'); // returns null if absent
const ks = await window.tt.cloudStorage.getKeys();
await window.tt.cloudStorage.removeItem('draft');
Conversaciones y multijugador
Capacidades de conversación · im.share / im.send / im.read / media.upload
Toda interacción con las conversaciones de TutuHai está mediada por el host (el usuario elige activamente una conversación); las mini-apps no pueden obtener la lista completa de conversaciones. im.read es una capacidad sensible.
// Conversation capabilities are all host-mediated (the user actively picks a conversation); always handle "user cancelled" and failure.
try {
// Share this mini-app's card to a conversation (needs im.share; if no conversation is passed the host shows a picker)
await window.tt.shareToChat({ title: 'Come vote for lunch 🍜' });
// Send a text notification to a conversation (needs im.send; the host shows a picker + preview, signed "via the X mini-app")
await window.tt.sendMessage('Vote result: Lanzhou beef noodles win');
// Read conversation messages (needs im.read, sensitive; the user picks a conversation each time, non-text is redacted)
const r = await window.tt.readMessages({ limit: 30 });
// Upload an image (needs media.upload; pass a dataURL, returns an absolute URL)
const url = await window.tt.uploadImage(dataUrl);
} catch (err) { // ❌ failure
// "User cancelled" (picker/preview cancelled) / authorization denied / "Call timed out" / network
console.warn('Capability call failed:', err.message);
}
Salas multijugador · im.room
Convierte una "conversación" en una sala en tiempo real para minijuegos / colaboración: crear/unirse a una sala, mensajes persistentes dentro de la sala y señales en tiempo real (sincronización de estado, ≤2KB, efímeras, no persistidas). El host puentea los frames en tiempo real en tu nombre, el JWT del host nunca entra en la mini-app; limitado a las salas que esta mini-app creó / las conversaciones a las que fuiste compartido — no puede tocar los otros chats privados del usuario.
// Multiplayer rooms (needs im.room): create/join/leave + in-room persistent messages + realtime signals (≤2KB, ephemeral, not persisted).
const { conversationId } = await window.tt.room.create({ title: 'Gomoku match' }); // create room, host auto-subscribes
await window.tt.room.join(conversationId); // idempotent join; host auto-subscribes to realtime frames
await window.tt.room.subscribe(conversationId); // subscribe to an existing conversation's realtime frames (e.g. a group you were shared into)
await window.tt.room.send(conversationId, 'Game on!'); // persistent text (visible even without opening the mini-app)
window.tt.room.signal(conversationId, { type:'move', cell:4 }); // send a realtime signal (state sync)
window.tt.room.setTyping(conversationId, true); // typing state (transient)
const members = await window.tt.room.members(conversationId); // roster [{userId,nickname,avatarUrl,online,isOwner}]
const past = await window.tt.room.history(conversationId, { limit: 50 }); // hydrate on reconnect (ascending)
await window.tt.room.leave(conversationId); // leave (empty rooms are auto-reclaimed)
// Realtime events (all under tt.room):
window.tt.room.onMessage((m) => appendMsg(m)); // new message {conversationId,id,senderId,senderName,kind,text,createdAt}
window.tt.room.onSignal((s) => applyMove(s.payload));// opponent's realtime action {conversationId,senderId,payload}
window.tt.room.onPresence((p) => refreshOnline(p)); // online/offline {userId,online}
window.tt.room.onTyping((t) => showTyping(t)); // typing {conversationId,userId,typing}
window.tt.room.onMember(() => reloadMembers()); // member joined/left {conversationId} → re-fetch members()
window.tt.room.onReconnect(() => rehydrate()); // dropped & reconnected → re-hydrate current state from history()/cloud
Hidratación al reconectar: las señales son de mejor esfuerzo y la pérdida de frames al desconectarse es normal. En
onReconnect, rehidrata el estado final desderoom.history()o los datos en la nube — no dependas de las señales como única fuente de verdad.
Componentes en línea · similares a componentes nativos · seguros para la privacidad
Una tarjeta enviada con shareToChat({inline:true}) renderiza un componente interactivo justo dentro de la burbuja del chat (p. ej. una encuesta, una valoración); los destinatarios lo operan como una función nativa sin abrir una ventana flotante. La mini-app renderiza una UI compacta basada en ctx.inline; la burbuja se ajusta automáticamente al contenido y cambia de tema en vivo con el modo claro/oscuro del host. Privacidad: una instancia en línea recibe solo un token restringido de "cloud.data únicamente, sin efectos secundarios" — puede leer colecciones públicas + escribir sus propios documentos, no puede tocar datos privados de otros, y no solicita autorización.
// —— Inline components: make a mini-app interact right in the chat bubble like a native feature (polls/ratings/relays…) ——
// 1) Send an inline card to a conversation (needs im.share): recipients operate it in the bubble without opening the mini-app
await window.tt.shareToChat({ inline: true, query: { pollId }, title: 'Poll', height: 200 });
// 2) The mini-app renders two forms based on ctx.inline
window.tt.ready((ctx) => {
if (ctx.inline) {
renderCompact(ctx.query.pollId); // inline: a compact "native-component-like" UI
window.tt.reportHeight(); // report height (the SDK also auto-reports via ResizeObserver; the bubble auto-sizes to content)
// When full functionality is needed, open the full page (floating/fullscreen) from the inline card:
// openBtn.onclick = () => window.tt.openFullPage('/detail?pollId=' + ctx.query.pollId);
} else {
renderFull(); // floating window: full creation UI
}
});
// Privacy: an inline instance gets only a "cloud.data only, no side effects" restricted token — it can read public collections + write its own documents,
// can't touch others' private data; doesn't prompt for authorization or pollute the authorization list. Sensitive capabilities (upload/send/location) are unavailable inline.
// Dark/mobile: inline cards switch light/dark live with the host and auto-fit width — no extra work for the developer.
Búsqueda de palabras / traducción · text.lookup / text.provider
El popover de búsqueda de palabras es en sí mismo la página en línea de una "mini-app proveedora" — su contenido/funcionalidad lo renderiza esa mini-app; el host solo proporciona la selección + el posicionamiento al pasar el cursor + un SDK flexible. Consumidores (haz que el texto de tu propia mini-app sea seleccionable): declara text.lookup, cero código — al seleccionar texto aparece la página en línea del proveedor justo debajo de la selección (también se admite el texto de los mensajes de chat). Proveedores (construir una mini-app de búsqueda): declara text.provider (otorgado tras revisión), la página en línea recibe las palabras vía tt.text.onLookup y se renderiza a sí misma; qué proveedor está activo se configura en el panel de administración — si no hay ninguno configurado/autorizado, la búsqueda está deshabilitada. Añade data-tt-no-lookup para excluir una región.
// ── A. Make "text inside your mini-app" selectable for lookup (consumer, needs text.lookup) ──【zero code】
// After declaring "text.lookup" in the manifest, when a user selects text in your mini-app, a popover
// 【the provider mini-app's inline page】pops up right below the selection (auto dictionary/translation). Chat message text is also supported (host built-in).
// Tap blank/scroll/Esc to hide; operating inside the popover doesn't close it. No JS needed.
// Opt out: add data-tt-no-lookup to regions you don't want selectable; <input type=password> is auto-excluded.
// ── B. Build a "word-lookup provider mini-app" (provider, needs text.provider, granted after review) ──
// The provider page (inline mode) receives the selected text pushed by the host via onLookup (pushed initially + on every new word, stays resident without reload),
// and looks up/translates/renders it into any UI; you can use tt.text.lookup to call the built-in engine, or your own cloud.data dictionary.
tt.ready(function () {
tt.text.onLookup(function (text) { // host pushes the selected text
tt.text.lookup(text).then(function (r) { // r={kind:'dict'|'translate',...}
render(r); // render into your own UI (auto-height)
});
});
});
// ── Escape hatch / flexible SDK primitives (available to any mini-app) ──
const r = await window.tt.text.lookup('lazy', { to:'en' }); // look up / translate on demand
window.tt.text.onSelect(function (sel){ /* {text, rect}; registering takes over, the default popover steps aside */ });
window.tt.openFloating({ path:'/detail', anchor: sel.rect, width:320, height:220 }); // open a floating window at the selection
window.tt.floating.moveTo(100, 200); // the floating window's position/size are fully adjustable via the SDK: setRect/moveTo/resize/close
Archivos y unidad en la nube
Manejo de archivos del chat · media.upload / im.share
Cuando un usuario toca "Abrir con" en una imagen/archivo del chat, puede elegir tu mini-app para manejarlo — siempre que hayas declarado un tipo de archivo coincidente en los fileHandlers de manifest.json (image/video/audio/pdf/office/text/any). Una vez abierto: getContextFile() obtiene el archivo, readFile() recupera los bytes en el mismo origen a través del host (evitando CORS, para que puedas analizar imágenes/audio/vídeo/cualquier formato), luego, tras procesar, uploadFile() + sendFileToChat() lo envía de vuelta a la conversación, o saveFile() lo descarga — un flujo sin fisuras.
// —— Handling chat files: the user taps "Open with" on a file and picks your mini-app (must declare matching kinds in manifest.fileHandlers) ——
const f = window.tt.getContextFile();
// f = {url,name,mime,size,kind:'image'|'file',conversationId,messageId} or null (when opened standalone)
if (f) {
const bytes = await window.tt.readFile(f.url); // host fetches bytes same-origin (avoids iframe CORS; limited to this site's /uploads output)
// bytes = {dataUrl, mime, name, size, url} — feed to <img>/<video>/<audio>/canvas to analyze any format
imgEl.src = bytes.dataUrl;
}
// —— Process the output → send back to the conversation or download (needs media.upload / im.share) ——
const out = canvas.toDataURL('image/webp', 0.9); // e.g. convert image to WebP
const up = await window.tt.uploadFile({ dataUrl: out, name: 'result.webp' }); // {url,name,size,mime} (≤20MB)
await window.tt.sendFileToChat({
url: up.url, name: up.name, mime: up.mime, size: up.size,
conversationId: f.conversationId // pass it to send straight to the original conversation (skip the picker); omit it and the host shows a conversation picker
});
await window.tt.saveFile({ dataUrl: out, name: 'result.webp' }); // or: download locally (host downloads on your behalf)
Tutu Drive · tt.drive (necesita media.upload)
Elige archivos de entrada desde Tutu Drive, o guarda tu salida en la unidad (mediado por el host: el usuario elige los archivos uno a uno en el selector de la unidad dentro de la página del host; la mini-app no posee un token de la unidad). Se rechaza cuando la unidad es inalcanzable / el Tutu ID no está federado — tras .catch, la mini-app puede recurrir a un uploadFile local.
// Pick files from the drive (the user picks in the host's drive picker; returns [] on cancel):
const picked = await window.tt.drive.pick({ multiple: true, accept: 'image/*' });
// picked = [{ nodeId, name, size, mime, downloadUrl }]
for (const file of picked) {
const bytes = await window.tt.readFile(file.downloadUrl); // fetch bytes to analyze/display
render(bytes.dataUrl);
}
// Save a file into the drive (pass this site's /uploads output absolutized as pullUrl, or a dataUrl directly):
const saved = await window.tt.drive.save({ pullUrl: up.url, name: 'export-result.png' });
// saved = { nodeId, name }
Interacción entre apps
Interacción entre apps · arrastrar entre apps · tt.link / tt.tray / tt.drag / tt.drop (no requiere autorización)
Varias mini-apps pueden estar abiertas al mismo tiempo (guardadas como una combinación para abrirse juntas con un clic, disposiciones multi-ventana 2/3/4, una barra lateral acoplada — todo gestionado por el host, sin necesidad de código), e interactúan mediante las siguientes capacidades:
tt.link: sincroniza eventos/estado en tiempo real con otras mini-apps abiertas (difusión o dirigido, ≤2KB, con límite de tasa 25/s; se descarta al cerrar la ventana, no persistido, no entre usuarios/conversaciones).tt.tray: recoge contenido en la bandeja del host para retransmitirlo, y luego inyéctalo en otra mini-app o conversación.tt.drag.start/tt.drag.bind: inicia un arrastre / vincula un elemento como fuente de arrastre que puede arrastrarse directamente a otra mini-app (arrastrar y soltar nativo de HTML5 entre mini-apps del mismo origen).tt.drop.accept: toda la mini-app puede recibir sueltas / inyecciones de la bandeja.tt.drop.zone: ★deja que un elemento interno específico detecte una suelta (con retroalimentación de resaltado al pasar por encima) y actúe sobre ella. Una mini-app puede tener varias zonas, cada una detectando de forma independiente.
Seguridad: una url envuelta como file solo acepta handles de /uploads/ de este sitio (el receptor recupera los bytes vía tt.readFile); los enlaces externos de origen cruzado falsificados se descartan; text/json/name tienen todos límites de tamaño.
// —— ① Event/state sync tt.link (broadcast in realtime with "other open mini-apps"; drops on window close, not persisted, not cross-user/conversation) ——
tt.link.send({ type: 'color', color: '#ef4444' }); // broadcast to all linked mini-apps (≤2KB, rate-limited 25/s)
tt.link.sendTo(appId, { type: 'ping' }); // send to a specific peer
tt.link.on((from, msg) => { /* from={appId,slug,name} */ apply(msg); });
const peers = await tt.link.peers(); // the other linked mini-apps right now [{appId,slug,name}]
// —— ② Tray relay tt.tray (pick up → drop into another mini-app / conversation) ——
await tt.tray.put({ kind:'json', name:'color', data:{ color:'#ef4444' } }); // put into the host tray
const items = await tt.tray.list(); // view the tray [parcel]
const p = await tt.tray.take(id); // take a parcel out
// parcel = { kind:'file'|'text'|'json', url?/text?/data?, name?, mime? }
// —— ③ Direct drag between apps 【drag source】tt.drag ——
tt.drag.start({ kind:'json', name:'color', data:{ color:'#ef4444' } }); // start a drag, returns {id}
tt.drag.bind(swatchEl, () => ({ kind:'json', name:'color', data:{ type:'color', color:'#ef4444' } }));
// getParcel() returns this drag's parcel; return null to not start. Between same-origin mini-apps it uses native HTML5 drag-and-drop with the browser's built-in ghost.
// —— ④ Receive 【whole window】tt.drop.accept ——
tt.drop.accept(['json','file'], (parcel) => { apply(parcel); }); // callback on drop anywhere in this window / tray injection
// —— ⑤ ★Receive 【element-level】tt.drop.zone ——
tt.drop.zone(slotEl, ['json','file'], {
onEnter: () => slotEl.classList.add('hot'), // drag into this element → only it highlights (internal elements sense independently)
onOver: () => {}, // while hovering (do continuous feedback)
onLeave: () => slotEl.classList.remove('hot'), // move out → clear highlight
onDrop: (parcel) => fill(slotEl, parcel), // dropped on this element → only it receives and acts
});
UI · ventana · host
UI · ventana · dispositivo (no requiere autorización)
Capacidades de UI al estilo wx.* de WeChat — el host renderiza toasts/diálogos/visores de imágenes reales, controla la ventana cápsula de la mini-app, y accede al portapapeles/vibración/marcado/ubicación/red — invocables sin solicitar autorización, haciendo a una mini-app tan potente como una app nativa.
// —— Interaction feedback ——
window.tt.showToast({ title: 'Saved', icon: 'success' }); // icon: success|error|loading|none
window.tt.hideToast();
window.tt.showLoading({ title: 'Processing…' }); // pair with window.tt.hideLoading()
window.tt.hideLoading();
const { confirm } = await window.tt.showModal({ title: 'Confirm', content: 'Delete this?' });
const { tapIndex } = await window.tt.showActionSheet({ itemList: ['Camera', 'Album'] }); // rejects on cancel
// —— Window / system info ——
window.tt.setNavigationBarTitle({ title: 'My page' }); // change the mini-app capsule title
const info = await window.tt.getSystemInfo(); // {theme, platform, windowWidth, windowHeight, safeAreaInsets, appName, version}
// —— Device ——
await window.tt.setClipboardData({ data: 'copied text' });
const { data } = await window.tt.getClipboardData();
window.tt.vibrateShort(); window.tt.vibrateLong(); // haptic feedback
window.tt.makePhoneCall({ phoneNumber: '10086' });
// —— Media ——
window.tt.previewImage({ urls: [url1, url2], current: url1 }); // fullscreen image preview
// —— Location / external link / network ——
const loc = await window.tt.getLocation(); // browser prompts for permission → {latitude, longitude, accuracy, speed}
window.tt.openLocation({ latitude: loc.latitude, longitude: loc.longitude, name: 'Store' }); // view on a map
window.tt.openLink({ url: 'https://example.com' }); // open in a new tab (http/https only)
const net = await window.tt.getNetworkType(); // {isConnected, networkType: wifi|4g|...}
Ventana flotante · pantalla completa · marca
Controla la ventana de la mini-app: abre una ventana flotante arrastrable desde una vista en línea/una selección, expande a pantalla completa / restaura, tiñe la cápsula del host, y escucha cambios de visibilidad/tamaño.
// —— Floating window (open a draggable floating window from inline / a selection) ——
window.tt.openFloating({ path:'/detail', anchor: rect, x:100, y:120, width:320, height:220 });
window.tt.floating.setRect({ x, y, width, height }); // also moveTo(x,y) / resize(w,h) / close()
window.tt.openFullPage('/detail'); // open the full page from an inline card (floating/fullscreen)
// —— Fullscreen / restore / close (desktop; mobile is already fullscreen) ——
window.tt.expand(); window.tt.collapse(); window.tt.close();
const dm = await window.tt.getDisplayMode(); // {maximized, mobile} — whether fullscreen / whether mobile
window.tt.onEvent('displayChanged', (p) => updateFullscreenChip(p.maximized)); // two-way sync with the host capsule's "fullscreen/restore"
window.tt.onEvent('viewportChanged', (p) => relayout(p.width, p.height)); // iframe size change → responsive re-layout
// —— Branding: tint the host capsule header/background ——
window.tt.setHeaderColor('#4f46e5');
window.tt.setBackgroundColor('#fdf6e3');
Botones del host · retroalimentación háptica · estilo Telegram (no requiere autorización)
Una mini-app controla los botones del chrome del host y recibe sus eventos de clic (bidireccional) — el botón principal inferior mainButton, el botón de retroceso del encabezado backButton, la retroalimentación háptica hapticFeedback. Esto permite a una mini-app integrarse profundamente con la UI del host (en lugar de ser una página aislada).
// MainButton (the host's big bottom button, controlled by the mini-app + receives clicks) — Telegram-style
window.tt.mainButton.setText('Submit order').show(); // chainable; setText/setParams/show/hide/enable/disable/showProgress/hideProgress
window.tt.mainButton.onClick(() => { // click callback (host → mini-app event); offClick to unbind
window.tt.mainButton.showProgress();
submit().finally(() => window.tt.mainButton.hideProgress());
});
// BackButton (the host header's back button)
window.tt.backButton.show(); // show/hide/onClick/offClick
window.tt.backButton.onClick(() => history.back());
// Generic event listening (same as the onClick above)
window.tt.onEvent('mainButtonClicked', handler);
window.tt.onEvent('backButtonClicked', handler);
window.tt.offEvent('mainButtonClicked', handler); // unbind
// HapticFeedback
window.tt.hapticFeedback.impactOccurred('light'); // light|medium|heavy|rigid|soft
window.tt.hapticFeedback.notificationOccurred('success'); // error|success|warning
window.tt.hapticFeedback.selectionChanged();
Tema · almacenamiento en la nube · estilo Telegram
colorScheme / themeParams mantienen los colores de la mini-app coherentes con el host y cambian con el modo claro/oscuro; locale se sincroniza con la i18n del host.
// —— Theme (consistent with the host's colors, switches with light/dark) ——
window.tt.colorScheme; // 'light' | 'dark'
window.tt.themeParams; // {bgColor,textColor,hintColor,linkColor,buttonColor,buttonTextColor,secondaryBgColor}
document.body.style.background = window.tt.themeParams.bgColor; // use the host color, consistent with the host
window.tt.onEvent('themeChanged', () => { // fires when the host toggles light/dark (same as onThemeChange)
applyTheme(window.tt.colorScheme, window.tt.themeParams);
});
// —— Locale i18n (synced with the host; the SDK already sets <html lang>) ——
window.tt.locale; // e.g. 'zh-CN' / 'en-US'; same as tt.context().locale
window.tt.onLocaleChange((locale) => renderInLang(locale)); // or onEvent('localeChanged')
Referencia
Manejo de errores
Cada window.tt.* devuelve una Promise y rechaza con un Error en caso de fallo. Las mini-apps de producción deben usar .catch / try-catch en cada llamada. err.message comunes:
| err.message | Significado / manejo sugerido |
|---|---|
User denied authorization |
La solicitud de autorización bajo demanda fue denegada → guía un reintento |
User cancelled |
El selector de conversación/previsualización fue cancelado → permanece en silencio |
Call timed out |
El host no respondió durante mucho tiempo (poco común) → solicita un reintento |
Data too large / too many documents / too many storage items |
Cuota excedida → recorta los datos |
public reads are limited to pub_-prefixed public collections |
Discrepancia en el nombre de la colección → usa un prefijo pub_ |
Forbidden (403) |
Un no desarrollador usó scope=all → sin permiso |
Network error / Failed to fetch |
Fallo de red → aviso amigable + reintento |
// Every tt.* returns a Promise and rejects an Error on failure; handle with .catch / try-catch.
window.tt.getProfile()
.then((me) => { /* … */ })
.catch((err) => {
switch (err.message) {
case 'User denied authorization': /* guide the user to retry authorization */ break;
case 'User cancelled': /* the user cancelled the conversation picker, stay silent */ break;
case 'Call timed out': /* the host was unresponsive for a long time (rare), prompt a retry */ break;
default: /* quota / permission (403) / network, etc — give a friendly prompt */
}
});
Modelo de autorización
Autorización bajo demanda: abrir una mini-app no requiere conceder todos los permisos por adelantado; el host solicita un único elemento solo cuando una capacidad se invoca por primera vez (permitir/denegar). El usuario puede "confiar en esta mini-app" para conceder todo de una vez, o alternar elementos individualmente y ver los registros de uso en la página de ajustes. El backend sigue revalidando en cada llamada (defensa en profundidad). Las capacidades de UI/ventana/dispositivo/tema/entre apps son utilizables sin autorización.
| Capacidad (scope) | Descripción | Sensible |
|---|---|---|
user.profile |
Obtener tu apodo y avatar | — |
cloud.data |
Datos en la nube (colecciones; pedidos/registros, etc.) | — |
storage.kv |
Almacenamiento de datos (KV) | — |
media.upload |
Subir imágenes/archivos (incl. la unidad tt.drive) | — |
im.share |
Compartir una tarjeta al chat | — |
im.send |
Enviar mensajes | — |
im.read |
Leer el historial de conversación | Sensible |
im.room |
Salas multijugador: enviar/recibir mensajes y leer el chat de la sala en tu nombre | Sensible |
text.lookup |
Búsqueda de palabras/traducción (el texto seleccionado se envía a un servicio de traducción) | Sensible |
text.provider |
Proveedor de búsqueda (la página de esta mini-app actúa como el popover de búsqueda/traducción) | Sensible |
Visibilidad
- Pública: aparece en "Descubrir" y en la búsqueda; cualquiera puede encontrarla.
- No listada: no aparece en descubrir/búsqueda; solo accesible mediante una tarjeta compartida o un enlace copiado (deep link) — para difusión de tráfico privado sin exposición pública. Actívala en la consola con un solo clic.
- Modo de apertura (escritorio): el "Modo de apertura" de la consola alterna "flotante (por defecto) / pantalla completa" — las apps de lienzo/pizarra/editor ponen
fullscreenpara llenar la pantalla al abrir; las tarjetas/formularios ligeros usan flotante. También puedes declarar el valor inicial en eldisplaydemanifest.json. En móvil siempre es pantalla completa, sin verse afectado por este ajuste.
Versiones y publicación
Modelo de versiones: borrador → en revisión → listo → en vivo.
Se conservan todas las versiones históricas, con reversión de un solo clic a cualquier versión histórica (intercambio instantáneo de la versión en vivo). Los rechazos muestran el motivo en el centro de notificaciones.
Restricciones y cuotas
- Datos en la nube: documento individual ≤ 8KB, ≤ 500 documentos por (mini-app, usuario),
list()≤ 200 a la vez (por más recientes); usa la paginación por cursorlistPage()(mine/all) para más. Nota: hacer agregación en el frontend (recuento/promedio) directamente conlist()subcontará la parte más antigua cuando una colección exceda los 200 y dará un resultado bajo — para totales completos usa la paginación listPage o acepta una aproximación. - KV: valor individual ≤ 8KB, ≤ 64 claves por (mini-app, usuario).
- Imágenes subidas ≤ 4MB; archivos subidos ≤ 20MB; el envío de mensajes tiene límite de tasa (≤ 20 por usuario por minuto); la carga útil de una señal de sala ≤ 2KB; mensaje individual de tt.link ≤ 2KB, limitado a 25/s.
- El paquete de código es un HTML de un solo archivo (prefiere el renderizado con DOM puro; evita
innerHTMLpara prevenir XSS). - Los tokens son de vida corta (unas 2 horas); el host los renueva silenciosamente tras la expiración; las llamadas a capacidades son revalidadas por el backend.
Ejemplos de referencia: el
applets/food(pedidos, datos en la nube) y elapplets/repair(solicitudes de reparación) del repositorio son ambos mini-apps solo-frontend + datos en la nube.