Skip to content

自己的消息类型 ​

业务卡片(红包、转账、订单)不进 SDK:它是一条带自定义 contentType 的普通消息,客户端注册一个消息类型来画它,卡片状态由宿主推送。业务的一切(谁能领、每人多少、钱)都归宿主,Talkwire 只负责两件事:发出那条显示卡片的消息,以及把卡片的新状态实时推给在线的成员。

完整的例子是 examples/red-packet:服务端一侧在 src/red-packets.ts,Android 一侧在 android/,sample-app 接了它。Web 示例没有接红包卡片,Web 一侧按同样的接口自己写。

三个部分 ​

部分谁做怎么做
卡片消息发送者的客户端用 engine.send 发一条普通消息:contentType 是宿主自定的类型,extra 带业务 id,content 放兜底文字
卡片的样子每个客户端注册一个 MessageType,按 contentType(和 extra)认出这条消息并画出来
卡片的状态宿主业务变化后调服务端的 custom-broadcast,在线客户端收到 custom_update;打开卡片时向宿主拉当前状态

服务端不解释 contentType,extra 是任意 JSON 值,两者都原样存储与转发(协议第 9.1 节)。不认识这个类型的客户端照常显示 content 的文字,所以兜底文字要能独立看懂。

发卡片消息 ​

以红包为例,发送者先调宿主建红包,宿主核对他是群成员后返回红包 id 和下一步要发的消息。客户端在群里发出:

json
{"action": "pub", "topic": "grp_…", "contentType": "red_packet", "content": "[Red packet]", "extra": {"redPacketId": "…"}}

在客户端这就是一次普通发送:把宿主返回的 content、contentType、extra 交给 engine.send(Web 的 OutgoingMessage 与 Android 的 OutgoingMessage 字段相同)。示例的 RedPacketSender 把这个入口放进附件面板,经 ImUiHost.attachmentActions 接入,只对群显示。

注册消息类型 ​

类型按 contentType(和 extra)认,从不看 content 的文字(红线 C7)。注册的类型排在内置类型之前,所以也可以接管一个内置的 contentType。

ts
new MessageTypeRegistry.Builder().register(type).build()
kotlin
val types = MessageTypeRegistry.Builder().register(RedPacketType(store)).build()
val state = rememberImChatState(engine, host, types)

Web 把注册表传给 TalkwireProvider 的 types,Android 传给 rememberImChatState。类型的名字与查找顺序两端一致:先是应用自己的,再是内置的撤回、文本、图片、视频、语音、文件、系统,最后是兜底的 UnknownType,它显示 content 的文字。

MessageType 接口 ​

Web(@talkwire/react)Android(talkwire-ui)说明
name—稳定的名字,用于测试与调试
matches(message)matches(message)是否由这个类型来画
framedframedtrue(默认)画在标准气泡里;false 是自己画外框的卡片
render(message, scope)@Composable Content(message, scope)画出来
preview(message, strings)preview(message, context)会话列表与通知里的一行文字
actions(message)actions(message)长按菜单给哪些操作。Web 不实现时没有菜单;Android 默认是回复、转发、多选、撤回

两端的 MessageScope 都带 isMine、resolveUrl(把存下的媒体地址变成可加载的地址)与 openImage。Web 还带 strings;Android 还带 onLongPress(打开消息操作)与 nameOf(uid)。类型需要的其他东西(HTTP 客户端、宿主的状态)由类型自己带进来,例如 RedPacketType(store)。

菜单里可用的操作:Web 是 copy、save、reply、forward、select、recall、retry、discard,其中 recall、retry、discard 只对自己的消息出现;Android 是 COPY、SAVE、SHARE、REPLY、FORWARD、SELECT、RECALL。红包卡片没有可复制的文字,RedPacketType.actions 返回空集合。

推送卡片状态 ​

宿主在业务变化之后调服务端的 custom-broadcast 接口。用 Node 宿主 SDK 时是 customBroadcast(target, kind, payload),target 是 { topic }(按会话)或 { targetUid }(按用户)。红包示例在有人领取后这样推:

