TutuHai

เอกสารเปิด Mini-App ของ TutuHai

การเชื่อมต่อ SDK · API ความสามารถ · ข้อมูลคลาวด์ · การอนุญาต · การเผยแพร่

Mini-app ของ TutuHai คือแอปน้ำหนักเบาที่ทำงานอยู่ภายใน TutuHai นักพัฒนาอัปโหลดเพียงชุดโค้ดฝั่งหน้าบ้าน (frontend code bundle) โดยแพลตฟอร์มเป็นผู้โฮสต์ให้ ความสามารถต่าง ๆ สื่อสารกับ TutuHai ผ่าน SDK window.tt โดยไม่ต้องมี backend เป็นของคุณเอง (ข้อมูลธุรกิจไหลผ่านคลาวด์ดาต้าของ TutuHai) การเชื่อมต่อ SDK · API ความสามารถ · คลาวด์ดาต้า · การอนุญาต · การเผยแพร่ — ทั้งหมดอยู่ในหน้าเดียว พร้อมตัวอย่างที่คัดลอกไปรันได้ทันที

สร้างมินิแอปในคอนโซล 7 หมวดหมู่ · 26 หัวข้อ · คลิกที่ชื่อเพื่อขยาย
สารบัญ · 26 หัวข้อ

เริ่มต้นใช้งาน

ความสามารถด้านข้อมูล

บทสนทนาและการเล่นหลายคน

ไฟล์และคลาวด์ไดรฟ์

การโต้ตอบข้ามแอป

UI · หน้าต่าง · โฮสต์

เอกสารอ้างอิง

เริ่มต้นใช้งาน

บทนำ

Mini-app ของ TutuHai คือแอปน้ำหนักเบาที่ทำงานอยู่ภายใน TutuHai นักพัฒนาอัปโหลดเพียงชุดโค้ดฝั่งหน้าบ้าน (frontend code bundle) โดยแพลตฟอร์มเป็นผู้โฮสต์ให้ ความสามารถต่าง ๆ สื่อสารกับ TutuHai ผ่าน SDK window.tt โดยไม่ต้องมี backend เป็นของคุณเอง (ข้อมูลธุรกิจไหลผ่านคลาวด์ดาต้าของ TutuHai)

การแยกส่วนและความปลอดภัย: mini-app ทำงานอยู่ใน iframe แบบแซนด์บ็อกซ์บน origin ที่แยกออกมาต่างหาก เซสชันการล็อกอินของโฮสต์จะไม่เข้าไปยัง mini-app เลย ทุกการเรียกจะได้รับโทเคนแบบจำกัดสิทธิ์ที่มีอายุสั้นซึ่งออกให้โดยโฮสต์ และ backend จะตรวจสอบซ้ำตามความสามารถ (scope)

เริ่มต้นอย่างรวดเร็ว
  1. ใน คอนโซล Mini-App (/applets) คลิก "New" เพื่อสร้าง mini-app (ชื่อ + slug ที่ไม่ซ้ำกัน)
  2. เขียน HTML ไฟล์เดียว (รวม SDK เข้าไป เรียกใช้ความสามารถผ่าน window.tt.*)
  3. สร้างเวอร์ชัน → กรอกความสามารถที่ร้องขอ → อัปโหลดชุดโค้ด (HTML ไฟล์เดียว)
  4. ส่งเข้าตรวจสอบ → ผู้ดูแลระบบอนุมัติ → เผยแพร่ขึ้น production ในคลิกเดียว
  5. ผู้ใช้ค้นหา/เปิดผ่านการค้นหาใน "Discover" หรือคุณแชร์การ์ด / คัดลอกลิงก์เพื่อเข้าถึงโดยตรง
ข้อกำหนดการแพ็กเกจ

Mini-app รองรับการอัปโหลดสองรูปแบบ: ① ชุด HTML ไฟล์เดียว (self-contained, entry = root, ง่ายที่สุด); ② zip ผลลัพธ์บิลด์จากเฟรมเวิร์กจริง (dist/ จาก npm run build พร้อม index.html + assets กระจายอยู่หลายไฟล์ — ดู "ผลลัพธ์บิลด์จากเฟรมเวิร์ก") แพลตฟอร์มจะรันการตรวจสอบข้อกำหนดและการปรับแต่งก่อนอัปโหลด

โครงสร้างชุดโค้ด (ผลลัพธ์บิลด์)

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

