feat: switch client API branding to MeetingIM

This commit is contained in:
richjohnrambo
2026-08-31 01:22:53 +08:00
parent c6a243ee59
commit 9f647de4d0
48 changed files with 717 additions and 271 deletions
+1 -1
View File
@@ -45,7 +45,7 @@ rm -fR ./releases/tmp
mkdir -p ./releases/tmp/templ mkdir -p ./releases/tmp/templ
# Copy templates and database initialization files # Copy templates and database initialization files
cp ./server/tinode.conf ./releases/tmp cp ./server/meetingim.conf ./releases/tmp
cp ./server/templ/*.templ ./releases/tmp/templ cp ./server/templ/*.templ ./releases/tmp/templ
cp ./tinode-db/data.json ./releases/tmp cp ./tinode-db/data.json ./releases/tmp
cp ./tinode-db/*.jpg ./releases/tmp cp ./tinode-db/*.jpg ./releases/tmp
+3 -3
View File
@@ -82,7 +82,7 @@ type audit struct {
func main() { func main() {
baseURL := flag.String("base", "https://api.118im.com", "HTTP base URL") baseURL := flag.String("base", "https://api.118im.com", "HTTP base URL")
wsURL := flag.String("ws", "wss://api.118im.com/v0/channels", "WebSocket URL") wsURL := flag.String("ws", "wss://api.118im.com/v0/channels", "WebSocket URL")
apiKey := flag.String("apikey", "AQAAAAABAAAoeOI7tA3HsYvdzDhYhZJy", "Tinode API key") apiKey := flag.String("apikey", "AQAAAAABAAAoeOI7tA3HsYvdzDhYhZJy", "MeetingIM API key")
tenant := flag.String("tenant", "default", "tenant code") tenant := flag.String("tenant", "default", "tenant code")
out := flag.String("out", "docs/test/im_frontend_backend_1_11_testenv_results_2026-08-20.md", "report path") out := flag.String("out", "docs/test/im_frontend_backend_1_11_testenv_results_2026-08-20.md", "report path")
flag.Parse() flag.Parse()
@@ -442,7 +442,7 @@ func (a *audit) call(method, path string, body any, token string, idem bool) (in
if err != nil { if err != nil {
return 0, imResp{}, "", err return 0, imResp{}, "", err
} }
req.Header.Set("X-Tinode-APIKey", a.apiKey) req.Header.Set("X-MeetingIM-APIKey", a.apiKey)
req.Header.Set("X-IM-Schema", "1") req.Header.Set("X-IM-Schema", "1")
req.Header.Set("X-Request-ID", "audit-"+strings.ReplaceAll(time.Now().Format("150405.000000000"), ".", "")) req.Header.Set("X-Request-ID", "audit-"+strings.ReplaceAll(time.Now().Format("150405.000000000"), ".", ""))
req.Header.Set("X-Tenant-Code", a.tenantCode) req.Header.Set("X-Tenant-Code", a.tenantCode)
@@ -452,7 +452,7 @@ func (a *audit) call(method, path string, body any, token string, idem bool) (in
if token != "" { if token != "" {
req.Header.Set("Authorization", "Bearer "+token) req.Header.Set("Authorization", "Bearer "+token)
if strings.HasPrefix(path, "/v0/file/") { if strings.HasPrefix(path, "/v0/file/") {
req.Header.Set("X-Tinode-Auth", "token "+base64.StdEncoding.EncodeToString([]byte(token))) req.Header.Set("X-MeetingIM-Auth", "token "+base64.StdEncoding.EncodeToString([]byte(token)))
} }
} }
if idem { if idem {
+6 -6
View File
@@ -70,7 +70,7 @@ type wsClient struct {
func main() { func main() {
baseURL := flag.String("base", "https://api.118im.com", "HTTP base URL") baseURL := flag.String("base", "https://api.118im.com", "HTTP base URL")
wsURL := flag.String("ws", "wss://api.118im.com/v0/channels", "WebSocket URL") wsURL := flag.String("ws", "wss://api.118im.com/v0/channels", "WebSocket URL")
apiKey := flag.String("apikey", "AQAAAAABAAAoeOI7tA3HsYvdzDhYhZJy", "Tinode API key") apiKey := flag.String("apikey", "AQAAAAABAAAoeOI7tA3HsYvdzDhYhZJy", "MeetingIM API key")
tenant := flag.String("tenant", "default", "tenant code") tenant := flag.String("tenant", "default", "tenant code")
out := flag.String("out", "docs/test/im_websocket_testenv_results_2026-08-21.md", "report path") out := flag.String("out", "docs/test/im_websocket_testenv_results_2026-08-21.md", "report path")
flag.Parse() flag.Parse()
@@ -601,7 +601,7 @@ func (a *audit) uploadFile() (string, error) {
return "", err return "", err
} }
req.Header.Set("Content-Type", writer.FormDataContentType()) req.Header.Set("Content-Type", writer.FormDataContentType())
req.Header.Set("X-Tinode-APIKey", a.apiKey) req.Header.Set("X-MeetingIM-APIKey", a.apiKey)
req.Header.Set("X-Tenant-Code", a.tenantCode) req.Header.Set("X-Tenant-Code", a.tenantCode)
resp, err := a.client.Do(req) resp, err := a.client.Do(req)
if err != nil { if err != nil {
@@ -771,7 +771,7 @@ func (c *wsClient) pubAndPeerReceives(peer *wsClient, topic, text string) (int,
"id": id, "id": id,
"topic": topic, "topic": topic,
"head": map[string]any{"mime": "text/plain"}, "head": map[string]any{"mime": "text/plain"},
// Keep this as native Tinode text content. Product-specific x-im // Keep this as native MeetingIM text content. Product-specific x-im
// headers should be covered by a separate WS message-header matrix. // headers should be covered by a separate WS message-header matrix.
"content": text, "content": text,
}}); err != nil { }}); err != nil {
@@ -945,7 +945,7 @@ func (a *audit) call(method, path string, body any, token string) (int, imResp,
return 0, imResp{}, err return 0, imResp{}, err
} }
req.Header.Set("Content-Type", "application/json") req.Header.Set("Content-Type", "application/json")
req.Header.Set("X-Tinode-APIKey", a.apiKey) req.Header.Set("X-MeetingIM-APIKey", a.apiKey)
req.Header.Set("X-Tenant-Code", a.tenantCode) req.Header.Set("X-Tenant-Code", a.tenantCode)
req.Header.Set("X-IM-Schema", "1") req.Header.Set("X-IM-Schema", "1")
req.Header.Set("X-Request-ID", "req-ws-audit-"+fmt.Sprintf("%d", time.Now().UnixNano())) req.Header.Set("X-Request-ID", "req-ws-audit-"+fmt.Sprintf("%d", time.Now().UnixNano()))
@@ -954,7 +954,7 @@ func (a *audit) call(method, path string, body any, token string) (int, imResp,
} }
if token != "" { if token != "" {
req.Header.Set("Authorization", "Bearer "+token) req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("X-Tinode-Auth", "token "+base64.StdEncoding.EncodeToString([]byte(token))) req.Header.Set("X-MeetingIM-Auth", "token "+base64.StdEncoding.EncodeToString([]byte(token)))
} }
resp, err := a.client.Do(req) resp, err := a.client.Do(req)
if err != nil { if err != nil {
@@ -986,7 +986,7 @@ func (a *audit) report() string {
fmt.Fprintf(&b, "- 用户 B:`%s` / `%s` / `%s`\n", a.userB, a.usernameB, a.phoneB) fmt.Fprintf(&b, "- 用户 B:`%s` / `%s` / `%s`\n", a.userB, a.usernameB, a.phoneB)
fmt.Fprintf(&b, "- API Key 与 Token 已脱敏,不在文档中展开。\n\n") fmt.Fprintf(&b, "- API Key 与 Token 已脱敏,不在文档中展开。\n\n")
fmt.Fprintf(&b, "## 2. 总体结论\n\n") fmt.Fprintf(&b, "## 2. 总体结论\n\n")
fmt.Fprintf(&b, "本轮专项验证 Tinode WebSocket 真实协议链路,共执行 `%d` 项,通过 `%d` 项,未通过 `%d` 项。\n\n", total, passed, total-passed) fmt.Fprintf(&b, "本轮专项验证 MeetingIM WebSocket 真实协议链路,共执行 `%d` 项,通过 `%d` 项,未通过 `%d` 项。\n\n", total, passed, total-passed)
fmt.Fprintf(&b, "| ID | 场景 | 方法 | 目标 | 结果 | 备注 |\n| --- | --- | --- | --- | --- | --- |\n") fmt.Fprintf(&b, "| ID | 场景 | 方法 | 目标 | 结果 | 备注 |\n| --- | --- | --- | --- | --- | --- |\n")
for _, r := range a.results { for _, r := range a.results {
status := "通过" status := "通过"
+35 -34
View File
@@ -29,7 +29,7 @@ API Key 是应用级接入标识,由部署人员在发布客户端前生成并
1. 新部署同时生成 API Key 和 HMAC Salt:在项目根目录执行 `go run ./keygen`。 1. 新部署同时生成 API Key 和 HMAC Salt:在项目根目录执行 `go run ./keygen`。
2. 已有服务端继续使用当前 `api_key_salt` 时,执行 `go run ./keygen -sequence <新序号> -salt '<服务端 api_key_salt>'` 生成兼容的普通应用 Key。 2. 已有服务端继续使用当前 `api_key_salt` 时,执行 `go run ./keygen -sequence <新序号> -salt '<服务端 api_key_salt>'` 生成兼容的普通应用 Key。
3. 输出中的 `HMAC salt` 配置到服务端 `server/tinode.conf` 的 `api_key_salt`;输出中的 `API key` 配置到 iOS、Android 或 Web 客户端构建配置/受控环境配置。 3. 输出中的 `HMAC salt` 配置到服务端 `server/meetingim.conf` 的 `api_key_salt`;输出中的 `API key` 配置到 iOS、Android 或 Web 客户端构建配置/受控环境配置。
4. 客户端启动时从自己的应用配置读取 API Key,不调用 IM 接口获取。禁止把 `-isroot 1` 生成的 Root Key 放进客户端。 4. 客户端启动时从自己的应用配置读取 API Key,不调用 IM 接口获取。禁止把 `-isroot 1` 生成的 Root Key 放进客户端。
生成示意: 生成示意:
@@ -44,7 +44,7 @@ API Key、企业码和用户 Token 是三个不同概念:
| 值 | 取得时间 | 作用 | 是否代表用户身份 | | 值 | 取得时间 | 作用 | 是否代表用户身份 |
| --- | --- | --- | --- | | --- | --- | --- | --- |
| `api_key` | 应用发布/环境配置时预置 | 允许应用访问 Tinode 入口 | 否 | | `api_key` | 应用发布/环境配置时预置 | 允许应用访问 MeetingIM 入口 | 否 |
| `tenant_code` | 用户输入企业码 | 将 Session 路由并绑定到企业 | 否 | | `tenant_code` | 用户输入企业码 | 将 Session 路由并绑定到企业 | 否 |
| `token` | 用户登录成功后由服务端返回 | 标识当前租户内的登录用户 | 是 | | `token` | 用户登录成功后由服务端返回 | 标识当前租户内的登录用户 | 是 |
@@ -57,11 +57,11 @@ API Key 可以被客户端应用提取,因此不能把它当成用户密码或
| 通道 | 地址 | 认证 | 用途 | | 通道 | 地址 | 认证 | 用途 |
| --- | --- | --- | --- | | --- | --- | --- | --- |
| WebSocket(浏览器/通用方式) | `wss://{host}/{api_path}v0/channels?apikey={api_key}` | Upgrade 校验 API Key,`hi` 绑定租户,`login` 登录 | Topic、消息、回执、在线状态、通话信令 | | WebSocket(浏览器/通用方式) | `wss://{host}/{api_path}v0/channels?apikey={api_key}` | Upgrade 校验 API Key,`hi` 绑定租户,`login` 登录 | Topic、消息、回执、在线状态、通话信令 |
| WebSocket(支持自定义 Header 的原生客户端) | `wss://{host}/{api_path}v0/channels` | Upgrade Header `X-Tinode-APIKey` | 同上 | | WebSocket(支持自定义 Header 的原生客户端) | `wss://{host}/{api_path}v0/channels` | Upgrade Header `X-MeetingIM-APIKey` | 同上 |
| 产品 HTTP | `https://{host}/{api_path}v1/im/*` | `Authorization: Bearer <params.token>` | 好友、群、收藏、搜索、工作台和设置 | | 产品 HTTP | `https://{host}/{api_path}v1/im/*` | `Authorization: Bearer <params.token>` | 好友、群、收藏、搜索、工作台和设置 |
| 文件 HTTP | `POST /v0/file/u/`、`GET /v0/file/s/{file_id}` | 当前用 `X-Tinode-Auth: token <base64_token>` | 文件上传下载;禁止 Query 传 Token | | 文件 HTTP | `POST /v0/file/u/`、`GET /v0/file/s/{file_id}` | 当前用 `X-MeetingIM-Auth: token <base64_token>` | 文件上传下载;禁止 Query 传 Token |
HTTP 请求统一通过 `X-Tinode-APIKey` 携带。浏览器 WebSocket 无法设置自定义 Upgrade Header 时才使用 `apikey` Query;日志和监控必须对该参数脱敏。产品 HTTP 还必须带 `X-IM-Schema: 1` 和 `X-Request-ID`;写操作带 `Idempotency-Key`。生产环境只允许 HTTPS/WSS。Token、密码、验证码、证件号不得写入 URL、普通日志、埋点或崩溃报告。 HTTP 请求统一通过 `X-MeetingIM-APIKey` 携带。浏览器 WebSocket 无法设置自定义 Upgrade Header 时才使用 `apikey` Query;日志和监控必须对该参数脱敏。产品 HTTP 还必须带 `X-IM-Schema: 1` 和 `X-Request-ID`;写操作带 `Idempotency-Key`。生产环境只允许 HTTPS/WSS。Token、密码、验证码、证件号不得写入 URL、普通日志、埋点或崩溃报告。
WebSocket Upgrade 阶段由 `server/hdl_websock.go` 先调用 `checkAPIKey`。缺少或无效时不会建立 WebSocket,直接返回 HTTP 403: WebSocket Upgrade 阶段由 `server/hdl_websock.go` 先调用 `checkAPIKey`。缺少或无效时不会建立 WebSocket,直接返回 HTTP 403:
@@ -221,7 +221,7 @@ WebSocket Upgrade 阶段由 `server/hdl_websock.go` 先调用 `checkAPIKey`。
```http ```http
POST /v0/channels/lp HTTP/1.1 POST /v0/channels/lp HTTP/1.1
Host: im.example.com Host: im.example.com
X-Tinode-APIKey: <预置的普通 API Key> X-MeetingIM-APIKey: <预置的普通 API Key>
Content-Length: 0 Content-Length: 0
``` ```
@@ -235,7 +235,7 @@ Content-Length: 0
```http ```http
POST /v0/channels/lp?sid=session-id HTTP/1.1 POST /v0/channels/lp?sid=session-id HTTP/1.1
X-Tinode-APIKey: <预置的普通 API Key> X-MeetingIM-APIKey: <预置的普通 API Key>
Content-Type: application/json Content-Type: application/json
{"hi":{"id":"hi-1","ver":"0.25","ua":"android/1.0.0","platf":"android","lang":"zh-CN","tenant":"acme"}} {"hi":{"id":"hi-1","ver":"0.25","ua":"android/1.0.0","platf":"android","lang":"zh-CN","tenant":"acme"}}
@@ -245,7 +245,7 @@ POST 只负责把命令送入 Session。客户端再保持一个不带 Body 的
```http ```http
GET /v0/channels/lp?sid=session-id HTTP/1.1 GET /v0/channels/lp?sid=session-id HTTP/1.1
X-Tinode-APIKey: <预置的普通 API Key> X-MeetingIM-APIKey: <预置的普通 API Key>
``` ```
收到 `hi` 成功后,以相同方式 POST `{login}`,再 GET 登录结果;登录成功后依次 POST `{sub me}`、`{sub fnd}` 和会话订阅,并持续发起下一次 GET 等待下行消息。 收到 `hi` 成功后,以相同方式 POST `{login}`,再 GET 登录结果;登录成功后依次 POST `{sub me}`、`{sub fnd}` 和会话订阅,并持续发起下一次 GET 等待下行消息。
@@ -270,7 +270,7 @@ X-Tinode-APIKey: <预置的普通 API Key>
本节用于 WebSocket 联调。除特别说明外: 本节用于 WebSocket 联调。除特别说明外:
- WebSocket 地址为 `wss://{host}/{api_path}v0/channels?apikey={api_key}`;支持自定义 Header 的原生客户端也可用 `X-Tinode-APIKey`。 - WebSocket 地址为 `wss://{host}/{api_path}v0/channels?apikey={api_key}`;支持自定义 Header 的原生客户端也可用 `X-MeetingIM-APIKey`。
- 连接建立后第一帧必须是 `{hi}`,`hi` 成功后才能 `{login}` 或 `{acc}`。 - 连接建立后第一帧必须是 `{hi}`,`hi` 成功后才能 `{login}` 或 `{acc}`。
- 每个上行命令都带客户端生成的 `id`,客户端用该 `id` 匹配 `{ctrl}`;同一连接内保持唯一即可。 - 每个上行命令都带客户端生成的 `id`,客户端用该 `id` 匹配 `{ctrl}`;同一连接内保持唯一即可。
- 服务端下行可能乱序到达,客户端不能假设 `{ctrl}` 一定先于 `{meta}` 或 `{data}`。 - 服务端下行可能乱序到达,客户端不能假设 `{ctrl}` 一定先于 `{meta}` 或 `{data}`。
@@ -336,7 +336,7 @@ X-Tinode-APIKey: <预置的普通 API Key>
##### 0.5.4.6 系统通知、好友和群业务下行 ##### 0.5.4.6 系统通知、好友和群业务下行
服务端主动下行分两类:Tinode 原生 Topic 消息使用 `{data}`、`{info}`、`{pres}` 等帧;IM 产品业务事件使用 `{info}` 帧下发,前端按 `info.what + info.event` 分发刷新。产品业务事件不占普通聊天 `seq`,不等同于系统通知列表记录;系统消息入口的未读数来自 `im_system_notifications` 和 `GET /v1/im/system-notifications`。 服务端主动下行分两类:MeetingIM 原生 Topic 消息使用 `{data}`、`{info}`、`{pres}` 等帧;IM 产品业务事件使用 `{info}` 帧下发,前端按 `info.what + info.event` 分发刷新。产品业务事件不占普通聊天 `seq`,不等同于系统通知列表记录;系统消息入口的未读数来自 `im_system_notifications` 和 `GET /v1/im/system-notifications`。
用户维度业务事件统一格式: 用户维度业务事件统一格式:
@@ -425,8 +425,8 @@ X-Tinode-APIKey: <预置的普通 API Key>
| WebSocket `login basic` | `{"login":{"id":"login-1","scheme":"basic","secret":"base64(username:password)"}}` | `{"ctrl":{"id":"login-1","code":200,"params":{"user":"usr...","token":"token...","expires":"2026-09-03T09:00:00Z","authlvl":"auth"}}}` | | WebSocket `login basic` | `{"login":{"id":"login-1","scheme":"basic","secret":"base64(username:password)"}}` | `{"ctrl":{"id":"login-1","code":200,"params":{"user":"usr...","token":"token...","expires":"2026-09-03T09:00:00Z","authlvl":"auth"}}}` |
| WebSocket `acc new` | `{"acc":{"id":"acc-1","user":"new","scheme":"basic","username":"alice","phone":"15500000000","secret":"base64(alice:password)","login":true,"cred":[{"meth":"tel","val":"15500000000","resp":"verification_token","params":{"challenge_id":"chg_123"}}]}}` | `{"ctrl":{"id":"acc-1","code":200,"params":{"user":"usr...","username":"alice","token":"token...","expires":"2026-09-03T09:00:00Z"}}}` | | WebSocket `acc new` | `{"acc":{"id":"acc-1","user":"new","scheme":"basic","username":"alice","phone":"15500000000","secret":"base64(alice:password)","login":true,"cred":[{"meth":"tel","val":"15500000000","resp":"verification_token","params":{"challenge_id":"chg_123"}}]}}` | `{"ctrl":{"id":"acc-1","code":200,"params":{"user":"usr...","username":"alice","token":"token...","expires":"2026-09-03T09:00:00Z"}}}` |
| `POST /v0/channels/lp` | Long Polling 首包 `{"hi":{"id":"hi-1","ver":"0.25","tenant":"default"}}` | `{"ctrl":{"id":"hi-1","code":201,"params":{"sid":"lp_sid_123"}}}` | | `POST /v0/channels/lp` | Long Polling 首包 `{"hi":{"id":"hi-1","ver":"0.25","tenant":"default"}}` | `{"ctrl":{"id":"hi-1","code":201,"params":{"sid":"lp_sid_123"}}}` |
| `POST /v0/file/u/` | Multipart 文件;Header `X-Tinode-Auth: token <base64_token>`;业务表单可带 `purpose=chat_file&topic=grp...` | `{"ctrl":{"code":200,"params":{"url":"/v0/file/s/file123","name":"a.pdf","size":1024,"mime":"application/pdf"}}}` | | `POST /v0/file/u/` | Multipart 文件;Header `X-MeetingIM-Auth: token <base64_token>`;业务表单可带 `purpose=chat_file&topic=grp...` | `{"ctrl":{"code":200,"params":{"url":"/v0/file/s/file123","name":"a.pdf","size":1024,"mime":"application/pdf"}}}` |
| `GET /v0/file/s/{file_id}` | Header `X-Tinode-Auth: token <base64_token>`,可带 `Range: bytes=0-` | 返回文件字节流;失败返回 `FILE_FORBIDDEN` 或 `FILE_UNAVAILABLE` | | `GET /v0/file/s/{file_id}` | Header `X-MeetingIM-Auth: token <base64_token>`,可带 `Range: bytes=0-` | 返回文件字节流;失败返回 `FILE_FORBIDDEN` 或 `FILE_UNAVAILABLE` |
### 0.6 Topic 与产品消息 ### 0.6 Topic 与产品消息
@@ -481,7 +481,7 @@ X-Tinode-APIKey: <预置的普通 API Key>
**企业解析接口:** **企业解析接口:**
- `POST /v1/im/tenants/resolve` 未登录可调用,但必须带 `X-Tinode-APIKey` 和 `X-IM-Schema: 1`;Header 传 `X-Tenant-Code: acme`,Body 可传 `{"tenant_code":"acme","platform":"android","app_version":"1.0.0"}`。 - `POST /v1/im/tenants/resolve` 未登录可调用,但必须带 `X-MeetingIM-APIKey` 和 `X-IM-Schema: 1`;Header 传 `X-Tenant-Code: acme`,Body 可传 `{"tenant_code":"acme","platform":"android","app_version":"1.0.0"}`。
- 成功数据:`tenant{code,name,tenant_desc,branding}`、`endpoints{websocket,api,file}`、`auth_methods`、`password_policy`、`app_update`。不得返回 `tenant_id` 或 `tenant_ticket`。 - 成功数据:`tenant{code,name,tenant_desc,branding}`、`endpoints{websocket,api,file}`、`auth_methods`、`password_policy`、`app_update`。不得返回 `tenant_id` 或 `tenant_ticket`。
- 后续:保存公开企业信息,连接响应给出的 WebSocket 地址,仍然发送 0.5 节的 `hi(tenant)` 做最终租户绑定;`hi` 成功后才进入 1.2。 - 后续:保存公开企业信息,连接响应给出的 WebSocket 地址,仍然发送 0.5 节的 `hi(tenant)` 做最终租户绑定;`hi` 成功后才进入 1.2。
- 目标失败协议:企业不存在、停用或不允许登录统一返回 `TENANT_UNAVAILABLE`;客户端不得区分不存在和停用。网络失败保留输入并允许重试。 - 目标失败协议:企业不存在、停用或不允许登录统一返回 `TENANT_UNAVAILABLE`;客户端不得区分不存在和停用。网络失败保留输入并允许重试。
@@ -530,7 +530,7 @@ X-Tinode-APIKey: <预置的普通 API Key>
- 可选字段:`license_file_id` 营业执照图片文件 ID,`enterprise_email` 企业邮箱。邮箱用于接收审核结果和后续通知,填写时必须符合邮箱格式。 - 可选字段:`license_file_id` 营业执照图片文件 ID,`enterprise_email` 企业邮箱。邮箱用于接收审核结果和后续通知,填写时必须符合邮箱格式。
- 扫描识别:扫码或 OCR 只能作为填充辅助,提交前仍以输入框中的 `credit_code` 为准;识别失败不阻塞手动填写。 - 扫描识别:扫码或 OCR 只能作为填充辅助,提交前仍以输入框中的 `credit_code` 为准;识别失败不阻塞手动填写。
- 图片上传:营业执照先调用公开用途受限上传接口,`purpose=enterprise_license`,支持 `jpg/png/webp/pdf`,单文件不超过 10MB。服务端通过当前 media handler 存储对象,生产环境必须配置 `oss` handler;上传成功返回 `file_id` 后再提交申请;未上传营业执照时 `license_file_id` 可为空。 - 图片上传:营业执照先调用公开用途受限上传接口,`purpose=enterprise_license`,支持 `jpg/png/webp/pdf`,单文件不超过 10MB。服务端通过当前 media handler 存储对象,生产环境必须配置 `oss` handler;上传成功返回 `file_id` 后再提交申请;未上传营业执照时 `license_file_id` 可为空。
- 提交接口:`POST /v1/im/tenant-applications`,未登录可调用,必须带 `X-Tinode-APIKey`、`X-IM-Schema: 1`、`X-Request-ID` 和 `Idempotency-Key`。 - 提交接口:`POST /v1/im/tenant-applications`,未登录可调用,必须带 `X-MeetingIM-APIKey`、`X-IM-Schema: 1`、`X-Request-ID` 和 `Idempotency-Key`。
- 请求体: - 请求体:
```json ```json
@@ -581,7 +581,7 @@ X-Tinode-APIKey: <预置的普通 API Key>
- 使用场景:企业码不可用、企业注册资料提交异常、审核通知未收到、登录入口无法进入等未登录阶段问题。 - 使用场景:企业码不可用、企业注册资料提交异常、审核通知未收到、登录入口无法进入等未登录阶段问题。
- 输入字段:`description` 必填,长度 10~1000 字符;`contact` 可选,用于客服回访;`tenant_code_hint` 可选,来自用户输入;`screenshot_file_ids` 最多 9 个。 - 输入字段:`description` 必填,长度 10~1000 字符;`contact` 可选,用于客服回访;`tenant_code_hint` 可选,来自用户输入;`screenshot_file_ids` 最多 9 个。
- 截图上传:截图先调用公开用途受限上传接口,`purpose=tenant_entry_feedback`,支持 `jpg/png/webp`,单张不超过 10MB。服务端通过当前 media handler 存储对象,生产环境必须配置 `oss` handler;上传失败时允许单张重试或删除。 - 截图上传:截图先调用公开用途受限上传接口,`purpose=tenant_entry_feedback`,支持 `jpg/png/webp`,单张不超过 10MB。服务端通过当前 media handler 存储对象,生产环境必须配置 `oss` handler;上传失败时允许单张重试或删除。
- 提交接口:`POST /v1/im/tenant-entry-feedback`,未登录可调用,必须带 `X-Tinode-APIKey`、`X-IM-Schema: 1`、`X-Request-ID` 和 `Idempotency-Key`。 - 提交接口:`POST /v1/im/tenant-entry-feedback`,未登录可调用,必须带 `X-MeetingIM-APIKey`、`X-IM-Schema: 1`、`X-Request-ID` 和 `Idempotency-Key`。
- 请求体: - 请求体:
```json ```json
@@ -619,8 +619,8 @@ X-Tinode-APIKey: <预置的普通 API Key>
- 前端动作:账号、密码非空且已勾选协议后启用登录;密码仅驻留当前页面内存。 - 前端动作:账号、密码非空且已勾选协议后启用登录;密码仅驻留当前页面内存。
- 协议:已完成 `hi` 的 WebSocket 发送 `{login scheme:"basic"}`,`secret=base64(username:password)`;Base64 不是加密,必须通过 WSS 传输,不得先登录再传企业码。 - 协议:已完成 `hi` 的 WebSocket 发送 `{login scheme:"basic"}`,`secret=base64(username:password)`;Base64 不是加密,必须通过 WSS 传输,不得先登录再传企业码。
- 成功:读取 `{ctrl params.user/token/expires/authlvl}`,Token 存 Keychain/Keystore;立即 `sub me`,再加载会话。前端不解码 Token,也不再次 Base64 编码。 - 成功:读取 `{ctrl params.user/token/expires/authlvl}`,Token 存 Keychain/Keystore;立即 `sub me`,再加载会话。前端不解码 Token,也不再次 Base64 编码。
- 产品 HTTP:使用同一个 `params.token` 作为 `Authorization: Bearer <params.token>`;所有 `/v1/im/*` HTTP 请求仍必须带 `X-Tinode-APIKey`、`X-IM-Schema: 1`,写请求再带 `Idempotency-Key`。 - 产品 HTTP:使用同一个 `params.token` 作为 `Authorization: Bearer <params.token>`;所有 `/v1/im/*` HTTP 请求仍必须带 `X-MeetingIM-APIKey`、`X-IM-Schema: 1`,写请求再带 `Idempotency-Key`。
- Token:当前复用 Tinode 单一登录 Token,默认有效期 14 天,不使用 Refresh Token。有效 Token 登录成功会签发新的 14 天 Token,客户端覆盖旧值;过期后重新账号密码登录。Token 已绑定 `tenant_id/user_id/auth_version`,修改密码、封禁账号后旧 Token 应失效。 - Token:当前复用 MeetingIM 单一登录 Token,默认有效期 14 天,不使用 Refresh Token。有效 Token 登录成功会签发新的 14 天 Token,客户端覆盖旧值;过期后重新账号密码登录。Token 已绑定 `tenant_id/user_id/auth_version`,修改密码、封禁账号后旧 Token 应失效。
- 密码:服务端使用 `bcrypt.DefaultCost=10` 生成单向哈希,`im_auth.secret` 只保存哈希;客户端和服务端不得记录密码或完整 Basic `secret`。 - 密码:服务端使用 `bcrypt.DefaultCost=10` 生成单向哈希,`im_auth.secret` 只保存哈希;客户端和服务端不得记录密码或完整 Basic `secret`。
- 错误:`AUTH_FAILED` 统一显示账号或密码错误;`TENANT_MISMATCH` 清 Token 并回企业码页;`ACCOUNT_DISABLED`、`REALNAME_REQUIRED`、`YOUTH_RESTRICTED` 按专用页面处理。 - 错误:`AUTH_FAILED` 统一显示账号或密码错误;`TENANT_MISMATCH` 清 Token 并回企业码页;`ACCOUNT_DISABLED`、`REALNAME_REQUIRED`、`YOUTH_RESTRICTED` 按专用页面处理。
@@ -778,7 +778,7 @@ X-Tinode-APIKey: <预置的普通 API Key>
以下示例用于本章 HTTP 联调快速查阅。除特别说明外: 以下示例用于本章 HTTP 联调快速查阅。除特别说明外:
- 产品 HTTP 均使用 `https://{host}/{api_path}v1/im`,请求头包含 `X-Tinode-APIKey`、`X-IM-Schema: 1`、`X-Request-ID`;登录后接口再带 `Authorization: Bearer <token>`。 - 产品 HTTP 均使用 `https://{host}/{api_path}v1/im`,请求头包含 `X-MeetingIM-APIKey`、`X-IM-Schema: 1`、`X-Request-ID`;登录后接口再带 `Authorization: Bearer <token>`。
- 写接口必须带 `Idempotency-Key`。二次认证接口按正文说明额外带 `X-Action-Token` 或 `X-Risk-Action-Token`。 - 写接口必须带 `Idempotency-Key`。二次认证接口按正文说明额外带 `X-Action-Token` 或 `X-Risk-Action-Token`。
- 成功响应统一包裹为 `{"code":0,"data":示例,"request_id":"...","server_time":"..."}`;下表只展示 `data`。 - 成功响应统一包裹为 `{"code":0,"data":示例,"request_id":"...","server_time":"..."}`;下表只展示 `data`。
- 失败响应统一包裹为 `{"code":30000,"reason":"MALFORMED_REQUEST","message":"...","details":{},"request_id":"...","server_time":"..."}`。 - 失败响应统一包裹为 `{"code":30000,"reason":"MALFORMED_REQUEST","message":"...","details":{},"request_id":"...","server_time":"..."}`。
@@ -842,7 +842,7 @@ X-Tinode-APIKey: <预置的普通 API Key>
**状态:基础消息操作部分已实现;产品权限矩阵需改造。** **状态:基础消息操作部分已实现;产品权限矩阵需改造。**
- 长按本身不请求。客户端根据消息的 `type/from/seq/created_at/status`、当前 Topic 权限和服务端下发的 `message_policy` 预裁剪菜单。 - 长按本身不请求。客户端根据消息的 `type/from/seq/created_at/status`、当前 Topic 权限和服务端下发的 `message_policy` 预裁剪菜单。
- 引用/复制为本地动作;收藏见 4.16;转发见 4.17~4.20;删除使用 Tinode `{del what:"msg",topic,delseq}`;撤回需新增 `POST /v1/im/topics/{topic}/messages/{seq}/recall`。 - 引用/复制为本地动作;收藏见 4.16;转发见 4.17~4.20;删除使用 MeetingIM `{del what:"msg",topic,delseq}`;撤回需新增 `POST /v1/im/topics/{topic}/messages/{seq}/recall`。
- 编辑使用 `POST /v1/im/topics/{topic}/messages/{seq}/revisions`,Body `{"content":...,"client_mid":"..."}`;仅文本且在时限内允许。 - 编辑使用 `POST /v1/im/topics/{topic}/messages/{seq}/revisions`,Body `{"content":...,"client_mid":"..."}`;仅文本且在时限内允许。
- 群回执调用 `GET /v1/im/topics/{topic}/messages/{seq}/receipts?cursor=`。所有最终操作由服务端验证消息归属、时限、群角色和租户。 - 群回执调用 `GET /v1/im/topics/{topic}/messages/{seq}/receipts?cursor=`。所有最终操作由服务端验证消息归属、时限、群角色和租户。
@@ -861,7 +861,7 @@ X-Tinode-APIKey: <预置的普通 API Key>
以下示例用于本章 HTTP 联调快速查阅。除特别说明外: 以下示例用于本章 HTTP 联调快速查阅。除特别说明外:
- 产品 HTTP 均使用 `https://{host}/{api_path}v1/im`,请求头包含 `X-Tinode-APIKey`、`X-IM-Schema: 1`、`X-Request-ID`;登录后接口再带 `Authorization: Bearer <token>`。 - 产品 HTTP 均使用 `https://{host}/{api_path}v1/im`,请求头包含 `X-MeetingIM-APIKey`、`X-IM-Schema: 1`、`X-Request-ID`;登录后接口再带 `Authorization: Bearer <token>`。
- 写接口必须带 `Idempotency-Key`。二次认证接口按正文说明额外带 `X-Action-Token` 或 `X-Risk-Action-Token`。 - 写接口必须带 `Idempotency-Key`。二次认证接口按正文说明额外带 `X-Action-Token` 或 `X-Risk-Action-Token`。
- 成功响应统一包裹为 `{"code":0,"data":示例,"request_id":"...","server_time":"..."}`;下表只展示 `data`。 - 成功响应统一包裹为 `{"code":0,"data":示例,"request_id":"...","server_time":"..."}`;下表只展示 `data`。
- 失败响应统一包裹为 `{"code":30000,"reason":"MALFORMED_REQUEST","message":"...","details":{},"request_id":"...","server_time":"..."}`。 - 失败响应统一包裹为 `{"code":30000,"reason":"MALFORMED_REQUEST","message":"...","details":{},"request_id":"...","server_time":"..."}`。
@@ -1194,7 +1194,7 @@ X-Tinode-APIKey: <预置的普通 API Key>
以下示例用于本章 HTTP 联调快速查阅。除特别说明外: 以下示例用于本章 HTTP 联调快速查阅。除特别说明外:
- 产品 HTTP 均使用 `https://{host}/{api_path}v1/im`,请求头包含 `X-Tinode-APIKey`、`X-IM-Schema: 1`、`X-Request-ID`;登录后接口再带 `Authorization: Bearer <token>`。 - 产品 HTTP 均使用 `https://{host}/{api_path}v1/im`,请求头包含 `X-MeetingIM-APIKey`、`X-IM-Schema: 1`、`X-Request-ID`;登录后接口再带 `Authorization: Bearer <token>`。
- 写接口必须带 `Idempotency-Key`。二次认证接口按正文说明额外带 `X-Action-Token` 或 `X-Risk-Action-Token`。 - 写接口必须带 `Idempotency-Key`。二次认证接口按正文说明额外带 `X-Action-Token` 或 `X-Risk-Action-Token`。
- 成功响应统一包裹为 `{"code":0,"data":示例,"request_id":"...","server_time":"..."}`;下表只展示 `data`。 - 成功响应统一包裹为 `{"code":0,"data":示例,"request_id":"...","server_time":"..."}`;下表只展示 `data`。
- 失败响应统一包裹为 `{"code":30000,"reason":"MALFORMED_REQUEST","message":"...","details":{},"request_id":"...","server_time":"..."}`。 - 失败响应统一包裹为 `{"code":30000,"reason":"MALFORMED_REQUEST","message":"...","details":{},"request_id":"...","server_time":"..."}`。
@@ -1361,7 +1361,7 @@ X-Tinode-APIKey: <预置的普通 API Key>
**状态:图片文件名与画面语义不一致;按“清除聊天记录”实施,需改造。** **状态:图片文件名与画面语义不一致;按“清除聊天记录”实施,需改造。**
- 确认调用 Tinode `{del what:"msg",topic,delseq:[{"low":1,"hi":last_seq+1}],hard:false}`,仅删除当前用户视图;或封装 `DELETE /v1/im/conversations/{topic}/messages`。 - 确认调用 MeetingIM `{del what:"msg",topic,delseq:[{"low":1,"hi":last_seq+1}],hard:false}`,仅删除当前用户视图;或封装 `DELETE /v1/im/conversations/{topic}/messages`。
- 不解除好友、不影响对方历史、不删除收藏;附件仅在无任何有效引用且过保留期后回收。 - 不解除好友、不影响对方历史、不删除收藏;附件仅在无任何有效引用且过保留期后回收。
- 前端需在确认文案明确“仅清除自己的聊天记录”,成功后清本地 Topic 消息缓存并保留会话关系。 - 前端需在确认文案明确“仅清除自己的聊天记录”,成功后清本地 Topic 消息缓存并保留会话关系。
@@ -1369,7 +1369,7 @@ X-Tinode-APIKey: <预置的普通 API Key>
以下示例用于本章 HTTP 联调快速查阅。除特别说明外: 以下示例用于本章 HTTP 联调快速查阅。除特别说明外:
- 产品 HTTP 均使用 `https://{host}/{api_path}v1/im`,请求头包含 `X-Tinode-APIKey`、`X-IM-Schema: 1`、`X-Request-ID`;登录后接口再带 `Authorization: Bearer <token>`。 - 产品 HTTP 均使用 `https://{host}/{api_path}v1/im`,请求头包含 `X-MeetingIM-APIKey`、`X-IM-Schema: 1`、`X-Request-ID`;登录后接口再带 `Authorization: Bearer <token>`。
- 写接口必须带 `Idempotency-Key`。二次认证接口按正文说明额外带 `X-Action-Token` 或 `X-Risk-Action-Token`。 - 写接口必须带 `Idempotency-Key`。二次认证接口按正文说明额外带 `X-Action-Token` 或 `X-Risk-Action-Token`。
- 成功响应统一包裹为 `{"code":0,"data":示例,"request_id":"...","server_time":"..."}`;下表只展示 `data`。 - 成功响应统一包裹为 `{"code":0,"data":示例,"request_id":"...","server_time":"..."}`;下表只展示 `data`。
- 失败响应统一包裹为 `{"code":30000,"reason":"MALFORMED_REQUEST","message":"...","details":{},"request_id":"...","server_time":"..."}`。 - 失败响应统一包裹为 `{"code":30000,"reason":"MALFORMED_REQUEST","message":"...","details":{},"request_id":"...","server_time":"..."}`。
@@ -1404,7 +1404,7 @@ X-Tinode-APIKey: <预置的普通 API Key>
<img src="images/群聊.png" alt="群聊" width="360"> <img src="images/群聊.png" alt="群聊" width="360">
**状态:Tinode 群 Topic 已实现基础能力;产品群列表与配额需改造。** **状态:MeetingIM 群 Topic 已实现基础能力;产品群列表与配额需改造。**
- 列表:`GET /v1/im/groups?category=owned|managed|joined&query=&cursor=`,返回 `topic,name,avatar_file_id,member_count,my_role,updated_at`。 - 列表:`GET /v1/im/groups?category=owned|managed|joined&query=&cursor=`,返回 `topic,name,avatar_file_id,member_count,my_role,updated_at`。
- 创建:`POST /v1/im/groups`,Body `{"name":"...","description":"...","member_user_ids":[],"avatar_file_id":"..."}`;`description` 可省略或传空字符串,最长 200 字符;原子校验租户群数上限和群成员上限,返回 `topic,name,avatar_file_id,description,owner,member_count,version,permissions`。 - 创建:`POST /v1/im/groups`,Body `{"name":"...","description":"...","member_user_ids":[],"avatar_file_id":"..."}`;`description` 可省略或传空字符串,最长 200 字符;原子校验租户群数上限和群成员上限,返回 `topic,name,avatar_file_id,description,owner,member_count,version,permissions`。
@@ -1677,7 +1677,7 @@ X-Tinode-APIKey: <预置的普通 API Key>
以下示例用于本章 HTTP 联调快速查阅。除特别说明外: 以下示例用于本章 HTTP 联调快速查阅。除特别说明外:
- 产品 HTTP 均使用 `https://{host}/{api_path}v1/im`,请求头包含 `X-Tinode-APIKey`、`X-IM-Schema: 1`、`X-Request-ID`;登录后接口再带 `Authorization: Bearer <token>`。 - 产品 HTTP 均使用 `https://{host}/{api_path}v1/im`,请求头包含 `X-MeetingIM-APIKey`、`X-IM-Schema: 1`、`X-Request-ID`;登录后接口再带 `Authorization: Bearer <token>`。
- 写接口必须带 `Idempotency-Key`。二次认证接口按正文说明额外带 `X-Action-Token` 或 `X-Risk-Action-Token`。 - 写接口必须带 `Idempotency-Key`。二次认证接口按正文说明额外带 `X-Action-Token` 或 `X-Risk-Action-Token`。
- 成功响应统一包裹为 `{"code":0,"data":示例,"request_id":"...","server_time":"..."}`;下表只展示 `data`。 - 成功响应统一包裹为 `{"code":0,"data":示例,"request_id":"...","server_time":"..."}`;下表只展示 `data`。
- 失败响应统一包裹为 `{"code":30000,"reason":"MALFORMED_REQUEST","message":"...","details":{},"request_id":"...","server_time":"..."}`。 - 失败响应统一包裹为 `{"code":30000,"reason":"MALFORMED_REQUEST","message":"...","details":{},"request_id":"...","server_time":"..."}`。
@@ -1812,7 +1812,7 @@ X-Tinode-APIKey: <预置的普通 API Key>
**状态:底层下载已实现租户校验;资源 ACL 需改造。** **状态:底层下载已实现租户校验;资源 ACL 需改造。**
- 客户端使用 `GET /v0/file/s/{file_id}`,Header 传 API Key 与 `X-Tinode-Auth: token <base64_token>`;禁止 `?auth=&secret=`。 - 客户端使用 `GET /v0/file/s/{file_id}`,Header 传 API Key 与 `X-MeetingIM-Auth: token <base64_token>`;禁止 `?auth=&secret=`。
- 服务端除 Token 租户外,还必须校验该用户通过消息、群文件或收藏拥有有效引用,并支持 `Range`、`ETag`、内容长度和安全文件名。 - 服务端除 Token 租户外,还必须校验该用户通过消息、群文件或收藏拥有有效引用,并支持 `Range`、`ETag`、内容长度和安全文件名。
- 下载前客户端校验本地空间和网络策略;`FILE_FORBIDDEN` 不自动重试,`FILE_UNAVAILABLE` 显示失效。 - 下载前客户端校验本地空间和网络策略;`FILE_FORBIDDEN` 不自动重试,`FILE_UNAVAILABLE` 显示失效。
@@ -1885,7 +1885,7 @@ X-Tinode-APIKey: <预置的普通 API Key>
以下示例用于本章 HTTP 联调快速查阅。除特别说明外: 以下示例用于本章 HTTP 联调快速查阅。除特别说明外:
- 产品 HTTP 均使用 `https://{host}/{api_path}v1/im`,请求头包含 `X-Tinode-APIKey`、`X-IM-Schema: 1`、`X-Request-ID`;登录后接口再带 `Authorization: Bearer <token>`。 - 产品 HTTP 均使用 `https://{host}/{api_path}v1/im`,请求头包含 `X-MeetingIM-APIKey`、`X-IM-Schema: 1`、`X-Request-ID`;登录后接口再带 `Authorization: Bearer <token>`。
- 写接口必须带 `Idempotency-Key`。二次认证接口按正文说明额外带 `X-Action-Token` 或 `X-Risk-Action-Token`。 - 写接口必须带 `Idempotency-Key`。二次认证接口按正文说明额外带 `X-Action-Token` 或 `X-Risk-Action-Token`。
- 成功响应统一包裹为 `{"code":0,"data":示例,"request_id":"...","server_time":"..."}`;下表只展示 `data`。 - 成功响应统一包裹为 `{"code":0,"data":示例,"request_id":"...","server_time":"..."}`;下表只展示 `data`。
- 失败响应统一包裹为 `{"code":30000,"reason":"MALFORMED_REQUEST","message":"...","details":{},"request_id":"...","server_time":"..."}`。 - 失败响应统一包裹为 `{"code":30000,"reason":"MALFORMED_REQUEST","message":"...","details":{},"request_id":"...","server_time":"..."}`。
@@ -2022,7 +2022,7 @@ X-Tinode-APIKey: <预置的普通 API Key>
以下示例用于本章 HTTP 联调快速查阅。除特别说明外: 以下示例用于本章 HTTP 联调快速查阅。除特别说明外:
- 产品 HTTP 均使用 `https://{host}/{api_path}v1/im`,请求头包含 `X-Tinode-APIKey`、`X-IM-Schema: 1`、`X-Request-ID`;登录后接口再带 `Authorization: Bearer <token>`。 - 产品 HTTP 均使用 `https://{host}/{api_path}v1/im`,请求头包含 `X-MeetingIM-APIKey`、`X-IM-Schema: 1`、`X-Request-ID`;登录后接口再带 `Authorization: Bearer <token>`。
- 写接口必须带 `Idempotency-Key`。二次认证接口按正文说明额外带 `X-Action-Token` 或 `X-Risk-Action-Token`。 - 写接口必须带 `Idempotency-Key`。二次认证接口按正文说明额外带 `X-Action-Token` 或 `X-Risk-Action-Token`。
- 成功响应统一包裹为 `{"code":0,"data":示例,"request_id":"...","server_time":"..."}`;下表只展示 `data`。 - 成功响应统一包裹为 `{"code":0,"data":示例,"request_id":"...","server_time":"..."}`;下表只展示 `data`。
- 失败响应统一包裹为 `{"code":30000,"reason":"MALFORMED_REQUEST","message":"...","details":{},"request_id":"...","server_time":"..."}`。 - 失败响应统一包裹为 `{"code":30000,"reason":"MALFORMED_REQUEST","message":"...","details":{},"request_id":"...","server_time":"..."}`。
@@ -2059,6 +2059,7 @@ X-Tinode-APIKey: <预置的普通 API Key>
- 根节点:`GET /v1/im/organization/departments?parent_id=root&cursor=`;子部门同接口传 `parent_id`;成员 `GET /v1/im/organization/departments/{id}/members?cursor=`。 - 根节点:`GET /v1/im/organization/departments?parent_id=root&cursor=`;子部门同接口传 `parent_id`;成员 `GET /v1/im/organization/departments/{id}/members?cursor=`。
- 部门返回 `department_id,name,parent_id,child_count,member_count,version`;成员只返回当前查看人可见字段。 - 部门返回 `department_id,name,parent_id,child_count,member_count,version`;成员只返回当前查看人可见字段。
- 全局搜索:`GET /v1/im/organization/search?q=&types=department,user&cursor=`。服务端执行组织范围授权,离职用户标记不可联系或从结果移除。 - 全局搜索:`GET /v1/im/organization/search?q=&types=department,user&cursor=`。服务端执行组织范围授权,离职用户标记不可联系或从结果移除。
- 组织架构可见范围由服务端按当前登录用户裁剪:上架测试账号可看全部组织;手机号注册账号默认只能看到自己与好友所在组织路径及成员;邀请码注册账号默认只能看到自己所在组织路径及本人。上架测试账号通过租户业务库 `im_org_visibility_overrides` 配置 `visibility_scope=all`,客户端不传可见范围参数。
### 9.3 会议通知-发起人状态.png ### 9.3 会议通知-发起人状态.png
@@ -2173,7 +2174,7 @@ X-Tinode-APIKey: <预置的普通 API Key>
以下示例用于本章 HTTP 联调快速查阅。除特别说明外: 以下示例用于本章 HTTP 联调快速查阅。除特别说明外:
- 产品 HTTP 均使用 `https://{host}/{api_path}v1/im`,请求头包含 `X-Tinode-APIKey`、`X-IM-Schema: 1`、`X-Request-ID`;登录后接口再带 `Authorization: Bearer <token>`。 - 产品 HTTP 均使用 `https://{host}/{api_path}v1/im`,请求头包含 `X-MeetingIM-APIKey`、`X-IM-Schema: 1`、`X-Request-ID`;登录后接口再带 `Authorization: Bearer <token>`。
- 写接口必须带 `Idempotency-Key`。二次认证接口按正文说明额外带 `X-Action-Token` 或 `X-Risk-Action-Token`。 - 写接口必须带 `Idempotency-Key`。二次认证接口按正文说明额外带 `X-Action-Token` 或 `X-Risk-Action-Token`。
- 成功响应统一包裹为 `{"code":0,"data":示例,"request_id":"...","server_time":"..."}`;下表只展示 `data`。 - 成功响应统一包裹为 `{"code":0,"data":示例,"request_id":"...","server_time":"..."}`;下表只展示 `data`。
- 失败响应统一包裹为 `{"code":30000,"reason":"MALFORMED_REQUEST","message":"...","details":{},"request_id":"...","server_time":"..."}`。 - 失败响应统一包裹为 `{"code":30000,"reason":"MALFORMED_REQUEST","message":"...","details":{},"request_id":"...","server_time":"..."}`。
@@ -2230,10 +2231,10 @@ X-Tinode-APIKey: <预置的普通 API Key>
<img src="images/个人信息.png" alt="个人信息" width="360"> <img src="images/个人信息.png" alt="个人信息" width="360">
**状态:Tinode `me` 资料更新已有基础;目标产品接口需改造。** **状态:MeetingIM `me` 资料更新已有基础;目标产品接口需改造。**
- 查询 `GET /v1/im/me/profile` 返回 `avatar_file_id,nickname,birth_date,signature,gender,region,email,public_account,version,field_rules`;`email` 当前仅为资料字段,不用于登录或找回密码。 - 查询 `GET /v1/im/me/profile` 返回 `avatar_file_id,nickname,birth_date,signature,gender,region,email,public_account,version,field_rules`;`email` 当前仅为资料字段,不用于登录或找回密码。
- 更新统一使用 `PATCH /v1/im/me/profile`,仅提交变更字段并带 `version`;服务端更新后同步 Tinode `me.public` 并推送资料事件。 - 更新统一使用 `PATCH /v1/im/me/profile`,仅提交变更字段并带 `version`;服务端更新后同步 MeetingIM `me.public` 并推送资料事件。
- 头像先以 `purpose=user_avatar` 上传再 PATCH;任何字段的可见性由隐私策略裁剪。 - 头像先以 `purpose=user_avatar` 上传再 PATCH;任何字段的可见性由隐私策略裁剪。
### 10.3 设置昵称.png ### 10.3 设置昵称.png
@@ -2420,7 +2421,7 @@ X-Tinode-APIKey: <预置的普通 API Key>
以下示例用于本章 HTTP 联调快速查阅。除特别说明外: 以下示例用于本章 HTTP 联调快速查阅。除特别说明外:
- 产品 HTTP 均使用 `https://{host}/{api_path}v1/im`,请求头包含 `X-Tinode-APIKey`、`X-IM-Schema: 1`、`X-Request-ID`;登录后接口再带 `Authorization: Bearer <token>`。 - 产品 HTTP 均使用 `https://{host}/{api_path}v1/im`,请求头包含 `X-MeetingIM-APIKey`、`X-IM-Schema: 1`、`X-Request-ID`;登录后接口再带 `Authorization: Bearer <token>`。
- 写接口必须带 `Idempotency-Key`。二次认证接口按正文说明额外带 `X-Action-Token` 或 `X-Risk-Action-Token`。 - 写接口必须带 `Idempotency-Key`。二次认证接口按正文说明额外带 `X-Action-Token` 或 `X-Risk-Action-Token`。
- 成功响应统一包裹为 `{"code":0,"data":示例,"request_id":"...","server_time":"..."}`;下表只展示 `data`。 - 成功响应统一包裹为 `{"code":0,"data":示例,"request_id":"...","server_time":"..."}`;下表只展示 `data`。
- 失败响应统一包裹为 `{"code":30000,"reason":"MALFORMED_REQUEST","message":"...","details":{},"request_id":"...","server_time":"..."}`。 - 失败响应统一包裹为 `{"code":30000,"reason":"MALFORMED_REQUEST","message":"...","details":{},"request_id":"...","server_time":"..."}`。
@@ -2505,7 +2506,7 @@ X-Tinode-APIKey: <预置的普通 API Key>
以下示例用于本章 HTTP 联调快速查阅。除特别说明外: 以下示例用于本章 HTTP 联调快速查阅。除特别说明外:
- 产品 HTTP 均使用 `https://{host}/{api_path}v1/im`,请求头包含 `X-Tinode-APIKey`、`X-IM-Schema: 1`、`X-Request-ID`;登录后接口再带 `Authorization: Bearer <token>`。 - 产品 HTTP 均使用 `https://{host}/{api_path}v1/im`,请求头包含 `X-MeetingIM-APIKey`、`X-IM-Schema: 1`、`X-Request-ID`;登录后接口再带 `Authorization: Bearer <token>`。
- 写接口必须带 `Idempotency-Key`。二次认证接口按正文说明额外带 `X-Action-Token` 或 `X-Risk-Action-Token`。 - 写接口必须带 `Idempotency-Key`。二次认证接口按正文说明额外带 `X-Action-Token` 或 `X-Risk-Action-Token`。
- 成功响应统一包裹为 `{"code":0,"data":示例,"request_id":"...","server_time":"..."}`;下表只展示 `data`。 - 成功响应统一包裹为 `{"code":0,"data":示例,"request_id":"...","server_time":"..."}`;下表只展示 `data`。
- 失败响应统一包裹为 `{"code":30000,"reason":"MALFORMED_REQUEST","message":"...","details":{},"request_id":"...","server_time":"..."}`。 - 失败响应统一包裹为 `{"code":30000,"reason":"MALFORMED_REQUEST","message":"...","details":{},"request_id":"...","server_time":"..."}`。
@@ -2527,7 +2528,7 @@ X-Tinode-APIKey: <预置的普通 API Key>
1. WebSocket `/v0/channels`、Wire Version `0.25` 和最低版本 `0.20`。 1. WebSocket `/v0/channels`、Wire Version `0.25` 和最低版本 `0.20`。
2. 第一条 `hi` 携带企业码并固定 Session 租户;响应包含公开租户信息、限制、ICE 配置和呼叫超时。 2. 第一条 `hi` 携带企业码并固定 Session 租户;响应包含公开租户信息、限制、ICE 配置和呼叫超时。
3. `basic`/`token` 登录,`sub/get/set/pub/leave/del/note` 基础 Tinode 协议。 3. `basic`/`token` 登录,`sub/get/set/pub/leave/del/note` 基础 MeetingIM 协议。
4. `pub` 成功返回 `202 + seq`,Topic 消息与基础回执同步。 4. `pub` 成功返回 `202 + seq`,Topic 消息与基础回执同步。
5. `/v0/file/u/` 和 `/v0/file/s/{file_id}` 的基础上传下载及租户存储隔离。 5. `/v0/file/u/` 和 `/v0/file/s/{file_id}` 的基础上传下载及租户存储隔离。
+13
View File
@@ -443,6 +443,19 @@ CREATE TABLE IF NOT EXISTS tinode.im_org_members (
CONSTRAINT fk_im_org_members_department FOREIGN KEY (department_id) REFERENCES tinode.im_org_departments(department_id) ON DELETE CASCADE CONSTRAINT fk_im_org_members_department FOREIGN KEY (department_id) REFERENCES tinode.im_org_departments(department_id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
CREATE TABLE IF NOT EXISTS tinode.im_org_visibility_overrides (
id BIGINT NOT NULL AUTO_INCREMENT,
user_id BIGINT NOT NULL,
visibility_scope VARCHAR(16) NOT NULL,
state VARCHAR(16) NOT NULL DEFAULT 'active',
remark VARCHAR(256) NOT NULL DEFAULT '',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
PRIMARY KEY (id),
UNIQUE INDEX uk_im_org_visibility_user (user_id),
INDEX idx_im_org_visibility_scope (visibility_scope, state)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
CREATE TABLE IF NOT EXISTS tinode.im_meetings ( CREATE TABLE IF NOT EXISTS tinode.im_meetings (
id BIGINT NOT NULL AUTO_INCREMENT, id BIGINT NOT NULL AUTO_INCREMENT,
meeting_id VARCHAR(64) NOT NULL, meeting_id VARCHAR(64) NOT NULL,
+2 -1
View File
@@ -280,6 +280,7 @@
10. 会议创建、变更、取消、提醒和参会回应已生成在线事件与系统通知,关键变更在事务内写 Outbox。 10. 会议创建、变更、取消、提醒和参会回应已生成在线事件与系统通知,关键变更在事务内写 Outbox。
11. 组织全局成员接口 `GET /v1/im/organization/members` 已具备。 11. 组织全局成员接口 `GET /v1/im/organization/members` 已具备。
12. 月报 reopen `POST /v1/im/reports/monthly/{id}/reopen` 已具备。 12. 月报 reopen `POST /v1/im/reports/monthly/{id}/reopen` 已具备。
13. 组织架构可见范围已按账号来源裁剪:邀请码注册默认仅本人,手机号注册默认本人和好友组织路径,上架测试账号可通过 `im_org_visibility_overrides.visibility_scope=all` 放开全量。
### 9.2 后续增强项 ### 9.2 后续增强项
@@ -288,7 +289,7 @@
| P0 | 工作台权限与功能开关配置化 | 当前模块基线可返回,后续需要按租户、部门、角色、后台配置动态裁剪。 | 不同用户登录同一租户看到不同授权模块。 | | P0 | 工作台权限与功能开关配置化 | 当前模块基线可返回,后续需要按租户、部门、角色、后台配置动态裁剪。 | 不同用户登录同一租户看到不同授权模块。 |
| P0 | 工作台未读角标实时事件 | `workbench_total_unread` 已有,后续需要模块未读聚合和 `workbench.badge.updated` 增量事件。 | 会议、日报、信箱未读变化后工作台角标实时更新。 | | P0 | 工作台未读角标实时事件 | `workbench_total_unread` 已有,后续需要模块未读聚合和 `workbench.badge.updated` 增量事件。 | 会议、日报、信箱未读变化后工作台角标实时更新。 |
| P0 | 组织数据后台维护链路 | 当前前台读接口和表已具备,后续需要后台导入、增删改部门、调整成员和离职状态。 | 后台修改组织后前台接口版本变化,客户端可刷新。 | | P0 | 组织数据后台维护链路 | 当前前台读接口和表已具备,后续需要后台导入、增删改部门、调整成员和离职状态。 | 后台修改组织后前台接口版本变化,客户端可刷新。 |
| P0 | 组织权限与可见字段裁剪 | 成员手机号、邮箱、职位等字段需要按组织角色、隐私和企业策略裁剪。 | 普通员工和管理员看到的成员字段不同且不可绕过。 | | P0 | 组织字段级隐私策略 | 组织架构范围裁剪已具备,成员手机号、邮箱、职位等字段后续仍需按组织角色、隐私和企业策略进一步细化。 | 普通员工和管理员看到的成员字段不同且不可绕过。 |
| P1 | 组织全局成员接口深度联调 | `GET /v1/im/organization/members` 已具备,仍需按部门、关键词、权限和分页做测试环境回归。 | 前端可直接分页查询并按部门过滤,字段裁剪符合权限。 | | P1 | 组织全局成员接口深度联调 | `GET /v1/im/organization/members` 已具备,仍需按部门、关键词、权限和分页做测试环境回归。 | 前端可直接分页查询并按部门过滤,字段裁剪符合权限。 |
| P1 | 会议列表完整联调 | 对接文档标注 `GET /v1/im/meetings?cursor=` 待补列表;代码有 `serveIMMeetings`,仍需用测试数据确认返回结构。 | 会议列表、详情、响应、取消端到端一致。 | | P1 | 会议列表完整联调 | 对接文档标注 `GET /v1/im/meetings?cursor=` 待补列表;代码有 `serveIMMeetings`,仍需用测试数据确认返回结构。 | 会议列表、详情、响应、取消端到端一致。 |
| P1 | 月报 reopen 审计与权限扩展 | reopen 接口已具备,后续需要后台权限、原因记录、审计和通知闭环。 | 已提交月报可按权限退回草稿并保留审计。 | | P1 | 月报 reopen 审计与权限扩展 | reopen 接口已具备,后续需要后台权限、原因记录、审计和通知闭环。 | 已提交月报可按权限退回草稿并保留审计。 |
+1 -1
View File
@@ -24,7 +24,7 @@
<request subst="true"> <request subst="true">
<websocket type="connect" path="/v0/channels?apikey=AQEAAAABAAD_rAp4DJh05a1HAwFT3A6K"> <websocket type="connect" path="/v0/channels?apikey=AQEAAAABAAD_rAp4DJh05a1HAwFT3A6K">
<!--http_header name="X-Tinode-APIKey" value="AQEAAAABAAD_rAp4DJh05a1HAwFT3A6K"/--> <!--http_header name="X-MeetingIM-APIKey" value="AQEAAAABAAD_rAp4DJh05a1HAwFT3A6K"/-->
</websocket> </websocket>
</request> </request>
+35
View File
@@ -275,6 +275,9 @@ func (a *adapter) Open(jsonconfig json.RawMessage) error {
if err = ensureIMFileReviewTables(a.db); err != nil { if err = ensureIMFileReviewTables(a.db); err != nil {
return errors.New("mysql adapter failed to ensure file review schema: " + err.Error()) return errors.New("mysql adapter failed to ensure file review schema: " + err.Error())
} }
if err = ensureIMOrganizationVisibilityTables(a.db); err != nil {
return errors.New("mysql adapter failed to ensure organization visibility schema: " + err.Error())
}
if err = ensureSnowflakePrimaryKeys(a.db); err != nil { if err = ensureSnowflakePrimaryKeys(a.db); err != nil {
return errors.New("mysql adapter failed to ensure snowflake primary keys: " + err.Error()) return errors.New("mysql adapter failed to ensure snowflake primary keys: " + err.Error())
} }
@@ -507,6 +510,10 @@ func (a *adapter) TenantAdapterFor(tenantID t.TenantID) (dbadapter.TenantBusines
db.Close() db.Close()
return nil, nil, err return nil, nil, err
} }
if err = ensureIMOrganizationVisibilityTables(db); err != nil {
db.Close()
return nil, nil, err
}
if err = ensureSnowflakePrimaryKeys(db); err != nil { if err = ensureSnowflakePrimaryKeys(db); err != nil {
db.Close() db.Close()
return nil, nil, err return nil, nil, err
@@ -1187,6 +1194,18 @@ func createIMOrganizationTables(tx *sql.Tx) error {
INDEX idx_im_org_members_display_name(display_name), INDEX idx_im_org_members_display_name(display_name),
CONSTRAINT fk_im_org_members_department FOREIGN KEY(department_id) REFERENCES im_org_departments(department_id) ON DELETE CASCADE CONSTRAINT fk_im_org_members_department FOREIGN KEY(department_id) REFERENCES im_org_departments(department_id) ON DELETE CASCADE
)`, )`,
`CREATE TABLE im_org_visibility_overrides(
id BIGINT NOT NULL AUTO_INCREMENT,
user_id BIGINT NOT NULL,
visibility_scope VARCHAR(16) NOT NULL,
state VARCHAR(16) NOT NULL DEFAULT 'active',
remark VARCHAR(256) NOT NULL DEFAULT '',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
PRIMARY KEY(id),
UNIQUE INDEX uk_im_org_visibility_user(user_id),
INDEX idx_im_org_visibility_scope(visibility_scope,state)
)`,
} }
for _, stmt := range statements { for _, stmt := range statements {
if _, err := tx.Exec(stmt); err != nil { if _, err := tx.Exec(stmt); err != nil {
@@ -1196,6 +1215,22 @@ func createIMOrganizationTables(tx *sql.Tx) error {
return nil return nil
} }
func ensureIMOrganizationVisibilityTables(exec sqlExecutor) error {
_, err := exec.Exec(`CREATE TABLE IF NOT EXISTS im_org_visibility_overrides(
id BIGINT NOT NULL AUTO_INCREMENT,
user_id BIGINT NOT NULL,
visibility_scope VARCHAR(16) NOT NULL,
state VARCHAR(16) NOT NULL DEFAULT 'active',
remark VARCHAR(256) NOT NULL DEFAULT '',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
PRIMARY KEY(id),
UNIQUE INDEX uk_im_org_visibility_user(user_id),
INDEX idx_im_org_visibility_scope(visibility_scope,state)
)`)
return err
}
func createIMMeetingTables(tx *sql.Tx) error { func createIMMeetingTables(tx *sql.Tx) error {
statements := []string{ statements := []string{
`CREATE TABLE im_meetings( `CREATE TABLE im_meetings(
+174 -24
View File
@@ -2681,12 +2681,28 @@ func (a *adapter) TenantOrganizationDepartments(id t.TenantID, uid t.Uid, parent
if cancel != nil { if cancel != nil {
defer cancel() defer cancel()
} }
rows, err := a.db.QueryxContext(ctx, `SELECT d.id,d.department_id,d.name,d.parent_id,d.sort_order,d.version,d.state,d.created_at,d.updated_at, visibility, err := a.organizationVisibility(ctx, uid)
(SELECT COUNT(*) FROM im_org_departments AS child WHERE child.parent_id=d.department_id AND child.state='active') AS child_count, if err != nil {
(SELECT COUNT(*) FROM im_org_members AS m WHERE m.department_id=d.department_id) AS member_count return nil, "", err
}
cte, cteArgs := visibility.departmentCTE()
memberCountSQL, memberCountArgs := visibility.memberCountSQL("m")
if visibility.Scope != orgVisibilityAll {
where = append(where, "d.department_id IN (SELECT department_id FROM visible_depts)")
}
queryArgs := append([]any{}, cteArgs...)
queryArgs = append(queryArgs, memberCountArgs...)
queryArgs = append(queryArgs, args...)
childCountSQL := `(SELECT COUNT(*) FROM im_org_departments AS child WHERE child.parent_id=d.department_id AND child.state='active')`
if visibility.Scope != orgVisibilityAll {
childCountSQL = `(SELECT COUNT(*) FROM im_org_departments AS child WHERE child.parent_id=d.department_id AND child.state='active' AND child.department_id IN (SELECT department_id FROM visible_depts))`
}
rows, err := a.db.QueryxContext(ctx, cte+`SELECT d.id,d.department_id,d.name,d.parent_id,d.sort_order,d.version,d.state,d.created_at,d.updated_at,
`+childCountSQL+` AS child_count,
(SELECT COUNT(*) FROM im_org_members AS m WHERE m.department_id=d.department_id AND m.employment_state='active'`+memberCountSQL+`) AS member_count
FROM im_org_departments AS d FROM im_org_departments AS d
WHERE `+strings.Join(where, " AND ")+` WHERE `+strings.Join(where, " AND ")+`
ORDER BY d.sort_order ASC,d.id ASC LIMIT ?`, args...) ORDER BY d.sort_order ASC,d.id ASC LIMIT ?`, queryArgs...)
if err != nil { if err != nil {
return nil, "", err return nil, "", err
} }
@@ -2726,7 +2742,7 @@ func (a *adapter) TenantOrganizationMembers(id t.TenantID, uid t.Uid, department
if limit <= 0 || limit > 100 { if limit <= 0 || limit > 100 {
limit = 50 limit = 50
} }
where := []string{"m.department_id=?"} where := []string{"m.department_id=?", "m.employment_state='active'"}
args := []any{strings.TrimSpace(departmentID)} args := []any{strings.TrimSpace(departmentID)}
if cursor != "" { if cursor != "" {
parsed, err := strconv.ParseInt(cursor, 10, 64) parsed, err := strconv.ParseInt(cursor, 10, 64)
@@ -2741,6 +2757,15 @@ func (a *adapter) TenantOrganizationMembers(id t.TenantID, uid t.Uid, department
if cancel != nil { if cancel != nil {
defer cancel() defer cancel()
} }
visibility, err := a.organizationVisibility(ctx, uid)
if err != nil {
return nil, "", err
}
visibilitySQL, visibilityArgs := visibility.memberWhereSQL("m")
if visibilitySQL != "" {
where = append(where, visibilitySQL)
args = append(args, visibilityArgs...)
}
rows, err := a.db.QueryxContext(ctx, `SELECT m.id,m.department_id,m.user_id,m.display_name,m.position,m.avatar_file_id,m.mobile,m.email, rows, err := a.db.QueryxContext(ctx, `SELECT m.id,m.department_id,m.user_id,m.display_name,m.position,m.avatar_file_id,m.mobile,m.email,
m.employment_state,m.contactable,m.sort_order,m.version,m.created_at,m.updated_at m.employment_state,m.contactable,m.sort_order,m.version,m.created_at,m.updated_at
FROM im_org_members AS m FROM im_org_members AS m
@@ -2809,6 +2834,15 @@ func (a *adapter) TenantOrganizationMembersAll(id t.TenantID, uid t.Uid, departm
if cancel != nil { if cancel != nil {
defer cancel() defer cancel()
} }
visibility, err := a.organizationVisibility(ctx, uid)
if err != nil {
return nil, "", err
}
visibilitySQL, visibilityArgs := visibility.memberWhereSQL("m")
if visibilitySQL != "" {
where = append(where, visibilitySQL)
args = append(args, visibilityArgs...)
}
rows, err := a.db.QueryxContext(ctx, `SELECT m.id,m.department_id,m.user_id,m.display_name,m.position,m.avatar_file_id,m.mobile,m.email, rows, err := a.db.QueryxContext(ctx, `SELECT m.id,m.department_id,m.user_id,m.display_name,m.position,m.avatar_file_id,m.mobile,m.email,
m.employment_state,m.contactable,m.sort_order,m.version,m.created_at,m.updated_at m.employment_state,m.contactable,m.sort_order,m.version,m.created_at,m.updated_at
FROM im_org_members AS m FROM im_org_members AS m
@@ -2866,29 +2900,45 @@ func (a *adapter) TenantOrganizationSearch(id t.TenantID, uid t.Uid, queryText,
return nil, "", t.ErrMalformed return nil, "", t.ErrMalformed
} }
like := "%" + strings.TrimSpace(queryText) + "%" like := "%" + strings.TrimSpace(queryText) + "%"
parts := make([]string, 0, 2)
args := make([]any, 0, 6)
if includeDepartments {
parts = append(parts, `SELECT 'department' AS result_type,d.id,d.department_id,d.name,d.parent_id,d.sort_order,d.version,d.state,d.created_at,d.updated_at,
(SELECT COUNT(*) FROM im_org_departments AS child WHERE child.parent_id=d.department_id AND child.state='active') AS child_count,
(SELECT COUNT(*) FROM im_org_members AS m WHERE m.department_id=d.department_id) AS member_count,
0 AS user_id,'' AS display_name,'' AS position,'' AS avatar_file_id,'' AS mobile,'' AS email,'' AS employment_state,0 AS contactable
FROM im_org_departments AS d WHERE d.state='active' AND d.name LIKE ?`)
args = append(args, like)
}
if includeUsers {
parts = append(parts, `SELECT 'user' AS result_type,m.id,'' AS department_id,'' AS name,m.department_id AS parent_id,m.sort_order,m.version,'' AS state,m.created_at,m.updated_at,
0 AS child_count,0 AS member_count,m.user_id,m.display_name,m.position,m.avatar_file_id,m.mobile,m.email,m.employment_state,m.contactable
FROM im_org_members AS m
WHERE m.display_name LIKE ? OR m.mobile LIKE ? OR m.email LIKE ? OR CAST(m.user_id AS CHAR) LIKE ?`)
args = append(args, like, like, like, like)
}
args = append(args, offset, limit+1)
ctx, cancel := a.getContext() ctx, cancel := a.getContext()
if cancel != nil { if cancel != nil {
defer cancel() defer cancel()
} }
rows, err := a.db.QueryxContext(ctx, `SELECT * FROM (`+strings.Join(parts, " UNION ALL ")+`) AS results visibility, err := a.organizationVisibility(ctx, uid)
if err != nil {
return nil, "", err
}
cte, cteArgs := visibility.departmentCTE()
parts := make([]string, 0, 2)
args := make([]any, 0, 10)
args = append(args, cteArgs...)
if includeDepartments {
departmentWhere := "d.state='active' AND d.name LIKE ?"
if visibility.Scope != orgVisibilityAll {
departmentWhere += " AND d.department_id IN (SELECT department_id FROM visible_depts)"
}
parts = append(parts, `SELECT 'department' AS result_type,d.id,d.department_id,d.name,d.parent_id,d.sort_order,d.version,d.state,d.created_at,d.updated_at,
(SELECT COUNT(*) FROM im_org_departments AS child WHERE child.parent_id=d.department_id AND child.state='active') AS child_count,
(SELECT COUNT(*) FROM im_org_members AS m WHERE m.department_id=d.department_id) AS member_count,
0 AS user_id,'' AS display_name,'' AS position,'' AS avatar_file_id,'' AS mobile,'' AS email,'' AS employment_state,0 AS contactable
FROM im_org_departments AS d WHERE `+departmentWhere)
args = append(args, like)
}
if includeUsers {
userWhere := "(m.display_name LIKE ? OR m.mobile LIKE ? OR m.email LIKE ? OR CAST(m.user_id AS CHAR) LIKE ?) AND m.employment_state='active'"
visibilitySQL, visibilityArgs := visibility.memberWhereSQL("m")
if visibilitySQL != "" {
userWhere += " AND " + visibilitySQL
}
parts = append(parts, `SELECT 'user' AS result_type,m.id,'' AS department_id,'' AS name,m.department_id AS parent_id,m.sort_order,m.version,'' AS state,m.created_at,m.updated_at,
0 AS child_count,0 AS member_count,m.user_id,m.display_name,m.position,m.avatar_file_id,m.mobile,m.email,m.employment_state,m.contactable
FROM im_org_members AS m
WHERE `+userWhere)
args = append(args, like, like, like, like)
args = append(args, visibilityArgs...)
}
args = append(args, offset, limit+1)
rows, err := a.db.QueryxContext(ctx, cte+`SELECT * FROM (`+strings.Join(parts, " UNION ALL ")+`) AS results
ORDER BY result_type ASC,id ASC LIMIT ?,?`, args...) ORDER BY result_type ASC,id ASC LIMIT ?,?`, args...)
if err != nil { if err != nil {
return nil, "", err return nil, "", err
@@ -2913,6 +2963,106 @@ func (a *adapter) TenantOrganizationSearch(id t.TenantID, uid t.Uid, queryText,
return items, nextCursor, nil return items, nextCursor, nil
} }
const (
orgVisibilityAll = "all"
orgVisibilityFriends = "friends"
orgVisibilitySelf = "self"
)
type organizationVisibility struct {
Scope string
UserID int64
}
func (a *adapter) organizationVisibility(ctx context.Context, uid t.Uid) (organizationVisibility, error) {
userID := decodeUID(uid)
visibility := organizationVisibility{Scope: orgVisibilitySelf, UserID: userID}
if userID <= 0 {
return visibility, t.ErrMalformed
}
var override string
err := a.db.QueryRowxContext(ctx, `SELECT visibility_scope FROM im_org_visibility_overrides
WHERE user_id=? AND state='active' LIMIT 1`, userID).Scan(&override)
if err != nil && err != sql.ErrNoRows {
return visibility, err
}
if err == nil {
switch strings.TrimSpace(override) {
case orgVisibilityAll, orgVisibilityFriends, orgVisibilitySelf:
visibility.Scope = strings.TrimSpace(override)
return visibility, nil
default:
return visibility, t.ErrMalformed
}
}
var phone sql.NullString
err = a.db.QueryRowxContext(ctx, `SELECT phone FROM im_users WHERE id=? AND state<>? LIMIT 1`, userID, t.StateDeleted).Scan(&phone)
if err == sql.ErrNoRows {
return visibility, t.ErrNotFound
}
if err != nil {
return visibility, err
}
var inviteUsed int
err = a.db.QueryRowxContext(ctx, `SELECT EXISTS(SELECT 1 FROM im_registration_invite_uses WHERE user_id=?)`, userID).Scan(&inviteUsed)
if err != nil {
return visibility, err
}
if inviteUsed > 0 {
visibility.Scope = orgVisibilitySelf
return visibility, nil
}
if phone.Valid && strings.TrimSpace(phone.String) != "" {
visibility.Scope = orgVisibilityFriends
}
return visibility, nil
}
func (v organizationVisibility) memberWhereSQL(alias string) (string, []any) {
alias = strings.TrimSpace(alias)
if alias == "" {
alias = "m"
}
switch v.Scope {
case orgVisibilityAll:
return "", nil
case orgVisibilityFriends:
return "(" + alias + ".user_id=? OR EXISTS (SELECT 1 FROM im_friendships AS fs WHERE fs.user_id=? AND fs.peer_id=" + alias + ".user_id AND fs.status=?))",
[]any{v.UserID, v.UserID, t.RelationshipFriend}
default:
return alias + ".user_id=?", []any{v.UserID}
}
}
func (v organizationVisibility) memberCountSQL(alias string) (string, []any) {
where, args := v.memberWhereSQL(alias)
if where == "" {
return "", nil
}
return " AND " + where, args
}
func (v organizationVisibility) departmentCTE() (string, []any) {
if v.Scope == orgVisibilityAll {
return "", nil
}
memberWhere, args := v.memberWhereSQL("m")
return `WITH RECURSIVE visible_member_depts AS (
SELECT DISTINCT m.department_id FROM im_org_members AS m
WHERE m.employment_state='active' AND ` + memberWhere + `
), visible_depts AS (
SELECT d.department_id,d.parent_id FROM im_org_departments AS d
JOIN visible_member_depts AS vmd ON vmd.department_id=d.department_id
WHERE d.state='active'
UNION DISTINCT
SELECT parent.department_id,parent.parent_id FROM im_org_departments AS parent
JOIN visible_depts AS child ON child.parent_id=parent.department_id
WHERE parent.state='active'
) `, args
}
func organizationSearchIncludes(resultTypes string) (bool, bool) { func organizationSearchIncludes(resultTypes string) (bool, bool) {
if strings.TrimSpace(resultTypes) == "" { if strings.TrimSpace(resultTypes) == "" {
return true, true return true, true
+3 -3
View File
@@ -300,7 +300,7 @@ func cacheControlHandler(maxAge int, handler http.Handler) http.Handler {
// Get API key from an HTTP request. // Get API key from an HTTP request.
func getAPIKey(req *http.Request) string { func getAPIKey(req *http.Request) string {
// Check header. // Check header.
apikey := req.Header.Get("X-Tinode-APIKey") apikey := req.Header.Get("X-MeetingIM-APIKey")
if apikey != "" { if apikey != "" {
return apikey return apikey
} }
@@ -328,8 +328,8 @@ func getAPIKey(req *http.Request) string {
// Extracts authorization credentials from an HTTP request. // Extracts authorization credentials from an HTTP request.
// Returns authentication method and secret. // Returns authentication method and secret.
func getHttpAuth(req *http.Request) (method, secret string) { func getHttpAuth(req *http.Request) (method, secret string) {
// Check X-Tinode-Auth header. // Check MeetingIM product auth header.
if parts := strings.Split(req.Header.Get("X-Tinode-Auth"), " "); len(parts) == 2 { if parts := strings.Split(req.Header.Get("X-MeetingIM-Auth"), " "); len(parts) == 2 {
method, secret = parts[0], parts[1] method, secret = parts[0], parts[1]
return return
} }
+18 -18
View File
@@ -110,7 +110,7 @@ func withIMHTTPProtocol(prefix string, next http.Handler) http.Handler {
wrt.Header().Set("X-Request-ID", requestID) wrt.Header().Set("X-Request-ID", requestID)
wrt.Header().Set("X-IM-Schema", "1") wrt.Header().Set("X-IM-Schema", "1")
if valid, _ := checkAPIKey(strings.TrimSpace(req.Header.Get("X-Tinode-APIKey"))); !valid { if valid, _ := checkAPIKey(strings.TrimSpace(getAPIKey(req))); !valid {
writeIMError(wrt, req, http.StatusForbidden, 30003, imReasonAPIKeyRequired, "valid API key required", nil) writeIMError(wrt, req, http.StatusForbidden, 30003, imReasonAPIKeyRequired, "valid API key required", nil)
return return
} }
@@ -7283,24 +7283,24 @@ func meetingPayloadWithUserSummaries(meeting *types.Meeting, viewer types.Uid, u
startAt := meeting.StartAt.UTC() startAt := meeting.StartAt.UTC()
endAt := meeting.EndAt.UTC() endAt := meeting.EndAt.UTC()
data := map[string]any{ data := map[string]any{
"meeting_id": meeting.MeetingID, "meeting_id": meeting.MeetingID,
"title": meeting.Title, "title": meeting.Title,
"organizer_id": meeting.OrganizerID.UserId(), "organizer_id": meeting.OrganizerID.UserId(),
"organizer": organizer, "organizer": organizer,
"start_at": startAt.Format(time.RFC3339Nano), "start_at": startAt.Format(time.RFC3339Nano),
"end_at": endAt.Format(time.RFC3339Nano), "end_at": endAt.Format(time.RFC3339Nano),
"meeting_time": meetingTimeRange(startAt, endAt), "meeting_time": meetingTimeRange(startAt, endAt),
"time_range": meetingTimeRange(startAt, endAt), "time_range": meetingTimeRange(startAt, endAt),
"location": meeting.Location, "location": meeting.Location,
"state": meeting.State, "state": meeting.State,
"reminder_minutes": meeting.ReminderMinutes, "reminder_minutes": meeting.ReminderMinutes,
"version": meeting.Version, "version": meeting.Version,
"participants": participants, "participants": participants,
"participant_count": len(participants), "participant_count": len(participants),
"participant_names": meetingParticipantNames(participants), "participant_names": meetingParticipantNames(participants),
"permissions": meetingPermissionsPayload(meeting, viewer), "permissions": meetingPermissionsPayload(meeting, viewer),
"created_at": meeting.CreatedAt.UTC().Format(time.RFC3339Nano), "created_at": meeting.CreatedAt.UTC().Format(time.RFC3339Nano),
"updated_at": meeting.UpdatedAt.UTC().Format(time.RFC3339Nano), "updated_at": meeting.UpdatedAt.UTC().Format(time.RFC3339Nano),
} }
if displayName, ok := organizer["display_name"]; ok { if displayName, ok := organizer["display_name"]; ok {
data["organizer_display_name"] = displayName data["organizer_display_name"] = displayName
@@ -7681,7 +7681,7 @@ func imCanViewLastSeen(tenantID types.TenantID, viewer, target types.Uid) bool {
if viewer == target { if viewer == target {
return true return true
} }
// Legacy deployments without the product privacy stores retain Tinode's // Legacy deployments without the product privacy stores retain MeetingIM's
// original presence behavior. // original presence behavior.
if store.Profiles == nil || store.Relations == nil { if store.Profiles == nil || store.Relations == nil {
return true return true
+41 -18
View File
@@ -1226,7 +1226,7 @@ func TestIMHTTPProtocolHeaders(t *testing.T) {
} }
req = httptest.NewRequest(http.MethodGet, "/v1/im/bootstrap", nil) req = httptest.NewRequest(http.MethodGet, "/v1/im/bootstrap", nil)
req.Header.Set("X-Tinode-APIKey", apiKey) req.Header.Set("X-MeetingIM-APIKey", apiKey)
rec = httptest.NewRecorder() rec = httptest.NewRecorder()
handler.ServeHTTP(rec, req) handler.ServeHTTP(rec, req)
if rec.Code != http.StatusBadRequest || decodeIMHTTPResponse(t, rec).Reason != imReasonSchemaUnsupported { if rec.Code != http.StatusBadRequest || decodeIMHTTPResponse(t, rec).Reason != imReasonSchemaUnsupported {
@@ -1234,7 +1234,7 @@ func TestIMHTTPProtocolHeaders(t *testing.T) {
} }
req = httptest.NewRequest(http.MethodGet, "/v1/im/bootstrap", nil) req = httptest.NewRequest(http.MethodGet, "/v1/im/bootstrap", nil)
req.Header.Set("X-Tinode-APIKey", apiKey) req.Header.Set("X-MeetingIM-APIKey", apiKey)
req.Header.Set("X-IM-Schema", "1") req.Header.Set("X-IM-Schema", "1")
req.Header.Set("X-Request-ID", "request-1") req.Header.Set("X-Request-ID", "request-1")
rec = httptest.NewRecorder() rec = httptest.NewRecorder()
@@ -1242,6 +1242,29 @@ func TestIMHTTPProtocolHeaders(t *testing.T) {
if rec.Code != http.StatusOK || called != 1 || rec.Header().Get("X-Request-ID") != "request-1" { if rec.Code != http.StatusOK || called != 1 || rec.Header().Get("X-Request-ID") != "request-1" {
t.Fatalf("valid protocol request: status=%d called=%d request_id=%q", rec.Code, called, rec.Header().Get("X-Request-ID")) t.Fatalf("valid protocol request: status=%d called=%d request_id=%q", rec.Code, called, rec.Header().Get("X-Request-ID"))
} }
req = httptest.NewRequest(http.MethodGet, "/v1/im/bootstrap", nil)
req.Header.Set("X-MeetingIM-APIKey", apiKey)
req.Header.Set("X-IM-Schema", "1")
rec = httptest.NewRecorder()
handler.ServeHTTP(rec, req)
if rec.Code != http.StatusOK || called != 2 {
t.Fatalf("legacy protocol request: status=%d called=%d", rec.Code, called)
}
}
func TestMeetingIMHTTPAuthHeaders(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/v0/file/s/file1", nil)
req.Header.Set("X-MeetingIM-APIKey", "new-api-key")
req.Header.Set("X-MeetingIM-Auth", "token new-token")
if got := getAPIKey(req); got != "new-api-key" {
t.Fatalf("getAPIKey new header=%q", got)
}
method, secret := getHttpAuth(req)
if method != "token" || secret != "new-token" {
t.Fatalf("getHttpAuth new header method=%q secret=%q", method, secret)
}
} }
func TestIMHTTPIdempotencyReplayAndConflict(t *testing.T) { func TestIMHTTPIdempotencyReplayAndConflict(t *testing.T) {
@@ -1263,7 +1286,7 @@ func TestIMHTTPIdempotencyReplayAndConflict(t *testing.T) {
request := func(body string) *httptest.ResponseRecorder { request := func(body string) *httptest.ResponseRecorder {
req := httptest.NewRequest(http.MethodPost, "/v1/im/test", strings.NewReader(body)) req := httptest.NewRequest(http.MethodPost, "/v1/im/test", strings.NewReader(body))
req.Header.Set("Authorization", "Bearer token-1") req.Header.Set("Authorization", "Bearer token-1")
req.Header.Set("X-Tinode-APIKey", apiKey) req.Header.Set("X-MeetingIM-APIKey", apiKey)
req.Header.Set("X-IM-Schema", "1") req.Header.Set("X-IM-Schema", "1")
req.Header.Set("Idempotency-Key", "same-key") req.Header.Set("Idempotency-Key", "same-key")
rec := httptest.NewRecorder() rec := httptest.NewRecorder()
@@ -1298,7 +1321,7 @@ func TestServeIMTenantResolveSuccessFromHeader(t *testing.T) {
globals.servingAt = "https://im.example.com/" globals.servingAt = "https://im.example.com/"
req := httptest.NewRequest(http.MethodPost, "/v1/im/tenants/resolve", nil) req := httptest.NewRequest(http.MethodPost, "/v1/im/tenants/resolve", nil)
req.Header.Set("X-Tinode-APIKey", makeTestAPIKey(t)) req.Header.Set("X-MeetingIM-APIKey", makeTestAPIKey(t))
req.Header.Set("X-Tenant-Code", " acme ") req.Header.Set("X-Tenant-Code", " acme ")
req.Header.Set("X-Request-ID", "req-1") req.Header.Set("X-Request-ID", "req-1")
rec := httptest.NewRecorder() rec := httptest.NewRecorder()
@@ -1331,7 +1354,7 @@ func TestIMHTTPProtocolDoesNotRequireIdempotencyForTenantResolve(t *testing.T) {
writeIMSuccess(w, req, map[string]any{"ok": true}) writeIMSuccess(w, req, map[string]any{"ok": true})
}) })
req := httptest.NewRequest(http.MethodPost, "/v1/im/tenants/resolve", nil) req := httptest.NewRequest(http.MethodPost, "/v1/im/tenants/resolve", nil)
req.Header.Set("X-Tinode-APIKey", makeTestAPIKey(t)) req.Header.Set("X-MeetingIM-APIKey", makeTestAPIKey(t))
req.Header.Set("X-IM-Schema", "1") req.Header.Set("X-IM-Schema", "1")
req.Header.Set("Authorization", "Bearer test-token") req.Header.Set("Authorization", "Bearer test-token")
rec := httptest.NewRecorder() rec := httptest.NewRecorder()
@@ -1362,7 +1385,7 @@ func TestServeIMTenantResolveUsesForwardedPublicHost(t *testing.T) {
req.Host = "internal:6060" req.Host = "internal:6060"
req.Header.Set("X-Forwarded-Proto", "https") req.Header.Set("X-Forwarded-Proto", "https")
req.Header.Set("X-Forwarded-Host", "api.118im.com") req.Header.Set("X-Forwarded-Host", "api.118im.com")
req.Header.Set("X-Tinode-APIKey", makeTestAPIKey(t)) req.Header.Set("X-MeetingIM-APIKey", makeTestAPIKey(t))
req.Header.Set("X-Tenant-Code", "acme") req.Header.Set("X-Tenant-Code", "acme")
rec := httptest.NewRecorder() rec := httptest.NewRecorder()
@@ -1393,7 +1416,7 @@ func TestServeIMTenantResolveSuccessFromBody(t *testing.T) {
req := httptest.NewRequest(http.MethodPost, "/v1/im/tenants/resolve", req := httptest.NewRequest(http.MethodPost, "/v1/im/tenants/resolve",
bytes.NewBufferString(`{"tenant_code":"acme","platform":"android","app_version":"1.0.0"}`)) bytes.NewBufferString(`{"tenant_code":"acme","platform":"android","app_version":"1.0.0"}`))
req.Header.Set("X-Tinode-APIKey", makeTestAPIKey(t)) req.Header.Set("X-MeetingIM-APIKey", makeTestAPIKey(t))
rec := httptest.NewRecorder() rec := httptest.NewRecorder()
serveIMTenantResolve(rec, req) serveIMTenantResolve(rec, req)
@@ -1441,7 +1464,7 @@ func TestServeIMTenantResolveRequiresAPIKey(t *testing.T) {
func TestServeIMTenantResolveRejectsMalformedTenantCode(t *testing.T) { func TestServeIMTenantResolveRejectsMalformedTenantCode(t *testing.T) {
req := httptest.NewRequest(http.MethodPost, "/v1/im/tenants/resolve", req := httptest.NewRequest(http.MethodPost, "/v1/im/tenants/resolve",
bytes.NewBufferString(`{"tenant_code":"`+strings.Repeat("a", 65)+`"}`)) bytes.NewBufferString(`{"tenant_code":"`+strings.Repeat("a", 65)+`"}`))
req.Header.Set("X-Tinode-APIKey", makeTestAPIKey(t)) req.Header.Set("X-MeetingIM-APIKey", makeTestAPIKey(t))
rec := httptest.NewRecorder() rec := httptest.NewRecorder()
serveIMTenantResolve(rec, req) serveIMTenantResolve(rec, req)
@@ -1467,7 +1490,7 @@ func TestServeIMTenantResolveHidesUnavailableTenantState(t *testing.T) {
req := httptest.NewRequest(http.MethodPost, "/v1/im/tenants/resolve", req := httptest.NewRequest(http.MethodPost, "/v1/im/tenants/resolve",
bytes.NewBufferString(`{"tenant_code":"acme"}`)) bytes.NewBufferString(`{"tenant_code":"acme"}`))
req.Header.Set("X-Tinode-APIKey", makeTestAPIKey(t)) req.Header.Set("X-MeetingIM-APIKey", makeTestAPIKey(t))
rec := httptest.NewRecorder() rec := httptest.NewRecorder()
serveIMTenantResolve(rec, req) serveIMTenantResolve(rec, req)
@@ -1488,7 +1511,7 @@ func TestServeIMTenantResolveHidesMissingTenant(t *testing.T) {
req := httptest.NewRequest(http.MethodPost, "/v1/im/tenants/resolve", req := httptest.NewRequest(http.MethodPost, "/v1/im/tenants/resolve",
bytes.NewBufferString(`{"tenant_code":"acme"}`)) bytes.NewBufferString(`{"tenant_code":"acme"}`))
req.Header.Set("X-Tinode-APIKey", makeTestAPIKey(t)) req.Header.Set("X-MeetingIM-APIKey", makeTestAPIKey(t))
rec := httptest.NewRecorder() rec := httptest.NewRecorder()
serveIMTenantResolve(rec, req) serveIMTenantResolve(rec, req)
@@ -1639,7 +1662,7 @@ func TestServeIMVerificationCodesCreatesChallenge(t *testing.T) {
req := httptest.NewRequest(http.MethodPost, "/v1/im/auth/verification-codes", req := httptest.NewRequest(http.MethodPost, "/v1/im/auth/verification-codes",
bytes.NewBufferString(`{"scene":"register","phone":"+8613800138000"}`)) bytes.NewBufferString(`{"scene":"register","phone":"+8613800138000"}`))
req.Header.Set("X-Tinode-APIKey", makeTestAPIKey(t)) req.Header.Set("X-MeetingIM-APIKey", makeTestAPIKey(t))
req.Header.Set("X-Tenant-Code", "acme") req.Header.Set("X-Tenant-Code", "acme")
rec := httptest.NewRecorder() rec := httptest.NewRecorder()
@@ -1689,7 +1712,7 @@ func TestServeIMVerificationCodesSkipsDeliveryWhenRegistrationSMSDisabled(t *tes
req := httptest.NewRequest(http.MethodPost, "/v1/im/auth/verification-codes", req := httptest.NewRequest(http.MethodPost, "/v1/im/auth/verification-codes",
bytes.NewBufferString(`{"scene":"register","phone":"+8613800138000"}`)) bytes.NewBufferString(`{"scene":"register","phone":"+8613800138000"}`))
req.Header.Set("X-Tinode-APIKey", makeTestAPIKey(t)) req.Header.Set("X-MeetingIM-APIKey", makeTestAPIKey(t))
req.Header.Set("X-Tenant-Code", "acme") req.Header.Set("X-Tenant-Code", "acme")
rec := httptest.NewRecorder() rec := httptest.NewRecorder()
@@ -1729,7 +1752,7 @@ func TestServeIMPasswordResetCodeFindsPhoneWithoutTelCredential(t *testing.T) {
req := httptest.NewRequest(http.MethodPost, "/v1/im/auth/verification-codes", req := httptest.NewRequest(http.MethodPost, "/v1/im/auth/verification-codes",
bytes.NewBufferString(`{"scene":"reset_password","phone":"13800138000"}`)) bytes.NewBufferString(`{"scene":"reset_password","phone":"13800138000"}`))
req.Header.Set("X-Tinode-APIKey", makeTestAPIKey(t)) req.Header.Set("X-MeetingIM-APIKey", makeTestAPIKey(t))
req.Header.Set("X-Tenant-Code", "acme") req.Header.Set("X-Tenant-Code", "acme")
rec := httptest.NewRecorder() rec := httptest.NewRecorder()
@@ -1768,7 +1791,7 @@ func TestServeIMVerificationCodesKeepsChallengeWhenDeliveryUnavailable(t *testin
req := httptest.NewRequest(http.MethodPost, "/v1/im/auth/verification-codes", req := httptest.NewRequest(http.MethodPost, "/v1/im/auth/verification-codes",
bytes.NewBufferString(`{"scene":"reset_password","phone":"+8613800138000"}`)) bytes.NewBufferString(`{"scene":"reset_password","phone":"+8613800138000"}`))
req.Header.Set("X-Tinode-APIKey", makeTestAPIKey(t)) req.Header.Set("X-MeetingIM-APIKey", makeTestAPIKey(t))
req.Header.Set("X-Tenant-Code", "acme") req.Header.Set("X-Tenant-Code", "acme")
rec := httptest.NewRecorder() rec := httptest.NewRecorder()
@@ -1786,7 +1809,7 @@ func TestServeIMVerificationCodesKeepsChallengeWhenDeliveryUnavailable(t *testin
func TestServeIMVerificationCodesRequiresTenantCode(t *testing.T) { func TestServeIMVerificationCodesRequiresTenantCode(t *testing.T) {
req := httptest.NewRequest(http.MethodPost, "/v1/im/auth/verification-codes", req := httptest.NewRequest(http.MethodPost, "/v1/im/auth/verification-codes",
bytes.NewBufferString(`{"scene":"register","phone":"+8613800138000"}`)) bytes.NewBufferString(`{"scene":"register","phone":"+8613800138000"}`))
req.Header.Set("X-Tinode-APIKey", makeTestAPIKey(t)) req.Header.Set("X-MeetingIM-APIKey", makeTestAPIKey(t))
rec := httptest.NewRecorder() rec := httptest.NewRecorder()
serveIMVerificationCodes(rec, req) serveIMVerificationCodes(rec, req)
@@ -1825,7 +1848,7 @@ func TestServeIMVerificationCodesRateLimited(t *testing.T) {
req := httptest.NewRequest(http.MethodPost, "/v1/im/auth/verification-codes", req := httptest.NewRequest(http.MethodPost, "/v1/im/auth/verification-codes",
bytes.NewBufferString(`{"scene":"register","phone":"+8613800138000"}`)) bytes.NewBufferString(`{"scene":"register","phone":"+8613800138000"}`))
req.Header.Set("X-Tinode-APIKey", makeTestAPIKey(t)) req.Header.Set("X-MeetingIM-APIKey", makeTestAPIKey(t))
req.Header.Set("X-Tenant-Code", "acme") req.Header.Set("X-Tenant-Code", "acme")
rec := httptest.NewRecorder() rec := httptest.NewRecorder()
@@ -1868,7 +1891,7 @@ func TestServeIMVerificationCodeVerifySuccess(t *testing.T) {
req := httptest.NewRequest(http.MethodPost, "/v1/im/auth/verification-codes/challenge-1/verify", req := httptest.NewRequest(http.MethodPost, "/v1/im/auth/verification-codes/challenge-1/verify",
bytes.NewBufferString(`{"code":"123456"}`)) bytes.NewBufferString(`{"code":"123456"}`))
req.Header.Set("X-Tinode-APIKey", makeTestAPIKey(t)) req.Header.Set("X-MeetingIM-APIKey", makeTestAPIKey(t))
req.Header.Set("X-Tenant-Code", "acme") req.Header.Set("X-Tenant-Code", "acme")
rec := httptest.NewRecorder() rec := httptest.NewRecorder()
@@ -1913,7 +1936,7 @@ func TestServeIMVerificationCodeVerifyInvalidCode(t *testing.T) {
req := httptest.NewRequest(http.MethodPost, "/v1/im/auth/verification-codes/challenge-1/verify", req := httptest.NewRequest(http.MethodPost, "/v1/im/auth/verification-codes/challenge-1/verify",
bytes.NewBufferString(`{"code":"654321"}`)) bytes.NewBufferString(`{"code":"654321"}`))
req.Header.Set("X-Tinode-APIKey", makeTestAPIKey(t)) req.Header.Set("X-MeetingIM-APIKey", makeTestAPIKey(t))
req.Header.Set("X-Tenant-Code", "acme") req.Header.Set("X-Tenant-Code", "acme")
rec := httptest.NewRecorder() rec := httptest.NewRecorder()
+1 -1
View File
@@ -341,7 +341,7 @@ func main() {
logFlags := flag.String("log_flags", "stdFlags", logFlags := flag.String("log_flags", "stdFlags",
"Comma-separated list of log flags (as defined in https://golang.org/pkg/log/#pkg-constants without the L prefix)") "Comma-separated list of log flags (as defined in https://golang.org/pkg/log/#pkg-constants without the L prefix)")
configfile := flag.String("config", "tinode.conf", "Path to config file.") configfile := flag.String("config", "meetingim.conf", "Path to config file.")
// Path to static content. // Path to static content.
staticPath := flag.String("static_data", defaultStaticPath, "File path to directory with static files to be served.") staticPath := flag.String("static_data", defaultStaticPath, "File path to directory with static files to be served.")
listenOn := flag.String("listen", "", "Override address and port to listen on for HTTP(S) clients.") listenOn := flag.String("listen", "", "Override address and port to listen on for HTTP(S) clients.")
+223
View File
@@ -0,0 +1,223 @@
{
"listen": ":6060",
"api_path": "/",
"cache_control": 39600,
"ws_compression_disabled": false,
"static_mount": "/",
"static_data": "static",
"grpc_listen": ":16060",
"grpc_keepalive_enabled": true,
"api_key_salt": "T713/rYYgW7g4m3vG6zGRh7+FM1t0T8j13koXScOAj4=",
"max_message_size": 131072,
"max_subscriber_count": 128,
"max_tag_count": 16,
"permanent_accounts": false,
"expvar": "/debug/vars",
"server_status": "-",
"use_x_forwarded_for": true,
"x_frame_options": "SAMEORIGIN",
"default_country_code": "CN",
"p2p_delete_enabled": true,
"msg_delete_age": 600,
"alias_tag": "alias",
"media": {
"use_handler": "fs",
"max_size": 104857600,
"gc_period": 3600,
"gc_block_size": 100,
"handlers": {
"fs": {
"upload_dir": "uploads",
"cache_control": "max-age=86400",
"cors_origins": ["*"]
}
}
},
"webrtc": {
"enabled": true,
"call_establishment_timeout": 30,
"ice_servers": [
{
"urls": ["stun:stun.l.google.com:19302"]
}
]
},
"tls": {
"enabled": false,
"http_redirect": "",
"strict_max_age": 0
},
"auth_config": {
"logical_names": [],
"basic": {
"add_to_tags": false,
"min_login_length": 1,
"min_password_length": 6
},
"token": {
"expire_in": 1209600,
"serial_num": 1,
"key": "wfaY2RgF2S1OQI/ZlK+LSrp1KB2jwAdGAIHQ7JZn+Kc="
},
"code": {
"expire_in": 900,
"max_retries": 3,
"code_length": 6
}
},
"store_config": {
"uid_key": "la6YsO+bNX/+XIkOqc5Svw==",
"max_results": 1024,
"use_adapter": "mysql",
"adapters": {
"mysql": {
"dsn": "mysql://root:oWxz*2=sb),ouW%p@tcp(64.118.137.182:53306)/tinode?parseTime=true&collation=utf8mb4_0900_ai_ci",
"tenant_registry_db": "hotgo",
"tenant_db_routing": true,
"max_open_conns": 64,
"max_idle_conns": 64,
"conn_max_lifetime": 60,
"sql_timeout": 10
}
},
"redis": {
"addr": "64.118.137.182:56379",
"username": "",
"password": "HMTJzkm.h>Y+E9ep",
"db": 0,
"key_prefix": "im",
"dial_timeout_ms": 2000,
"read_timeout_ms": 2000,
"write_timeout_ms": 2000,
"pending_ttl_seconds": 120,
"done_ttl_seconds": 86400,
"failed_ttl_seconds": 600,
"hot_message_ttl_seconds": 86400,
"presence_ttl_seconds": 120,
"cache_ttl_seconds": 300,
"short_state_ttl_seconds": 600,
"group_fanout_enabled": true
}
},
"kafka": {
"enabled": true,
"brokers": ["64.118.137.182:9094"],
"client_id": "im-server",
"consumer_group": "im-server",
"dial_timeout_ms": 3000,
"write_timeout_ms": 3000,
"read_timeout_ms": 3000,
"required_acks": 1,
"allow_auto_topic_creation": false,
"tls_enabled": false,
"sasl_enabled": true,
"sasl_mechanism": "SCRAM-SHA-512",
"sasl_username": "kafka-external",
"sasl_password": "gr58tNnlZOVGW3AmMbL2",
"max_retry_count": 5,
"retry_backoff_ms": [10000, 30000, 60000, 300000, 900000],
"dead_letter_retention_seconds": 7776000,
"topics": {
"admin_command": "im.admin.command",
"admin_command_result": "im.admin.command.result",
"config_event": "im.config.event",
"business_event": "im.business.event",
"message_persist": "im.message.persist",
"message_push": "im.message.push",
"file_security": "im.file.security",
"file_lifecycle": "im.file.lifecycle",
"workbench_event": "im.workbench.event",
"cache_invalidate": "im.cache.invalidate",
"audit_event": "im.audit.event",
"dead_letter": "im.dead.letter"
}
},
"internal_api": {
"enabled": true,
"time_window_seconds": 300,
"services": {
"hotgo-admin": {
"secret": "8569f5626a54950539eece9f731c9e3bf073daa74af8d23735d19afacac40f4b",
"allow": ["admin_query"]
}
}
},
"admin_internal_api": {
"enabled": true,
"base_url": "http://im-admin:8000",
"service_name": "im-server",
"secret": "b96832c04dbfb6dd768930ec9f082ecd90d87a1ffdaa493aa070617c48de9097",
"timeout_ms": 3000
},
"acc_validation": {
"email": {
"add_to_tags": false,
"required": [],
"config": {
"host_url": "http://64.118.137.182:6060/",
"sender": "MeetingIM <noreply@118im.com>",
"validation_templ": "server/templ/email-validation-en.templ",
"reset_secret_templ": "server/templ/email-password-reset-en.templ",
"debug_response": "123456",
"max_retries": 3
}
},
"tel": {
"add_to_tags": false,
"required": [],
"config": {
"host_url": "http://64.118.137.182:6060/",
"sender": "MeetingIM",
"universal_templ": "server/templ/sms-universal-en.templ",
"debug_response": "123456",
"max_retries": 3
}
}
},
"acc_gc_config": {
"enabled": true,
"gc_period": 3600,
"gc_block_size": 10,
"gc_min_account_age": 30
},
"push": [
{
"name": "stdout",
"config": {
"enabled": false
}
},
{
"name": "fcm",
"config": {
"enabled": false
}
},
{
"name": "tnpg",
"config": {
"enabled": false
}
}
],
"cluster_config": {
"self": "",
"nodes": [],
"failover": {
"enabled": false
}
},
"plugins": []
}
+7 -7
View File
@@ -9,7 +9,7 @@ HTTP_BASE_PORT=6060
# Port where the first node will listen for gRPC intra-cluster connections. # Port where the first node will listen for gRPC intra-cluster connections.
GRPC_BASE_PORT=16060 GRPC_BASE_PORT=16060
USAGE="Usage: $0 [ --config <path_to_tinode.conf> ] {start|stop}" USAGE="Usage: $0 [ --config <path_to_meetingim.conf> ] {start|stop}"
# Your server binary may have a different name and location. # Your server binary may have a different name and location.
SERVER='./server' SERVER='./server'
@@ -34,9 +34,9 @@ while [[ $# -gt 0 ]]; do
;; ;;
start) start)
if [ ! -z "$config" ] ; then if [ ! -z "$config" ] ; then
TINODE_CONF=$config MEETINGIM_CONF=$config
else else
TINODE_CONF="tinode.conf" MEETINGIM_CONF="meetingim.conf"
fi fi
if [ ! -z "${static_data+x}" ] ; then if [ ! -z "${static_data+x}" ] ; then
STATIC_DATA_DIR=$static_data STATIC_DATA_DIR=$static_data
@@ -51,10 +51,10 @@ while [[ $# -gt 0 ]]; do
for NODE_NAME in "${ALL_NODE_NAMES[@]}" for NODE_NAME in "${ALL_NODE_NAMES[@]}"
do do
# Start the node # Start the node
$SERVER -config=${TINODE_CONF} -cluster_self=${NODE_NAME} -listen=:${HTTP_PORT} -grpc_listen=:${GRPC_PORT} -static_data=${STATIC_DATA_DIR} -log_flags=stdFlags,shortfile & $SERVER -config=${MEETINGIM_CONF} -cluster_self=${NODE_NAME} -listen=:${HTTP_PORT} -grpc_listen=:${GRPC_PORT} -static_data=${STATIC_DATA_DIR} -log_flags=stdFlags,shortfile &
# Save PID of the node to a temp file. # Save PID of the node to a temp file.
# /var/tmp/ does not requre root access. # /var/tmp/ does not requre root access.
echo $!> "/var/tmp/tinode-${NODE_NAME}.pid" echo $!> "/var/tmp/meetingim-${NODE_NAME}.pid"
# Increment ports for the next node. # Increment ports for the next node.
HTTP_PORT=$((HTTP_PORT+1)) HTTP_PORT=$((HTTP_PORT+1))
GRPC_PORT=$((GRPC_PORT+1)) GRPC_PORT=$((GRPC_PORT+1))
@@ -67,9 +67,9 @@ while [[ $# -gt 0 ]]; do
for NODE_NAME in "${ALL_NODE_NAMES[@]}" for NODE_NAME in "${ALL_NODE_NAMES[@]}"
do do
# Read PIDs of running nodes from temp files and kill them. # Read PIDs of running nodes from temp files and kill them.
kill `cat /var/tmp/tinode-${NODE_NAME}.pid` kill `cat /var/tmp/meetingim-${NODE_NAME}.pid`
# Clean up: delete temp files. # Clean up: delete temp files.
rm "/var/tmp/tinode-${NODE_NAME}.pid" rm "/var/tmp/meetingim-${NODE_NAME}.pid"
done done
exit 0 exit 0
;; ;;
+12 -12
View File
@@ -1,7 +1,7 @@
#!/bin/bash #!/bin/bash
BINARY_PATH=$GOPATH/bin BINARY_PATH=$GOPATH/bin
TINODE_BINARY=$BINARY_PATH/server MEETINGIM_BINARY=$BINARY_PATH/server
# Kills and removes any running containers. # Kills and removes any running containers.
cleanup() { cleanup() {
@@ -45,23 +45,23 @@ setup() {
echo -n "." echo -n "."
sleep 1 sleep 1
done done
# Make sure there's no Tinode server binary in the current directory. # Make sure there's no MeetingIM server binary in the current directory.
if [ -f "./server" ]; then if [ -f "./server" ]; then
rm ./server rm ./server
fi fi
} }
# Compiles Tinode binaries. # Compiles MeetingIM binaries.
build() { build() {
go install -tags mysql -ldflags "-X main.buildstamp=`date -u '+%Y%m%dT%H:%M:%SZ'`" \ go install -tags mysql -ldflags "-X main.buildstamp=`date -u '+%Y%m%dT%H:%M:%SZ'`" \
github.com/tinode/chat/tinode-db \ github.com/tinode/chat/tinode-db \
github.com/tinode/chat/server && \ github.com/tinode/chat/server && \
ln -s $TINODE_BINARY ln -s $MEETINGIM_BINARY
} }
# Initializes Tinode database. # Initializes MeetingIM database.
init-db() { init-db() {
$GOPATH/bin/tinode-db -config=./tinode.conf -data=../tinode-db/data.json $GOPATH/bin/tinode-db -config=./meetingim.conf -data=../tinode-db/data.json
} }
wait-for() { wait-for() {
@@ -71,7 +71,7 @@ wait-for() {
done done
} }
# Brings up a three-node Tinode cluster. # Brings up a three-node MeetingIM cluster.
run-server() { run-server() {
./run-cluster.sh -s "" start && wait-for 16060 ./run-cluster.sh -s "" start && wait-for 16060
} }
@@ -80,7 +80,7 @@ send-requests() {
local expect=12 local expect=12
local port=$1 local port=$1
local id=$2 local id=$2
local outfile=$(mktemp /tmp/tinode-${id}.txt) local outfile=$(mktemp /tmp/meetingim-${id}.txt)
pushd . pushd .
cd ../tn-cli cd ../tn-cli
python3 tn-cli.py --host=localhost:${port} --no-login < sample-script.txt > $outfile || fail "Test script failed (instance port ${port})" python3 tn-cli.py --host=localhost:${port} --no-login < sample-script.txt > $outfile || fail "Test script failed (instance port ${port})"
@@ -103,14 +103,14 @@ trap 'cleanup ; fail "For Unexpected Reasons"'\
run_id=`date +%s` run_id=`date +%s`
echo "+----------------------------------------------------+" echo "+----------------------------------------------------+"
echo "| Tinode sanity test. |" echo "| MeetingIM sanity test. |"
echo "+----------------------------------------------------+" echo "+----------------------------------------------------+"
echo "Timestamp = ${run_id}" echo "Timestamp = ${run_id}"
setup || fail "Test setup failed." setup || fail "Test setup failed."
build || fail "Could not build Tinode binaries" build || fail "Could not build MeetingIM binaries"
init-db || fail "Could not initialize Tinode database" init-db || fail "Could not initialize MeetingIM database"
run-server || fail "Could not start tinode" run-server || fail "Could not start meetingim"
# Test requests. # Test requests.
send-requests 16060 $run_id send-requests 16060 $run_id
+1 -1
View File
@@ -60,7 +60,7 @@ func openAdapter(workerId int, jsonconf json.RawMessage) error {
adp = v adp = v
} }
} else { } else {
return errors.New("store: db adapter is not specified. Please set `store_config.use_adapter` in `tinode.conf`") return errors.New("store: db adapter is not specified. Please set `store_config.use_adapter` in `meetingim.conf`")
} }
} }
+6 -6
View File
@@ -8,7 +8,7 @@
{{define "subject" -}} {{define "subject" -}}
Reset Tinode password Reset MeetingIM password
{{- end}} {{- end}}
{{define "body_html" -}} {{define "body_html" -}}
@@ -17,7 +17,7 @@ Reset Tinode password
<p>Hello.</p> <p>Hello.</p>
<p>You recently requested to reset the password for your <a href="{{.HostUrl}}">Tinode</a> account. <p>You recently requested to reset the password for your <a href="{{.HostUrl}}">MeetingIM</a> account.
Use the link or code below to reset it. The link and code are valid for the next 24 hours only.</p> Use the link or code below to reset it. The link and code are valid for the next 24 hours only.</p>
<blockquote><a href="{{.HostUrl}}#reset?scheme={{.Scheme}}&code={{.Code}}&cred={{.Cred}}">Click</a> to reset your password.</blockquote> <blockquote><a href="{{.HostUrl}}#reset?scheme={{.Scheme}}&code={{.Code}}&cred={{.Cred}}">Click</a> to reset your password.</blockquote>
@@ -36,7 +36,7 @@ Use the link or code below to reset it. The link and code are valid for the next
<p>If you did not request a password reset, please ignore this message.</p> <p>If you did not request a password reset, please ignore this message.</p>
<p><a href="https://tinode.co/">Tinode Team</a></p> <p><a href="https://118im.com/">MeetingIM Team</a></p>
</body> </body>
</html> </html>
@@ -46,7 +46,7 @@ Use the link or code below to reset it. The link and code are valid for the next
Hello. Hello.
You recently requested to reset the password for your Tinode account ({{.HostUrl}}). You recently requested to reset the password for your MeetingIM account ({{.HostUrl}}).
Use the link or code below to reset it. The link and code are valid for the next 24 hours only. Use the link or code below to reset it. The link and code are valid for the next 24 hours only.
{{.HostUrl}}#reset?scheme={{.Scheme}}&code={{.Code}}&cred={{.Cred}} {{.HostUrl}}#reset?scheme={{.Scheme}}&code={{.Code}}&cred={{.Cred}}
@@ -62,7 +62,7 @@ In case you have forgotten, here is your login: {{.}}.
If you did not request a password reset, please ignore this message. If you did not request a password reset, please ignore this message.
Tinode Team MeetingIM Team
https://tinode.co/ https://118im.com/
{{- end}} {{- end}}
+6 -6
View File
@@ -8,7 +8,7 @@
{{define "subject" -}} {{define "subject" -}}
Reestablecer contraseña de Tinode Reestablecer contraseña de MeetingIM
{{- end}} {{- end}}
{{define "body_html" -}} {{define "body_html" -}}
@@ -17,7 +17,7 @@ Reestablecer contraseña de Tinode
<p>Hola.</p> <p>Hola.</p>
<p>Recientemente solicitaste reestablecer la contraseña para tu cuenta <a href="{{.HostUrl}}">Tinode</a>. <p>Recientemente solicitaste reestablecer la contraseña para tu cuenta <a href="{{.HostUrl}}">MeetingIM</a>.
Usa el enlace de abajo para reestablecerla. El enlace es válido solamente por las siguientes 24 horas.</p> Usa el enlace de abajo para reestablecerla. El enlace es válido solamente por las siguientes 24 horas.</p>
<blockquote><a href="{{.HostUrl}}#reset?scheme={{.Scheme}}&token={{.Token}}">Clic</a> para reestablecer contraseña.</blockquote> <blockquote><a href="{{.HostUrl}}#reset?scheme={{.Scheme}}&token={{.Token}}">Clic</a> para reestablecer contraseña.</blockquote>
@@ -36,7 +36,7 @@ Usa el enlace de abajo para reestablecerla. El enlace es válido solamente por l
<p>Si no solicitaste el reestablecimiento de tu contrseña, por favor ignora este mensaje.</p> <p>Si no solicitaste el reestablecimiento de tu contrseña, por favor ignora este mensaje.</p>
<p><a href="https://tinode.co/">Equipo de Tinode</a></p> <p><a href="https://118im.com/">Equipo de MeetingIM</a></p>
</body> </body>
</html> </html>
@@ -46,7 +46,7 @@ Usa el enlace de abajo para reestablecerla. El enlace es válido solamente por l
Hola. Hola.
Recientemente solicitaste reestablecer la contraseña para tu cuenta Tinode ({{.HostUrl}}). Recientemente solicitaste reestablecer la contraseña para tu cuenta MeetingIM ({{.HostUrl}}).
Usa el enlace de abajo para reestablecerla. El enlace es válido solamente por las siguientes 24 horas. Usa el enlace de abajo para reestablecerla. El enlace es válido solamente por las siguientes 24 horas.
{{.HostUrl}}#reset?scheme={{.Scheme}}&token={{.Token}} {{.HostUrl}}#reset?scheme={{.Scheme}}&token={{.Token}}
@@ -62,7 +62,7 @@ En caso de que lo hayas olvidado, tu usuario es: {{.}}.
Si no solicitaste el reestablecimiento de tu contrseña, por favor ignora este mensaje. Si no solicitaste el reestablecimiento de tu contrseña, por favor ignora este mensaje.
Equipo de Tinode Equipo de MeetingIM
https://tinode.co/ https://118im.com/
{{- end}} {{- end}}
+5 -5
View File
@@ -8,7 +8,7 @@
{{define "subject" -}} {{define "subject" -}}
Réinitialiser votre mot de passe Tinode Réinitialiser votre mot de passe MeetingIM
{{- end}} {{- end}}
{{define "body_html" -}} {{define "body_html" -}}
@@ -17,7 +17,7 @@ Réinitialiser votre mot de passe Tinode
<p>Bonjour.</p> <p>Bonjour.</p>
<p>Vous avez récemment demandé à réinitialiser le mot de passe de votre compte <a href="{{.HostUrl}}">Tinode</a>. <p>Vous avez récemment demandé à réinitialiser le mot de passe de votre compte <a href="{{.HostUrl}}">MeetingIM</a>.
Utilisez le lien ou le code ci-dessous pour le réinitialiser. Le lien et le code sont valides pour les prochaines 24 heures seulement.</p> Utilisez le lien ou le code ci-dessous pour le réinitialiser. Le lien et le code sont valides pour les prochaines 24 heures seulement.</p>
<blockquote><a href="{{.HostUrl}}#reset?scheme={{.Scheme}}&token={{.Token}}">Cliquer ici</a> pour réinitialiser votre mot de passe.</blockquote> <blockquote><a href="{{.HostUrl}}#reset?scheme={{.Scheme}}&token={{.Token}}">Cliquer ici</a> pour réinitialiser votre mot de passe.</blockquote>
@@ -36,7 +36,7 @@ Utilisez le lien ou le code ci-dessous pour le réinitialiser. Le lien et le cod
<p>Si vous n'avez pas demandé la réinitialisation de votre mot de passe, veuillez ignorer ce message.</p> <p>Si vous n'avez pas demandé la réinitialisation de votre mot de passe, veuillez ignorer ce message.</p>
<p><a href="https://tinode.co/">L'équipe Tinode</a></p> <p><a href="https://118im.com/">L'équipe MeetingIM</a></p>
</body> </body>
</html> </html>
@@ -62,7 +62,7 @@ Au cas où vous l'auriez oublié, voici votre login : {{.}}.
Si vous n'avez pas demandé la réinitialisation de votre mot de passe, veuillez ignorer ce message. Si vous n'avez pas demandé la réinitialisation de votre mot de passe, veuillez ignorer ce message.
L'équipe Tinode L'équipe MeetingIM
https://tinode.co/ https://118im.com/
{{- end}} {{- end}}
+6 -6
View File
@@ -8,7 +8,7 @@
{{define "subject" -}} {{define "subject" -}}
Redefinir senha Tinode Redefinir senha MeetingIM
{{- end}} {{- end}}
{{define "body_html" -}} {{define "body_html" -}}
@@ -17,7 +17,7 @@ Redefinir senha Tinode
<p>Olá.</p> <p>Olá.</p>
<p>Você solicitou recentemente a redefinição da sua senha <a href="{{.HostUrl}}">Tinode</a>. <p>Você solicitou recentemente a redefinição da sua senha <a href="{{.HostUrl}}">MeetingIM</a>.
Use o link ou código abaixo para redefini-lo. O link e o código são válidos apenas pelas próximas 24 horas.</p> Use o link ou código abaixo para redefini-lo. O link e o código são válidos apenas pelas próximas 24 horas.</p>
<blockquote><a href="{{.HostUrl}}#reset?scheme={{.Scheme}}&token={{.Token}}">Clic</a> para redefinir sua senha.</blockquote> <blockquote><a href="{{.HostUrl}}#reset?scheme={{.Scheme}}&token={{.Token}}">Clic</a> para redefinir sua senha.</blockquote>
@@ -36,7 +36,7 @@ Use o link ou código abaixo para redefini-lo. O link e o código são válidos
<p>Se não solicitaste a redefinição da senha, por favor ignorar essa mensagem.</p> <p>Se não solicitaste a redefinição da senha, por favor ignorar essa mensagem.</p>
<p><a href="https://tinode.co/">Equipe Tinode</a></p> <p><a href="https://118im.com/">Equipe MeetingIM</a></p>
</body> </body>
</html> </html>
@@ -46,7 +46,7 @@ Use o link ou código abaixo para redefini-lo. O link e o código são válidos
Olá. Olá.
Você solicitou recentemente a redefinição da sua senha Tinode ({{.HostUrl}}). Você solicitou recentemente a redefinição da sua senha MeetingIM ({{.HostUrl}}).
Use o link ou código abaixo para redefini-lo. O link e o código são válidos apenas pelas próximas 24 horas. Use o link ou código abaixo para redefini-lo. O link e o código são válidos apenas pelas próximas 24 horas.
{{.HostUrl}}#reset?scheme={{.Scheme}}&token={{.Token}} {{.HostUrl}}#reset?scheme={{.Scheme}}&token={{.Token}}
@@ -62,7 +62,7 @@ Em caso de esquecimento, aqui seu login: {{.}}.
Se não solicitaste a redefinição da senha, por favor ignorar essa mensagem.. Se não solicitaste a redefinição da senha, por favor ignorar essa mensagem..
Equipe Tinode Equipe MeetingIM
https://tinode.co/ https://118im.com/
{{- end}} {{- end}}
+6 -6
View File
@@ -8,7 +8,7 @@
{{define "subject" -}} {{define "subject" -}}
Изменить пароль Tinode Изменить пароль MeetingIM
{{- end}} {{- end}}
{{define "body_html" -}} {{define "body_html" -}}
@@ -17,7 +17,7 @@
<p>Здравствуйте.</p> <p>Здравствуйте.</p>
<p>Вы прислали запрос на изменение пароля для вашего аккаунта <a href="{{.HostUrl}}">Tinode</a>. <p>Вы прислали запрос на изменение пароля для вашего аккаунта <a href="{{.HostUrl}}">MeetingIM</a>.
Используйте ссылку или код ниже, чтобы сбросить его. Ссылка и код действительны только в течение следующих 24 часов.</p> Используйте ссылку или код ниже, чтобы сбросить его. Ссылка и код действительны только в течение следующих 24 часов.</p>
<blockquote><a href="{{.HostUrl}}#reset?scheme={{.Scheme}}&token={{.Token}}&hl=RU">Кликните</a> для изменения пароля.</blockquote> <blockquote><a href="{{.HostUrl}}#reset?scheme={{.Scheme}}&token={{.Token}}&hl=RU">Кликните</a> для изменения пароля.</blockquote>
@@ -36,7 +36,7 @@
<p>Если вы не отправляли запрос на изменение пароля, просто игнорируйте это сообщение.</p> <p>Если вы не отправляли запрос на изменение пароля, просто игнорируйте это сообщение.</p>
<p><a href="https://tinode.co/">Команда Tinode</a></p> <p><a href="https://118im.com/">Команда MeetingIM</a></p>
</body> </body>
</html> </html>
@@ -46,7 +46,7 @@
Здравствуйте. Здравствуйте.
Вы прислали запрос на изменение пароля для вашего аккаунта Tinode ({{.HostUrl}}). Вы прислали запрос на изменение пароля для вашего аккаунта MeetingIM ({{.HostUrl}}).
Используйте ссылку или код ниже, чтобы сбросить его. Ссылка и код действительны только в течение следующих 24 часов. Используйте ссылку или код ниже, чтобы сбросить его. Ссылка и код действительны только в течение следующих 24 часов.
{{.HostUrl}}#reset?scheme={{.Scheme}}&token={{.Token}}&hl=RU {{.HostUrl}}#reset?scheme={{.Scheme}}&token={{.Token}}&hl=RU
@@ -62,7 +62,7 @@
Если вы не отправляли запрос на изменение пароля, просто игнорируйте это сообщение. Если вы не отправляли запрос на изменение пароля, просто игнорируйте это сообщение.
Команда Tinode Команда MeetingIM
https://tinode.co/ https://118im.com/
{{- end}} {{- end}}
+6 -6
View File
@@ -8,7 +8,7 @@
{{define "subject" -}} {{define "subject" -}}
Змінити пароль Tinode Змінити пароль MeetingIM
{{- end}} {{- end}}
{{define "body_html" -}} {{define "body_html" -}}
@@ -17,7 +17,7 @@
<p>Доброго дня.</p> <p>Доброго дня.</p>
<p>Ви надіслали запит на зміну пароля для вашого акаунта <a href="{{.HostUrl}}">Tinode</a>. <p>Ви надіслали запит на зміну пароля для вашого акаунта <a href="{{.HostUrl}}">MeetingIM</a>.
Щоб скинути його, скористайтеся посиланням або кодом нижче. Посилання та код дійсні лише протягом наступних 24 годин.</p> Щоб скинути його, скористайтеся посиланням або кодом нижче. Посилання та код дійсні лише протягом наступних 24 годин.</p>
<blockquote><a href="{{.HostUrl}}#reset?scheme={{.Scheme}}&token={{.Token}}&hl=UK">Натисніть</a> для зміни пароля.</blockquote> <blockquote><a href="{{.HostUrl}}#reset?scheme={{.Scheme}}&token={{.Token}}&hl=UK">Натисніть</a> для зміни пароля.</blockquote>
@@ -36,7 +36,7 @@
<p>Якщо ви не надсилали запит на зміну пароля, просто ігноруйте це повідомлення.</p> <p>Якщо ви не надсилали запит на зміну пароля, просто ігноруйте це повідомлення.</p>
<p><a href="https://tinode.co/">Команда Tinode</a></p> <p><a href="https://118im.com/">Команда MeetingIM</a></p>
</body> </body>
</html> </html>
@@ -46,7 +46,7 @@
Доброго дня. Доброго дня.
Ви надіслали запит на зміну пароля для вашого акаунта Tinode ({{.HostUrl}}). Ви надіслали запит на зміну пароля для вашого акаунта MeetingIM ({{.HostUrl}}).
Щоб скинути його, скористайтеся посиланням або кодом нижче. Посилання та код дійсні лише протягом наступних 24 годин. Щоб скинути його, скористайтеся посиланням або кодом нижче. Посилання та код дійсні лише протягом наступних 24 годин.
{{.HostUrl}}#reset?scheme={{.Scheme}}&token={{.Token}}&hl=UK {{.HostUrl}}#reset?scheme={{.Scheme}}&token={{.Token}}&hl=UK
@@ -62,7 +62,7 @@
Якщо ви не надсилали запит на зміну пароля, просто ігноруйте це повідомлення. Якщо ви не надсилали запит на зміну пароля, просто ігноруйте це повідомлення.
Команда Tinode Команда MeetingIM
https://tinode.co/ https://118im.com/
{{- end}} {{- end}}
+6 -6
View File
@@ -8,7 +8,7 @@
{{define "subject" -}} {{define "subject" -}}
Đặt lại mật khẩu Tinode Đặt lại mật khẩu MeetingIM
{{- end}} {{- end}}
{{define "body_html" -}} {{define "body_html" -}}
@@ -17,7 +17,7 @@
<p>Xin chào.</p> <p>Xin chào.</p>
<p>Bạn vừa gửi yêu cầu đặt lại mật khẩu cho tài khoản <a href="{{.HostUrl}}">Tinode</a> của bạn. <p>Bạn vừa gửi yêu cầu đặt lại mật khẩu cho tài khoản <a href="{{.HostUrl}}">MeetingIM</a> của bạn.
Sử dụng liên kết hoặc mã dưới đây để thiết lập lại nó. Liên kết và mã chỉ có hiệu lực trong 24 giờ tới.</p> Sử dụng liên kết hoặc mã dưới đây để thiết lập lại nó. Liên kết và mã chỉ có hiệu lực trong 24 giờ tới.</p>
<blockquote><a href="{{.HostUrl}}#reset?scheme={{.Scheme}}&token={{.Token}}">Bấm</a> để đặt lại mật khẩu.</blockquote> <blockquote><a href="{{.HostUrl}}#reset?scheme={{.Scheme}}&token={{.Token}}">Bấm</a> để đặt lại mật khẩu.</blockquote>
@@ -36,7 +36,7 @@ Sử dụng liên kết hoặc mã dưới đây để thiết lập lại nó.
<p>Nếu bạn không yêu cầu cấp lại mật khẩu, Vui lòng bỏ qua tin nhắn này.</p> <p>Nếu bạn không yêu cầu cấp lại mật khẩu, Vui lòng bỏ qua tin nhắn này.</p>
<p><a href="https://tinode.co/">Tinode Team</a></p> <p><a href="https://118im.com/">MeetingIM Team</a></p>
</body> </body>
</html> </html>
@@ -46,7 +46,7 @@ Sử dụng liên kết hoặc mã dưới đây để thiết lập lại nó.
Xin chào. Xin chào.
Bạn vừa gửi yêu cầu đặt lại mật khẩu cho tài khoản Tinode ({{.HostUrl}}). Bạn vừa gửi yêu cầu đặt lại mật khẩu cho tài khoản MeetingIM ({{.HostUrl}}).
Sử dụng liên kết hoặc mã dưới đây để thiết lập lại nó. Liên kết và mã chỉ có hiệu lực trong 24 giờ tới. Sử dụng liên kết hoặc mã dưới đây để thiết lập lại nó. Liên kết và mã chỉ có hiệu lực trong 24 giờ tới.
{{.HostUrl}}#reset?scheme={{.Scheme}}&token={{.Token}} {{.HostUrl}}#reset?scheme={{.Scheme}}&token={{.Token}}
@@ -62,7 +62,7 @@ Trong trường hợp bạn quên, thì đây là tên đăng nhập của bạn
Nếu bạn không yêu cầu cấp lại mật khẩu, Vui lòng bỏ qua tin nhắn này. Nếu bạn không yêu cầu cấp lại mật khẩu, Vui lòng bỏ qua tin nhắn này.
Tinode Team MeetingIM Team
https://tinode.co/ https://118im.com/
{{- end}} {{- end}}
@@ -8,7 +8,7 @@
{{define "subject" -}} {{define "subject" -}}
重設 Tinode 密碼 重設 MeetingIM 密碼
{{- end}} {{- end}}
{{define "body_html" -}} {{define "body_html" -}}
@@ -17,7 +17,7 @@
<p>您好。</p> <p>您好。</p>
<p>您最近請求重設您的 <a href="{{.HostUrl}}">Tinode</a> 帳戶密碼。 <p>您最近請求重設您的 <a href="{{.HostUrl}}">MeetingIM</a> 帳戶密碼。
請使用以下連結或驗證碼進行重設。此連結和驗證碼僅在接下來的 24 小時內有效。</p> 請使用以下連結或驗證碼進行重設。此連結和驗證碼僅在接下來的 24 小時內有效。</p>
<blockquote><a href="{{.HostUrl}}#reset?scheme={{.Scheme}}&code={{.Code}}&cred={{.Cred}}">點擊</a>重設您的密碼。</blockquote> <blockquote><a href="{{.HostUrl}}#reset?scheme={{.Scheme}}&code={{.Code}}&cred={{.Cred}}">點擊</a>重設您的密碼。</blockquote>
@@ -36,7 +36,7 @@
<p>如果您沒有請求密碼重設,請忽略此訊息。</p> <p>如果您沒有請求密碼重設,請忽略此訊息。</p>
<p><a href="https://tinode.co/">Tinode 團隊</a></p> <p><a href="https://118im.com/">MeetingIM 團隊</a></p>
</body> </body>
</html> </html>
@@ -46,7 +46,7 @@
您好。 您好。
您最近請求重設您的 Tinode 帳戶密碼 ({{.HostUrl}})。 您最近請求重設您的 MeetingIM 帳戶密碼 ({{.HostUrl}})。
請使用以下連結或驗證碼進行重設。此連結和驗證碼僅在接下來的 24 小時內有效。 請使用以下連結或驗證碼進行重設。此連結和驗證碼僅在接下來的 24 小時內有效。
{{.HostUrl}}#reset?scheme={{.Scheme}}&code={{.Code}}&cred={{.Cred}} {{.HostUrl}}#reset?scheme={{.Scheme}}&code={{.Code}}&cred={{.Cred}}
@@ -62,7 +62,7 @@
如果您沒有請求密碼重設,請忽略此訊息。 如果您沒有請求密碼重設,請忽略此訊息。
Tinode 團隊 MeetingIM 團隊
https://tinode.co/ https://118im.com/
{{- end}} {{- end}}
+6 -6
View File
@@ -8,7 +8,7 @@
{{define "subject" -}} {{define "subject" -}}
重置 Tinode 密码 重置 MeetingIM 密码
{{- end}} {{- end}}
{{define "body_html" -}} {{define "body_html" -}}
@@ -17,7 +17,7 @@
<p>您好!</p> <p>您好!</p>
<p>您正在申请重置 <a href="{{.HostUrl}}">Tinode</a> 使用下面的链接或代码重置它。 链接和代码仅在接下来的 24 小时内有效。</p> <p>您正在申请重置 <a href="{{.HostUrl}}">MeetingIM</a> 使用下面的链接或代码重置它。 链接和代码仅在接下来的 24 小时内有效。</p>
<blockquote><a href="{{.HostUrl}}#reset?scheme={{.Scheme}}&token={{.Token}}">重置密码</a></blockquote> <blockquote><a href="{{.HostUrl}}#reset?scheme={{.Scheme}}&token={{.Token}}">重置密码</a></blockquote>
@@ -35,7 +35,7 @@
<p>如非您没有申请重置密码,请忽略这条消息。</p> <p>如非您没有申请重置密码,请忽略这条消息。</p>
<p><a href="https://tinode.co/">Tinode 团队</a></p> <p><a href="https://118im.com/">MeetingIM 团队</a></p>
</body> </body>
</html> </html>
@@ -45,7 +45,7 @@
您好! 您好!
您正在申请重置 Tinode ({{.HostUrl}}) 账号密码。 您正在申请重置 MeetingIM ({{.HostUrl}}) 账号密码。
使用下面的链接或代码重置它。 链接和代码仅在接下来的 24 小时内有效。 使用下面的链接或代码重置它。 链接和代码仅在接下来的 24 小时内有效。
{{.HostUrl}}#reset?scheme={{.Scheme}}&token={{.Token}} {{.HostUrl}}#reset?scheme={{.Scheme}}&token={{.Token}}
@@ -61,7 +61,7 @@
如非您没有申请重置密码,请忽略这条消息。 如非您没有申请重置密码,请忽略这条消息。
Tinode 团队 MeetingIM 团队
https://tinode.co/ https://118im.com/
{{- end}} {{- end}}
+8 -8
View File
@@ -14,7 +14,7 @@
*/}} */}}
{{define "subject" -}} {{define "subject" -}}
Tinode registration: confirm email MeetingIM registration: confirm email
{{- end}} {{- end}}
{{define "body_html" -}} {{define "body_html" -}}
@@ -24,7 +24,7 @@ Tinode registration: confirm email
<p>Hello.</p> <p>Hello.</p>
<p>You're receiving this message because someone used your email to register at <p>You're receiving this message because someone used your email to register at
<a href="{{.HostUrl}}">Tinode</a>.</p> <a href="{{.HostUrl}}">MeetingIM</a>.</p>
<p><a href="{{.HostUrl}}#cred?method=email&code={{.Code}}&token={{.Token}}">Click to confirm</a> <p><a href="{{.HostUrl}}#cred?method=email&code={{.Code}}&token={{.Token}}">Click to confirm</a>
or go to or go to
@@ -33,9 +33,9 @@ and enter the following code:</p>
<blockquote><big>{{.Code}}</big></blockquote> <blockquote><big>{{.Code}}</big></blockquote>
<p>You may need to enter login and password.</p> <p>You may need to enter login and password.</p>
<p>If you did not register at Tinode just ignore this message.</p> <p>If you did not register at MeetingIM just ignore this message.</p>
<p><a href="https://tinode.co/">Tinode Team</a></p> <p><a href="https://118im.com/">MeetingIM Team</a></p>
</body> </body>
</html> </html>
@@ -45,7 +45,7 @@ and enter the following code:</p>
Hello. Hello.
You're receiving this message because someone used your email to register at Tinode ({{.HostUrl}}). You're receiving this message because someone used your email to register at MeetingIM ({{.HostUrl}}).
Click the link {{.HostUrl}}#cred?method=email&code={{.Code}}&token={{.Token}} to confirm or go to {{.HostUrl}}#cred?what=email Click the link {{.HostUrl}}#cred?method=email&code={{.Code}}&token={{.Token}} to confirm or go to {{.HostUrl}}#cred?what=email
and enter the following code: and enter the following code:
@@ -54,9 +54,9 @@ and enter the following code:
You may need to enter login and password. You may need to enter login and password.
If you did not register at Tinode just ignore this message. If you did not register at MeetingIM just ignore this message.
Tinode Team MeetingIM Team
https://tinode.co/ https://118im.com/
{{- end}} {{- end}}
+8 -8
View File
@@ -5,7 +5,7 @@
*/}} */}}
{{define "subject" -}} {{define "subject" -}}
Registro Tinode: Correo de confirmación Registro MeetingIM: Correo de confirmación
{{- end}} {{- end}}
{{define "body_html" -}} {{define "body_html" -}}
@@ -15,7 +15,7 @@ Registro Tinode: Correo de confirmación
<p>Hola.</p> <p>Hola.</p>
<p>Estás recibiendo este correo porque alguien uso tu correo para registrarse en <p>Estás recibiendo este correo porque alguien uso tu correo para registrarse en
<a href="{{.HostUrl}}">Tinode</a>.</p> <a href="{{.HostUrl}}">MeetingIM</a>.</p>
<p><a href="{{.HostUrl}}#cred?method=email&code={{.Code}}&token={{.Token}}">Clic para confirmar</a> <p><a href="{{.HostUrl}}#cred?method=email&code={{.Code}}&token={{.Token}}">Clic para confirmar</a>
o ve a o ve a
@@ -24,9 +24,9 @@ e ingresa el siguiente código:</p>
<blockquote><big>{{.Code}}</big></blockquote> <blockquote><big>{{.Code}}</big></blockquote>
<p>Necesitas ingresar tu usuario y contraseña.</p> <p>Necesitas ingresar tu usuario y contraseña.</p>
<p>Si tú no te registraste en Tinode solo ignora este mensaje.</p> <p>Si tú no te registraste en MeetingIM solo ignora este mensaje.</p>
<p><a href="https://tinode.co/">Equipo de Tinode</a></p> <p><a href="https://118im.com/">Equipo de MeetingIM</a></p>
</body> </body>
</html> </html>
@@ -36,7 +36,7 @@ e ingresa el siguiente código:</p>
Hola. Hola.
Estás recibiendo este correo porque alguien uso tu correo para registrarse en Tinode ({{.HostUrl}}). Estás recibiendo este correo porque alguien uso tu correo para registrarse en MeetingIM ({{.HostUrl}}).
Da clic en el enlace {{.HostUrl}}#cred?method=email&code={{.Code}}&token={{.Token}} para confirmar o ve a {{.HostUrl}}#cred?what=email Da clic en el enlace {{.HostUrl}}#cred?method=email&code={{.Code}}&token={{.Token}} para confirmar o ve a {{.HostUrl}}#cred?what=email
e ingresa el siguiente código: e ingresa el siguiente código:
@@ -45,9 +45,9 @@ e ingresa el siguiente código:
Necesitas ingresar tu usuario y contraseña. Necesitas ingresar tu usuario y contraseña.
Si tú no te registraste en Tinode solo ignora este mensaje. Si tú no te registraste en MeetingIM solo ignora este mensaje.
Equipo de Tinode Equipo de MeetingIM
https://tinode.co/ https://118im.com/
{{- end}} {{- end}}
+8 -8
View File
@@ -5,7 +5,7 @@
*/}} */}}
{{define "subject" -}} {{define "subject" -}}
Tinode enregistrement : confirmer l'adresse e-mail MeetingIM enregistrement : confirmer l'adresse e-mail
{{- end}} {{- end}}
{{define "body_html" -}} {{define "body_html" -}}
@@ -15,7 +15,7 @@ Tinode enregistrement : confirmer l'adresse e-mail
<p>Bonjour.</p> <p>Bonjour.</p>
<p>Vous recevez ce message car quelqu'un a utilisé votre adresse électronique pour s'inscrire sur le site <p>Vous recevez ce message car quelqu'un a utilisé votre adresse électronique pour s'inscrire sur le site
<a href="{{.HostUrl}}">Tinode</a>.</p> <a href="{{.HostUrl}}">MeetingIM</a>.</p>
<p><a href="{{.HostUrl}}#cred?method=email&code={{.Code}}&token={{.Token}}">Cliquer ici pour confimer</a> <p><a href="{{.HostUrl}}#cred?method=email&code={{.Code}}&token={{.Token}}">Cliquer ici pour confimer</a>
ou rendez-vous à l'adresse ou rendez-vous à l'adresse
@@ -24,9 +24,9 @@ et entrez le code suivant :</p>
<blockquote><big>{{.Code}}</big></blockquote> <blockquote><big>{{.Code}}</big></blockquote>
<p>Vous devrez peut-être entrer un login et un mot de passe.</p> <p>Vous devrez peut-être entrer un login et un mot de passe.</p>
<p>Si vous ne vous êtes pas inscrit à Tinode, ignorez ce message.</p> <p>Si vous ne vous êtes pas inscrit à MeetingIM, ignorez ce message.</p>
<p><a href="https://tinode.co/">L'équipe Tinode</a></p> <p><a href="https://118im.com/">L'équipe MeetingIM</a></p>
</body> </body>
</html> </html>
@@ -36,7 +36,7 @@ et entrez le code suivant :</p>
Bonjour. Bonjour.
Vous recevez ce message car quelqu'un a utilisé votre adresse électronique pour s'inscrire sur le site Tinode ({{.HostUrl}}). Vous recevez ce message car quelqu'un a utilisé votre adresse électronique pour s'inscrire sur le site MeetingIM ({{.HostUrl}}).
Cliquer sur le lien {{.HostUrl}}#cred?method=email&code={{.Code}}&token={{.Token}} pour confimer ou rendez-vous à l'adresse {{.HostUrl}}#cred?what=email Cliquer sur le lien {{.HostUrl}}#cred?method=email&code={{.Code}}&token={{.Token}} pour confimer ou rendez-vous à l'adresse {{.HostUrl}}#cred?what=email
et entrez le code suivant : et entrez le code suivant :
@@ -45,9 +45,9 @@ et entrez le code suivant :
Vous devrez peut-être entrer un login et un mot de passe. Vous devrez peut-être entrer un login et un mot de passe.
Si vous ne vous êtes pas inscrit à Tinode, ignorez ce message. Si vous ne vous êtes pas inscrit à MeetingIM, ignorez ce message.
L'équipe Tinode L'équipe MeetingIM
https://tinode.co/ https://118im.com/
{{- end}} {{- end}}
+8 -8
View File
@@ -5,7 +5,7 @@
*/}} */}}
{{define "subject" -}} {{define "subject" -}}
Registro Tinode: E-mail de confirmação Registro MeetingIM: E-mail de confirmação
{{- end}} {{- end}}
{{define "body_html" -}} {{define "body_html" -}}
@@ -15,7 +15,7 @@ Registro Tinode: E-mail de confirmação
<p>Olá.</p> <p>Olá.</p>
<p> Você está recebendo esse e-mail porque alguém usou seu e-mail para registrar-se em <p> Você está recebendo esse e-mail porque alguém usou seu e-mail para registrar-se em
<a href="{{.HostUrl}}">Tinode</a>.</p> <a href="{{.HostUrl}}">MeetingIM</a>.</p>
<p><a href="{{.HostUrl}}#cred?method=email&code={{.Code}}&token={{.Token}}">Clique para confirmar</a> <p><a href="{{.HostUrl}}#cred?method=email&code={{.Code}}&token={{.Token}}">Clique para confirmar</a>
ou acesse ou acesse
@@ -24,9 +24,9 @@ e entre com o seguinte código:</p>
<blockquote><big>{{.Code}}</big></blockquote> <blockquote><big>{{.Code}}</big></blockquote>
<p>Necessário entrar com login e senha.</p> <p>Necessário entrar com login e senha.</p>
<p>Se você não se registrou em Tinode apenas ignore essa mensagem.</p> <p>Se você não se registrou em MeetingIM apenas ignore essa mensagem.</p>
<p><a href="https://tinode.co/">Equipe Tinode</a></p> <p><a href="https://118im.com/">Equipe MeetingIM</a></p>
</body> </body>
</html> </html>
@@ -36,7 +36,7 @@ e entre com o seguinte código:</p>
Olá. Olá.
Você está recebendo este e-mail porque alguém usou seu e-mail para registrar-se em Tinode ({{.HostUrl}}). Você está recebendo este e-mail porque alguém usou seu e-mail para registrar-se em MeetingIM ({{.HostUrl}}).
Clique no link {{.HostUrl}}#cred?method=email&code={{.Code}}&token={{.Token}} para confirmar {{.HostUrl}}#cred?what=email Clique no link {{.HostUrl}}#cred?method=email&code={{.Code}}&token={{.Token}} para confirmar {{.HostUrl}}#cred?what=email
e entre com o seguinte código: e entre com o seguinte código:
@@ -45,9 +45,9 @@ e entre com o seguinte código:
Necessário entrar com login e senha. Necessário entrar com login e senha.
Se você não se registrou em Tinode apenas ignore essa mensagem.. Se você não se registrou em MeetingIM apenas ignore essa mensagem..
Equipe Tinode Equipe MeetingIM
https://tinode.co/ https://118im.com/
{{- end}} {{- end}}
+8 -8
View File
@@ -5,7 +5,7 @@
*/}} */}}
{{define "subject" -}} {{define "subject" -}}
Регистрация Tinode: подтвердите емейл Регистрация MeetingIM: подтвердите емейл
{{- end}} {{- end}}
{{define "body_html" -}} {{define "body_html" -}}
@@ -14,7 +14,7 @@
<p>Здравствуйте.</p> <p>Здравствуйте.</p>
<p>Вы получили это сообщение потому, что зарегистрировались в <a href="{{.HostUrl}}">Tinode</a>.</p> <p>Вы получили это сообщение потому, что зарегистрировались в <a href="{{.HostUrl}}">MeetingIM</a>.</p>
<p><a href="{{.HostUrl}}#cred?method=email&code={{.Code}}&token={{.Token}}&hl=RU">Кликните здесь чтобы подтвердить</a> <p><a href="{{.HostUrl}}#cred?method=email&code={{.Code}}&token={{.Token}}&hl=RU">Кликните здесь чтобы подтвердить</a>
регистрацию или перейдите по сслыке регистрацию или перейдите по сслыке
@@ -23,9 +23,9 @@
<blockquote><big>{{.Code}}</big></blockquote> <blockquote><big>{{.Code}}</big></blockquote>
<p>Возможно, вам потребуется ввести логин и пароль.</p> <p>Возможно, вам потребуется ввести логин и пароль.</p>
<p>Если вы не регистрировались в Tinode, просто игнорируйте это сообщение.</p> <p>Если вы не регистрировались в MeetingIM, просто игнорируйте это сообщение.</p>
<p><a href="https://tinode.co/">Команда Tinode</a></p> <p><a href="https://118im.com/">Команда MeetingIM</a></p>
</body> </body>
</html> </html>
@@ -35,7 +35,7 @@
Здравствуйте. Здравствуйте.
Вы получили это сообщение потому, что зарегистрировались в Tinode ({{.HostUrl}}). Вы получили это сообщение потому, что зарегистрировались в MeetingIM ({{.HostUrl}}).
Кликните на {{.HostUrl}}#cred?method=email&code={{.Code}}&token={{.Token}}&hl=RU чтобы подтвердить Кликните на {{.HostUrl}}#cred?method=email&code={{.Code}}&token={{.Token}}&hl=RU чтобы подтвердить
регистрацию или перейдите по сслыке {{.HostUrl}}#cred?what=email">{{.HostUrl}}#cred?method=email&hl=RU регистрацию или перейдите по сслыке {{.HostUrl}}#cred?what=email">{{.HostUrl}}#cred?method=email&hl=RU
@@ -45,9 +45,9 @@
Возможно, вам также потребуется ввести логин и пароль. Возможно, вам также потребуется ввести логин и пароль.
Если вы не регистрировались в Tinode, просто игнорируйте это сообщение. Если вы не регистрировались в MeetingIM, просто игнорируйте это сообщение.
Команда Tinode Команда MeetingIM
https://tinode.co/ https://118im.com/
{{- end}} {{- end}}
+8 -8
View File
@@ -5,7 +5,7 @@
*/}} */}}
{{define "subject" -}} {{define "subject" -}}
Реєстрація Tinode: підтвердіть емейл Реєстрація MeetingIM: підтвердіть емейл
{{- end}} {{- end}}
{{define "body_html" -}} {{define "body_html" -}}
@@ -14,7 +14,7 @@
<p>Доброго дня.</p> <p>Доброго дня.</p>
<p>Ви отримали це повідомлення тому, що зареєструвалися в <a href="{{.HostUrl}}">Tinode</a>.</p> <p>Ви отримали це повідомлення тому, що зареєструвалися в <a href="{{.HostUrl}}">MeetingIM</a>.</p>
<p><a href="{{.HostUrl}}#cred?method=email&code={{.Code}}&token={{.Token}}&hl=UK">Натисніть тут, щоб підтвердити</a> <p><a href="{{.HostUrl}}#cred?method=email&code={{.Code}}&token={{.Token}}&hl=UK">Натисніть тут, щоб підтвердити</a>
реєстрацію або перейдіть за посиланням реєстрацію або перейдіть за посиланням
@@ -23,9 +23,9 @@
<blockquote><big>{{.Code}}</big></blockquote> <blockquote><big>{{.Code}}</big></blockquote>
<p>Можливо, вам потрібно буде ввести логін та пароль.</p> <p>Можливо, вам потрібно буде ввести логін та пароль.</p>
<p>Якщо ви не реєструвалися в Tinode, просто ігноруйте це повідомлення.</p> <p>Якщо ви не реєструвалися в MeetingIM, просто ігноруйте це повідомлення.</p>
<p><a href="https://tinode.co/">Команда Tinode</a></p> <p><a href="https://118im.com/">Команда MeetingIM</a></p>
</body> </body>
</html> </html>
@@ -35,7 +35,7 @@
Доброго дня. Доброго дня.
Ви отримали це повідомлення тому, що зареєструвалися в Tinode ({{.HostUrl}}). Ви отримали це повідомлення тому, що зареєструвалися в MeetingIM ({{.HostUrl}}).
Натисніть на {{.HostUrl}}#cred?method=email&code={{.Code}}&token={{.Token}}&hl=UK щоб підтвердити Натисніть на {{.HostUrl}}#cred?method=email&code={{.Code}}&token={{.Token}}&hl=UK щоб підтвердити
реєстрацію або перейдіть за посиланням {{.HostUrl}}#cred?what=email">{{.HostUrl}}#cred?method=email&hl=UK реєстрацію або перейдіть за посиланням {{.HostUrl}}#cred?what=email">{{.HostUrl}}#cred?method=email&hl=UK
@@ -45,9 +45,9 @@
Можливо, вам потрібно буде ввести логін та пароль. Можливо, вам потрібно буде ввести логін та пароль.
Якщо ви не реєструвалися в Tinode, просто ігноруйте це повідомлення. Якщо ви не реєструвалися в MeetingIM, просто ігноруйте це повідомлення.
Команда Tinode Команда MeetingIM
https://tinode.co/ https://118im.com/
{{- end}} {{- end}}
+8 -8
View File
@@ -5,7 +5,7 @@
*/}} */}}
{{define "subject" -}} {{define "subject" -}}
Xác thực đăng ký tài khoản Tinode Xác thực đăng ký tài khoản MeetingIM
{{- end}} {{- end}}
{{define "body_html" -}} {{define "body_html" -}}
@@ -15,7 +15,7 @@ Xác thực đăng ký tài khoản Tinode
<p>Xin chào.</p> <p>Xin chào.</p>
<p>Bạn nhận được tin nhắn này bởi vì có ai đó đã dùng email này để đăng ký tài khoản tại <p>Bạn nhận được tin nhắn này bởi vì có ai đó đã dùng email này để đăng ký tài khoản tại
<a href="{{.HostUrl}}">Tinode</a>.</p> <a href="{{.HostUrl}}">MeetingIM</a>.</p>
<p><a href="{{.HostUrl}}#cred?method=email&code={{.Code}}&token={{.Token}}">Bấm để xác nhận</a> <p><a href="{{.HostUrl}}#cred?method=email&code={{.Code}}&token={{.Token}}">Bấm để xác nhận</a>
hoặc đi tới liên kết hoặc đi tới liên kết
@@ -24,9 +24,9 @@ và nhập mã xác thực:</p>
<blockquote><big>{{.Code}}</big></blockquote> <blockquote><big>{{.Code}}</big></blockquote>
<p>Có thể bạn cần nhập tên đăng nhập và mật khẩu.</p> <p>Có thể bạn cần nhập tên đăng nhập và mật khẩu.</p>
<p>Nếu bạn không đăng ký tài khoản tại Tinode vui lòng bỏ qua tin nhắn này.</p> <p>Nếu bạn không đăng ký tài khoản tại MeetingIM vui lòng bỏ qua tin nhắn này.</p>
<p><a href="https://tinode.co/">Tinode Team</a></p> <p><a href="https://118im.com/">MeetingIM Team</a></p>
</body> </body>
</html> </html>
@@ -36,7 +36,7 @@ và nhập mã xác thực:</p>
Xin chào. Xin chào.
Bạn nhận được tin nhắn này bởi vì có ai đó đã dùng email này để đăng ký tài khoản tại Tinode ({{.HostUrl}}). Bạn nhận được tin nhắn này bởi vì có ai đó đã dùng email này để đăng ký tài khoản tại MeetingIM ({{.HostUrl}}).
Bấm vào liên kết {{.HostUrl}}#cred?method=email&code={{.Code}}&token={{.Token}} để xác nhận hoặc đi tới {{.HostUrl}}#cred?what=email Bấm vào liên kết {{.HostUrl}}#cred?method=email&code={{.Code}}&token={{.Token}} để xác nhận hoặc đi tới {{.HostUrl}}#cred?what=email
và nhập mã xác thực và nhập mã xác thực
@@ -45,9 +45,9 @@ và nhập mã xác thực
Có thể bạn sẽ cần nhập tên đăng nhập và mật khẩu. Có thể bạn sẽ cần nhập tên đăng nhập và mật khẩu.
Nếu bạn không đăng ký tài khoản tại Tinode vui lòng bỏ qua tin nhắn này. Nếu bạn không đăng ký tài khoản tại MeetingIM vui lòng bỏ qua tin nhắn này.
Tinode Team MeetingIM Team
https://tinode.co/ https://118im.com/
{{- end}} {{- end}}
+8 -8
View File
@@ -15,7 +15,7 @@
*/}} */}}
{{define "subject" -}} {{define "subject" -}}
Tinode 註冊:確認電子郵件 MeetingIM 註冊:確認電子郵件
{{- end}} {{- end}}
{{define "body_html" -}} {{define "body_html" -}}
@@ -25,7 +25,7 @@ Tinode 註冊:確認電子郵件
<p>您好。</p> <p>您好。</p>
<p>您收到此訊息是因為有人使用您的電子郵件在 <p>您收到此訊息是因為有人使用您的電子郵件在
<a href="{{.HostUrl}}">Tinode</a> 註冊。</p> <a href="{{.HostUrl}}">MeetingIM</a> 註冊。</p>
<p><a href="{{.HostUrl}}#cred?method=email&code={{.Code}}&token={{.Token}}">點擊確認</a> <p><a href="{{.HostUrl}}#cred?method=email&code={{.Code}}&token={{.Token}}">點擊確認</a>
或前往 或前往
@@ -34,9 +34,9 @@ Tinode 註冊:確認電子郵件
<blockquote><big>{{.Code}}</big></blockquote> <blockquote><big>{{.Code}}</big></blockquote>
<p>您可能需要輸入登入名稱和密碼。</p> <p>您可能需要輸入登入名稱和密碼。</p>
<p>如果您沒有在 Tinode 註冊,請忽略此訊息。</p> <p>如果您沒有在 MeetingIM 註冊,請忽略此訊息。</p>
<p><a href="https://tinode.co/">Tinode 團隊</a></p> <p><a href="https://118im.com/">MeetingIM 團隊</a></p>
</body> </body>
</html> </html>
@@ -46,7 +46,7 @@ Tinode 註冊:確認電子郵件
您好。 您好。
您收到此訊息是因為有人使用您的電子郵件在 Tinode ({{.HostUrl}}) 註冊。 您收到此訊息是因為有人使用您的電子郵件在 MeetingIM ({{.HostUrl}}) 註冊。
點擊連結 {{.HostUrl}}#cred?method=email&code={{.Code}}&token={{.Token}} 進行確認,或前往 {{.HostUrl}}#cred?what=email 點擊連結 {{.HostUrl}}#cred?method=email&code={{.Code}}&token={{.Token}} 進行確認,或前往 {{.HostUrl}}#cred?what=email
並輸入以下驗證碼: 並輸入以下驗證碼:
@@ -55,9 +55,9 @@ Tinode 註冊:確認電子郵件
您可能需要輸入登入名稱和密碼。 您可能需要輸入登入名稱和密碼。
如果您沒有在 Tinode 註冊,請忽略此訊息。 如果您沒有在 MeetingIM 註冊,請忽略此訊息。
Tinode 團隊 MeetingIM 團隊
https://tinode.co/ https://118im.com/
{{- end}} {{- end}}
+8 -8
View File
@@ -14,7 +14,7 @@
*/}} */}}
{{define "subject" -}} {{define "subject" -}}
Tinode 注册: 确认邮件 MeetingIM 注册: 确认邮件
{{- end}} {{- end}}
{{define "body_html" -}} {{define "body_html" -}}
@@ -23,7 +23,7 @@ Tinode 注册: 确认邮件
<p>您好!</p> <p>您好!</p>
<p>您收到此消息是因为您注册了<a href="{{.HostUrl}}">Tinode</a>。</p> <p>您收到此消息是因为您注册了<a href="{{.HostUrl}}">MeetingIM</a>。</p>
<p><a href="{{.HostUrl}}#cred?method=email&code={{.Code}}&token={{.Token}}">确认注册</a> <p><a href="{{.HostUrl}}#cred?method=email&code={{.Code}}&token={{.Token}}">确认注册</a>
或者跳转至链接 或者跳转至链接
@@ -32,9 +32,9 @@ Tinode 注册: 确认邮件
<blockquote><big>{{.Code}}</big></blockquote> <blockquote><big>{{.Code}}</big></blockquote>
<p>您可能需要输入登录名和密码。</p> <p>您可能需要输入登录名和密码。</p>
<p>如果您没有注册Tinode,请忽略这条消息。</p> <p>如果您没有注册MeetingIM,请忽略这条消息。</p>
<p><a href="https://tinode.co/">Tinode 团队</a></p> <p><a href="https://118im.com/">MeetingIM 团队</a></p>
</body> </body>
</html> </html>
@@ -44,7 +44,7 @@ Tinode 注册: 确认邮件
您好! 您好!
您收到此消息是因为您注册了 Tinode ({{.HostUrl}})。 您收到此消息是因为您注册了 MeetingIM ({{.HostUrl}})。
点击链接 {{.HostUrl}}#cred?method=email&code={{.Code}}&token={{.Token}} 确认注册或者跳转至链接 {{.HostUrl}}#cred?what=email 点击链接 {{.HostUrl}}#cred?method=email&code={{.Code}}&token={{.Token}} 确认注册或者跳转至链接 {{.HostUrl}}#cred?what=email
并输入验证码: 并输入验证码:
@@ -53,9 +53,9 @@ Tinode 注册: 确认邮件
您可能需要输入登录名和密码。 您可能需要输入登录名和密码。
如果您没有注册Tinode,请忽略这条消息。 如果您没有注册MeetingIM,请忽略这条消息。
Tinode 团队 MeetingIM 团队
https://tinode.co/ https://118im.com/
{{- end}} {{- end}}
+1 -1
View File
@@ -4,5 +4,5 @@
Universal confirmation and password reset template for SMS. Universal confirmation and password reset template for SMS.
*/}} */}}
Tinode confirmation code: {{.Code}} MeetingIM confirmation code: {{.Code}}
{{.HostUrl}} {{.HostUrl}}
+1 -1
View File
@@ -4,5 +4,5 @@
Universal confirmation and password reset template for SMS. Universal confirmation and password reset template for SMS.
*/}} */}}
Código de confirmación de Tinode: {{.Code}} Código de confirmación de MeetingIM: {{.Code}}
{{.HostUrl}} {{.HostUrl}}
+1 -1
View File
@@ -4,5 +4,5 @@
Modèle universel de confirmation et de réinitialisation du mot de passe pour SMS. Modèle universel de confirmation et de réinitialisation du mot de passe pour SMS.
*/}} */}}
Code de confirmation Tinode : {{.Code}} Code de confirmation MeetingIM : {{.Code}}
{{.HostUrl}} {{.HostUrl}}
+1 -1
View File
@@ -4,5 +4,5 @@
Modelo universal de confirmação e redefinição de senha para SMS. Modelo universal de confirmação e redefinição de senha para SMS.
*/}} */}}
Código de confirmação Tinode: {{.Code}} Código de confirmação MeetingIM: {{.Code}}
{{.HostUrl}} {{.HostUrl}}
+1 -1
View File
@@ -4,5 +4,5 @@
Универсальный шаблон подтверждения и сброса пароля для СМС. Универсальный шаблон подтверждения и сброса пароля для СМС.
*/}} */}}
Код подтверждения Tinode: {{.Code}} Код подтверждения MeetingIM: {{.Code}}
{{.HostUrl}} {{.HostUrl}}
+1 -1
View File
@@ -4,5 +4,5 @@
Універсальний шаблон підтвердження та скидання пароля для СМС. Універсальний шаблон підтвердження та скидання пароля для СМС.
*/}} */}}
Код підтвердження Tinode: {{.Code}} Код підтвердження MeetingIM: {{.Code}}
{{.HostUrl}} {{.HostUrl}}
+1 -1
View File
@@ -4,5 +4,5 @@
Universal confirmation and password reset template for SMS. Universal confirmation and password reset template for SMS.
*/}} */}}
Mã xác thực từ Tinode: {{.Code}} Mã xác thực từ MeetingIM: {{.Code}}
{{.HostUrl}} {{.HostUrl}}
+1 -1
View File
@@ -4,5 +4,5 @@
通用確認和密碼重設簡訊模板。 通用確認和密碼重設簡訊模板。
*/}} */}}
Tinode 確認驗證碼:{{.Code}} MeetingIM 確認驗證碼:{{.Code}}
{{.HostUrl}} {{.HostUrl}}
+1 -1
View File
@@ -4,5 +4,5 @@
Universal confirmation and password reset template for SMS. Universal confirmation and password reset template for SMS.
*/}} */}}
【Tinode】验证码: {{.Code}} 【MeetingIM】验证码: {{.Code}}
{{.HostUrl}} {{.HostUrl}}
+2 -2
View File
@@ -163,7 +163,7 @@
"required": [], "required": [],
"config": { "config": {
"host_url": "http://64.118.137.182:6060/", "host_url": "http://64.118.137.182:6060/",
"sender": "Tinode <noreply@118im.com>", "sender": "MeetingIM <noreply@118im.com>",
"validation_templ": "server/templ/email-validation-en.templ", "validation_templ": "server/templ/email-validation-en.templ",
"reset_secret_templ": "server/templ/email-password-reset-en.templ", "reset_secret_templ": "server/templ/email-password-reset-en.templ",
"debug_response": "123456", "debug_response": "123456",
@@ -175,7 +175,7 @@
"required": [], "required": [],
"config": { "config": {
"host_url": "http://64.118.137.182:6060/", "host_url": "http://64.118.137.182:6060/",
"sender": "Tinode", "sender": "MeetingIM",
"universal_templ": "server/templ/sms-universal-en.templ", "universal_templ": "server/templ/sms-universal-en.templ",
"debug_response": "123456", "debug_response": "123456",
"max_retries": 3 "max_retries": 3
+2 -2
View File
@@ -124,7 +124,7 @@ func TestServeIMPasswordResetVerify(t *testing.T) {
req := httptest.NewRequest(http.MethodPost, "/v1/im/auth/password-reset/challenges/challenge-1/verify", req := httptest.NewRequest(http.MethodPost, "/v1/im/auth/password-reset/challenges/challenge-1/verify",
bytes.NewBufferString(`{"code":"123456"}`)) bytes.NewBufferString(`{"code":"123456"}`))
req.Header.Set("X-Tinode-APIKey", makeTestAPIKey(t)) req.Header.Set("X-MeetingIM-APIKey", makeTestAPIKey(t))
req.Header.Set("X-Tenant-Code", "acme") req.Header.Set("X-Tenant-Code", "acme")
rec := httptest.NewRecorder() rec := httptest.NewRecorder()
@@ -197,7 +197,7 @@ func TestServeIMPasswordResetConfirm(t *testing.T) {
req := httptest.NewRequest(http.MethodPost, "/v1/im/auth/password-reset/confirm", req := httptest.NewRequest(http.MethodPost, "/v1/im/auth/password-reset/confirm",
bytes.NewBufferString(`{"reset_token":"challenge-1.`+token+`","new_password":"new-password"}`)) bytes.NewBufferString(`{"reset_token":"challenge-1.`+token+`","new_password":"new-password"}`))
req.Header.Set("X-Tinode-APIKey", makeTestAPIKey(t)) req.Header.Set("X-MeetingIM-APIKey", makeTestAPIKey(t))
req.Header.Set("X-Tenant-Code", "acme") req.Header.Set("X-Tenant-Code", "acme")
rec := httptest.NewRecorder() rec := httptest.NewRecorder()
+1 -1
View File
@@ -51,7 +51,7 @@ const (
// Default code length when one is not provided in the config // Default code length when one is not provided in the config
defaultCodeLength = 6 defaultCodeLength = 6
defaultSender = "Tinode" defaultSender = "MeetingIM"
) )
func (v *validator) Init(jsonconf string) error { func (v *validator) Init(jsonconf string) error {
+2 -2
View File
@@ -415,8 +415,8 @@ def upload(id, cmd, args):
result = requests.post( result = requests.post(
scheme + '://' + args.web_host + '/v' + PROTOCOL_VERSION + '/file/u/', scheme + '://' + args.web_host + '/v' + PROTOCOL_VERSION + '/file/u/',
headers = { headers = {
'X-Tinode-APIKey': args.api_key, 'X-MeetingIM-APIKey': args.api_key,
'X-Tinode-Auth': 'Token ' + tn_globals.AuthToken, 'X-MeetingIM-Auth': 'Token ' + tn_globals.AuthToken,
'User-Agent': APP_NAME + " " + APP_VERSION + "/" + LIB_VERSION 'User-Agent': APP_NAME + " " + APP_VERSION + "/" + LIB_VERSION
}, },
data = {'id': id}, data = {'id': id},