ts
await im.customBroadcast({ topic: packet.topic }, "red_packet", cardFor(packet, ""));

在线的成员收到:

json
{"action": "custom_update", "topic": "grp_…", "what": "red_packet", "extra": {"id": "…", "claimed": 2, "remaining": 1, …}}

协议对 custom_update 的规定(附录 B):

  • 按会话推送时,订阅了该会话的连接都收到,帧里带 topic;按用户推送时,该用户的每个连接都收到,帧里没有 topic。
  • what 符合 ^[a-z][a-z0-9_.-]{0,63}$,含义由宿主约定。客户端应当按 what 分派,必须忽略不认识的 what。
  • 不进离线队列,也不重发。客户端打开会话或卡片时应当向宿主拉取当前状态,不能只靠这个推送。

客户端在 ImClient.events 上收到它,引擎不处理:Web 是 { kind: "custom", topic?, what, extra },Android 是 ImEvent.Custom(topic, what, extra)。参考部署的旧推送 red_packet_update 也会转成 what 为 red_packet 的同一种事件;新宿主不应当再用它。

宿主相关的接口见 Node 宿主 SDK,帧的完整定义见规范全文。

红包示例 ​

  1. 发送者调宿主 POST /api/v1/red-packets(topic、total、count),宿主核对他是群成员后建红包,返回红包 id 和下一步要发的消息。
  2. 发送者的客户端在群里发一条 contentType 为 red_packet 的消息。
  3. 成员调宿主 POST /api/v1/red-packets/:id/claim。宿主用 topicMembersResolve 向 IM 服务端核对成员身份,记下这次领取,再用 customBroadcast 推送;客户端按 what 找到对应的卡片并刷新。
  4. 因为 custom_update 不进离线队列,客户端打开卡片时调 GET /api/v1/red-packets/:id 拉当前状态。

推送给全群的状态不含任何人的金额;每个人只在自己的应答里看到自己领了多少(myAmount)。示例的账本在内存里,只为让示例短。真实的宿主在自己的数据库里扣发送者、给领取者入账,与“记下领取”放在同一个事务里,提交之后再推送。

Android 一侧的三个类:

  • RedPacketType:消息类型。matches 只看 contentType,打开时调 store.refresh(id) 拉状态,点击时领取。
  • RedPacketStore:卡片状态。收集 client.events 里 what == "red_packet" 的 ImEvent.Custom 合并进来;推送不带个人金额,所以保留先前应答里本人的 myAmount。
  • RedPacketSender:附件面板入口与建红包的对话框,建好后用 engine.send 发出卡片消息。
RedPacketType.kt
kt
package io.talkwire.examples.redpacket

import android.content.Context
import android.widget.Toast
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.background
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.Redeem
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import io.talkwire.core.engine.ChatEngine
import io.talkwire.core.engine.OutgoingMessage
import io.talkwire.core.model.ChatMessage
import io.talkwire.core.model.ConversationId
import io.talkwire.ui.components.ImAlertDialog
import io.talkwire.ui.host.AttachmentAction
import io.talkwire.ui.message.MessageAction
import io.talkwire.ui.message.MessageScope
import io.talkwire.ui.message.MessageType
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch

/**
 * The red packet bubble. Registered with
 * `MessageTypeRegistry.Builder().register(RedPacketType(store))`; that is
 * the whole integration on the message side.
 */
class RedPacketType(private val store: RedPacketStore) : MessageType {
    override fun matches(message: ChatMessage): Boolean = message.contentType == RedPacketStore.WHAT

    override val framed: Boolean get() = false

    @Composable
    override fun Content(message: ChatMessage, scope: MessageScope) {
        val id = redPacketIdOf(message.extra)
        val cards by store.cards.collectAsState()
        val card = id?.let { cards[it] }
        LaunchedEffect(id) { if (id != null) store.refresh(id) }
        val context = LocalContext.current
        val tapScope = rememberCoroutineScope()
        RedPacketBubble(card, message.content, onClaim = { if (id != null) onTap(id, context, tapScope) }, onLongPress = scope.onLongPress)
    }