ชุดโค้ดต้องเป็นไปตามเงื่อนไขต่อไปนี้ (ตรวจอัตโนมัติเมื่ออัปโหลด):

  • Entry: ไฟล์ HTML เดียวที่มี <!doctype html> และ <html> เป็นราก
  • รองรับมือถือ: ต้องมี <meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
  • SDK: รวม <script src="/applet-sdk.js"> (แพลตฟอร์มจะเขียนใหม่ให้ชี้ไปยังที่อยู่แบบสัมบูรณ์ของโฮสต์)
  • Self-contained: ฝัง CSS/JS แบบ inline; หากคุณจำเป็นต้องใช้สคริปต์ภายนอก อนุญาตเฉพาะ SDK ของแพลตฟอร์ม + CDN ของเฟรมเวิร์กที่รู้จักกันดี (unpkg / jsdelivr / cdnjs / esm.sh) เท่านั้น — ห้ามใช้สคริปต์ระยะไกลตามอำเภอใจ (ด้วยเหตุผลด้านความปลอดภัย) รูปภาพและสื่ออื่น ๆ ให้ผ่าน tt.uploadImage หรือ CDN
  • ขนาด: HTML ไฟล์เดียว ≤ 1MB; zip หลายไฟล์รวม ≤ 8MB, ต่อไฟล์ ≤ 1MB, ≤ 100 ไฟล์; รูปที่อัปโหลด ≤ 4MB
  • ข้อมูล: ไม่มี backend เป็นของคุณเอง — ข้อมูลธุรกิจไหลผ่านคลาวด์ดาต้า tt.cloud / tt.*storage

📱🖥 ใช้ได้ทั้งมือถือ / เดสก์ท็อป (โค้ดชุดเดียว ทั้งสองพื้นผิว)

ชุดโค้ดเดียวกันนี้ทำงานใน iframe แบบแยกส่วนภายใน TutuHai; โฮสต์รองรับทั้ง มือถือ (เต็มจอ) และ เดสก์ท็อป (แผงหน้าต่าง / ขยายเต็มจอได้) ให้เขียนโค้ดชุดเดียวที่ใช้ได้ทั่วไปด้วย เลย์เอาต์แบบ responsive: ① viewport-fit=cover + พื้นที่ปลอดภัย env(safe-area-inset-*); ② overlay เป็น bottom sheet (มือถือ) ↔ จัดกึ่งกลาง (เดสก์ท็อป @media(min-width:480px)); ③ พื้นที่สัมผัส ≥ 44px; ④ ตามธีม มืด/สว่าง ของโฮสต์ (tt.onThemeChange / [data-theme]); ⑤ DOM ล้วน ไม่ฮาร์ดโค้ดความกว้าง วิธีนี้ทำให้ประสบการณ์ใช้งานสอดคล้องกันทั้งบนโทรศัพท์และคอมพิวเตอร์

ผลลัพธ์บิลด์จากเฟรมเวิร์ก (dist.zip)

