通訊軟體裡的 mini app:微信、Telegram 的近期案例與平台設計

微信和 Telegram 這兩年的公開資料顯示,mini app 可以做到上億人使用,宿主要處理的事則集中在身分、權限、儲存和付款。下面先整理案例,再談宿主平台在這幾件事上怎麼設計。

〈從裝置出發打造通訊軟體〉把伺服器縮成只負責轉送,〈從通訊軟體到社群與短影片〉談過 mini app 的 JavaScript bridge 和身分驗證,這篇接著看通訊軟體怎麼變成別人程式的平台。

為什麼是通訊軟體

維基百科對 super app 的定義,是同時提供即時通訊、支付等多種服務,讓使用者在同一個 App 裡聊天也消費,微信常被拿來當代表。

第三方程式放進通訊軟體,使用者不必另外下載或註冊,打開對話裡的連結就能用,付款也沿用已經綁好的方式。這些程式出了問題,宿主也脫不了責任,Apple 審核指南 4.7 就把責任寫在宿主 App 身上。

微信:小程式和小遊戲

微信在 2017 年推出小程式。依維基百科整理,2017 年底上線的小遊戲「跳一跳」三天內有 4 億人玩過,兩週後日活躍使用者達 1 億;2018 年 1 月微信公布小程式數量為 58 萬。騰訊 2022 年 1 月的官方文章寫到,2021 年小程式平均日活躍使用者超過 4.5 億,活躍小程式數量比 2020 年多了 41%。

小遊戲近年的數字,出自騰訊新聞對 2026 年 1 月 15 日微信公開課 PRO 小遊戲專場的報導:

  • 月活躍使用者超過 5 億,其中重度遊戲的月活躍使用者超過 3 億。
  • 2025 年有超過 300 款遊戲單季營收超過人民幣 1,000 萬元,近 70 款日活躍使用者破百萬。
  • 開發者超過 40 萬,整體商業規模成長接近 20%。

2025 年 11 月 13 日 Apple 發表 Mini Apps Partner Program,符合條件的 mini app 數位商品內購抽成降到 15%,隔天微信就宣布小程式和小遊戲將在 iOS 支援虛擬支付。微信官方文件目前寫明,小程式裡的虛擬商品購買和付款都要串接小程式虛擬支付,iOS 需要微信 8.0.68 以上並走 Apple 內購,Android、鴻蒙、Windows 則走微信支付。

Telegram:Mini Apps 與 Stars

Telegram 的 Mini Apps 是 bot 打開的網頁,跑在 Telegram 的 WebView 裡。這兩年的主要更新:

  • 2024 年 6 月 6 日推出 Telegram Stars,數位商品一律用 Stars 付款。使用者透過 Apple、Google 的內購買 Stars,開發者可以經由 Fragment 換成 Toncoin 提領,實體商品照舊走原本的金流。Telegram 當時說每月有超過 4 億人使用 bot 和 mini app,屬官方自述。
  • 2024 年 11 月 17 日的 Bot API 8.0(Mini Apps 2.0)加入全螢幕、主畫面捷徑、地理位置、裝置動作感測、Stars 訂閱,以及前一篇提到的 Ed25519 第三方驗證。
  • 2025 年 4 月 11 日的 Bot API 9.0 加入 DeviceStorage 和 SecureStorage,後者在 iOS 用 Keychain、Android 用 Keystore 存敏感資料。

2024 年規模最大的兩個案例都是 tap-to-earn 遊戲,建在最初由 Telegram 開發的 TON 區塊鏈上(見維基百科 Telegram 條目):

  • Notcoin:依 Decrypt 的整理,累計 3,500 萬名玩家,2024 年高峰時日活躍使用者 600 萬。代幣 NOT 在 2024 年 5 月 16 日上線,玩家用遊戲內累積的點數兌換。
  • Hamster Kombat:2024 年 3 月推出。BeInCrypto 引用 Protos 的數字,2024 年 7 月月活躍使用者達 3 億,9 月發完代幣後,到 11 月剩 4,100 萬,少了約 2.6 億。報導引述一位錢包業者主管的說法:拿到空投的人把代幣賣掉,也就沒有理由留下來。