    /** Claims while there is something to claim; otherwise shows how it stands. */
    private fun onTap(id: String, context: Context, scope: CoroutineScope) {
        val card = store.cards.value[id]
        if (card != null && (card.myAmount != null || card.remaining == 0)) {
            showState(context, card)
            return
        }
        scope.launch {
            store.claim(id).onSuccess { showState(context, it) }.onFailure { e ->
                val c = (e as? RedPacketException)?.card
                if (c != null) showState(context, c) else Toast.makeText(context, context.getString(R.string.redpacket_failed), Toast.LENGTH_SHORT).show()
            }
        }
    }

    @OptIn(ExperimentalFoundationApi::class)
    @Composable
    private fun RedPacketBubble(card: RedPacketCard?, fallback: String, onClaim: () -> Unit, onLongPress: () -> Unit) {
        val opened = card != null && (card.myAmount != null || card.remaining == 0)
        val status = when {
            card == null -> fallback
            card.myAmount != null -> stringResource(R.string.redpacket_you_got, card.myAmount)
            card.remaining == 0 -> stringResource(R.string.redpacket_all_claimed, card.claimed, card.count)
            else -> stringResource(R.string.redpacket_tap_to_open)
        }
        val top = if (opened) Color(0xFFF3B9A2) else Color(0xFFF35543)
        val end = if (opened) Color(0xFFE59A82) else Color(0xFFE0362B)
        Column(
            Modifier
                .width(220.dp)
                .clip(RoundedCornerShape(8.dp))
                .background(Brush.verticalGradient(listOf(top, end)))
                .combinedClickable(onLongClick = onLongPress, onClick = onClaim)
                .testTag("redpacket_card"),
        ) {
            Row(Modifier.padding(12.dp), verticalAlignment = Alignment.CenterVertically) {
                Text("🧧", fontSize = 28.sp)
                Spacer(Modifier.width(10.dp))
                Column {
                    Text(stringResource(R.string.redpacket_title), color = Color.White, fontSize = 15.sp, fontWeight = FontWeight.Medium)
                    Text(status, color = Color.White.copy(alpha = 0.85f), fontSize = 12.sp)
                }
            }
            Text(
                stringResource(R.string.redpacket_label),
                color = Color.White.copy(alpha = 0.7f),
                fontSize = 11.sp,
                modifier = Modifier.fillMaxWidth().background(Color.White.copy(alpha = 0.12f)).padding(horizontal = 12.dp, vertical = 4.dp),
            )
        }
    }

    override fun preview(message: ChatMessage, context: Context): String = context.getString(R.string.redpacket_preview)

    /** A card has no text to copy and nothing to save; it can only be forwarded as is. */
    override fun actions(message: ChatMessage): Set<MessageAction> = emptySet()

    private fun showState(context: Context, card: RedPacketCard) {
        val text = when {
            card.myAmount != null -> context.getString(R.string.redpacket_you_got, card.myAmount)
            else -> context.getString(R.string.redpacket_all_claimed, card.claimed, card.count)
        }
        Toast.makeText(context, text, Toast.LENGTH_SHORT).show()
    }
}

/**
 * The attachment panel entry that creates and sends a packet; groups only,
 * like the host's route. Shown through `ImUiHost.attachmentActions`.
 */
class RedPacketSender(private val api: RedPacketApi, private val engine: ChatEngine, private val store: RedPacketStore, private val scope: CoroutineScope) {
    /** Which conversation the create dialog is open for; drawn by [Dialog]. */
    private val target = mutableStateOf<ConversationId?>(null)

    fun action(conversation: ConversationId, label: String): AttachmentAction? =
        if (!conversation.isGroup) null else AttachmentAction("red_packet", label, Icons.Outlined.Redeem) { target.value = it }