นอกจาก HTML ไฟล์เดียวแล้ว คุณยังสามารถอัปโหลด ผลลัพธ์บิลด์จากเฟรมเวิร์กจริง ได้ด้วย — ใช้ React / Vue / Svelte / Angular / Solid / Astro / Next (static export) / vanilla… npm run build ของ toolchain ใด ๆ ก็ได้ จากนั้น zip โฟลเดอร์ dist/ (ที่มี index.html + assets/*.js/css + ฟอนต์/รูปภาพ) แล้วอัปโหลดเพื่อให้โฮสต์

ไม่ยึดติดกับเฟรมเวิร์ก: แพลตฟอร์มรู้จักเพียง "สัญญาชุดโค้ดสแตติกสากล (universal static bundle contract)" หนึ่งเดียว — entry index.html + การอ้างอิง asset แบบสัมพัทธ์ + SDK เฟรมเวิร์กใด ๆ ที่สามารถผลิต dist แบบสแตติกที่เป็นไปตามสัญญานี้ได้ ก็รองรับทั้งสิ้น; scaffold ด้านล่างเป็นเพียงทางลัดที่คัดสรรมา ไม่ใช่ขีดจำกัดของการรองรับ

โครงสร้าง zip (ผลลัพธ์บิลด์ รากของ zip = รากของชุดโค้ด)

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…

การปรับแต่งสามขั้นตอน (ใช้ได้กับทุกเฟรมเวิร์ก):

  1. ตั้งค่า base แบบสัมพัทธ์ (แนะนำ ปลอดภัยที่สุด) — ทำให้ผลลัพธ์อ้างอิง asset ด้วยพาธสัมพัทธ์ (./assets/x.js แทน /assets/x.js แบบสัมบูรณ์จากราก) เพื่อให้การโฮสต์ภายใต้ /<slug>/ ไม่มีทางพลาด (ค่า base เริ่มต้นก็ใช้ได้เช่นกัน: แพลตฟอร์มจะเขียนการอ้างอิงสแตติกแบบสัมบูรณ์จากรากใน HTML/CSS ใหม่โดยอัตโนมัติ และใช้ Referer fallback สำหรับ asset แบบสัมบูรณ์จากรากที่ถูกสร้างขึ้นตอนรันไทม์ — เช่นการ preload ของ CSS ที่ถูก code-split; แต่ในกรณีขอบเช่น Referrer-Policy ที่เข้มงวด / offline-prefetch อาจไม่มี Referer และ fallback จะล้มเหลว ดังนั้น base แบบสัมพัทธ์จึงปลอดภัยที่สุด)
  2. เพิ่ม manifest — วาง manifest.json ไว้ในไดเรกทอรีสแตติก (เช่น static/ ของ Vite/SvelteKit หรือ public/ ของเฟรมเวิร์กส่วนใหญ่) เพื่อให้ไปอยู่ที่ราก dist หลังบิลด์; หรือละ manifest ไว้แล้วเพิ่ม <meta name="tt:slug" content="…"> (พร้อม tt:name / tt:version / tt:scopes) ใน index.html เป็น fallback
  3. รวม SDK — มีสองวิธี: ① npm install (แนะนำ เหมาะกับ scaffold จริงที่สุด): หลังจาก npm i @tutuhai/applet-sdk ให้ import { tt } from '@tutuhai/applet-sdk' — จะถูกรวมเข้าไปในผลลัพธ์ตอนบิลด์ พร้อมชนิดข้อมูล TypeScript ไม่ต้องแก้ index.html; ② หรือเขียน <script src="/applet-sdk.js"> ใน index.html (แพลตฟอร์มเขียนใหม่ให้ชี้ไปยังที่อยู่สัมบูรณ์ของโฮสต์) แล้วใช้ global window.tt

📦 SDK จาก npm (ทดสอบแล้วกับ scaffold ทางการของ React / Vue / Svelte)

สร้างโปรเจกต์ด้วย npm create vite@latest -- --template react-ts | vue-ts | svelte-ts ติดตั้ง @tutuhai/applet-sdk ตัวเดียวกัน แล้ว import เข้ามา — เป็นซอร์สหลายไฟล์จริง npm run build จะผลิต chunk หลายตัว + entry index.html หนึ่งตัว; zip แล้วอัปโหลด

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

ตัวอย่างที่รันได้จริงในรีโป: applets/frameworks/{react,vue,svelte} (โปรเจกต์ scaffold ทางการจริงสามชุด ทั้งหมด import แพ็กเกจ SDK ตัวเดียวกัน) ซอร์สแพ็กเกจ SDK: applet-sdk/

ฟิลด์ของ 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: ประกาศรูปแบบการเปิดบนเดสก์ท็อป — สำหรับแอปประเภท canvas/whiteboard/editor ควรใช้ fullscreen, สำหรับการ์ด/ฟอร์มน้ำหนักเบาให้ใช้ค่าเริ่มต้น window เป็นเพียง ค่าเริ่มต้น เท่านั้น; หลังเผยแพร่แล้วคุณสามารถเปลี่ยน "โหมดเปิด (Open mode)" ได้ทุกเมื่อในคอนโซล (คอนโซลมีอำนาจเหนือกว่า) มือถือเป็นเต็มจอเสมอ ไม่ได้รับผลจากฟิลด์นี้ เมื่อละ manifest ไว้ <meta name="tt:display" content="fullscreen"> จะทำงานเป็น fallback
  • fileHandlers: ประกาศว่า ประเภทไฟล์ใดบ้าง ที่ mini-app ของคุณสามารถจัดการได้จากแชท — เมื่อผู้ใช้แตะ "เปิดด้วย (Open with)" บนไฟล์ในแชท mini-app ที่ประกาศประเภทที่ตรงกันจะปรากฏเป็นตัวเลือก; แตะหนึ่งตัวจะส่งไฟล์นั้นเข้าสู่ mini-app ของคุณโดยตรง (ดู "การจัดการไฟล์ในแชท") แต่ละรายการ: kinds เป็นหนึ่งใน image / video / audio / pdf / office / text / any (ระบุได้หลายค่า, any = ไฟล์ใดก็ได้); role เป็น editor (เปิดในตัวแก้ไข) หรือ viewer (แสดงตัวอย่าง); label เป็นตัวเลือก (≤20 อักขระ ชื่อที่แสดงของตัวเลือก) สูงสุด 8 รายการ การควบคุมการมองเห็นตรงกับ visibility: private/self-use ใช้ได้โดยไม่ต้องตรวจสอบ (ปรากฏเฉพาะใน "เปิดด้วย" ของคุณเอง); public ต้องได้รับการอนุมัติจากผู้ดูแลระบบก่อนจึงจะมีผลกับทุกคน

การตั้งค่า "base แบบสัมพัทธ์" หนึ่งบรรทัดต่อเฟรมเวิร์ก:

// 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">

⚠ SPA ที่ใช้ routing ฝั่งไคลเอนต์ (SvelteKit / React Router / Vue Router / Angular) Mini-app ถูกโฮสต์อยู่ภายใต้ subpath /<slug>/ SPA ที่ใช้ routing ฝั่งไคลเอนต์ต้องตั้ง "router base" เป็น slug ของคุณ มิฉะนั้น router ของเฟรมเวิร์กจะจับคู่พาธปัจจุบันไม่ได้ → หน้าทั้งหน้า 404 (asset โหลดได้ แต่ routing รายงานว่าไม่พบ) การตั้งเพียง base ของ asset แบบสัมพัทธ์ ยังไม่พอ — นั่นแก้ได้แค่ URL ของ asset ไม่ใช่ routing แต่ละเฟรมเวิร์ก: SvelteKit kit.paths.base='/<slug>'; React Router <BrowserRouter basename="/<slug>">; Vue Router createWebHistory('/<slug>/'); Angular APP_BASE_HREF='/<slug>/' (แอปที่ไม่มี routing ฝั่งไคลเอนต์ — เรนเดอร์ล้วน / React ไฟล์เดียวที่ไม่มี Router / vanilla — ไม่ได้รับผล)

⚠ การตรวจสอบตอนอัปโหลด ตอนอัปโหลด แพลตฟอร์มจะรัน "การตรวจสอบสัญญา (contract check-up)" บน dist: entry / พาธสัมพัทธ์ / manifest / การอ้างอิง SDK / ขีดจำกัด / MIME แต่ละอย่างจะถูกตรวจสอบพร้อมคำแนะนำแบบ inline รวม ≤ 8MB, ต่อไฟล์ ≤ 1MB, ≤ 100 ไฟล์, MIME ที่อยู่ในไวต์ลิสต์เท่านั้น (html/css/js/json/รูปภาพ/ฟอนต์/map/wasm) บีบอัดและ subset ฟอนต์เพื่อลดขนาด สำหรับผลลัพธ์เฟรมเวิร์กขนาดใหญ่ (เช่น tldraw / excalidraw ที่มี chunk เดียว >1MB) ที่เกินขีดจำกัดต่อไฟล์/รวมเริ่มต้น ให้ขอทีมปฏิบัติการเพิ่มขีดจำกัด "ไบต์ต่อไฟล์" / "รวมหลังแตกไฟล์" ในแผงผู้ดูแลระบบ (เปลี่ยนได้ตอนรันไทม์ มีผลทันที); การแยก manualChunks ก็ช่วยให้ vendor ต่ำกว่าขีดจำกัดได้เช่นกัน

ตัวอย่างขั้นต่ำ

mini-app ที่สมบูรณ์และรันได้จริง — รวม SDK เข้าไป อ่านชื่อเล่นของผู้ใช้:

<!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>
การเชื่อมต่อ SDK

รวมสคริปต์ SDK ไว้ใน HTML ของ mini-app คุณ แล้วใช้ 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>

อย่าฮาร์ดโค้ดโดเมนของโฮสต์ลงใน mini-app ของคุณ เขียนพาธสัมพัทธ์ /applet-sdk.js (หรือ origin placeholder ใด ๆ) — แพลตฟอร์มจะเขียน URL ของสคริปต์ SDK ใหม่ให้ชี้ไปยังโฮสต์ปัจจุบันตอนเสิร์ฟเมื่อแอปของคุณทำงานใน iframe ดังนั้นแม้ TutuHai จะเปลี่ยนโดเมน หรือโดเมนถูกบล็อก ทุก mini-app ที่เผยแพร่แล้วก็ยังทำงานต่อได้โดยไม่ต้องแก้โค้ดและไม่ต้องเผยแพร่ใหม่ — ผู้ปฏิบัติการเพียงสลับค่า config ค่าเดียว

Callback ตอน ready, context, และธีม/ภาษา:

// 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
});
ตัวอย่างเฟรมเวิร์ก

window.tt ไม่ยึดติดกับเฟรมเวิร์กและทำงานได้โดยตรงในทุกเฟรมเวิร์กหลัก (ไฟล์เดียว ไม่ต้องบิลด์) แต่ละตัวอย่างมีการจัดการทั้งกรณี สำเร็จ ✅ และล้มเหลว ❌ (การอนุญาตถูกปฏิเสธ / ข้อผิดพลาดเครือข่าย):

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))     // ❌
    );
  }
}

ตัวอย่างที่รันได้จริงแบบครบถ้วนอยู่ในรีโป: applet-platform/samples/demo-react.html, demo-svelte.html, demo-vue.html

ความสามารถด้านข้อมูล

โปรไฟล์ผู้ใช้ · 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());
คลาวด์ดาต้า · cloud.data

คอลเลกชันแบบมีโครงสร้างที่โฮสต์ให้ ซึ่งช่วยให้ mini-app เก็บข้อมูลธุรกิจได้อย่างถาวร โดยไม่ต้องมี backend เป็นของตัวเอง มีระดับการมองเห็นสามระดับ:

  • mine: อ่าน/เขียนได้เฉพาะเอกสารของคุณเองเท่านั้น (ค่าเริ่มต้น)
  • all: อ่าน/เขียนได้ทุกอย่าง เฉพาะนักพัฒนา (เจ้าของ) ของ mini-app เท่านั้น — สำหรับ "คอนโซลร้านค้า" ที่จะดูคำสั่งซื้อ/ตั๋วทั้งหมด
  • public: ผู้ใช้ที่ล็อกอินแล้วคนใดก็ตามอ่านได้ทุกอย่าง ชื่อคอลเลกชันต้องขึ้นต้นด้วย pub_ — สำหรับคอมมูนิตี้/มาร์เก็ตเพลส/ฟอรัม; การเขียนและการแก้ไขยังคงจำกัดเฉพาะผู้เขียน
// 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);
}

แต่ละแถวจะอ่านกลับมาในรูปแบบ { id, ownerId, mine, data:{…your fields}, createdAt, updatedAt } — ฟิลด์ของคุณทั้งหมดอยู่ใน data (เช่น row.data.title)

พื้นที่จัดเก็บ KV · storage.kv

Key-value ที่แยกตาม (mini-app, ผู้ใช้) เหมาะกับสถานะส่วนตัวขนาดเล็ก เช่น จำนวนการเช็กอิน แบบร่าง ฯลฯ tt.cloudStorage (setItem/getItem/getKeys/removeItem) คือชื่อแทนสไตล์ 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');

บทสนทนาและการเล่นหลายคน

ความสามารถด้านบทสนทนา · im.share / im.send / im.read / media.upload

การโต้ตอบทั้งหมดกับบทสนทนาของ TutuHai มีโฮสต์เป็นตัวกลาง (ผู้ใช้เป็นผู้เลือกบทสนทนาเอง); mini-app ไม่สามารถดึงรายการบทสนทนาทั้งหมดได้ im.read เป็นความสามารถที่ละเอียดอ่อน

// 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);
}
ห้องหลายผู้เล่น · im.room

เปลี่ยน "บทสนทนา" ให้เป็นห้องแบบเรียลไทม์สำหรับมินิเกม / การทำงานร่วมกัน: สร้าง/เข้าร่วมห้อง ข้อความถาวร และ สัญญาณเรียลไทม์ ภายในห้อง (การซิงก์สถานะ, ≤2KB, ชั่วคราว ไม่เก็บถาวร) โฮสต์จะเชื่อมเฟรมเรียลไทม์ในนามของคุณ JWT ของโฮสต์ไม่เข้าไปยัง mini-app เลย; จำกัดเฉพาะห้องที่ mini-app นี้สร้าง / บทสนทนาที่คุณถูกแชร์เข้ามา — เข้าถึงแชทส่วนตัวอื่น ๆ ของผู้ใช้ไม่ได้

// 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

การเติมสถานะเมื่อเชื่อมต่อใหม่ (Reconnect hydration): สัญญาณเป็นแบบ best-effort และการสูญเสียเฟรมตอนหลุดการเชื่อมต่อเป็นเรื่องปกติ เมื่อเกิด onReconnect ให้เติมสถานะสุดท้ายจาก room.history() หรือคลาวด์ดาต้าใหม่ — อย่าพึ่งพาสัญญาณเป็นแหล่งความจริงเดียว

คอมโพเนนต์แบบ inline · เหมือนคอมโพเนนต์เนทีฟ · ปลอดภัยต่อความเป็นส่วนตัว

การ์ดที่ส่งด้วย shareToChat({inline:true}) จะ เรนเดอร์คอมโพเนนต์แบบโต้ตอบได้ภายในฟองแชทโดยตรง (เช่น โพล การให้คะแนน); ผู้รับใช้งานมันได้เหมือนฟีเจอร์เนทีฟโดยไม่ต้องเปิดหน้าต่างลอย mini-app เรนเดอร์ UI แบบกะทัดรัดตาม ctx.inline; ฟองแชทปรับขนาดตามเนื้อหาโดยอัตโนมัติและเปลี่ยนธีมทันทีตามโหมดมืด/สว่างของโฮสต์ ความเป็นส่วนตัว: อินสแตนซ์ inline จะได้เพียงโทเคนแบบจำกัด "เฉพาะ cloud.data ไม่มี side effect" — อ่านคอลเลกชันสาธารณะได้ + เขียนเอกสารของตัวเองได้ แตะข้อมูลส่วนตัวของผู้อื่นไม่ได้ และไม่ถามขออนุญาต

// —— 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.
การค้นหาคำศัพท์ / แปล · text.lookup / text.provider

ป็อปโอเวอร์การค้นหาคำศัพท์ ตัวมันเองก็คือหน้า inline ของ "provider mini-app" — เนื้อหา/ฟังก์ชันทั้งหมดถูกเรนเดอร์โดย mini-app ตัวนั้น; โฮสต์เพียงจัดเตรียมการเลือกข้อความ + การจัดตำแหน่งเมื่อ hover + SDK ที่ยืดหยุ่นให้ ผู้บริโภค (Consumers) (ทำให้ข้อความใน mini-app ของคุณเลือกได้): ประกาศ text.lookup โค้ดเป็นศูนย์ — เลือกข้อความแล้วหน้า inline ของ provider จะเด้งขึ้นมาใต้ข้อความที่เลือกทันที (รองรับข้อความในข้อความแชทด้วย) ผู้ให้บริการ (Providers) (สร้าง mini-app ค้นหาคำศัพท์): ประกาศ text.provider (มอบให้หลังตรวจสอบ) หน้า inline จะรับคำผ่าน tt.text.onLookup แล้วเรนเดอร์เอง; provider ตัวใดที่ทำงานอยู่นั้นตั้งค่าในแผงผู้ดูแลระบบ — หากไม่มีการตั้งค่า/มอบสิทธิ์ การค้นหาคำจะถูกปิดใช้งาน เพิ่ม data-tt-no-lookup เพื่อยกเว้นพื้นที่หนึ่ง

// ── 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

ไฟล์และคลาวด์ไดรฟ์

การจัดการไฟล์ในแชท · media.upload / im.share

เมื่อผู้ใช้แตะ "เปิดด้วย (Open with)" บน รูปภาพ/ไฟล์ ในแชท พวกเขาสามารถเลือก mini-app ของคุณเพื่อจัดการมันได้ — โดยมีเงื่อนไขว่าคุณประกาศประเภทไฟล์ที่ตรงกันไว้ใน fileHandlers ของ manifest.json (image/video/audio/pdf/office/text/any) เมื่อเปิดแล้ว: getContextFile() ดึงไฟล์, readFile() ดึงไบต์แบบ same-origin ผ่านโฮสต์ (เลี่ยง CORS จึงวิเคราะห์รูปภาพ/เสียง/วิดีโอ/รูปแบบใดก็ได้), จากนั้นหลังประมวลผล uploadFile() + sendFileToChat() ส่งกลับเข้าบทสนทนา หรือ saveFile() ดาวน์โหลด — เป็นขั้นตอนที่ลื่นไหลไร้รอยต่อ

// —— 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 (ต้องการ media.upload)

เลือกไฟล์เข้ามาจาก Tutu Drive หรือบันทึกผลลัพธ์ของคุณลงในไดรฟ์ (มีโฮสต์เป็นตัวกลาง: ผู้ใช้เลือกไฟล์ทีละไฟล์ในตัวเลือกไฟล์ของไดรฟ์ ภายในหน้าโฮสต์; mini-app ไม่ถือโทเคนของไดรฟ์) จะปฏิเสธเมื่อเข้าถึงไดรฟ์ไม่ได้ / Tutu ID ไม่ได้เชื่อมโยง (federated) — หลัง .catch mini-app สามารถถอยไปใช้ uploadFile ภายในเครื่องได้

// 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 }

การโต้ตอบข้ามแอป

การโต้ตอบข้ามแอป · ลากระหว่างแอป · tt.link / tt.tray / tt.drag / tt.drop (ไม่ต้องขออนุญาต)

Mini-app หลายตัวสามารถ เปิดพร้อมกันได้ (บันทึกเป็น คอมโบ (combo) เพื่อเปิดพร้อมกันในคลิกเดียว, เลย์เอาต์หลายหน้าต่างแบบ 2/3/4, แถบด้านข้างแบบ docked — ทั้งหมดจัดการโดยโฮสต์ ไม่ต้องเขียนโค้ด) และโต้ตอบกันผ่านความสามารถต่อไปนี้:

  • tt.link: ซิงก์เหตุการณ์/สถานะแบบเรียลไทม์กับ mini-app อื่นที่เปิดอยู่ (บรอดแคสต์หรือเจาะจงเป้าหมาย, ≤2KB, จำกัดอัตรา 25/วินาที; หลุดเมื่อปิดหน้าต่าง ไม่เก็บถาวร ไม่ข้ามผู้ใช้/บทสนทนา)
  • tt.tray: หยิบเนื้อหาขึ้นถาดของโฮสต์ เพื่อส่งต่อ แล้วฉีดเข้า mini-app อื่นหรือบทสนทนา
  • tt.drag.start / tt.drag.bind: เริ่มการลาก / ผูกอิลิเมนต์ให้เป็น แหล่งลากที่ลากตรงเข้าไปยัง mini-app อื่นได้ (การลากและวาง HTML5 เนทีฟระหว่าง mini-app ที่ same-origin)
  • tt.drop.accept: ทั้ง mini-app สามารถรับการวาง / การฉีดจากถาดได้
  • tt.drop.zone: ★ให้ อิลิเมนต์ภายในตัวหนึ่งโดยเฉพาะ รับรู้การวาง (พร้อมฟีดแบ็กไฮไลต์เมื่อ hover) และทำงานกับมัน mini-app หนึ่งตัวมีได้หลาย zone แต่ละ zone รับรู้แยกอิสระ

ความปลอดภัย: url ที่ห่อด้วย file รับได้เฉพาะ handle /uploads/ ของไซต์นี้ (ผู้รับดึงไบต์ผ่าน tt.readFile); ลิงก์ภายนอกข้าม origin ที่ปลอมแปลงจะถูกทิ้ง; text/json/name ล้วนมีเพดานขนาด

// —— ① 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 · หน้าต่าง · โฮสต์

UI · หน้าต่าง · อุปกรณ์ (ไม่ต้องขออนุญาต)

ความสามารถ UI แบบเดียวกับ wx.* ของ WeChat — โฮสต์เรนเดอร์ toast/dialog/ตัวดูรูปภาพจริง ควบคุมหน้าต่างแบบแคปซูลของ mini-app และเข้าถึงคลิปบอร์ด/การสั่น/การโทร/ตำแหน่ง/เครือข่าย — เรียกใช้ได้โดยไม่ต้องขออนุญาต ทำให้ mini-app ทรงพลังเทียบเท่าแอปเนทีฟ

// —— 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|...}
หน้าต่างลอย · เต็มจอ · แบรนดิ้ง

ควบคุมหน้าต่างของ mini-app: เปิดหน้าต่างลอยที่ลากได้จาก inline/การเลือกข้อความ ขยายเป็นเต็มจอ / คืนสภาพ แต่งสีแคปซูลของโฮสต์ และรับฟังการเปลี่ยนแปลงการแสดงผล/ขนาด

// —— 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');
ปุ่มของโฮสต์ · haptics · สไตล์ Telegram (ไม่ต้องขออนุญาต)

Mini-app ควบคุมปุ่มของ chrome โฮสต์ และรับเหตุการณ์การคลิกของปุ่มเหล่านั้น (สองทาง) — ปุ่มหลักด้านล่าง mainButton, ปุ่มย้อนกลับบนหัว backButton, haptics hapticFeedback วิธีนี้ช่วยให้ mini-app ผสานเข้ากับ UI ของโฮสต์อย่างลึกซึ้ง (แทนที่จะเป็นหน้าที่โดดเดี่ยว)

// 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();
ธีม · cloud storage · สไตล์ Telegram

colorScheme / themeParams ทำให้สีของ mini-app สอดคล้องกับโฮสต์และเปลี่ยนตามโหมดมืด/สว่าง; locale ซิงก์กับ i18n ของโฮสต์

// —— 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')

เอกสารอ้างอิง

การจัดการข้อผิดพลาด

ทุก window.tt.* คืนค่าเป็น Promise และปฏิเสธด้วย Error เมื่อล้มเหลว mini-app ที่ใช้งานจริงต้อง .catch / try-catch ทุกการเรียก err.message ที่พบบ่อย:

err.message ความหมาย / แนวทางจัดการที่แนะนำ
User denied authorization คำขออนุญาตแบบ on-demand ถูกปฏิเสธ → แนะนำให้ลองใหม่
User cancelled ตัวเลือกบทสนทนา/ตัวอย่างถูกยกเลิก → เงียบไว้
Call timed out โฮสต์ไม่ตอบสนองเป็นเวลานาน (พบได้ยาก) → แนะนำให้ลองใหม่
Data too large / too many documents / too many storage items เกินโควตา → ลดข้อมูลลง
public reads are limited to pub_-prefixed public collections การตั้งชื่อคอลเลกชันไม่ตรง → ใช้คำนำหน้า pub_
Forbidden (403) ผู้ที่ไม่ใช่นักพัฒนาใช้ scope=all → ไม่มีสิทธิ์
Network error / Failed to fetch เครือข่ายล้มเหลว → แจ้งเตือนแบบเป็นมิตร + ลองใหม่
// 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 */
    }
  });