安全方面,KAUST 的研究者在 2026 年 8 月 18 日於 arXiv 發表 TENET,依熱門程度加權分層抽樣挑了 61 個 Mini Apps,其中 37 個符合分析條件,30 個(81.1%)有安全問題:17 個的 session token 或 JWT 可以被重用,16 個把私鑰或其他金鑰存得不安全,5 個把錢包助記詞存在本地。受影響的 App 合計月活躍使用者超過 5,300 萬;論文也寫到,Telegram 官方錢包曾把助記詞以明文存在 local storage。研究者已向 Telegram 和相關開發者通報,論文另外提到 Telegram 提供的 SecureStorage 和 DeviceStorage(Bot API 9.0,見上),複查時官方錢包已不再以明文保存助記詞。

其他近期案例

  • Discord:2024 年 9 月 26 日把 Embedded App SDK 開放給所有開發者。官方文件說 Activities 是放在 iframe 裡的網頁應用,透過 SDK 和 Discord 用戶端溝通,用 OAuth2 授權,對外的網路請求受 Content Security Policy 限制,要走 Discord 的 proxy。同一篇公告寫到超過 25% 的月活躍使用者會使用 App,屬官方自述。
  • LINE:MINI App 的運作方式,以及 2025 年起可以在一般瀏覽器開啟的變化,見〈從通訊軟體到社群與短影片〉。
  • Max:俄羅斯 VK 在 2025 年 3 月 26 日推出的通訊軟體,內建 bot 和 mini app 的開發工具。依維基百科整理,2025 年 6 月通過的法律要求 9 月 1 日起在俄羅斯販售的手機預先安裝 Max,官員也宣布會整合政府服務入口 Gosuslugi。一般訊息沒有端對端加密。使用人數只有 VK 自己公布,維基百科註明這些數字可能高估。

平台規則與標準

審核指南 4.7 前一篇寫過。2025 年的 Mini Apps Partner Program 把 mini app 定義為以 HTML5、JavaScript 這類網頁技術打造、在原生宿主 App 裡發布的獨立體驗。mini app 要適用 15% 的抽成,宿主就得支援 Declared Age Range API 和 Advanced Commerce API,等於年齡確認和數位商品付款都由宿主處理。

跨平台的標準沒有成形。W3C 在 2021 年成立 MiniApps Working Group,起草 Lifecycle、Addressing、Manifest、Packaging 等規格,2025 年 TPAC 的會議摘要寫到規格已接近穩定,最大的障礙是實作太少。W3C 在 GitHub 上的 repo 註明這個工作小組在 2026 年 8 月 26 日關閉,後續討論移到 WebView Community Group。

宿主平台怎麼設計

執行環境

做法有兩種:直接載入網頁(Telegram、LINE 用 WebView,Discord 用 iframe),或像微信那樣把渲染層和邏輯層拆開,邏輯跑在碰不到 DOM 的 JavaScript 執行環境。直接載入網頁,現成的網站幾乎可以原樣搬進來;拆成兩層,宿主比較能控制第三方碰得到哪些 API,開發者則得另外學一套框架。

套件大小也要管。微信規定單一主套件或分包不能超過 2 MB,所有分包合計不超過 30 MB(服務商代開發的是 20 MB)。有大小上限,宿主就容易預先下載和快取;直接載入網頁沒有這一層,載入速度看第三方自己的伺服器。

身分

宿主手上有使用者的真實帳號,這個帳號不該交給第三方。微信的做法是:

  • 前端呼叫 wx.login() 只拿到一次性的 code,交給開發者自己的伺服器。
  • 伺服器拿 code 和 AppSecret 呼叫 code2Session,換到 OpenID、UnionID 和 session_key。文件寫明 session_key 不該傳到小程式前端,code 只能用一次。
  • OpenID 每個小程式不同,UnionID 只在同一個開放平台帳號底下的 App 之間相同。