    /** Draw once, above the chat UI. */
    @Composable
    fun Dialog() {
        val id = target.value ?: return
        val context = LocalContext.current
        var total by remember(id) { mutableStateOf("10") }
        var count by remember(id) { mutableStateOf("3") }
        val t = total.toIntOrNull()
        val c = count.toIntOrNull()
        val valid = t != null && c != null && c in 1..100 && t >= c
        ImAlertDialog(
            onDismissRequest = { target.value = null },
            title = stringResource(R.string.redpacket_send_title),
            confirmLabel = stringResource(R.string.redpacket_send),
            confirmEnabled = valid,
            dismissLabel = stringResource(R.string.redpacket_cancel),
            onConfirm = {
                target.value = null
                scope.launch {
                    runCatching {
                        val (card, message) = api.create(id.id, t!!, c!!)
                        store.merge(card)
                        engine.send(id, OutgoingMessage(content = message.string("content") ?: "[Red packet]", contentType = message.string("contentType"), extra = message.get("extra")))
                    }.onFailure { Toast.makeText(context, context.getString(R.string.redpacket_failed), Toast.LENGTH_SHORT).show() }
                }
            },
        ) {
            Column {
                OutlinedTextField(total, { total = it.filter(Char::isDigit).take(7) }, label = { Text(stringResource(R.string.redpacket_total)) }, keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), singleLine = true)
                Spacer(Modifier.height(8.dp))
                OutlinedTextField(count, { count = it.filter(Char::isDigit).take(3) }, label = { Text(stringResource(R.string.redpacket_count)) }, keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), singleLine = true)
            }
        }
    }
}
RedPackets.kt
kt
package io.talkwire.examples.redpacket

import io.talkwire.core.client.ImClient
import io.talkwire.core.client.ImEvent
import io.talkwire.core.client.TokenProvider
import io.talkwire.core.json.Json
import io.talkwire.core.json.JsonObject
import io.talkwire.core.json.JsonValue
import io.talkwire.core.json.jsonObject
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import okhttp3.HttpUrl.Companion.toHttpUrl
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody
import java.io.IOException

/** What every member may see of a red packet; [myAmount] only in the viewer's own answers. */
data class RedPacketCard(
    val id: String,
    val topic: String,
    val senderId: String,
    val total: Int,
    val count: Int,
    val claimed: Int,
    val remaining: Int,
    val myAmount: Int?,
) {
    companion object {
        fun parse(o: JsonObject): RedPacketCard? {
            val id = o.nonEmptyString("id") ?: return null
            return RedPacketCard(
                id = id,
                topic = o.string("topic") ?: "",
                senderId = o.string("senderId") ?: "",
                total = o.int("total") ?: 0,
                count = o.int("count") ?: 0,
                claimed = o.int("claimed") ?: 0,
                remaining = o.int("remaining") ?: 0,
                myAmount = o.int("myAmount"),
            )
        }
    }
}

/** The id a red packet message carries in `extra.redPacketId`. */
fun redPacketIdOf(extra: JsonValue?): String? = (extra as? JsonObject)?.nonEmptyString("redPacketId")

class RedPacketException(val status: Int, message: String, val card: RedPacketCard? = null) : IOException(message)

/** The host's red packet routes (examples/red-packet/src/red-packets.ts). */
class RedPacketApi(private val hostBaseUrl: String, private val tokens: TokenProvider, private val http: OkHttpClient = OkHttpClient()) {
    /** Creates a packet; returns its card and the message to publish next. */
    suspend fun create(topic: String, total: Int, count: Int): Pair<RedPacketCard, JsonObject> {
        val o = call("POST", "/api/v1/red-packets", jsonObject { put("topic", topic); put("total", total); put("count", count) })
        val card = (o.get("packet") as? JsonObject)?.let(RedPacketCard::parse) ?: throw RedPacketException(200, "reply without packet")
        val message = o.get("message") as? JsonObject ?: throw RedPacketException(200, "reply without message")
        return card to message
    }

    suspend fun get(id: String): RedPacketCard = RedPacketCard.parse(call("GET", "/api/v1/red-packets/$id", null)) ?: throw RedPacketException(200, "bad card")

    /** The claimer's card; a 409 (already claimed, all gone) carries the current card too. */
    suspend fun claim(id: String): RedPacketCard = RedPacketCard.parse(call("POST", "/api/v1/red-packets/$id/claim", jsonObject {})) ?: throw RedPacketException(200, "bad card")