แบบจำลองการอนุญาต

การอนุญาตแบบ on-demand: การเปิด mini-app ไม่ต้องให้สิทธิ์ทั้งหมดล่วงหน้า; โฮสต์จะถามขอทีละรายการเฉพาะเมื่อความสามารถถูกเรียกใช้ครั้งแรกเท่านั้น (อนุญาต/ปฏิเสธ) ผู้ใช้สามารถ "เชื่อถือ mini-app นี้" เพื่อให้สิทธิ์ทั้งหมดพร้อมกัน หรือสลับทีละรายการและดูบันทึกการใช้งานในหน้าตั้งค่า backend ยังคงตรวจสอบซ้ำในทุกการเรียก (การป้องกันเชิงลึก) ความสามารถด้าน UI/หน้าต่าง/อุปกรณ์/ธีม/ข้ามแอปใช้งานได้ โดยไม่ต้องขออนุญาต

ความสามารถ (scope) คำอธิบาย ละเอียดอ่อน
user.profile ดึงชื่อเล่นและอวาตาร์ของคุณ
cloud.data คลาวด์ดาต้า (คอลเลกชัน; คำสั่งซื้อ/บันทึก ฯลฯ)
storage.kv พื้นที่จัดเก็บข้อมูล (KV)
media.upload อัปโหลดรูปภาพ/ไฟล์ (รวมถึงไดรฟ์ tt.drive)
im.share แชร์การ์ดเข้าแชท
im.send ส่งข้อความ
im.read อ่านประวัติบทสนทนา ละเอียดอ่อน
im.room ห้องหลายผู้เล่น: ส่ง/รับข้อความและอ่านแชทในห้องในนามของคุณ ละเอียดอ่อน
text.lookup ค้นหาคำ/แปล (ข้อความที่เลือกถูกส่งไปยังบริการแปล) ละเอียดอ่อน
text.provider ผู้ให้บริการค้นหาคำ (หน้าของ mini-app นี้ทำหน้าที่เป็นป็อปโอเวอร์ค้นหา/แปล) ละเอียดอ่อน
การมองเห็น (Visibility)
  • สาธารณะ (Public): ปรากฏใน "Discover" และการค้นหา; ใครก็ค้นเจอได้
  • ไม่แสดงในรายการ (Unlisted): ไม่อยู่ใน discover/การค้นหา; เข้าถึงได้เฉพาะผ่านการ์ดที่แชร์หรือลิงก์ที่คัดลอก (deep link) — สำหรับการกระจายในกลุ่มปิดโดยไม่เปิดเผยต่อสาธารณะ สลับได้ในคอนโซลด้วยคลิกเดียว
  • โหมดเปิด (เดสก์ท็อป): "โหมดเปิด (Open mode)" ในคอนโซลสลับระหว่าง "ลอย (ค่าเริ่มต้น) / เต็มจอ" — แอปประเภท canvas/whiteboard/editor ตั้งเป็น fullscreen เพื่อเต็มจอเมื่อเปิด; การ์ด/ฟอร์มน้ำหนักเบาใช้แบบลอย คุณยังสามารถประกาศค่าเริ่มต้นใน display ของ manifest.json ได้ มือถือเป็นเต็มจอเสมอ ไม่ได้รับผลจากการตั้งค่านี้