每個 mini app 拿到的 ID 都不一樣,不同開發者的 mini app 就沒辦法靠 ID 把同一個使用者串起來。微信沒有公開 OpenID 怎麼產生,下面用 HMAC 示範一種可行的做法,再試著重放 code、把 code 拿到別的 App 兌換,以及未經同意呼叫定位:

import hashlib
import hmac
import platform
import secrets
import time
from dataclasses import dataclass, field


class HostError(Exception):
    pass


@dataclass(slots=True)
class MiniApp:
    app_id: str
    developer: str
    app_secret: str
    granted: set[str] = field(default_factory=set)


class Host:
    """The super app: issues identities and brokers every bridge call."""

    CODE_TTL = 300  # seconds

    def __init__(self) -> None:
        self._key = secrets.token_bytes(32)  # never leaves the host's servers
        self._apps: dict[str, MiniApp] = {}
        self._codes: dict[str, tuple[str, str, float]] = {}

    def register(self, app: MiniApp) -> None:
        self._apps[app.app_id] = app

    def _derive(self, label: str, user_id: str) -> str:
        msg = f"{label}\x00{user_id}".encode()
        return hmac.new(self._key, msg, hashlib.sha256).hexdigest()[:16]

    def open_id(self, user_id: str, app_id: str) -> str:
        # Pairwise: each mini app sees a different ID for the same user.
        return self._derive("app:" + app_id, user_id)

    def union_id(self, user_id: str, developer: str) -> str:
        # Shared only across apps owned by the same developer.
        return self._derive("dev:" + developer, user_id)

    def login(self, user_id: str, app_id: str) -> str:
        # Runs inside the client; the mini app front end only gets a code.
        code = secrets.token_urlsafe(16)
        self._codes[code] = (user_id, app_id, time.monotonic())
        return code

    def exchange(self, code: str, app_id: str, app_secret: str) -> dict[str, str]:
        # Called by the mini app's own server, never from the front end.
        app = self._apps.get(app_id)
        if app is None or not hmac.compare_digest(app.app_secret, app_secret):
            raise HostError("bad app credentials")
        entry = self._codes.pop(code, None)  # single use
        if entry is None:
            raise HostError("unknown or used code")
        user_id, bound_app, issued = entry
        if bound_app != app_id:
            raise HostError("code issued to another app")
        if time.monotonic() - issued > self.CODE_TTL:
            raise HostError("code expired")
        return {
            "open_id": self.open_id(user_id, app_id),
            "union_id": self.union_id(user_id, app.developer),
        }

    def grant(self, app_id: str, scope: str) -> None:
        # Recorded after the host shows its own consent dialog.
        self._apps[app_id].granted.add(scope)

    def call(self, app_id: str, method: str, scope: str | None = None) -> str:
        app = self._apps.get(app_id)
        if app is None:
            raise HostError("unknown app")
        if scope is not None and scope not in app.granted:
            raise HostError(f"{method} needs {scope}")
        return f"{method} ok"


host = Host()
coffee = MiniApp("wx-coffee", developer="dev-a", app_secret=secrets.token_hex(16))
bakery = MiniApp("wx-bakery", developer="dev-a", app_secret=secrets.token_hex(16))
game = MiniApp("wx-game", developer="dev-b", app_secret=secrets.token_hex(16))
for app in (coffee, bakery, game):
    host.register(app)

ids = {}
for app in (coffee, bakery, game):
    code = host.login("user-42", app.app_id)
    ids[app.app_id] = host.exchange(code, app.app_id, app.app_secret)

print("open_id differs per app:", len({v["open_id"] for v in ids.values()}) == 3)
print("same developer, same union_id:",
      ids["wx-coffee"]["union_id"] == ids["wx-bakery"]["union_id"])
print("other developer, other union_id:",
      ids["wx-coffee"]["union_id"] != ids["wx-game"]["union_id"])

