快速开始
本页在一台开发机上跑起整套 Talkwire:PostgreSQL、IM 服务端、开发宿主 examples/sample-host、Web 示例 web/sample,以及可选的 Android 示例 android/sample-app。跑通后用两个名字登录,就能互发消息、建群、扫码入群、打演示通话。
Talkwire 还没有发布到 npm 和 Maven,下面的命令都在仓库里运行。
先不想在本机搭
云端测试服务 imtalk.openedskill.com 上已经跑好了一整套,打开就能用 Web 示例试,也能让 Android 示例或自己的客户端直接连上去。
注意
开发宿主的 /dev/login 凭名字就能登录任何人,默认只监听 127.0.0.1,只能在本机开发用。
准备
| 工具 | 版本 | 用于 |
|---|---|---|
| Go | 1.26.4 及以上 | 构建服务端 |
| Node.js | 22 及以上(宿主 SDK 声明的最低版本是 20) | 宿主 SDK、开发宿主、Web 示例 |
| PostgreSQL | 一个空库;CI 用的是 PostgreSQL 17 | 服务端的数据,服务端启动时自己在库里建表 |
| JDK 17、Android SDK(platform 34)、一台打开了 USB 调试的手机 | — | 只有跑 Android 示例时需要 |
整套跑起来是三个进程,开发宿主和 IM 服务端必须用同一个 JWT_SECRET:宿主用它签发登录令牌,服务端用它验证。
| 进程 | 地址 |
|---|---|
| 开发宿主 | http://127.0.0.1:6001 |
| IM 服务端 | 端口 6062,WebSocket 在 /ws/im |
| Web 示例(Vite) | http://127.0.0.1:5173 |
1. 构建服务端
cd server
go build -o bin/talkwire-server.exe .Linux 和 macOS 上输出文件用 bin/talkwire-server,不带 .exe;后面的命令同样去掉 .exe。
2. 启动开发宿主
开发宿主经 file: 依赖引用 Node 宿主 SDK,而 SDK 的入口在构建产物 dist/ 里,所以先构建 SDK:
cd host-sdk-node
npm install
npm run build再启动开发宿主(另开一个终端,从仓库根目录起):
cd examples/sample-host
npm install
JWT_SECRET=<至少 32 个字符> npm startWindows PowerShell 里写成 $env:JWT_SECRET="…"; npm start。JWT_SECRET 不足 32 个字符时开发宿主拒绝启动。
| 环境变量 | 默认 | 作用 |
|---|---|---|
JWT_SECRET | 无,必填 | 与 IM 服务端共享的密钥,至少 32 个字符 |
HOST | 127.0.0.1 | 监听地址 |
PORT | 6001 | 监听端口 |
IM_BASE_URL | http://127.0.0.1:6062 | IM 服务端的地址,开发宿主经它调 IM 的内部接口 |
MEDIA_DIR | data/media(相对启动目录) | 上传的媒体文件存在这里 |
开发宿主用 @talkwire/host-node 提供 IM 服务端要调的宿主接口、App 用的资料与联系人接口、媒体上传,另有红包示例、只有信令的演示通话和后台接口。它把用户和联系人存在内存里,重启后每个人都要重新登录。它的入口 examples/sample-host/src/main.ts:
import path from "node:path";
import { buildSampleHost } from "./app.ts";
const secret = process.env.JWT_SECRET;
if (!secret || secret.length < 32) {
console.error("Set JWT_SECRET (at least 32 characters), the same value the IM server uses.");
process.exit(1);
}
const app = await buildSampleHost({
secret,
imBaseUrl: process.env.IM_BASE_URL ?? "http://127.0.0.1:6062",
mediaDir: process.env.MEDIA_DIR ?? path.resolve("data/media"),
});
// Loopback only: /dev/login signs anyone in.
await app.listen({ host: process.env.HOST ?? "127.0.0.1", port: Number(process.env.PORT ?? 6001) });3. 启动 IM 服务端
另开一个终端,在仓库根目录:
JWT_SECRET=<同上> DB_HOST=127.0.0.1 DB_PORT=5432 DB_USER=<用户> DB_PASSWORD=<密码> DB_NAME=<库名> server/bin/talkwire-server.exeDB_PASSWORD和JWT_SECRET没有默认值,缺任何一个服务端启动即退出。- 其余用默认值就能和开发宿主连上:宿主模式
http、宿主地址http://127.0.0.1:6001、协议档位strict、端口 6062、装宿主支持的全部模块。 - 服务端监听
0.0.0.0,启动时在空库里建表,日志里出现IM Server listening on 0.0.0.0:6062即就绪。
提示
服务端拼数据库连接串时不给值加引号,口令里有空格会连不上,本地开发时换一个不含空格的口令。
健康检查端点会顺带检查数据库:
curl http://127.0.0.1:6062/api/health正常时返回 {"status":"ok","service":"im-server"};连不上数据库时返回 503。完整的配置项见环境变量。
4. 打开 Web 示例
再开一个终端:
cd web
npm install
npm run sample打开 http://127.0.0.1:5173。示例的 Vite 开发服务器把 /dev、/api、/media 转给开发宿主、把 /ws 转给 IM 服务端,页面只和一个源打交道;它直接引用 web/packages/ 下各包的源码,不需要先构建。地址可以用 SAMPLE_HOST_URL、SAMPLE_IM_URL、SAMPLE_PORT 改。
在两个浏览器(或一个普通窗口、一个无痕窗口)里用不同的名字登录,名字是 2–20 个字母、数字或下划线。后登录的人自动和之前登录过的人互为好友。然后可以试:
- 互发消息、建群;聊天页头的“⋯”打开会话信息页,管理群成员与角色、改群名、清空、退群或解散。
- 群信息页里有群二维码与入群审核,把入群链接拿到另一个浏览器里打开就能加入。
- 单聊页头发起语音或视频通话。开发宿主给的是演示凭证,只有信令,没有声音和画面。
- 页头的“管理后台”(
#/admin)是后台页面:用名字和角色(admin或superadmin)登录,能看到每通通话的记录;超级管理员能为双方清空单聊。
5. 装上 Android 示例(可选)
Android 示例与 Web 示例连同一个开发宿主和 IM 服务端,手机上的用户可以和网页上的用户互发消息。构建前在 android/local.properties 里写好 Android SDK 的路径 sdk.dir。
手机经 USB 访问电脑上的端口:
adb reverse tcp:6001 tcp:6001
adb reverse tcp:6062 tcp:6062
adb reverse tcp:5173 tcp:5173 # 可选:示例的群二维码链接指向 Web 示例的入群页装上示例(包名 io.talkwire.sample,不影响手机上的其他 App):
cd android
./gradlew :sample-app:installDebug- 只有一台手机时,先登录一个名字,点左下角“退出”,再用另一个名字登录;切换账号时本地缓存整个清掉,不会串号。
- 会话列表右上角“+ → 发起聊天”能看到之前登录过的人。
- 示例没有配置 Firebase 项目,所以没有离线推送;通话只有信令(
NoMediaEngine)。 - 扫码入群:在 App 里粘贴入群链接,或打开
talkwiresample://join/<令牌>。
不开浏览器和手机也能跑一遍
两套自动化用例会自己起开发宿主和 IM 服务端,把同样的流程走一遍。它们都需要构建好的服务端二进制、第 2 步构建好的宿主 SDK,以及环境变量 TALKWIRE_TEST_DSN:一个可以随便写的 PostgreSQL 测试库,写成 libpq 的 key=value 形式。
- Web:在
web/下npm run e2e。它用自己的端口 16001、16062、15173,不碰已经在跑的开发环境;默认用本机的 Microsoft Edge,E2E_CHANNEL=chrome改用 Chrome。走完单聊、群聊、多端、扫码入群、通话、后台。 - Android:
talkwire-core的SampleStackTest(在android/下./gradlew :talkwire-core:test),两个用户走一遍登录、单聊、语音、建群和红包。
注意
TALKWIRE_TEST_DSN 只能指向一次性的测试库:测试会在里面建临时 schema、写入数据。
示例里的接入代码
两个示例登录之后建起的对象就是接入方要写的全部代码:令牌、地址、本地存储、宿主适配(ImUiHost)和通知。
/**
* Everything the sample builds after sign-in: the client (through a tab
* transport, so tabs of one account share a connection), the engine on an
* IndexedDB store, the host adapter and media.
*/
import { ChatEngine, ImClient, MediaSender, MediaUploader, Signal, createTabTransport, type TabTransport } from "@talkwire/client";
import type { ImProfile, ImUiHost } from "@talkwire/react";
import { IdbChatStore } from "@talkwire/storage-idb";
// One SharedWorker per account holds the WebSocket for all of its tabs.
import TalkwireWorker from "./talkwire-worker.ts?sharedworker";
export interface Session {
userId: string;
uid: string;
name: string;
accessToken: string;
/** The space signed in to, on the sandbox host (examples/sample-host/src/sandbox.ts). */
space?: string;
}
/** What the sandbox host says about itself; null on the plain sample host. */
export interface SandboxInfo {
sandbox: true;
resetTime: string | null;
docsUrl: string | null;
limits: { usersPerSpace: number; maxUploadBytes: number; uploadsPerHour: number };
}
export async function sandboxInfo(): Promise<SandboxInfo | null> {
try {
const resp = await fetch("/sandbox/info");
if (!resp.ok) return null;
const body = (await resp.json()) as Partial<SandboxInfo>;
return body.sandbox === true ? (body as SandboxInfo) : null;
} catch {
return null;
}
}
/** A new space on the sandbox host; its code is what others sign in to. */
export async function createSpace(): Promise<string> {
const resp = await fetch("/sandbox/spaces", { method: "POST" });
const body = (await resp.json().catch(() => ({}))) as { space?: string; error?: string };
if (!resp.ok || !body.space) throw new Error(body.error ?? `could not make a space (${resp.status})`);
return body.space;
}
const SESSION_KEY = "talkwire-sample-session";
const DEVICE_KEY = "talkwire-sample-device";
export function savedSession(): Session | null {
try {
const raw = localStorage.getItem(SESSION_KEY);
return raw ? (JSON.parse(raw) as Session) : null;
} catch {
return null;
}
}
export function saveSession(s: Session | null): void {
try {
if (s) localStorage.setItem(SESSION_KEY, JSON.stringify(s));
else localStorage.removeItem(SESSION_KEY);
} catch {
// private mode: the session lasts as long as the page
}
}
/** This browser profile's device name for multi-device login (section 6.2). */
function deviceName(): string {
try {
let d = localStorage.getItem(DEVICE_KEY);
if (!d) {
d = "web-" + crypto.randomUUID().slice(0, 8);
localStorage.setItem(DEVICE_KEY, d);
}
return d;
} catch {
return "web-" + crypto.randomUUID().slice(0, 8);
}
}
export async function devLogin(name: string, space?: string): Promise<Session> {
const resp = await fetch("/dev/login", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(space ? { name, space } : { name }) });
const body = (await resp.json().catch(() => ({}))) as Partial<Session> & { error?: string };
if (!resp.ok || !body.accessToken) throw new Error(body.error ?? `sign-in failed (${resp.status})`);
return body as Session;
}
export interface Stack {
session: Session;
client: ImClient;
engine: ChatEngine;
host: ImUiHost & { contacts: Signal<ImProfile[]> };
tabs: TabTransport;
refreshContacts(): Promise<void>;
close(): void;
}
export async function buildStack(session: Session): Promise<Stack> {
const token = () => session.accessToken;
const wsUrl = `${location.protocol === "https:" ? "wss" : "ws"}://${location.host}/ws/im`;
const tabs = createTabTransport({
name: session.userId,
sharedWorker:
typeof SharedWorker === "function" && !new URLSearchParams(location.search).has("noworker")
? () => new TalkwireWorker({ name: "talkwire:" + session.userId })
: undefined,
});
const client = new ImClient({ endpoints: [wsUrl], tokenProvider: token, transport: tabs.transport, deviceName: deviceName() });
const store = await IdbChatStore.open({ name: "talkwire-sample", encrypt: true });
const engine = new ChatEngine(client, store, { appUserId: session.userId });
const authed = (path: string) => fetch(path, { headers: { Authorization: `Bearer ${session.accessToken}` } }).then((r) => (r.ok ? r.json() : Promise.reject(new Error(String(r.status)))));
const contacts = new Signal<ImProfile[]>([]);
const toProfile = (p: { uid: string; displayName?: string; avatarUrl?: string | null }): ImProfile => ({ uid: p.uid, name: p.displayName || p.uid, ...(p.avatarUrl ? { avatarUrl: p.avatarUrl } : {}) });
const refreshContacts = async () => {
const r = (await authed("/api/v1/chat/contacts?status=accepted")) as { items: { uid: string; displayName: string; avatarUrl: string | null }[] };
contacts.set(r.items.map(toProfile));
// Single chats come from the host's contacts (the protocol lists no single chats).
for (const c of r.items) await engine.ensureConversation(c.uid, c.displayName);
};
const uploader = new MediaUploader(location.origin, token);
const host: Stack["host"] = {
contacts,
media: new MediaSender(engine, uploader),
profiles: async (uids) => {
const r = (await authed("/api/v1/chat/profiles?uids=" + encodeURIComponent(uids.join(",")))) as { items: { uid: string; displayName: string; avatarUrl: string | null }[] };
return r.items.map(toProfile);
},
resolveMediaUrl: (url) => (url ? new URL(url, location.origin).toString() : undefined),
};
await engine.open();
client.events.on((e) => {
if (e.kind === "contactSync") void refreshContacts().catch(() => {});
});
await refreshContacts().catch(() => {});
client.start();
return {
session,
client,
engine,
host,
tabs,
refreshContacts,
close() {
engine.close();
client.stop();
tabs.close();
store.close();
},
};
}package io.talkwire.sample
import android.app.PendingIntent
import android.content.Context
import android.content.Intent
import android.provider.Settings
import io.talkwire.android.crypto.AndroidKeystoreKeyWrapper
import io.talkwire.android.media.MediaSender
import io.talkwire.android.notify.CallNotification
import io.talkwire.android.notify.ImNotifier
import io.talkwire.android.notify.MessageNotification
import io.talkwire.android.notify.NotificationController
import io.talkwire.android.notify.SoundPoolAlertPlayer
import io.talkwire.call.CallManager
import io.talkwire.call.CallSystem
import io.talkwire.call.CallType
import io.talkwire.call.NoMediaEngine
import io.talkwire.call.OngoingCallNotice
import io.talkwire.core.client.ImClient
import io.talkwire.core.client.ImClientConfig
import io.talkwire.core.client.TokenProvider
import io.talkwire.core.engine.ChatEngine
import io.talkwire.core.engine.EngineConfig
import io.talkwire.core.media.MediaUploader
import io.talkwire.examples.redpacket.RedPacketApi
import io.talkwire.examples.redpacket.RedPacketSender
import io.talkwire.examples.redpacket.RedPacketStore
import io.talkwire.examples.redpacket.RedPacketType
import io.talkwire.groupjoin.GroupJoinClient
import io.talkwire.storage.room.RoomChatStore
import io.talkwire.ui.host.ImDeepLink
import io.talkwire.ui.message.MessageTypeRegistry
import io.talkwire.ui.screens.ImBannerState
import io.talkwire.videocompress.Media3VideoCompressor
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.launch
import okhttp3.OkHttpClient
/**
* Everything Talkwire needs for one signed-in account. This is the whole
* integration an app writes: a token provider, the endpoints, a store,
* the host adapter ([SampleHost]) and the notification setup.
*/
class ChatSession(context: Context, val account: Account, http: OkHttpClient) {
private val app = context.applicationContext
val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
private val tokens = TokenProvider { account.token }
val client = ImClient(
ImClientConfig(
endpoints = listOf(SampleConfig.IM_URL),
tokenProvider = tokens,
// Stable per install, so a second phone signed in to the same account stays connected too.
deviceName = "sample-" + (Settings.Secure.getString(app.contentResolver, Settings.Secure.ANDROID_ID) ?: "device").take(16),
),
scope,
)
val engine = ChatEngine(
client,
RoomChatStore.open(app, keys = AndroidKeystoreKeyWrapper()),
account.userId,
scope,
EngineConfig(),
)
private val redPacketApi = RedPacketApi(SampleConfig.HOST_URL, tokens, http)
val redPackets = RedPacketStore(redPacketApi, client, scope)
val redPacketSender = RedPacketSender(redPacketApi, engine, redPackets, scope)
/** The built-in types plus the host's red packet: registering is all it takes. */
val types: MessageTypeRegistry = MessageTypeRegistry.Builder().register(RedPacketType(redPackets)).build()
/**
* Calls (optional module talkwire-call). The sample has no RTC service, so
* the media engine only logs: ringing, answering, the call screen and the
* notices work, sound and video do not. An app passes a real adapter,
* such as talkwire-call-dingrtc's DingRtcMediaEngine.
*/
val calls = CallManager(client, engine.me, NoMediaEngine(), scope)
/** Joining groups by QR code (optional module talkwire-group-join). */
val groupJoin = GroupJoinClient(client)
val host = SampleHost(
http,
tokens,
MediaSender(app, engine, MediaUploader(SampleConfig.HOST_URL, tokens, http), videoCompressor = Media3VideoCompressor(app)),
redPacketSender,
app.getString(R.string.sample_red_packet),
calls,
groupJoin,
engine,
)
val banners = ImBannerState()
private val notifier = ImNotifier(app, ImNotifier.Channels(app.getString(R.string.sample_channel_messages), app.getString(R.string.sample_channel_calls), R.drawable.ic_sample_notification))
val notifications = NotificationController(
engine,
notifier,
SoundPoolAlertPlayer(app, R.raw.sample_ding),
{ message, groupName ->
val contact = host.contacts.value.firstOrNull { it.uid == message.from }?.name
MessageNotification(
conversation = message.conversation,
title = groupName ?: contact ?: app.getString(R.string.sample_new_message),
text = types.preview(message, app),
contentIntent = openIntent(ImDeepLink.Conversation(message.conversation)),
)
},
banners.sink,
scope,
)
private val callSystem = CallSystem(
app,
calls,
notifications,
scope,
incomingNotice = { ring ->
CallNotification(
title = ring.callerName ?: app.getString(R.string.sample_someone),
text = app.getString(if (ring.callType == CallType.VIDEO) R.string.sample_incoming_video else R.string.sample_incoming_voice),
fullScreenIntent = appIntent(),
contentIntent = appIntent(),
)
},
ongoingNotice = {
OngoingCallNotice(
app.getString(R.string.sample_ongoing_call),
app.getString(R.string.sample_ongoing_call_text),
R.drawable.ic_sample_notification,
appIntent(),
)
},
)
fun start() {
notifier.createChannels()
notifications.start()
calls.start()
callSystem.start()
scope.launch {
engine.start()
runCatching { host.refreshContacts() }
}
}
fun close() {
callSystem.stop()
calls.stop()
notifications.stop()
client.stop()
engine.close()
scope.cancel()
}
/** Brings the app to the front as it was; the call screen shows over whatever is open. */
private fun appIntent(): PendingIntent {
val i = Intent(app, MainActivity::class.java).addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP)
return PendingIntent.getActivity(app, 0, i, PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT)
}
private fun openIntent(link: ImDeepLink): PendingIntent {
val i = Intent(Intent.ACTION_VIEW, link.uri, app, MainActivity::class.java).addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP)
return PendingIntent.getActivity(app, link.uri.hashCode(), i, PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT)
}
}下一步
- 安装与配置、环境变量:在服务器上部署 IM 服务端。
- 签发 IM 令牌、Node 宿主 SDK:把示例宿主换成你自己的业务后端。
- Web SDK、Android SDK:在自己的 App 里接入客户端。
- 音视频通话、扫码入群:装可选模块。