เวอร์ชันและการเผยแพร่

แบบจำลองเวอร์ชัน: แบบร่าง (draft) → กำลังตรวจสอบ (in review) → พร้อม (ready) → เผยแพร่แล้ว (live)

เวอร์ชันประวัติทั้งหมดถูกเก็บไว้ พร้อม การย้อนกลับในคลิกเดียว (one-click rollback) ไปยังเวอร์ชันประวัติใด ๆ (สลับเวอร์ชันที่ live ทันที) การถูกปฏิเสธจะแสดงเหตุผลในศูนย์การแจ้งเตือน

ข้อจำกัดและโควตา
  • คลาวด์ดาต้า: เอกสารเดียว ≤ 8KB, ≤ 500 เอกสารต่อ (mini-app, ผู้ใช้), list() ครั้งละ ≤ 200 (เรียงตามใหม่สุด); ใช้การแบ่งหน้าด้วย cursor listPage() (mine/all) สำหรับมากกว่านั้น หมายเหตุ: การทำ aggregation ที่ฝั่งหน้าบ้าน (นับ/หาค่าเฉลี่ย) โดยตรงด้วย list() จะนับส่วนที่เก่าที่สุดขาดไปเมื่อคอลเลกชันเกิน 200 และให้ผลลัพธ์ต่ำกว่าจริง — สำหรับยอดรวมทั้งหมดให้ใช้การแบ่งหน้า listPage หรือยอมรับค่าประมาณ
  • KV: ค่าเดียว ≤ 8KB, ≤ 64 คีย์ต่อ (mini-app, ผู้ใช้)
  • รูปที่อัปโหลด ≤ 4MB; ไฟล์ที่อัปโหลด ≤ 20MB; การส่งข้อความจำกัดอัตรา (≤ 20 ต่อผู้ใช้ต่อนาที); payload สัญญาณของห้อง ≤ 2KB; ข้อความเดียวของ tt.link ≤ 2KB, จำกัดที่ 25/วินาที
  • ชุดโค้ดเป็น HTML ไฟล์เดียว (แนะนำการเรนเดอร์แบบ DOM ล้วน; หลีกเลี่ยง innerHTML เพื่อป้องกัน XSS)
  • โทเคนมีอายุสั้น (ประมาณ 2 ชั่วโมง); โฮสต์ต่ออายุให้แบบเงียบ ๆ หลังหมดอายุ; การเรียกความสามารถถูกตรวจสอบซ้ำโดย backend

ตัวอย่างอ้างอิง: applets/food (การสั่งอาหาร, คลาวด์ดาต้า) และ applets/repair (คำขอซ่อม) ในรีโป ทั้งคู่เป็น mini-app แบบหน้าบ้านล้วน + คลาวด์ดาต้า