checks = []
code = host.login("user-42", "wx-coffee")
host.exchange(code, "wx-coffee", coffee.app_secret)
checks.append(("replay code", lambda: host.exchange(code, "wx-coffee", coffee.app_secret)))
stolen = host.login("user-42", "wx-coffee")
checks.append(("code to other app", lambda: host.exchange(stolen, "wx-game", game.app_secret)))
checks.append(("location without consent", lambda: host.call("wx-game", "getLocation", "scope.userLocation")))
for name, fn in checks:
    try:
        fn()
        print(f"{name}: allowed")
    except HostError as e:
        print(f"{name}: rejected ({e})")

host.grant("wx-game", "scope.userLocation")
print("location after consent:", host.call("wx-game", "getLocation", "scope.userLocation"))
print("Python", platform.python_version())

輸出:

open_id differs per app: True
same developer, same union_id: True
other developer, other union_id: True
replay code: rejected (unknown or used code)
code to other app: rejected (code issued to another app)
location without consent: rejected (getLocation needs scope.userLocation)
location after consent: getLocation ok
Python 3.13.16

code 綁定當初核發的 App,兌換時先從表裡移除再檢查,所以被拿去別的 App 兌換的 code 也跟著作廢。ID 用宿主自己的金鑰做 HMAC 推導,不必另外存對照表,只要金鑰不離開宿主的伺服器,第三方就無法從 OpenID 反推真實帳號。實際系統還得考慮金鑰輪換:換了金鑰,所有 OpenID 都會跟著變,所以推導用的金鑰幾乎不能換,要換就得另外存一份對照表。Telegram 的 initData 走另一條路,由平台對使用者資料簽章,mini app 的伺服器負責驗章,寫法見前一篇。

權限

微信的權限用 scope 管理,例如 scope.userLocation、scope.camera、scope.record。第一次呼叫時跳出同意視窗,使用者拒絕之後不會再跳,得到設定頁重新打開;同意則一直有效,直到使用者刪除小程式。視窗上會顯示開發者在隱私保護指引裡填的用途說明。

同意視窗必須由宿主的原生介面顯示,第三方只能發出請求。要是同意與否取決於第三方自己畫的按鈕,它就能畫一顆長得一模一樣的按鈕誘導使用者點下去。授權紀錄也要以 App 為單位存在宿主這邊,bridge 每收到一次呼叫就查一次。

儲存

TENET 的發現有一大半和儲存有關:私鑰、助記詞直接寫進 WebView 的 local storage,可重用的 session token 也以明文留在本地。宿主管不到第三方怎麼寫程式,只能提供更安全的選項,像 Telegram 的 SecureStorage 接到系統的 Keychain 和 Keystore,再在文件裡寫清楚哪些資料不該放進 local storage。

支付

數位商品和實體商品要分開處理。Telegram 把數位商品全導向 Stars,微信把虛擬商品導向小程式虛擬支付,兩者在 iOS 上都經過 Apple 內購,實體商品則各自走原本的金流。設計時就把商品類型當成一個欄位,因為抽成、退款流程和適用的平台規則都由它決定,Apple 的方案還要求回報退款相關的使用資訊。

和端對端加密的衝突

第一篇的前提是伺服器看不到內容,mini app 的資料卻一定會送到第三方的伺服器。使用者在加密聊天室裡打開 mini app,聊天內容仍然是加密的,但他在 mini app 裡輸入的東西,連同宿主帶進去的使用者資料和群組資訊,都會離開加密範圍。宿主可以讓這條邊界看得見:初始化資料只帶必要的欄位,分享群組資訊前另外取得同意,介面上也要讓使用者分得出眼前是宿主還是第三方的畫面。

延伸討論:LINE MINI App 的條件與缺口