    private suspend fun call(method: String, path: String, body: JsonObject?): JsonObject = withContext(Dispatchers.IO) {
        val token = tokens.accessToken(forceRefresh = false) ?: throw RedPacketException(401, "not signed in")
        val url = hostBaseUrl.toHttpUrl().resolve(path) ?: throw IllegalArgumentException(path)
        val req = Request.Builder().url(url).header("Authorization", "Bearer $token")
            .method(method, body?.toString()?.toRequestBody("application/json".toMediaType()))
            .build()
        http.newCall(req).execute().use { resp ->
            val text = resp.body?.string() ?: ""
            val o = runCatching { Json.parseObject(text) }.getOrNull()
            if (!resp.isSuccessful) {
                throw RedPacketException(resp.code, o?.string("error") ?: "HTTP ${resp.code}", (o?.get("card") as? JsonObject)?.let(RedPacketCard::parse))
            }
            o ?: throw RedPacketException(resp.code, "reply is not JSON")
        }
    }
}

/**
 * Card states, kept current by the host's `custom_update` pushes. A push
 * carries no one's amount, so the viewer's own [RedPacketCard.myAmount]
 * from an earlier answer is kept.
 */
class RedPacketStore(private val api: RedPacketApi, client: ImClient, private val scope: CoroutineScope) {
    private val _cards = MutableStateFlow<Map<String, RedPacketCard>>(emptyMap())
    val cards: StateFlow<Map<String, RedPacketCard>> = _cards.asStateFlow()

    init {
        scope.launch {
            client.events.collect { e ->
                if (e is ImEvent.Custom && e.what == WHAT) (e.extra as? JsonObject)?.let(RedPacketCard::parse)?.let(::merge)
            }
        }
    }

    fun merge(card: RedPacketCard) {
        _cards.update { all -> all + (card.id to card.copy(myAmount = card.myAmount ?: all[card.id]?.myAmount)) }
    }

    /** Loads the current state; pushes are not queued for clients that were offline. */
    fun refresh(id: String) {
        scope.launch { runCatching { api.get(id) }.onSuccess(::merge) }
    }

    suspend fun claim(id: String): Result<RedPacketCard> = runCatching { api.claim(id) }
        .onSuccess(::merge)
        .onFailure { e -> (e as? RedPacketException)?.card?.let(::merge) }

    companion object {
        /** The host's custom message type and update kind. */
        const val WHAT: String = "red_packet"
    }
}
宿主一侧:red-packets.ts
ts
// A host-owned message type: the red packet. The host owns everything about
// it (who may claim, how much each gets, the money); Talkwire only carries the
// message that shows the card and the live updates to it.
//
//   1. The sender creates a packet here and gets its id.
//   2. The sender's client publishes an ordinary message carrying the id:
//      {"action":"pub","topic":...,"contentType":"red_packet","content":"[Red packet]","extra":{"redPacketId":...}}
//   3. A member claims it here. The host checks membership with the IM
//      server, records the claim, and pushes the card's new state to the
//      members connected to the conversation (custom-broadcast, kind
//      "red_packet"), who receive {"action":"custom_update","what":"red_packet",...}.
//   4. A client opening the card fetches its state here: updates are not
//      queued for clients that were offline.
//
// The ledger is in memory to keep the example short. A real host debits and
// credits its own balances in its own database, in one transaction with the
// claim, and pushes the update only after that commits.

import { randomUUID } from "node:crypto";
import type { FastifyInstance, FastifyRequest } from "fastify";
import type { ImClient } from "@talkwire/host-node";

export interface RedPacketState {
  id: string;
  topic: string;
  senderId: string;
  total: number;
  count: number;
  claims: Array<{ userId: string; amount: number }>;
  createdAt: string;
}

export interface RedPacketOptions {
  im: ImClient;
  /** The signed-in user's host id, or null. */
  authenticate(request: FastifyRequest): Promise<string | null>;
}

/** The card a client shows: the state without who got how much, except the viewer's own share. */
export function cardFor(p: RedPacketState, viewer: string) {
  return {
    id: p.id,
    topic: p.topic,
    senderId: p.senderId,
    total: p.total,
    count: p.count,
    claimed: p.claims.length,
    remaining: p.count - p.claims.length,
    myAmount: p.claims.find((c) => c.userId === viewer)?.amount ?? null,
  };
}

