TutuHai ミニアプリは TutuHai の中で動く軽量アプリです。開発者はフロントエンドのコードバンドルだけをアップロードし、プラットフォームがホスティングします。機能は window.tt SDK を通じて TutuHai とやり取りし、独自のバックエンドは不要です(業務データは TutuHai クラウドデータを経由)。SDK 連携 · 機能 API · クラウドデータ · 認可 · 公開 — すべて 1 ページに、コピーしてすぐ動くサンプル付き。
目次 · 26 個のトピック
はじめに
データ機能
会話とマルチプレイヤー
ファイルとクラウドドライブ
アプリ間連携
UI · ウィンドウ · ホスト
リファレンス
はじめに
イントロダクション
TutuHai ミニアプリは TutuHai の中で動く軽量アプリです。開発者はフロントエンドのコードバンドルだけをアップロードし、プラットフォームがホスティングします。機能は window.tt SDK を通じて TutuHai とやり取りし、独自のバックエンドは不要です(業務データは TutuHai クラウドデータを経由)。
分離とセキュリティ:ミニアプリは独立したオリジンのサンドボックス化された iframe 内で動作し、ホストのログインセッションはミニアプリに入りません。呼び出しごとにホストが短命で権限を絞ったトークンを発行し、バックエンドが機能(スコープ)ごとに再検証します。
クイックスタート
- ミニアプリコンソール(
/applets)で「新規」をクリックしてミニアプリを作成(名前 + 一意の slug)。 - 単一ファイルの HTML を書く(SDK を読み込み、
window.tt.*経由で機能を呼ぶ)。 - バージョンを作成 → 必要な機能を記入 → コードバンドルをアップロード(単一ファイル HTML)。
- 審査に提出 → 管理者が承認 → ワンクリックで本番に公開。
- ユーザーは「見つける」検索から見つけて開くか、あなたがカードを共有 / リンクをコピーして直接アクセスします。
パッケージング仕様
ミニアプリは 2 つのアップロード形式に対応:① 単一ファイル HTML バンドル(自己完結、エントリー = ルート、最もシンプル);② 実際のフレームワークのビルド出力 zip(npm run build の dist/。index.html + 複数ファイルにまたがるアセット — 「フレームワークのビルド出力」を参照)。プラットフォームはアップロード前に仕様チェックと最適化を行います。
バンドル構造(ビルド出力)
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
コードバンドルは次を満たす必要があります(アップロード時に自動チェック):
- エントリー:
<!doctype html>とルートの<html>を持つ単一の HTML ファイル。 - モバイル対応:
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">を必ず含める。 - SDK:
<script src="/applet-sdk.js">を含める(プラットフォームがホストの絶対アドレスに書き換えます)。 - 自己完結:CSS/JS をインライン化。外部スクリプトが必要な場合は、プラットフォーム SDK + 著名なフレームワーク CDN(unpkg / jsdelivr / cdnjs / esm.sh)のみ許可 — 任意のリモートスクリプトは禁止(セキュリティ)。画像やその他のメディアは
tt.uploadImageまたは CDN 経由。 - サイズ:単一ファイル HTML ≤ 1MB;複数ファイル zip は合計 ≤ 8MB、1 ファイル ≤ 1MB、≤ 100 ファイル;アップロード画像 ≤ 4MB。
- データ:独自のバックエンドは不要 — 業務データは
tt.cloudクラウドデータ /tt.*storageを経由。
📱🖥 モバイル / デスクトップ共通(1 つのコードベースで両方)
同じバンドルが TutuHai 内の独立した iframe で動作し、ホストがモバイル(全画面)とデスクトップ(パネル / 全画面化可能)の両方で表示します。レスポンシブレイアウトの 1 つの共通コードベースで書きます:① viewport-fit=cover + セーフエリア env(safe-area-inset-*);② オーバーレイはボトムシート(モバイル)↔ 中央寄せ(デスクトップ、@media(min-width:480px));③ タッチターゲット ≥ 44px;④ ホストのダーク/ライトに追従(tt.onThemeChange / [data-theme]);⑤ 純粋な DOM、幅のハードコーディングなし。これで携帯でもパソコンでも体験が一貫します。
フレームワークのビルド出力(dist.zip)
単一ファイル HTML のほか、実際のフレームワークのビルド出力もアップロードできます — React / Vue / Svelte / Angular / Solid / Astro / Next(静的エクスポート) / vanilla… どのツールチェーンの npm run build でも、dist/(index.html + assets/*.js/css + フォント/画像)を zip にしてアップロードすればホスティングされます。
フレームワーク非依存:プラットフォームが認識するのは 1 つの「汎用静的バンドル契約」だけ — エントリー
index.html+ 相対アセット参照 + 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…
3 ステップの適応(どのフレームワークでも通用):
- 相対 base を設定(推奨、最も安全) — アセットを相対パス(
/assets/x.jsのルート絶対ではなく./assets/x.js)で参照させ、/<slug>/配下でのホスティングを確実にします。(デフォルトの base でも動きます:プラットフォームは HTML/CSS 内のルート絶対な静的参照を自動書き換えし、実行時に生成されるルート絶対アセット — コード分割 CSS のプリロードなど — には Referer フォールバックを使います。ただし厳格なReferrer-Policy/ オフラインプリフェッチのエッジケースでは Referer が欠落してフォールバックが失敗するため、相対 base が最も安全です。) - manifest を追加 —
manifest.jsonを静的ディレクトリ(例:Vite/SvelteKit のstatic/、多くのフレームワークのpublic/)に置き、ビルド後にdistルートに配置されるようにします;または manifest を省略し、index.htmlに<meta name="tt:slug" content="…">(およびtt:name / tt:version / tt:scopes)をフォールバックとして追加します。 - SDK を含める — 2 通り:① npm install(推奨、実 scaffold に最適):
npm i @tutuhai/applet-sdk後、import { tt } from '@tutuhai/applet-sdk'— ビルド時に出力へバンドルされ、TypeScript 型付き、index.htmlの編集不要;② またはindex.htmlに<script src="/applet-sdk.js">を書き(プラットフォームがホストの絶対アドレスに書き換え)、グローバルのwindow.ttを使う。
📦 npm SDK(React / Vue / Svelte の公式 scaffold で検証済み)
npm create vite@latest -- --template react-ts | vue-ts | svelte-ts でプロジェクトを作成し、同じ @tutuhai/applet-sdk をインストールして import します。実際の複数ファイルのソースで、npm run build が複数のチャンク + 1 つのエントリー 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}(3 つの実際の公式 scaffold プロジェクト。すべて同じ SDK パッケージを import)。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:デスクトップでの開き方を宣言 — キャンバス/ホワイトボード/エディタ系アプリは
fullscreenを、軽量なカード/フォームはデフォルトのwindowを推奨。あくまで初期値で、公開後もコンソールの「開き方」でいつでも変更可能(コンソールが優先)。モバイルは常に全画面で、このフィールドの影響を受けません。manifest を省略した場合は<meta name="tt:display" content="fullscreen">がフォールバックになります。 - fileHandlers:チャットからあなたのミニアプリが扱えるファイル種別を宣言 — ユーザーがチャット内のファイルで「アプリで開く」をタップすると、一致する種別を宣言したミニアプリが候補に表示され、タップするとそのファイルがミニアプリに直接送られます(「チャットファイルの処理」を参照)。各項目:
kindsはimage / video / audio / pdf / office / text / anyのいずれか(複数可、any= 任意のファイル);roleはeditor(エディタで開く)またはviewer(プレビュー);labelは任意(≤20 文字、候補の表示名)。最大 8 項目。ゲーティングは可視性と一致:private/self-useは審査なしで動作(自分の「アプリで開く」にのみ表示);publicは全員に有効化される前に管理者の承認が必要。
フレームワークごとの「相対 base」設定を 1 行で:
// 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(SvelteKit / React Router / Vue Router / Angular) ミニアプリは
/<slug>/サブパス配下でホスティングされます。クライアントサイドルーティングの SPA は「ルーター base」を自分の slug に設定する必要があります。さもないとフレームワークのルーターが現在のパスに一致できず → ページ全体が 404(アセットは読み込まれるがルーティングが not found を報告)。相対アセット base の設定だけでは不十分です — それはアセット URL を直すだけで、ルーティングは直しません。フレームワーク別:SvelteKitkit.paths.base='/<slug>';React Router<BrowserRouter basename="/<slug>">;Vue RoutercreateWebHistory('/<slug>/');AngularAPP_BASE_HREF='/<slug>/'。(クライアントサイドルーティングを使わないアプリ — 純粋なレンダリング / Router なしの単一ページ React / vanilla — は影響を受けません。)
⚠ アップロードチェック アップロード時、プラットフォームは dist に対して「契約チェック」を実行:エントリー / 相対パス / manifest / SDK 参照 / 制限 / MIME をそれぞれインラインヒント付きで検証します。合計 ≤ 8MB、1 ファイル ≤ 1MB、≤ 100 ファイル、ホワイトリストの MIME のみ(html/css/js/json/画像/フォント/map/wasm)。フォントは圧縮・サブセット化してサイズを抑えてください。デフォルトの 1 ファイル/合計制限を超える重量級フレームワーク出力(例:1MB 超の単一チャンクを持つ tldraw / excalidraw)の場合は、運用チームに管理パネルで「1 ファイルのバイト数」/「解凍後の合計」の上限を引き上げてもらってください(実行時に変更可、即時反映);
manualChunksの分割でも vendor を上限以下にできます。
最小サンプル
完全に動作するミニアプリ — 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 連携
ミニアプリの HTML に SDK スクリプトを含め、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>
ホストのドメインをミニアプリにハードコードしないでください。 相対の
/applet-sdk.js(または任意のプレースホルダーオリジン)を書いてください — プラットフォームはあなたのアプリが iframe 内で動くとき、配信時に SDK スクリプト URL を現在のホストへ書き換えます。したがって TutuHai がドメインを変更しても、ドメインがブロックされても、公開済みのすべてのミニアプリはコード変更も再公開もなしで動き続けます — 運用者が設定値を 1 つ切り替えるだけです。
Ready コールバック、コンテキスト、テーマ/ロケール:
// 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
ミニアプリが独自のバックエンドなしで業務データを永続化できる、ホスティングされた構造化コレクション。3 段階の可視性:
mine:自分のドキュメントのみ読み書き(デフォルト)。all:すべてを読み書き、ミニアプリ開発者(オーナー)のみ — 「マーチャントコンソール」で全注文/チケットを見るため。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
(ミニアプリ, ユーザー)ごとに分離されたキー・バリューで、チェックイン回数や下書きなど小さなプライベート状態に適しています。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 の会話とのやり取りはすべてホストが仲介します(ユーザーが自分で会話を選ぶ);ミニアプリは会話の全リストを取得できません。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 はミニアプリに入りません;このミニアプリが作成したルーム / あなたが共有された会話に限定され、ユーザーの他のプライベートチャットには触れられません。
// 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
再接続時のハイドレーション:シグナルはベストエフォートで、切断時のフレーム損失は正常です。
onReconnectではroom.history()またはクラウドデータから最終状態を再構築してください — シグナルを唯一の情報源として頼らないこと。
インラインコンポーネント · ネイティブ機能のように · プライバシー安全
shareToChat({inline:true}) で送ったカードは、チャットの吹き出しの中に対話的なコンポーネントを直接レンダリングします(例:投票、評価);受信者はフローティングウィンドウを開かず、ネイティブ機能のように操作できます。ミニアプリは ctx.inline に基づいてコンパクトな UI をレンダリングし、吹き出しは内容に合わせて自動サイズ調整され、ホストのライト/ダークにライブで追従します。プライバシー:インラインインスタンスには「cloud.data のみ、副作用なし」の制限付きトークンだけが与えられます — 公開コレクションの読み取り + 自分のドキュメントの書き込みができ、他人のプライベートデータには触れられず、認可を求めません。
// —— 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
単語検索のポップオーバーは、それ自体が「プロバイダーミニアプリ」のインラインページです — その内容/機能はすべてそのミニアプリがレンダリングし、ホストは選択 + ホバー位置決め + 柔軟な SDK を提供するだけです。利用側(自分のミニアプリ内のテキストを選択可能にする):text.lookup を宣言、コード不要 — テキストを選択すると、プロバイダーのインラインページが選択範囲のすぐ下にポップします(チャットメッセージのテキストにも対応)。プロバイダー側(検索ミニアプリを作る):text.provider を宣言(審査後に付与)、インラインページが tt.text.onLookup で単語を受け取り自身をレンダリングします;どのプロバイダーが有効かは管理パネルで設定します — 未設定/未認可なら検索は無効。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
ユーザーがチャット内の画像/ファイルで「アプリで開く」をタップすると、あなたのミニアプリを選んで処理できます — ただし manifest.json の fileHandlers(image/video/audio/pdf/office/text/any)で一致するファイル種別を宣言している場合に限ります。開いたら:getContextFile() でファイルを取得、readFile() でホスト経由の同一オリジンでバイトを取得(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)
図図ドライブ · tt.drive(media.upload が必要)
図図ドライブからファイルを取り込む、または出力をドライブに保存します(ホスト仲介:ユーザーはホストページ内のドライブピッカーでファイルを 1 つずつ選び、ミニアプリはドライブトークンを保持しません)。ドライブに到達できない / 図図 ID が連携されていない場合は拒否されます — .catch の後、ミニアプリはローカルの 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(認可不要)
複数のミニアプリを同時に開くことができ(ワンクリックで一緒に開くコンボとして保存、2/3/4 のマルチウィンドウレイアウト、ドックされたサイドバー — すべてホストが管理、コード不要)、次の機能で連携します:
tt.link:他の開いているミニアプリとイベント/状態をリアルタイム同期(ブロードキャストまたは指定先、≤2KB、レート制限 25/s;ウィンドウを閉じると消失、非永続、ユーザー/会話をまたがない)。tt.tray:コンテンツをホストのトレイに拾い上げて中継し、別のミニアプリや会話に注入。tt.drag.start/tt.drag.bind:ドラッグを開始 / 要素を別のミニアプリへ直接ドラッグできるドラッグソースとしてバインド(同一オリジンのミニアプリ間のネイティブ HTML5 ドラッグ&ドロップ)。tt.drop.accept:ミニアプリ全体がドロップ / トレイ注入を受け取れる。tt.drop.zone:★特定の内部要素にドロップを感知させ(ホバーハイライトのフィードバック付き)、それに応じて動作させる。1 つのミニアプリに複数のゾーンを持て、それぞれが独立して感知します。
セキュリティ:file でラップされた url はこのサイトの /uploads/ ハンドルのみ受け付けます(受信側は tt.readFile でバイトを取得);偽造されたクロスオリジンの外部リンクは破棄され、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 · ウィンドウ · デバイス(認可不要)
WeChat の wx.* のような UI 機能 — ホストが本物のトースト/ダイアログ/画像ビューアをレンダリングし、ミニアプリのカプセルウィンドウを制御し、クリップボード/バイブレーション/発信/位置情報/ネットワークにアクセスします — 認可を要求せずに呼び出せ、ミニアプリをネイティブアプリ並みに強力にします。
// —— 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|...}
フローティングウィンドウ · 全画面 · ブランディング
ミニアプリのウィンドウを制御します:インライン/選択範囲からドラッグ可能なフローティングウィンドウを開く、全画面に拡大 / 復元、ホストカプセルの色付け、表示/サイズ変更のリッスン。
// —— 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');
ホストのボタン · ハプティクス · Telegram 風(認可不要)
ミニアプリはホストのクロムのボタンを制御し、そのクリックイベントを受け取ります(双方向) — 下部のメインボタン mainButton、ヘッダーの戻るボタン backButton、ハプティクス hapticFeedback。これによりミニアプリはホスト 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();
テーマ · クラウドストレージ · Telegram 風
colorScheme / themeParams はミニアプリの色をホストと一致させ、ライト/ダークで切り替えます;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 を reject します。本番のミニアプリはすべての呼び出しを .catch / try-catch する必要があります。よくある err.message:
| err.message | 意味 / 推奨する処理 |
|---|---|
User denied authorization |
オンデマンドの認可プロンプトが拒否された → 再試行を促す |
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 */
}
});
認可モデル
オンデマンド認可:ミニアプリを開くのに事前にすべての権限を付与する必要はありません;ホストは機能が最初に呼ばれたときに 1 項目だけプロンプトします(許可/拒否)。ユーザーは「このミニアプリを信頼」して一度にすべてを付与するか、項目ごとに切り替え、設定ページで利用記録を確認できます。バックエンドは呼び出しごとに再検証します(多層防御)。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 |
検索プロバイダー(このミニアプリのページが検索/翻訳のポップオーバーになる) | センシティブ |
可視性
- 公開:「見つける」と検索に表示され、誰でも見つけられます。
- 限定公開:見つける/検索に出ず、共有カードやコピーしたリンク(ディープリンク)からのみ到達可能 — 公開露出なしでのプライベート拡散向け。コンソールでワンクリック切り替え。
- 開き方(デスクトップ):コンソールの「開き方」で「フローティング(デフォルト) / 全画面」を切り替え — キャンバス/ホワイトボード/エディタ系アプリは
fullscreenにして開いたときに画面いっぱいに;軽量なカード/フォームはフローティングを使用。manifest.jsonのdisplayで初期値を宣言することもできます。モバイルは常に全画面で、この設定の影響を受けません。
バージョンと公開
バージョンモデル:下書き → 審査中 → 準備完了 → 公開中。
すべての過去バージョンは保持され、任意の過去バージョンへワンクリックでロールバックできます(公開バージョンを即座に入れ替え)。却下時は通知センターに理由が表示されます。
制約とクォータ
- クラウドデータ:1 ドキュメント ≤ 8KB、(ミニアプリ, ユーザー)ごとに ≤ 500 ドキュメント、
list()は 1 回 ≤ 200(新しい順);それ以上はlistPage()のカーソルページネーション(mine/all)を使用。注意:list()を使ってフロントエンドで直接集計(カウント/平均)すると、コレクションが 200 を超えたとき最も古い部分がカウント漏れし、少なく出ます — 全体の合計には listPage ページネーションを使うか、近似値を受け入れてください。 - KV:1 値 ≤ 8KB、(ミニアプリ, ユーザー)ごとに ≤ 64 キー。
- アップロード画像 ≤ 4MB;アップロードファイル ≤ 20MB;メッセージ送信はレート制限(ユーザーごとに 1 分 ≤ 20 件);ルームシグナルのペイロード ≤ 2KB;tt.link の 1 メッセージ ≤ 2KB、25/s にスロットル。
- コードバンドルは単一ファイル HTML(純粋な DOM レンダリングを推奨;XSS を防ぐため
innerHTMLを避ける)。 - トークンは短命(約 2 時間);ホストが期限切れ後に静かに更新;機能呼び出しはバックエンドが再検証します。
参考サンプル:リポジトリの
applets/food(注文、クラウドデータ)とapplets/repair(修理依頼)はどちらも純フロントエンド + クラウドデータのミニアプリです。