LINE 這一年多在台灣和日本陸續補 MINI App 的功能,公開的進度如下:

  • 台灣:2025 年 10 月 22 日宣布導入內購和廣告分潤,在錢包頁加了 MINI Home 入口,並預告 2026 年上半年推出 NFC 裝置 LINE Touch,手機一碰就打開 MINI App,當時接入的品牌超過 100 個。既有的 LIFF 網站可以直接升級,不用重寫。2026 年 9 月 17 日的年會公布台灣官方帳號超過 340 萬,也和經濟部商業發展署簽了中小企業數位轉型的合作備忘錄。
  • 日本:2026 年 2 月 19 日正式推出內購,只開放給認證過的 MINI App,要事前申請和審查;7 月 1 日起收服務費,10 月 1 日起對應 Apple 的 Mini Apps Partner Program。9 月 14 日修訂的政策規定,MINI App 裡的廣告只能用 LY Ads Network。

拿前面幾節的條件對照,LINE 有關係鏈也有既有的店家:台灣使用者約 2,100 萬人(依 LINE-Break 的 Black Hat 簡報),官方帳號超過 340 萬,支付、入口和審核也都有公開規則。

從公開資料看,還缺這幾塊:

  • 沒有可以比較的規模數字。微信公布過小遊戲的月活躍使用者和開發者數,Telegram 公布過 bot 與 mini app 的月使用人數;LINE 台灣公布的是品牌數和官方帳號數,MINI App 本身的使用人數、交易額和第三方開發者數都沒有公開,單位不同,沒辦法放在一起比。
  • 入口的方向不同。微信小遊戲和 Telegram 的 tap-to-earn 遊戲都是在聊天裡傳開的;LINE 台灣這次強調的入口是錢包頁和實體店的 NFC 觸碰,比較接近店家會員和線下導流。對話裡的分享能帶來多少新使用者,目前沒有公開數字。
  • 支付各市場不一樣。日本的 LINE Pay 在 2025 年 4 月 30 日結束,匯款和支付併入 PayPay,台灣和泰國的 LINE Pay 繼續營運。日本的內購規則、服務費和 Apple 方案對應都已公布,台灣目前公開的只有 2025 年 10 月的導入公告。
  • 加密和信任。第一篇整理過,LINE-Break 指出 Letter Sealing v2 設計上沒有 forward secrecy,聊天室加入 bot 後也會失去端對端加密,而 MINI App 可以連結官方帳號,2026 年 9 月起日本的 MINI App 還能連結多個官方帳號。日本總務省在 2024 年 3 月 5 日和 4 月 16 日,因約 30 萬筆資料外洩兩度對 LY 行政指導,要求切開和 NAVER 共用的網路與驗證系統,並檢討資本關係。
  • 執行環境是 WebView。LIFF 是跑在 WebView 裡的網頁,宿主能管的範圍比微信拆成兩層的做法小。TENET 只量測 Telegram,LINE MINI App 目前沒有同類的公開研究。

LINE 已在台灣和日本補上內購、入口和廣告規則,但公開資料還不足以判斷它能不能走到微信的規模。之後可以觀察:MINI App 的使用人數或交易額會不會公布,台灣內購的開放條件和時程,還有遊戲類能不能靠聊天室分享長起來。

補充筆記

  • 通訊軟體當 mini app 宿主,靠的是好友關係、支付和對話裡的分享,代價是要替第三方的安全、隱私和付款負責。
  • 微信小遊戲月活躍使用者超過 5 億;Apple 2025 年 11 月推出 15% 抽成的 Mini Apps Partner Program,微信隔天宣布 iOS 支援虛擬支付。
  • Hamster Kombat 2024 年 7 月月活躍使用者達 3 億,發完代幣後到 11 月剩 4,100 萬。
  • TENET 分析的 37 個 Telegram Mini Apps 有 30 個有安全問題,集中在 token 可重用,以及私鑰、助記詞的本地儲存。
  • LINE 已在台灣和日本補上內購、廣告分潤和新入口,但 MINI App 的使用規模沒有公開,支付規則各市場不同,聊天加密和 2024 年的行政指導則牽涉信任。
  • 宿主要決定執行環境、每個 App 不同的使用者 ID、由宿主顯示的同意視窗、安全儲存的預設選項,以及依商品類型區分的支付流程。

延伸閱讀

想法與技術判斷出自 Sheng,和 Claude 一起起草 · 範例在 Python 3.13.16 實測。