mirror of
https://github.com/openimsdk/open-im-server.git
synced 2026-07-11 07:11:07 +08:00
Merge c17a2cd9a089415dac3c968f5adb0d5ae6b84d48 into 1036e81eb483e09a3a606cc8dd7970c7c5f74c70
This commit is contained in:
commit
80f102be31
33
scripts/debug_msggateway_ws.sh
Executable file
33
scripts/debug_msggateway_ws.sh
Executable file
@ -0,0 +1,33 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||||
|
MODE="${1:-before-rpc}"
|
||||||
|
|
||||||
|
case "$MODE" in
|
||||||
|
gateway)
|
||||||
|
export OPENIM_WS_BREAK_ON_SEND=1
|
||||||
|
;;
|
||||||
|
before-rpc)
|
||||||
|
export OPENIM_WS_BREAK_BEFORE_SENDMSG_RPC=1
|
||||||
|
;;
|
||||||
|
after-rpc)
|
||||||
|
export OPENIM_WS_BREAK_AFTER_SENDMSG_RPC=1
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
echo "usage: $0 [gateway|before-rpc|after-rpc]" >&2
|
||||||
|
exit 1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
if [ -d /opt/homebrew/opt/openjdk ]; then
|
||||||
|
export JAVA_HOME=/opt/homebrew/opt/openjdk
|
||||||
|
export PATH="/opt/homebrew/bin:$JAVA_HOME/bin:$PATH"
|
||||||
|
fi
|
||||||
|
|
||||||
|
export PATH="$HOME/go/bin:$PATH"
|
||||||
|
|
||||||
|
cd "$ROOT_DIR"
|
||||||
|
|
||||||
|
exec dlv debug ./cmd -- -c ./config
|
||||||
@ -1,2 +1,49 @@
|
|||||||
# OpenIM Web E2E
|
# OpenIM Web E2E
|
||||||
|
|
||||||
|
Minimal local web page for exercising OpenIM HTTP message APIs from a browser.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /Users/ren_yu/open-source/open-im-server/test/e2e/web
|
||||||
|
python3 -m http.server 18080
|
||||||
|
```
|
||||||
|
|
||||||
|
Open [http://127.0.0.1:18080](http://127.0.0.1:18080) after the OpenIM server is running.
|
||||||
|
|
||||||
|
Pages:
|
||||||
|
|
||||||
|
- `http://127.0.0.1:18080/` - token and message API tester
|
||||||
|
- `http://127.0.0.1:18080/chat.html` - simplest dev chat client
|
||||||
|
- `http://127.0.0.1:18080/ws-demo.html` - simplest native browser OpenIM WS client
|
||||||
|
|
||||||
|
Default local endpoints:
|
||||||
|
|
||||||
|
- API: `http://127.0.0.1:10002`
|
||||||
|
- WebSocket gateway: `ws://127.0.0.1:10001`
|
||||||
|
|
||||||
|
Defaults from this repo:
|
||||||
|
|
||||||
|
- admin user ID: `imAdmin`
|
||||||
|
- secret: `openIM123`
|
||||||
|
|
||||||
|
Notes:
|
||||||
|
|
||||||
|
- `/auth/get_admin_token` requires the admin user to already exist.
|
||||||
|
- `/msg/send_msg` requires an admin token in the `token` header.
|
||||||
|
- The included page currently sends plain text messages through `/msg/send_msg`.
|
||||||
|
- `chat.html` is a development-only client: it uses admin-backed APIs to send and search messages between two users.
|
||||||
|
- `ws-demo.html` performs a real browser WebSocket handshake and sends OpenIM WS envelopes over binary frames.
|
||||||
|
- The WS gateway in this repo currently expects `isBackground=false` in the query string; omitting it causes the handshake to fail before upgrade.
|
||||||
|
|
||||||
|
Debugging:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /Users/ren_yu/open-source/open-im-server
|
||||||
|
./scripts/debug_msggateway_ws.sh before-rpc
|
||||||
|
```
|
||||||
|
|
||||||
|
- `gateway` stops at the WS gateway after the envelope is decoded.
|
||||||
|
- `before-rpc` stops right before `g.msgClient.MsgClient.SendMsg(...)`.
|
||||||
|
- `after-rpc` stops right after `g.msgClient.MsgClient.SendMsg(...)` returns.
|
||||||
|
- These breakpoints only apply to `ws-demo.html`, not `chat.html` or `/msg/send_msg`.
|
||||||
|
|||||||
732
test/e2e/web/chat.html
Normal file
732
test/e2e/web/chat.html
Normal file
@ -0,0 +1,732 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<title>OpenIM Simple Chat Client</title>
|
||||||
|
<style>
|
||||||
|
:root {
|
||||||
|
--bg: #f2efe8;
|
||||||
|
--panel: rgba(255, 250, 242, 0.92);
|
||||||
|
--panel-strong: #fffdf8;
|
||||||
|
--border: #d6c6aa;
|
||||||
|
--text: #1f1a14;
|
||||||
|
--muted: #665b4d;
|
||||||
|
--accent: #b55637;
|
||||||
|
--accent-2: #23516b;
|
||||||
|
--accent-soft: #f4ddcf;
|
||||||
|
--bubble-self: #284b63;
|
||||||
|
--bubble-peer: #e9dcc5;
|
||||||
|
--ok: #1b6a56;
|
||||||
|
--err: #a2332a;
|
||||||
|
}
|
||||||
|
|
||||||
|
* {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
min-height: 100vh;
|
||||||
|
font-family: "Avenir Next", "Segoe UI", sans-serif;
|
||||||
|
color: var(--text);
|
||||||
|
background:
|
||||||
|
radial-gradient(circle at top left, rgba(181, 86, 55, 0.12), transparent 28%),
|
||||||
|
radial-gradient(circle at bottom right, rgba(35, 81, 107, 0.16), transparent 30%),
|
||||||
|
linear-gradient(180deg, #f7f3eb, var(--bg));
|
||||||
|
}
|
||||||
|
|
||||||
|
main {
|
||||||
|
max-width: 1280px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 24px 16px 40px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header {
|
||||||
|
margin-bottom: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
margin: 0 0 8px;
|
||||||
|
font-size: clamp(30px, 4vw, 46px);
|
||||||
|
line-height: 1;
|
||||||
|
letter-spacing: -0.04em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lead {
|
||||||
|
margin: 0;
|
||||||
|
max-width: 860px;
|
||||||
|
color: var(--muted);
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.layout {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 360px minmax(0, 1fr);
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel {
|
||||||
|
background: var(--panel);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 22px;
|
||||||
|
box-shadow: 0 16px 40px rgba(31, 27, 22, 0.06);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar {
|
||||||
|
padding: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-shell {
|
||||||
|
display: grid;
|
||||||
|
grid-template-rows: auto auto minmax(420px, 1fr) auto;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section + .section {
|
||||||
|
margin-top: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
h2 {
|
||||||
|
margin: 0 0 12px;
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
label {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
input,
|
||||||
|
textarea {
|
||||||
|
width: 100%;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
background: var(--panel-strong);
|
||||||
|
border-radius: 14px;
|
||||||
|
padding: 11px 12px;
|
||||||
|
color: var(--text);
|
||||||
|
font: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
textarea {
|
||||||
|
min-height: 92px;
|
||||||
|
resize: vertical;
|
||||||
|
}
|
||||||
|
|
||||||
|
.grid {
|
||||||
|
display: grid;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.grid.two {
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.actions {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 10px;
|
||||||
|
margin-top: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
button {
|
||||||
|
border: 0;
|
||||||
|
border-radius: 999px;
|
||||||
|
padding: 10px 16px;
|
||||||
|
font: inherit;
|
||||||
|
color: white;
|
||||||
|
cursor: pointer;
|
||||||
|
background: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
button.alt {
|
||||||
|
background: var(--accent-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
button.ghost {
|
||||||
|
background: #6d624f;
|
||||||
|
}
|
||||||
|
|
||||||
|
button:disabled {
|
||||||
|
opacity: 0.6;
|
||||||
|
cursor: progress;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status {
|
||||||
|
margin-top: 12px;
|
||||||
|
border-radius: 14px;
|
||||||
|
padding: 12px 14px;
|
||||||
|
background: #f4ecdf;
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status.ok {
|
||||||
|
color: var(--ok);
|
||||||
|
}
|
||||||
|
|
||||||
|
.status.err {
|
||||||
|
color: var(--err);
|
||||||
|
}
|
||||||
|
|
||||||
|
.note {
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topbar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 18px 18px 14px;
|
||||||
|
border-bottom: 1px solid rgba(214, 198, 170, 0.8);
|
||||||
|
}
|
||||||
|
|
||||||
|
.room-title {
|
||||||
|
font-size: 20px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.room-meta {
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sender-bar {
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 14px 18px;
|
||||||
|
border-bottom: 1px solid rgba(214, 198, 170, 0.65);
|
||||||
|
background: rgba(255, 248, 238, 0.85);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sender-chip {
|
||||||
|
border-radius: 999px;
|
||||||
|
padding: 10px 14px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
background: white;
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sender-chip.active {
|
||||||
|
background: var(--accent-soft);
|
||||||
|
border-color: color-mix(in srgb, var(--accent) 35%, white);
|
||||||
|
color: #6e2f19;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.messages {
|
||||||
|
padding: 18px;
|
||||||
|
overflow: auto;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
min-height: 420px;
|
||||||
|
max-height: 65vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty {
|
||||||
|
margin: auto;
|
||||||
|
max-width: 440px;
|
||||||
|
text-align: center;
|
||||||
|
color: var(--muted);
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.msg {
|
||||||
|
max-width: min(72%, 640px);
|
||||||
|
padding: 12px 14px;
|
||||||
|
border-radius: 18px;
|
||||||
|
box-shadow: 0 8px 18px rgba(31, 27, 22, 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.msg.self {
|
||||||
|
align-self: flex-end;
|
||||||
|
background: var(--bubble-self);
|
||||||
|
color: white;
|
||||||
|
border-bottom-right-radius: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.msg.peer {
|
||||||
|
align-self: flex-start;
|
||||||
|
background: var(--bubble-peer);
|
||||||
|
color: var(--text);
|
||||||
|
border-bottom-left-radius: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.msg-head {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
font-size: 12px;
|
||||||
|
opacity: 0.86;
|
||||||
|
}
|
||||||
|
|
||||||
|
.msg-body {
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-break: break-word;
|
||||||
|
line-height: 1.45;
|
||||||
|
}
|
||||||
|
|
||||||
|
.composer {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1fr) auto;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 16px 18px 18px;
|
||||||
|
border-top: 1px solid rgba(214, 198, 170, 0.8);
|
||||||
|
background: rgba(255, 248, 238, 0.9);
|
||||||
|
}
|
||||||
|
|
||||||
|
.composer textarea {
|
||||||
|
min-height: 78px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.debug {
|
||||||
|
margin-top: 12px;
|
||||||
|
padding: 12px 14px;
|
||||||
|
border-radius: 14px;
|
||||||
|
background: #f7f1e8;
|
||||||
|
color: var(--muted);
|
||||||
|
font-family: "SFMono-Regular", "Consolas", monospace;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.5;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 980px) {
|
||||||
|
.layout {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.grid.two,
|
||||||
|
.composer {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.msg {
|
||||||
|
max-width: 88%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<main>
|
||||||
|
<div class="header">
|
||||||
|
<h1>OpenIM Simple Chat Client</h1>
|
||||||
|
<p class="lead">
|
||||||
|
这是一个最小开发态聊天客户端。它用 admin token 代发消息、用搜索接口轮询消息列表,
|
||||||
|
目的是让你先在本地把“能聊起来”这件事跑通。它不等价于正式客户端,不做权限隔离。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="layout">
|
||||||
|
<aside class="panel sidebar">
|
||||||
|
<div class="section">
|
||||||
|
<h2>连接</h2>
|
||||||
|
<div class="grid">
|
||||||
|
<div>
|
||||||
|
<label for="apiBase">API Base URL</label>
|
||||||
|
<input id="apiBase" value="http://127.0.0.1:10002" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label for="operationID">Operation ID</label>
|
||||||
|
<input id="operationID" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="section">
|
||||||
|
<h2>管理员</h2>
|
||||||
|
<div class="grid two">
|
||||||
|
<div>
|
||||||
|
<label for="adminUserID">Admin User ID</label>
|
||||||
|
<input id="adminUserID" value="imAdmin" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label for="adminSecret">Secret</label>
|
||||||
|
<input id="adminSecret" value="openIM123" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="section">
|
||||||
|
<h2>聊天双方</h2>
|
||||||
|
<div class="grid two">
|
||||||
|
<div>
|
||||||
|
<label for="userA">User A</label>
|
||||||
|
<input id="userA" value="user_1" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label for="userB">User B</label>
|
||||||
|
<input id="userB" value="user_2" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="actions">
|
||||||
|
<button id="bootstrapBtn">连接并初始化</button>
|
||||||
|
<button class="alt" id="refreshBtn">刷新消息</button>
|
||||||
|
<button class="ghost" id="togglePollBtn">暂停轮询</button>
|
||||||
|
</div>
|
||||||
|
<div class="note" style="margin-top: 10px">
|
||||||
|
“连接并初始化”会做三件事:拿 admin token、确保 `user_1` / `user_2`
|
||||||
|
存在、拉取当前聊天记录。
|
||||||
|
</div>
|
||||||
|
<div id="status" class="status"><pre>Ready.</pre></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="section">
|
||||||
|
<h2>调试</h2>
|
||||||
|
<div id="debug" class="debug">Waiting for first request.</div>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<section class="panel chat-shell">
|
||||||
|
<div class="topbar">
|
||||||
|
<div>
|
||||||
|
<div class="room-title">双人单聊</div>
|
||||||
|
<div class="room-meta" id="roomMeta">未连接</div>
|
||||||
|
</div>
|
||||||
|
<div class="room-meta" id="pollMeta">轮询中</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="sender-bar">
|
||||||
|
<button class="sender-chip active" id="senderABtn" type="button">当前发送者: user_1</button>
|
||||||
|
<button class="sender-chip" id="senderBBtn" type="button">切到 user_2 发送</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="messages" id="messages">
|
||||||
|
<div class="empty">
|
||||||
|
还没有消息。先点左侧“连接并初始化”,然后选择发送者发第一条消息。
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="composer">
|
||||||
|
<textarea id="composer" placeholder="输入消息,按按钮发送"></textarea>
|
||||||
|
<button id="sendBtn" type="button">发送消息</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
const $ = (id) => document.getElementById(id);
|
||||||
|
|
||||||
|
const els = {
|
||||||
|
apiBase: $("apiBase"),
|
||||||
|
operationID: $("operationID"),
|
||||||
|
adminUserID: $("adminUserID"),
|
||||||
|
adminSecret: $("adminSecret"),
|
||||||
|
userA: $("userA"),
|
||||||
|
userB: $("userB"),
|
||||||
|
bootstrapBtn: $("bootstrapBtn"),
|
||||||
|
refreshBtn: $("refreshBtn"),
|
||||||
|
togglePollBtn: $("togglePollBtn"),
|
||||||
|
senderABtn: $("senderABtn"),
|
||||||
|
senderBBtn: $("senderBBtn"),
|
||||||
|
status: $("status"),
|
||||||
|
debug: $("debug"),
|
||||||
|
messages: $("messages"),
|
||||||
|
roomMeta: $("roomMeta"),
|
||||||
|
pollMeta: $("pollMeta"),
|
||||||
|
composer: $("composer"),
|
||||||
|
sendBtn: $("sendBtn"),
|
||||||
|
};
|
||||||
|
|
||||||
|
const state = {
|
||||||
|
adminToken: "",
|
||||||
|
activeSender: "A",
|
||||||
|
polling: true,
|
||||||
|
pollTimer: null,
|
||||||
|
lastMessageKey: "",
|
||||||
|
};
|
||||||
|
|
||||||
|
function ensureOperationID() {
|
||||||
|
if (!els.operationID.value.trim()) {
|
||||||
|
els.operationID.value = `chat-${Date.now()}`;
|
||||||
|
}
|
||||||
|
return els.operationID.value.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
function currentUser() {
|
||||||
|
return state.activeSender === "A" ? els.userA.value.trim() : els.userB.value.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
function peerUser() {
|
||||||
|
return state.activeSender === "A" ? els.userB.value.trim() : els.userA.value.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
function setStatus(message, type = "ok") {
|
||||||
|
els.status.className = `status ${type}`;
|
||||||
|
els.status.innerHTML = `<pre>${message}</pre>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function setDebug(value) {
|
||||||
|
els.debug.textContent = typeof value === "string" ? value : JSON.stringify(value, null, 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
function setBusy(button, busy) {
|
||||||
|
button.disabled = busy;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function postJSON(path, body, token = "") {
|
||||||
|
const operationID = ensureOperationID();
|
||||||
|
const url = `${els.apiBase.value.replace(/\/$/, "")}${path}`;
|
||||||
|
const headers = {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
operationID,
|
||||||
|
...(token ? { token } : {}),
|
||||||
|
};
|
||||||
|
setDebug({ url, headers, body });
|
||||||
|
|
||||||
|
const response = await fetch(url, {
|
||||||
|
method: "POST",
|
||||||
|
headers,
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
});
|
||||||
|
const text = await response.text();
|
||||||
|
let data;
|
||||||
|
try {
|
||||||
|
data = JSON.parse(text);
|
||||||
|
} catch {
|
||||||
|
data = { raw: text };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!response.ok || data.errCode) {
|
||||||
|
throw new Error(data.errDlt || data.errMsg || `HTTP ${response.status}`);
|
||||||
|
}
|
||||||
|
return data.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchAdminToken() {
|
||||||
|
const data = await postJSON("/auth/get_admin_token", {
|
||||||
|
secret: els.adminSecret.value.trim(),
|
||||||
|
userID: els.adminUserID.value.trim(),
|
||||||
|
});
|
||||||
|
state.adminToken = data.token;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function ensureUsers() {
|
||||||
|
try {
|
||||||
|
await postJSON(
|
||||||
|
"/user/user_register",
|
||||||
|
{
|
||||||
|
users: [
|
||||||
|
{ userID: els.userA.value.trim(), nickname: "User A" },
|
||||||
|
{ userID: els.userB.value.trim(), nickname: "User B" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
state.adminToken
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : String(error);
|
||||||
|
if (!message.includes("registered already")) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function searchDirection(sendID, recvID) {
|
||||||
|
const data = await postJSON(
|
||||||
|
"/msg/search_msg",
|
||||||
|
{
|
||||||
|
sendID,
|
||||||
|
recvID,
|
||||||
|
sessionType: 1,
|
||||||
|
pagination: {
|
||||||
|
pageNumber: 1,
|
||||||
|
showNumber: 50,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
state.adminToken
|
||||||
|
);
|
||||||
|
return data.chatLogs || [];
|
||||||
|
}
|
||||||
|
|
||||||
|
function decodeTextContent(chatLog) {
|
||||||
|
if (!chatLog) return "";
|
||||||
|
if (chatLog.contentType !== 101 || !chatLog.content) {
|
||||||
|
return `[contentType=${chatLog.contentType}]`;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(chatLog.content);
|
||||||
|
return parsed.content || chatLog.content;
|
||||||
|
} catch {
|
||||||
|
return chatLog.content;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeChatLogs(chatLogs) {
|
||||||
|
return chatLogs
|
||||||
|
.map((item) => item.chatLog)
|
||||||
|
.filter(Boolean)
|
||||||
|
.map((log) => ({
|
||||||
|
key: log.serverMsgID || `${log.clientMsgID}-${log.sendTime}`,
|
||||||
|
sender: log.sendID,
|
||||||
|
receiver: log.recvID,
|
||||||
|
sendTime: Number(log.sendTime || 0),
|
||||||
|
text: decodeTextContent(log),
|
||||||
|
}))
|
||||||
|
.sort((a, b) => a.sendTime - b.sendTime);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadMessages() {
|
||||||
|
if (!state.adminToken) {
|
||||||
|
throw new Error("admin token is missing");
|
||||||
|
}
|
||||||
|
const a = els.userA.value.trim();
|
||||||
|
const b = els.userB.value.trim();
|
||||||
|
const [ab, ba] = await Promise.all([searchDirection(a, b), searchDirection(b, a)]);
|
||||||
|
const merged = normalizeChatLogs([...ab, ...ba]);
|
||||||
|
renderMessages(merged);
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatTime(ts) {
|
||||||
|
if (!ts) return "";
|
||||||
|
return new Date(ts).toLocaleTimeString("zh-CN", {
|
||||||
|
hour: "2-digit",
|
||||||
|
minute: "2-digit",
|
||||||
|
second: "2-digit",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderMessages(messages) {
|
||||||
|
els.roomMeta.textContent = `${els.userA.value.trim()} <-> ${els.userB.value.trim()}`;
|
||||||
|
if (!messages.length) {
|
||||||
|
els.messages.innerHTML = `
|
||||||
|
<div class="empty">
|
||||||
|
当前这两个用户之间还没有查到消息。发送第一条后会自动刷新。
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const html = messages
|
||||||
|
.map((msg) => {
|
||||||
|
const css = msg.sender === currentUser() ? "self" : "peer";
|
||||||
|
return `
|
||||||
|
<div class="msg ${css}">
|
||||||
|
<div class="msg-head">
|
||||||
|
<span>${msg.sender}</span>
|
||||||
|
<span>${formatTime(msg.sendTime)}</span>
|
||||||
|
</div>
|
||||||
|
<div class="msg-body">${escapeHTML(msg.text)}</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
})
|
||||||
|
.join("");
|
||||||
|
|
||||||
|
const nextKey = messages[messages.length - 1]?.key || "";
|
||||||
|
const shouldStickBottom = !state.lastMessageKey || nextKey !== state.lastMessageKey;
|
||||||
|
state.lastMessageKey = nextKey;
|
||||||
|
els.messages.innerHTML = html;
|
||||||
|
if (shouldStickBottom) {
|
||||||
|
els.messages.scrollTop = els.messages.scrollHeight;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeHTML(value) {
|
||||||
|
return value
|
||||||
|
.replaceAll("&", "&")
|
||||||
|
.replaceAll("<", "<")
|
||||||
|
.replaceAll(">", ">");
|
||||||
|
}
|
||||||
|
|
||||||
|
async function sendMessage() {
|
||||||
|
const text = els.composer.value.trim();
|
||||||
|
if (!text) {
|
||||||
|
throw new Error("message content is empty");
|
||||||
|
}
|
||||||
|
await postJSON(
|
||||||
|
"/msg/send_msg",
|
||||||
|
{
|
||||||
|
sendID: currentUser(),
|
||||||
|
recvID: peerUser(),
|
||||||
|
sessionType: 1,
|
||||||
|
contentType: 101,
|
||||||
|
content: {
|
||||||
|
content: text,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
state.adminToken
|
||||||
|
);
|
||||||
|
els.composer.value = "";
|
||||||
|
await loadMessages();
|
||||||
|
}
|
||||||
|
|
||||||
|
function setActiveSender(which) {
|
||||||
|
state.activeSender = which;
|
||||||
|
const a = els.userA.value.trim();
|
||||||
|
const b = els.userB.value.trim();
|
||||||
|
els.senderABtn.textContent = `当前发送者: ${which === "A" ? a : b}`;
|
||||||
|
els.senderBBtn.textContent = `切到 ${which === "A" ? b : a} 发送`;
|
||||||
|
els.senderABtn.classList.toggle("active", which === "A");
|
||||||
|
els.senderBBtn.classList.toggle("active", which === "B");
|
||||||
|
}
|
||||||
|
|
||||||
|
function startPolling() {
|
||||||
|
stopPolling();
|
||||||
|
state.polling = true;
|
||||||
|
els.pollMeta.textContent = "轮询中";
|
||||||
|
els.togglePollBtn.textContent = "暂停轮询";
|
||||||
|
state.pollTimer = setInterval(() => {
|
||||||
|
loadMessages().catch((error) => {
|
||||||
|
setStatus(error instanceof Error ? error.message : String(error), "err");
|
||||||
|
});
|
||||||
|
}, 2000);
|
||||||
|
}
|
||||||
|
|
||||||
|
function stopPolling() {
|
||||||
|
if (state.pollTimer) {
|
||||||
|
clearInterval(state.pollTimer);
|
||||||
|
state.pollTimer = null;
|
||||||
|
}
|
||||||
|
state.polling = false;
|
||||||
|
els.pollMeta.textContent = "轮询已暂停";
|
||||||
|
els.togglePollBtn.textContent = "恢复轮询";
|
||||||
|
}
|
||||||
|
|
||||||
|
async function bootstrap() {
|
||||||
|
await fetchAdminToken();
|
||||||
|
await ensureUsers();
|
||||||
|
await loadMessages();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function run(button, fn, okText) {
|
||||||
|
try {
|
||||||
|
setBusy(button, true);
|
||||||
|
setStatus("Running...", "ok");
|
||||||
|
await fn();
|
||||||
|
setStatus(okText, "ok");
|
||||||
|
} catch (error) {
|
||||||
|
setStatus(error instanceof Error ? error.message : String(error), "err");
|
||||||
|
} finally {
|
||||||
|
setBusy(button, false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
els.bootstrapBtn.addEventListener("click", () => run(els.bootstrapBtn, bootstrap, "Chat client is ready."));
|
||||||
|
els.refreshBtn.addEventListener("click", () => run(els.refreshBtn, loadMessages, "Messages refreshed."));
|
||||||
|
els.sendBtn.addEventListener("click", () => run(els.sendBtn, sendMessage, "Message sent."));
|
||||||
|
els.senderABtn.addEventListener("click", () => setActiveSender("A"));
|
||||||
|
els.senderBBtn.addEventListener("click", () => setActiveSender(state.activeSender === "A" ? "B" : "A"));
|
||||||
|
els.togglePollBtn.addEventListener("click", () => {
|
||||||
|
if (state.polling) {
|
||||||
|
stopPolling();
|
||||||
|
} else {
|
||||||
|
startPolling();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
ensureOperationID();
|
||||||
|
setActiveSender("A");
|
||||||
|
startPolling();
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
515
test/e2e/web/index.html
Normal file
515
test/e2e/web/index.html
Normal file
@ -0,0 +1,515 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<title>OpenIM Local Message Tester</title>
|
||||||
|
<style>
|
||||||
|
:root {
|
||||||
|
--bg: #f4f1ea;
|
||||||
|
--panel: #fffaf2;
|
||||||
|
--panel-2: #f0e7d8;
|
||||||
|
--border: #d8c8aa;
|
||||||
|
--text: #1f1b16;
|
||||||
|
--muted: #645948;
|
||||||
|
--accent: #b24c2f;
|
||||||
|
--accent-2: #284b63;
|
||||||
|
--ok: #1e6f5c;
|
||||||
|
--err: #aa2e25;
|
||||||
|
}
|
||||||
|
|
||||||
|
* {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
font-family: "Avenir Next", "Segoe UI", sans-serif;
|
||||||
|
color: var(--text);
|
||||||
|
background:
|
||||||
|
radial-gradient(circle at top left, rgba(178, 76, 47, 0.12), transparent 30%),
|
||||||
|
radial-gradient(circle at bottom right, rgba(40, 75, 99, 0.16), transparent 35%),
|
||||||
|
linear-gradient(180deg, #f8f3ea 0%, var(--bg) 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
main {
|
||||||
|
max-width: 1100px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 32px 20px 48px;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
margin: 0 0 8px;
|
||||||
|
font-size: clamp(28px, 4vw, 44px);
|
||||||
|
line-height: 1.05;
|
||||||
|
letter-spacing: -0.03em;
|
||||||
|
}
|
||||||
|
|
||||||
|
p.lead {
|
||||||
|
margin: 0 0 24px;
|
||||||
|
max-width: 760px;
|
||||||
|
color: var(--muted);
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(12, 1fr);
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card {
|
||||||
|
grid-column: span 12;
|
||||||
|
background: color-mix(in srgb, var(--panel) 92%, white);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 18px;
|
||||||
|
padding: 18px;
|
||||||
|
box-shadow: 0 10px 30px rgba(31, 27, 22, 0.06);
|
||||||
|
}
|
||||||
|
|
||||||
|
.card.half {
|
||||||
|
grid-column: span 6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card h2 {
|
||||||
|
margin: 0 0 14px;
|
||||||
|
font-size: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, 1fr);
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.row.single {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
label {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
input,
|
||||||
|
textarea,
|
||||||
|
select {
|
||||||
|
width: 100%;
|
||||||
|
padding: 11px 12px;
|
||||||
|
border-radius: 12px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
background: #fffdf8;
|
||||||
|
color: var(--text);
|
||||||
|
font: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
textarea {
|
||||||
|
min-height: 120px;
|
||||||
|
resize: vertical;
|
||||||
|
}
|
||||||
|
|
||||||
|
.actions {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 10px;
|
||||||
|
margin-top: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
button {
|
||||||
|
border: 0;
|
||||||
|
border-radius: 999px;
|
||||||
|
padding: 10px 16px;
|
||||||
|
font: inherit;
|
||||||
|
cursor: pointer;
|
||||||
|
color: white;
|
||||||
|
background: var(--accent);
|
||||||
|
transition: transform 120ms ease, opacity 120ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
button.alt {
|
||||||
|
background: var(--accent-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
button.ghost {
|
||||||
|
background: #73624d;
|
||||||
|
}
|
||||||
|
|
||||||
|
button:hover {
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
|
||||||
|
button:disabled {
|
||||||
|
opacity: 0.6;
|
||||||
|
cursor: progress;
|
||||||
|
transform: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hint {
|
||||||
|
margin-top: 10px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--muted);
|
||||||
|
line-height: 1.45;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status {
|
||||||
|
margin-top: 14px;
|
||||||
|
padding: 12px 14px;
|
||||||
|
border-radius: 12px;
|
||||||
|
font-size: 14px;
|
||||||
|
background: var(--panel-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.status.ok {
|
||||||
|
color: var(--ok);
|
||||||
|
border: 1px solid color-mix(in srgb, var(--ok) 28%, white);
|
||||||
|
}
|
||||||
|
|
||||||
|
.status.err {
|
||||||
|
color: var(--err);
|
||||||
|
border: 1px solid color-mix(in srgb, var(--err) 28%, white);
|
||||||
|
}
|
||||||
|
|
||||||
|
pre {
|
||||||
|
margin: 0;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-break: break-word;
|
||||||
|
font-family: "SFMono-Regular", "Consolas", monospace;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 860px) {
|
||||||
|
.card.half {
|
||||||
|
grid-column: span 12;
|
||||||
|
}
|
||||||
|
|
||||||
|
.row {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<main>
|
||||||
|
<h1>OpenIM Local Message Tester</h1>
|
||||||
|
<p class="lead">
|
||||||
|
这个页面只依赖浏览器和本地 OpenIM HTTP API。默认对接
|
||||||
|
<code>http://127.0.0.1:10002</code>,用于快速拿 token、发文本消息、查看原始响应。
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="grid">
|
||||||
|
<section class="card half">
|
||||||
|
<h2>连接配置</h2>
|
||||||
|
<div class="row">
|
||||||
|
<div>
|
||||||
|
<label for="apiBase">API Base URL</label>
|
||||||
|
<input id="apiBase" value="http://127.0.0.1:10002" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label for="wsBase">WebSocket Gateway</label>
|
||||||
|
<input id="wsBase" value="ws://127.0.0.1:10001" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="row single" style="margin-top: 12px">
|
||||||
|
<div>
|
||||||
|
<label for="operationID">Operation ID</label>
|
||||||
|
<input id="operationID" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="hint">
|
||||||
|
当前页面实际调用的是 HTTP API。WebSocket 地址先展示出来,后续如果你要测推送回包,可以直接在这个页面上扩展。
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="card half">
|
||||||
|
<h2>管理员 Token</h2>
|
||||||
|
<div class="row">
|
||||||
|
<div>
|
||||||
|
<label for="adminUserID">Admin User ID</label>
|
||||||
|
<input id="adminUserID" value="imAdmin" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label for="adminSecret">Secret</label>
|
||||||
|
<input id="adminSecret" value="openIM123" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="row single" style="margin-top: 12px">
|
||||||
|
<div>
|
||||||
|
<label for="adminToken">Admin Token</label>
|
||||||
|
<textarea id="adminToken" placeholder="点击按钮自动获取,或手动粘贴"></textarea>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="actions">
|
||||||
|
<button id="getAdminTokenBtn">获取 Admin Token</button>
|
||||||
|
</div>
|
||||||
|
<div class="hint">
|
||||||
|
这里命中 <code>/auth/get_admin_token</code>。注意:
|
||||||
|
<code>imAdmin</code> 必须已经在你的本地环境里存在。
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="card">
|
||||||
|
<h2>用户 Token</h2>
|
||||||
|
<div class="row">
|
||||||
|
<div>
|
||||||
|
<label for="userID">User ID</label>
|
||||||
|
<input id="userID" placeholder="例如 user_1" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label for="platformID">Platform ID</label>
|
||||||
|
<input id="platformID" type="number" value="5" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="row single" style="margin-top: 12px">
|
||||||
|
<div>
|
||||||
|
<label for="userToken">User Token</label>
|
||||||
|
<textarea id="userToken" placeholder="可选,用于单独验证 token 获取链路"></textarea>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="actions">
|
||||||
|
<button class="alt" id="getUserTokenBtn">获取 User Token</button>
|
||||||
|
</div>
|
||||||
|
<div class="hint">
|
||||||
|
这里命中 <code>/auth/get_user_token</code>,请求头使用上面的 admin token。
|
||||||
|
页面发消息本身不依赖 user token。
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="card">
|
||||||
|
<h2>发送文本消息</h2>
|
||||||
|
<div class="row">
|
||||||
|
<div>
|
||||||
|
<label for="sendID">Send ID</label>
|
||||||
|
<input id="sendID" value="imAdmin" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label for="recvID">Recv ID</label>
|
||||||
|
<input id="recvID" placeholder="例如 user_2" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="row" style="margin-top: 12px">
|
||||||
|
<div>
|
||||||
|
<label for="sessionType">Session Type</label>
|
||||||
|
<select id="sessionType">
|
||||||
|
<option value="1" selected>1 - SingleChatType</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label for="contentType">Content Type</label>
|
||||||
|
<select id="contentType">
|
||||||
|
<option value="101" selected>101 - Text</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="row single" style="margin-top: 12px">
|
||||||
|
<div>
|
||||||
|
<label for="textContent">Text Content</label>
|
||||||
|
<textarea id="textContent">hello from localhost web</textarea>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="actions">
|
||||||
|
<button id="sendTextBtn">发送文本消息</button>
|
||||||
|
</div>
|
||||||
|
<div class="hint">
|
||||||
|
这里命中 <code>/msg/send_msg</code>,当前只封装最简单的文本消息格式:
|
||||||
|
<code>{"content":{"content":"..."}}</code>。
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="card">
|
||||||
|
<h2>状态</h2>
|
||||||
|
<div id="status" class="status"><pre>Ready.</pre></div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="card">
|
||||||
|
<h2>最后一次请求</h2>
|
||||||
|
<pre id="lastRequest">{}</pre>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="card">
|
||||||
|
<h2>最后一次响应</h2>
|
||||||
|
<pre id="lastResponse">{}</pre>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
const $ = (id) => document.getElementById(id);
|
||||||
|
|
||||||
|
const els = {
|
||||||
|
apiBase: $("apiBase"),
|
||||||
|
operationID: $("operationID"),
|
||||||
|
adminUserID: $("adminUserID"),
|
||||||
|
adminSecret: $("adminSecret"),
|
||||||
|
adminToken: $("adminToken"),
|
||||||
|
userID: $("userID"),
|
||||||
|
platformID: $("platformID"),
|
||||||
|
userToken: $("userToken"),
|
||||||
|
sendID: $("sendID"),
|
||||||
|
recvID: $("recvID"),
|
||||||
|
sessionType: $("sessionType"),
|
||||||
|
contentType: $("contentType"),
|
||||||
|
textContent: $("textContent"),
|
||||||
|
lastRequest: $("lastRequest"),
|
||||||
|
lastResponse: $("lastResponse"),
|
||||||
|
status: $("status"),
|
||||||
|
getAdminTokenBtn: $("getAdminTokenBtn"),
|
||||||
|
getUserTokenBtn: $("getUserTokenBtn"),
|
||||||
|
sendTextBtn: $("sendTextBtn"),
|
||||||
|
};
|
||||||
|
|
||||||
|
function pretty(value) {
|
||||||
|
return JSON.stringify(value, null, 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
function setStatus(message, type = "ok") {
|
||||||
|
els.status.className = `status ${type}`;
|
||||||
|
els.status.innerHTML = `<pre>${message}</pre>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function setResponsePayload(payload) {
|
||||||
|
els.lastResponse.textContent = pretty(payload);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ensureOperationID() {
|
||||||
|
if (!els.operationID.value.trim()) {
|
||||||
|
els.operationID.value = `web-${Date.now()}`;
|
||||||
|
}
|
||||||
|
return els.operationID.value.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
function setBusy(button, busy) {
|
||||||
|
button.disabled = busy;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function postJSON(path, body, token = "") {
|
||||||
|
const url = `${els.apiBase.value.replace(/\/$/, "")}${path}`;
|
||||||
|
const operationID = ensureOperationID();
|
||||||
|
const headers = {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
operationID,
|
||||||
|
...(token ? { token } : {}),
|
||||||
|
};
|
||||||
|
els.lastRequest.textContent = pretty({ url, headers, body });
|
||||||
|
setResponsePayload({ pending: true });
|
||||||
|
|
||||||
|
const controller = new AbortController();
|
||||||
|
const timeout = setTimeout(() => controller.abort("Request timeout after 8s"), 8000);
|
||||||
|
|
||||||
|
let response;
|
||||||
|
try {
|
||||||
|
response = await fetch(url, {
|
||||||
|
method: "POST",
|
||||||
|
headers,
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
signal: controller.signal,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
setResponsePayload({
|
||||||
|
networkError: true,
|
||||||
|
name: error instanceof Error ? error.name : "Error",
|
||||||
|
message: error instanceof Error ? error.message : String(error),
|
||||||
|
url,
|
||||||
|
});
|
||||||
|
throw error;
|
||||||
|
} finally {
|
||||||
|
clearTimeout(timeout);
|
||||||
|
}
|
||||||
|
|
||||||
|
const text = await response.text();
|
||||||
|
let data;
|
||||||
|
try {
|
||||||
|
data = JSON.parse(text);
|
||||||
|
} catch {
|
||||||
|
data = { raw: text };
|
||||||
|
}
|
||||||
|
|
||||||
|
setResponsePayload({
|
||||||
|
status: response.status,
|
||||||
|
ok: response.ok,
|
||||||
|
body: data,
|
||||||
|
});
|
||||||
|
|
||||||
|
return { response, data };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getAdminToken() {
|
||||||
|
const body = {
|
||||||
|
secret: els.adminSecret.value.trim(),
|
||||||
|
userID: els.adminUserID.value.trim(),
|
||||||
|
};
|
||||||
|
const { data } = await postJSON("/auth/get_admin_token", body);
|
||||||
|
const token = data?.data?.token || data?.token;
|
||||||
|
if (!token) {
|
||||||
|
throw new Error(`admin token missing: ${pretty(data)}`);
|
||||||
|
}
|
||||||
|
els.adminToken.value = token;
|
||||||
|
setStatus("Admin token acquired.", "ok");
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getUserToken() {
|
||||||
|
const adminToken = els.adminToken.value.trim();
|
||||||
|
if (!adminToken) {
|
||||||
|
throw new Error("admin token is required");
|
||||||
|
}
|
||||||
|
const body = {
|
||||||
|
userID: els.userID.value.trim(),
|
||||||
|
platformID: Number(els.platformID.value),
|
||||||
|
};
|
||||||
|
const { data } = await postJSON("/auth/get_user_token", body, adminToken);
|
||||||
|
const token = data?.data?.token || data?.token;
|
||||||
|
if (!token) {
|
||||||
|
throw new Error(`user token missing: ${pretty(data)}`);
|
||||||
|
}
|
||||||
|
els.userToken.value = token;
|
||||||
|
setStatus("User token acquired.", "ok");
|
||||||
|
}
|
||||||
|
|
||||||
|
async function sendTextMessage() {
|
||||||
|
const adminToken = els.adminToken.value.trim();
|
||||||
|
if (!adminToken) {
|
||||||
|
throw new Error("admin token is required");
|
||||||
|
}
|
||||||
|
const recvID = els.recvID.value.trim();
|
||||||
|
if (!recvID) {
|
||||||
|
throw new Error("recvID is required");
|
||||||
|
}
|
||||||
|
const body = {
|
||||||
|
sendID: els.sendID.value.trim(),
|
||||||
|
recvID,
|
||||||
|
sessionType: Number(els.sessionType.value),
|
||||||
|
contentType: Number(els.contentType.value),
|
||||||
|
content: {
|
||||||
|
content: els.textContent.value,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
await postJSON("/msg/send_msg", body, adminToken);
|
||||||
|
setStatus("Message request sent. Inspect the raw response below.", "ok");
|
||||||
|
}
|
||||||
|
|
||||||
|
async function run(button, fn) {
|
||||||
|
try {
|
||||||
|
setBusy(button, true);
|
||||||
|
setStatus("Running...", "ok");
|
||||||
|
await fn();
|
||||||
|
} catch (error) {
|
||||||
|
setResponsePayload({
|
||||||
|
error: true,
|
||||||
|
name: error instanceof Error ? error.name : "Error",
|
||||||
|
message: error instanceof Error ? error.message : String(error),
|
||||||
|
});
|
||||||
|
setStatus(error instanceof Error ? error.message : String(error), "err");
|
||||||
|
} finally {
|
||||||
|
setBusy(button, false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
els.getAdminTokenBtn.addEventListener("click", () => run(els.getAdminTokenBtn, getAdminToken));
|
||||||
|
els.getUserTokenBtn.addEventListener("click", () => run(els.getUserTokenBtn, getUserToken));
|
||||||
|
els.sendTextBtn.addEventListener("click", () => run(els.sendTextBtn, sendTextMessage));
|
||||||
|
ensureOperationID();
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
757
test/e2e/web/ws-demo.html
Normal file
757
test/e2e/web/ws-demo.html
Normal file
@ -0,0 +1,757 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<title>OpenIM WS Demo</title>
|
||||||
|
<style>
|
||||||
|
:root {
|
||||||
|
--bg: #f2efe8;
|
||||||
|
--panel: rgba(255, 250, 242, 0.94);
|
||||||
|
--panel-2: #fffdf8;
|
||||||
|
--border: #d5c6ab;
|
||||||
|
--text: #1e1a14;
|
||||||
|
--muted: #665c4f;
|
||||||
|
--accent: #b45736;
|
||||||
|
--accent-2: #23506a;
|
||||||
|
--ok: #1c6a57;
|
||||||
|
--err: #a2352d;
|
||||||
|
}
|
||||||
|
|
||||||
|
* {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
min-height: 100vh;
|
||||||
|
font-family: "Avenir Next", "Segoe UI", sans-serif;
|
||||||
|
color: var(--text);
|
||||||
|
background:
|
||||||
|
radial-gradient(circle at top left, rgba(180, 87, 54, 0.13), transparent 28%),
|
||||||
|
radial-gradient(circle at bottom right, rgba(35, 80, 106, 0.16), transparent 30%),
|
||||||
|
linear-gradient(180deg, #f7f2ea, var(--bg));
|
||||||
|
}
|
||||||
|
|
||||||
|
main {
|
||||||
|
max-width: 1280px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 24px 16px 40px;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
margin: 0 0 8px;
|
||||||
|
font-size: clamp(30px, 4vw, 46px);
|
||||||
|
letter-spacing: -0.04em;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lead {
|
||||||
|
margin: 0 0 18px;
|
||||||
|
color: var(--muted);
|
||||||
|
line-height: 1.5;
|
||||||
|
max-width: 900px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.layout {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 380px minmax(0, 1fr);
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel {
|
||||||
|
background: var(--panel);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 22px;
|
||||||
|
box-shadow: 0 16px 40px rgba(31, 27, 22, 0.06);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar {
|
||||||
|
padding: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content {
|
||||||
|
display: grid;
|
||||||
|
grid-template-rows: auto auto auto minmax(420px, 1fr);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section + .section {
|
||||||
|
margin-top: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
h2 {
|
||||||
|
margin: 0 0 12px;
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
label {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
input,
|
||||||
|
textarea {
|
||||||
|
width: 100%;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
background: var(--panel-2);
|
||||||
|
border-radius: 14px;
|
||||||
|
padding: 11px 12px;
|
||||||
|
color: var(--text);
|
||||||
|
font: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
textarea {
|
||||||
|
min-height: 100px;
|
||||||
|
resize: vertical;
|
||||||
|
}
|
||||||
|
|
||||||
|
.grid {
|
||||||
|
display: grid;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.grid.two {
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.actions {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 10px;
|
||||||
|
margin-top: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
button {
|
||||||
|
border: 0;
|
||||||
|
border-radius: 999px;
|
||||||
|
padding: 10px 16px;
|
||||||
|
color: white;
|
||||||
|
background: var(--accent);
|
||||||
|
font: inherit;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
button.alt {
|
||||||
|
background: var(--accent-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
button.ghost {
|
||||||
|
background: #706451;
|
||||||
|
}
|
||||||
|
|
||||||
|
button:disabled {
|
||||||
|
opacity: 0.6;
|
||||||
|
cursor: progress;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status {
|
||||||
|
margin-top: 12px;
|
||||||
|
padding: 12px 14px;
|
||||||
|
border-radius: 14px;
|
||||||
|
background: #f4ecdf;
|
||||||
|
line-height: 1.5;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status.ok {
|
||||||
|
color: var(--ok);
|
||||||
|
}
|
||||||
|
|
||||||
|
.status.err {
|
||||||
|
color: var(--err);
|
||||||
|
}
|
||||||
|
|
||||||
|
.note {
|
||||||
|
color: var(--muted);
|
||||||
|
line-height: 1.5;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 16px 18px;
|
||||||
|
border-bottom: 1px solid rgba(213, 198, 171, 0.75);
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbar .meta {
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.block {
|
||||||
|
padding: 16px 18px;
|
||||||
|
border-bottom: 1px solid rgba(213, 198, 171, 0.65);
|
||||||
|
}
|
||||||
|
|
||||||
|
.block:last-child {
|
||||||
|
border-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.console {
|
||||||
|
overflow: auto;
|
||||||
|
padding: 18px;
|
||||||
|
min-height: 420px;
|
||||||
|
max-height: 68vh;
|
||||||
|
background: rgba(255, 252, 247, 0.75);
|
||||||
|
font-family: "SFMono-Regular", "Consolas", monospace;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.line {
|
||||||
|
padding: 10px 12px;
|
||||||
|
border-radius: 12px;
|
||||||
|
background: rgba(255, 255, 255, 0.7);
|
||||||
|
border: 1px solid rgba(213, 198, 171, 0.5);
|
||||||
|
margin-bottom: 10px;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
.line.in {
|
||||||
|
border-left: 4px solid #23506a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.line.out {
|
||||||
|
border-left: 4px solid #b45736;
|
||||||
|
}
|
||||||
|
|
||||||
|
.line.sys {
|
||||||
|
border-left: 4px solid #7a6a56;
|
||||||
|
}
|
||||||
|
|
||||||
|
.line.err {
|
||||||
|
border-left: 4px solid #a2352d;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 980px) {
|
||||||
|
.layout {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.grid.two {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/protobufjs@7.4.0/dist/protobuf.min.js"></script>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<main>
|
||||||
|
<h1>OpenIM WS Demo</h1>
|
||||||
|
<p class="lead">
|
||||||
|
这个页面是真正走 WebSocket 的最小 OpenIM 浏览器客户端。它使用浏览器原生
|
||||||
|
<code>new WebSocket(url)</code> 握手,再用 OpenIM 的 JSON envelope +
|
||||||
|
protobuf 数据体发二进制帧。
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="layout">
|
||||||
|
<aside class="panel sidebar">
|
||||||
|
<div class="section">
|
||||||
|
<h2>连接参数</h2>
|
||||||
|
<div class="grid">
|
||||||
|
<div>
|
||||||
|
<label for="apiBase">API Base URL</label>
|
||||||
|
<input id="apiBase" value="http://127.0.0.1:10002" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label for="wsBase">WS URL</label>
|
||||||
|
<input id="wsBase" value="ws://127.0.0.1:10001/" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label for="operationID">Operation ID</label>
|
||||||
|
<input id="operationID" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="section">
|
||||||
|
<h2>管理员</h2>
|
||||||
|
<div class="grid two">
|
||||||
|
<div>
|
||||||
|
<label for="adminUserID">Admin User ID</label>
|
||||||
|
<input id="adminUserID" value="imAdmin" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label for="adminSecret">Secret</label>
|
||||||
|
<input id="adminSecret" value="openIM123" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="section">
|
||||||
|
<h2>当前登录用户</h2>
|
||||||
|
<div class="grid two">
|
||||||
|
<div>
|
||||||
|
<label for="userID">User ID</label>
|
||||||
|
<input id="userID" value="user_1" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label for="peerUserID">Peer User ID</label>
|
||||||
|
<input id="peerUserID" value="user_2" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="grid two" style="margin-top: 12px">
|
||||||
|
<div>
|
||||||
|
<label for="platformID">Platform ID</label>
|
||||||
|
<input id="platformID" value="5" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label for="sdkType">SDK Type</label>
|
||||||
|
<input id="sdkType" value="js" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="section">
|
||||||
|
<h2>文本消息</h2>
|
||||||
|
<textarea id="textContent">hello from native browser websocket</textarea>
|
||||||
|
<div class="actions">
|
||||||
|
<button id="prepareBtn">拿 Token</button>
|
||||||
|
<button class="alt" id="connectBtn">连接 WS</button>
|
||||||
|
<button class="ghost" id="disconnectBtn">断开</button>
|
||||||
|
</div>
|
||||||
|
<div class="actions">
|
||||||
|
<button id="getSeqBtn">WSGetNewestSeq</button>
|
||||||
|
<button class="alt" id="sendMsgBtn">WSSendMsg</button>
|
||||||
|
</div>
|
||||||
|
<div class="note" style="margin-top: 10px">
|
||||||
|
这里不是 HTTP fallback。点击“连接 WS”后,会真的对
|
||||||
|
<code>ws://127.0.0.1:10001</code> 发浏览器原生 WebSocket 握手请求。
|
||||||
|
</div>
|
||||||
|
<div id="status" class="status"><pre>Ready.</pre></div>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<section class="panel content">
|
||||||
|
<div class="toolbar">
|
||||||
|
<div>
|
||||||
|
<strong>连接状态</strong>
|
||||||
|
<div class="meta" id="connectionMeta">未连接</div>
|
||||||
|
</div>
|
||||||
|
<div class="meta" id="reqMeta">msgIncr=0</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="block">
|
||||||
|
<h2>Last URL</h2>
|
||||||
|
<div id="lastURL" class="note">尚未构造</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="block">
|
||||||
|
<h2>Raw Envelope</h2>
|
||||||
|
<div id="rawEnvelope" class="note">尚未发送</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="console" id="console"></div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
const $ = (id) => document.getElementById(id);
|
||||||
|
|
||||||
|
const els = {
|
||||||
|
apiBase: $("apiBase"),
|
||||||
|
wsBase: $("wsBase"),
|
||||||
|
operationID: $("operationID"),
|
||||||
|
adminUserID: $("adminUserID"),
|
||||||
|
adminSecret: $("adminSecret"),
|
||||||
|
userID: $("userID"),
|
||||||
|
peerUserID: $("peerUserID"),
|
||||||
|
platformID: $("platformID"),
|
||||||
|
sdkType: $("sdkType"),
|
||||||
|
textContent: $("textContent"),
|
||||||
|
prepareBtn: $("prepareBtn"),
|
||||||
|
connectBtn: $("connectBtn"),
|
||||||
|
disconnectBtn: $("disconnectBtn"),
|
||||||
|
getSeqBtn: $("getSeqBtn"),
|
||||||
|
sendMsgBtn: $("sendMsgBtn"),
|
||||||
|
status: $("status"),
|
||||||
|
connectionMeta: $("connectionMeta"),
|
||||||
|
reqMeta: $("reqMeta"),
|
||||||
|
lastURL: $("lastURL"),
|
||||||
|
rawEnvelope: $("rawEnvelope"),
|
||||||
|
console: $("console"),
|
||||||
|
};
|
||||||
|
|
||||||
|
const state = {
|
||||||
|
adminToken: "",
|
||||||
|
userToken: "",
|
||||||
|
ws: null,
|
||||||
|
msgIncr: 0,
|
||||||
|
proto: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
const SDKWS_PROTO = `
|
||||||
|
syntax = "proto3";
|
||||||
|
package openim.sdkws;
|
||||||
|
|
||||||
|
enum PullOrder {
|
||||||
|
PullOrderAsc = 0;
|
||||||
|
PullOrderDesc = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
message GetMaxSeqReq {
|
||||||
|
string userID = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
message GetMaxSeqResp {
|
||||||
|
map<string, int64> maxSeqs = 1;
|
||||||
|
map<string, int64> minSeqs = 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
message PullMsgs {
|
||||||
|
repeated MsgData Msgs = 1;
|
||||||
|
bool isEnd = 2;
|
||||||
|
int64 endSeq = 3;
|
||||||
|
}
|
||||||
|
|
||||||
|
message MsgData {
|
||||||
|
string sendID = 1;
|
||||||
|
string recvID = 2;
|
||||||
|
string groupID = 3;
|
||||||
|
string clientMsgID = 4;
|
||||||
|
string serverMsgID = 5;
|
||||||
|
int32 senderPlatformID = 6;
|
||||||
|
string senderNickname = 7;
|
||||||
|
string senderFaceURL = 8;
|
||||||
|
int32 sessionType = 9;
|
||||||
|
int32 msgFrom = 10;
|
||||||
|
int32 contentType = 11;
|
||||||
|
bytes content = 12;
|
||||||
|
int64 seq = 14;
|
||||||
|
int64 sendTime = 15;
|
||||||
|
int64 createTime = 16;
|
||||||
|
int32 status = 17;
|
||||||
|
bool isRead = 18;
|
||||||
|
map<string, bool> options = 19;
|
||||||
|
string attachedInfo = 22;
|
||||||
|
string ex = 23;
|
||||||
|
}
|
||||||
|
|
||||||
|
message PushMessages {
|
||||||
|
map<string, PullMsgs> msgs = 1;
|
||||||
|
map<string, PullMsgs> notificationMsgs = 2;
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
const MSG_PROTO = `
|
||||||
|
syntax = "proto3";
|
||||||
|
package openim.msg;
|
||||||
|
|
||||||
|
message SendMsgResp {
|
||||||
|
string serverMsgID = 1;
|
||||||
|
string clientMsgID = 2;
|
||||||
|
int64 sendTime = 3;
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
function ensureOperationID() {
|
||||||
|
if (!els.operationID.value.trim()) {
|
||||||
|
els.operationID.value = `ws-${Date.now()}`;
|
||||||
|
}
|
||||||
|
return els.operationID.value.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
function setStatus(message, type = "ok") {
|
||||||
|
els.status.className = `status ${type}`;
|
||||||
|
els.status.innerHTML = `<pre>${message}</pre>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function logLine(type, title, payload) {
|
||||||
|
const line = document.createElement("div");
|
||||||
|
line.className = `line ${type}`;
|
||||||
|
const text = typeof payload === "string" ? payload : JSON.stringify(payload, null, 2);
|
||||||
|
line.textContent = `${title}\n${text}`;
|
||||||
|
els.console.prepend(line);
|
||||||
|
}
|
||||||
|
|
||||||
|
function setConnectionMeta(text) {
|
||||||
|
els.connectionMeta.textContent = text;
|
||||||
|
}
|
||||||
|
|
||||||
|
function nextMsgIncr() {
|
||||||
|
state.msgIncr += 1;
|
||||||
|
els.reqMeta.textContent = `msgIncr=${state.msgIncr}`;
|
||||||
|
return String(state.msgIncr);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function postJSON(path, body, token = "") {
|
||||||
|
const operationID = ensureOperationID();
|
||||||
|
const url = `${els.apiBase.value.replace(/\/$/, "")}${path}`;
|
||||||
|
const headers = {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
operationID,
|
||||||
|
...(token ? { token } : {}),
|
||||||
|
};
|
||||||
|
const response = await fetch(url, {
|
||||||
|
method: "POST",
|
||||||
|
headers,
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
});
|
||||||
|
const data = await response.json();
|
||||||
|
if (!response.ok || data.errCode) {
|
||||||
|
throw new Error(data.errDlt || data.errMsg || `HTTP ${response.status}`);
|
||||||
|
}
|
||||||
|
return data.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function prepareTokens() {
|
||||||
|
const admin = await postJSON("/auth/get_admin_token", {
|
||||||
|
secret: els.adminSecret.value.trim(),
|
||||||
|
userID: els.adminUserID.value.trim(),
|
||||||
|
});
|
||||||
|
state.adminToken = admin.token;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await postJSON(
|
||||||
|
"/user/user_register",
|
||||||
|
{
|
||||||
|
users: [
|
||||||
|
{ userID: els.userID.value.trim(), nickname: els.userID.value.trim() },
|
||||||
|
{ userID: els.peerUserID.value.trim(), nickname: els.peerUserID.value.trim() },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
state.adminToken
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : String(error);
|
||||||
|
if (!message.includes("registered already")) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = await postJSON(
|
||||||
|
"/auth/get_user_token",
|
||||||
|
{
|
||||||
|
userID: els.userID.value.trim(),
|
||||||
|
platformID: Number(els.platformID.value),
|
||||||
|
},
|
||||||
|
state.adminToken
|
||||||
|
);
|
||||||
|
state.userToken = user.token;
|
||||||
|
}
|
||||||
|
|
||||||
|
function initProto() {
|
||||||
|
if (state.proto) return state.proto;
|
||||||
|
const root = new protobuf.Root();
|
||||||
|
protobuf.parse(SDKWS_PROTO, root);
|
||||||
|
protobuf.parse(MSG_PROTO, root);
|
||||||
|
state.proto = {
|
||||||
|
root,
|
||||||
|
GetMaxSeqReq: root.lookupType("openim.sdkws.GetMaxSeqReq"),
|
||||||
|
GetMaxSeqResp: root.lookupType("openim.sdkws.GetMaxSeqResp"),
|
||||||
|
MsgData: root.lookupType("openim.sdkws.MsgData"),
|
||||||
|
PushMessages: root.lookupType("openim.sdkws.PushMessages"),
|
||||||
|
SendMsgResp: root.lookupType("openim.msg.SendMsgResp"),
|
||||||
|
};
|
||||||
|
return state.proto;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildWSURL() {
|
||||||
|
const url = new URL(els.wsBase.value);
|
||||||
|
url.searchParams.set("sendID", els.userID.value.trim());
|
||||||
|
url.searchParams.set("token", state.userToken);
|
||||||
|
url.searchParams.set("operationID", ensureOperationID());
|
||||||
|
url.searchParams.set("platformID", els.platformID.value.trim());
|
||||||
|
url.searchParams.set("sdkType", els.sdkType.value.trim());
|
||||||
|
url.searchParams.set("isBackground", "false");
|
||||||
|
url.searchParams.set("isMsgResp", "true");
|
||||||
|
return url.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
function base64FromBytes(bytes) {
|
||||||
|
let binary = "";
|
||||||
|
for (const b of bytes) binary += String.fromCharCode(b);
|
||||||
|
return btoa(binary);
|
||||||
|
}
|
||||||
|
|
||||||
|
function bytesFromBase64(base64) {
|
||||||
|
const binary = atob(base64);
|
||||||
|
const bytes = new Uint8Array(binary.length);
|
||||||
|
for (let i = 0; i < binary.length; i += 1) {
|
||||||
|
bytes[i] = binary.charCodeAt(i);
|
||||||
|
}
|
||||||
|
return bytes;
|
||||||
|
}
|
||||||
|
|
||||||
|
function sendEnvelope(reqIdentifier, payloadBytes) {
|
||||||
|
if (!state.ws || state.ws.readyState !== WebSocket.OPEN) {
|
||||||
|
throw new Error("websocket is not connected");
|
||||||
|
}
|
||||||
|
const envelope = {
|
||||||
|
reqIdentifier,
|
||||||
|
token: state.userToken,
|
||||||
|
sendID: els.userID.value.trim(),
|
||||||
|
operationID: ensureOperationID(),
|
||||||
|
msgIncr: nextMsgIncr(),
|
||||||
|
data: base64FromBytes(payloadBytes),
|
||||||
|
};
|
||||||
|
els.rawEnvelope.textContent = JSON.stringify(envelope, null, 2);
|
||||||
|
logLine("out", `WS Send ${reqIdentifier}`, envelope);
|
||||||
|
const jsonBytes = new TextEncoder().encode(JSON.stringify(envelope));
|
||||||
|
state.ws.send(jsonBytes);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function connectWS() {
|
||||||
|
if (!state.userToken) {
|
||||||
|
throw new Error("user token is missing, click '拿 Token' first");
|
||||||
|
}
|
||||||
|
if (state.ws && state.ws.readyState === WebSocket.OPEN) {
|
||||||
|
throw new Error("websocket already connected");
|
||||||
|
}
|
||||||
|
|
||||||
|
const url = buildWSURL();
|
||||||
|
els.lastURL.textContent = url;
|
||||||
|
|
||||||
|
const ws = new WebSocket(url);
|
||||||
|
ws.binaryType = "arraybuffer";
|
||||||
|
|
||||||
|
ws.onopen = () => {
|
||||||
|
setConnectionMeta(`OPEN as ${els.userID.value.trim()}`);
|
||||||
|
setStatus("WebSocket connected.", "ok");
|
||||||
|
logLine("sys", "WS Open", url);
|
||||||
|
};
|
||||||
|
|
||||||
|
ws.onclose = (event) => {
|
||||||
|
setConnectionMeta(`CLOSED code=${event.code}`);
|
||||||
|
logLine("sys", "WS Close", { code: event.code, reason: event.reason });
|
||||||
|
};
|
||||||
|
|
||||||
|
ws.onerror = () => {
|
||||||
|
setStatus("WebSocket error.", "err");
|
||||||
|
logLine("err", "WS Error", "See browser console/network panel for handshake details.");
|
||||||
|
};
|
||||||
|
|
||||||
|
ws.onmessage = (event) => {
|
||||||
|
if (typeof event.data === "string") {
|
||||||
|
logLine("in", "WS Text", event.data);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const text = new TextDecoder().decode(new Uint8Array(event.data));
|
||||||
|
let envelope;
|
||||||
|
try {
|
||||||
|
envelope = JSON.parse(text);
|
||||||
|
} catch {
|
||||||
|
logLine("err", "WS Binary Parse Error", text);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
logLine("in", `WS Recv ${envelope.reqIdentifier}`, envelope);
|
||||||
|
decodeEnvelope(envelope);
|
||||||
|
};
|
||||||
|
|
||||||
|
state.ws = ws;
|
||||||
|
}
|
||||||
|
|
||||||
|
function disconnectWS() {
|
||||||
|
if (state.ws) {
|
||||||
|
state.ws.close();
|
||||||
|
state.ws = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function decodeEnvelope(envelope) {
|
||||||
|
const proto = initProto();
|
||||||
|
if (!envelope.data) return;
|
||||||
|
const bytes = bytesFromBase64(envelope.data);
|
||||||
|
try {
|
||||||
|
switch (envelope.reqIdentifier) {
|
||||||
|
case 1001: {
|
||||||
|
const decoded = proto.GetMaxSeqResp.decode(bytes);
|
||||||
|
logLine("sys", "Decoded GetMaxSeqResp", proto.GetMaxSeqResp.toObject(decoded, { longs: String }));
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 1003: {
|
||||||
|
const decoded = proto.SendMsgResp.decode(bytes);
|
||||||
|
logLine("sys", "Decoded SendMsgResp", proto.SendMsgResp.toObject(decoded, { longs: String }));
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 2001: {
|
||||||
|
const decoded = proto.PushMessages.decode(bytes);
|
||||||
|
logLine("sys", "Decoded PushMessages", proto.PushMessages.toObject(decoded, { longs: String, bytes: String }));
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
logLine("sys", "Unknown Proto Payload", { reqIdentifier: envelope.reqIdentifier, base64: envelope.data });
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logLine("err", "Proto Decode Error", error instanceof Error ? error.message : String(error));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function sendGetNewestSeq() {
|
||||||
|
const proto = initProto();
|
||||||
|
const message = proto.GetMaxSeqReq.create({
|
||||||
|
userID: els.userID.value.trim(),
|
||||||
|
});
|
||||||
|
const bytes = proto.GetMaxSeqReq.encode(message).finish();
|
||||||
|
sendEnvelope(1001, bytes);
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeClientMsgID() {
|
||||||
|
return `browser-${Date.now()}-${Math.random().toString(16).slice(2, 10)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function sendTextMessage() {
|
||||||
|
const proto = initProto();
|
||||||
|
const contentBytes = new TextEncoder().encode(
|
||||||
|
JSON.stringify({
|
||||||
|
content: els.textContent.value,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
const now = Date.now();
|
||||||
|
const message = proto.MsgData.create({
|
||||||
|
sendID: els.userID.value.trim(),
|
||||||
|
recvID: els.peerUserID.value.trim(),
|
||||||
|
clientMsgID: makeClientMsgID(),
|
||||||
|
senderPlatformID: Number(els.platformID.value),
|
||||||
|
sessionType: 1,
|
||||||
|
msgFrom: 100,
|
||||||
|
contentType: 101,
|
||||||
|
content: contentBytes,
|
||||||
|
sendTime: now,
|
||||||
|
createTime: now,
|
||||||
|
});
|
||||||
|
const bytes = proto.MsgData.encode(message).finish();
|
||||||
|
sendEnvelope(1003, bytes);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function run(button, fn, okText) {
|
||||||
|
try {
|
||||||
|
setBusy(button, true);
|
||||||
|
setStatus("Running...", "ok");
|
||||||
|
await fn();
|
||||||
|
if (okText) {
|
||||||
|
setStatus(okText, "ok");
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
setStatus(error instanceof Error ? error.message : String(error), "err");
|
||||||
|
logLine("err", "Action Error", error instanceof Error ? error.message : String(error));
|
||||||
|
} finally {
|
||||||
|
setBusy(button, false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function setBusy(button, busy) {
|
||||||
|
button.disabled = busy;
|
||||||
|
}
|
||||||
|
|
||||||
|
ensureOperationID();
|
||||||
|
initProto();
|
||||||
|
|
||||||
|
els.prepareBtn.addEventListener("click", () => run(els.prepareBtn, prepareTokens, "Tokens prepared."));
|
||||||
|
els.connectBtn.addEventListener("click", () => run(els.connectBtn, connectWS, ""));
|
||||||
|
els.disconnectBtn.addEventListener("click", () => run(els.disconnectBtn, async () => disconnectWS(), "Disconnected."));
|
||||||
|
els.getSeqBtn.addEventListener("click", () => run(els.getSeqBtn, async () => sendGetNewestSeq(), "WSGetNewestSeq sent."));
|
||||||
|
els.sendMsgBtn.addEventListener("click", () => run(els.sendMsgBtn, async () => sendTextMessage(), "WSSendMsg sent."));
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
Loading…
x
Reference in New Issue
Block a user