/** Equal shares; the last claim takes what is left. */
function nextShare(p: RedPacketState): number {
  const left = p.total - p.claims.reduce((sum, c) => sum + c.amount, 0);
  const remaining = p.count - p.claims.length;
  return remaining === 1 ? left : Math.floor(p.total / p.count);
}

export async function redPacketRoutes(app: FastifyInstance, options: RedPacketOptions): Promise<void> {
  const packets = new Map<string, RedPacketState>();
  const { im } = options;

  const isMember = async (topic: string, userId: string) => {
    const res = await im.topicMembersResolve(topic, [userId]);
    return res.members[userId] === true;
  };

  app.post("/api/v1/red-packets", async (request, reply) => {
    const me = await options.authenticate(request);
    if (!me) return reply.status(401).send({ error: "unauthorized" });
    const { topic, total, count } = (request.body ?? {}) as { topic?: unknown; total?: unknown; count?: unknown };
    if (typeof topic !== "string" || !topic.startsWith("grp_")) return reply.status(400).send({ error: "topic must be a group" });
    if (!Number.isInteger(count) || (count as number) < 1 || (count as number) > 100) return reply.status(400).send({ error: "count must be 1-100" });
    if (!Number.isInteger(total) || (total as number) < (count as number) || (total as number) > 1_000_000) {
      return reply.status(400).send({ error: "total must be at least count" });
    }
    if (!(await isMember(topic, me))) return reply.status(403).send({ error: "not a member of the group" });
    // A real host debits the sender's balance here.
    const packet: RedPacketState = { id: randomUUID(), topic, senderId: me, total: total as number, count: count as number, claims: [], createdAt: new Date().toISOString() };
    packets.set(packet.id, packet);
    return {
      packet: cardFor(packet, me),
      // What the sender's client publishes to the group next.
      message: { contentType: "red_packet", content: "[Red packet]", extra: { redPacketId: packet.id } },
    };
  });

  app.get("/api/v1/red-packets/:id", async (request, reply) => {
    const me = await options.authenticate(request);
    if (!me) return reply.status(401).send({ error: "unauthorized" });
    const packet = packets.get((request.params as { id: string }).id);
    if (!packet || !(await isMember(packet.topic, me))) return reply.status(404).send({ error: "no such red packet" });
    return cardFor(packet, me);
  });

  app.post("/api/v1/red-packets/:id/claim", async (request, reply) => {
    const me = await options.authenticate(request);
    if (!me) return reply.status(401).send({ error: "unauthorized" });
    const packet = packets.get((request.params as { id: string }).id);
    if (!packet || !(await isMember(packet.topic, me))) return reply.status(404).send({ error: "no such red packet" });
    if (packet.claims.some((c) => c.userId === me)) return reply.status(409).send({ error: "already claimed", card: cardFor(packet, me) });
    if (packet.claims.length >= packet.count) return reply.status(409).send({ error: "all claimed", card: cardFor(packet, me) });
    // Single-threaded here; a real host makes the check and the claim one transaction.
    packet.claims.push({ userId: me, amount: nextShare(packet) });
    // A real host credits the claimer's balance here, then pushes.
    try {
      await im.customBroadcast({ topic: packet.topic }, "red_packet", cardFor(packet, ""));
    } catch (err) {
      // The claim stands; clients that missed the push see it when they fetch the card.
      request.log.warn({ err }, "red packet update not pushed");
    }
    return cardFor(packet, me);
  });
}

运行示例的测试:先构建 Node 宿主 SDK,再在示例目录里安装与测试。

bash
cd host-sdk-node && npm install && npm run build
cd ../examples/red-packet && npm install
npm run typecheck
npm test

Android 一侧的单测是 :example-red-packet:testDebugUnitTest(在 android/ 下用 Gradle 运行)。红包卡片、会话列表预览、领取与实时更新都在一台小米(Android 15)真机上看过。

核心以 Apache-2.0 发布;端到端加密模块为 AGPLv3。