diff --git a/docs/IM-前后端对接文档.md b/docs/IM-前后端对接文档.md index abc6f36..8e58092 100644 --- a/docs/IM-前后端对接文档.md +++ b/docs/IM-前后端对接文档.md @@ -2068,9 +2068,12 @@ X-MeetingIM-APIKey: <预置的普通 API Key> | --- | --- | --- | | WebSocket 发起通话 | `{"pub":{"id":"call-1","topic":"p2p...","head":{"mime":"application/json","webrtc":"started","x-im-type":"call"},"content":{"callId":"call_1","callType":"audio","audio":true,"video":false}}}` | `{"ctrl":{"id":"call-1","code":202,"params":{"seq":12}}}` | | WebSocket 通话事件 | `{"note":{"topic":"p2p...","what":"call","event":"accept","seq":12,"payload":{"callId":"call_1"}}}` | 对端收到 `{"info":{"topic":"p2p...","what":"call","event":"accept","seq":12,"payload":{"callId":"call_1"}}}` | +| `POST /v1/im/calls/rtc-join` | `{"topic":"p2p...","call_id":"call_1","seq":12,"call_type":"audio","role":"caller"}` | `{"provider":"agora","auth_mode":"token","app_id":"声网 App ID","channel_name":"call_call_1","uid":"usr...","token":"007eJx...","token_expires_at":"2026-09-02T10:00:00Z","call_id":"call_1","topic":"p2p...","seq":12,"call_type":"audio","role":"caller"}` | | `GET /v1/im/calls?limit=20&cursor=` | Query only | `{"items":[{"call_id":"call_1","topic":"p2p...","seq":12,"call_type":"audio","status":"finished","started_at":"2026-08-20T09:00:00Z","duration_ms":60000,"peer":{"user_id":"usr...","display_name":"张三"}}],"next_cursor":""}` | | `GET /v1/im/calls/{topic}?cursor=` | Path topic | `{"topic":"p2p...","items":[{"call_id":"call_1","seq":12,"status":"missed","call_type":"video","created_at":"2026-08-20T09:00:00Z"}],"next_cursor":""}` | +声网模式下,新客户端在发起方收到 `ctrl.params.seq` 后、被叫方点击接听后,分别调用 `POST /v1/im/calls/rtc-join` 获取 `app_id/channel_name/uid/token`。当前按正式 `token` 模式接入,后端使用 `im_shengwang` 分组中的 `agora_app_certificate` 生成 RTC Token。客户端不接触 `app_certificate`、`customer_id`、`customer_secret`,也不再通过 IM WebSocket 交换 SDP/ICE,音视频媒体协商和传输由声网 RTC SDK 完成。 + ## 9. 企业工作台与办公协同 ### 9.1 工作台.png diff --git a/docs/third/IM-声网音视频通话完整调用流程.md b/docs/third/IM-声网音视频通话完整调用流程.md index caff0de..d0a53a4 100644 --- a/docs/third/IM-声网音视频通话完整调用流程.md +++ b/docs/third/IM-声网音视频通话完整调用流程.md @@ -33,10 +33,10 @@ | 配置项 | 示例 | 说明 | | --- | --- | --- | | `agora_enabled` | `true` | 是否启用声网音视频。 | -| `agora_auth_mode` | `app_id` | 调试模式,客户端入会不需要 Token。 | +| `agora_auth_mode` | `token` | 正式模式,服务端生成 RTC Token。 | | `agora_app_id` | `xxxxxxxx` | 声网项目 App ID,允许返回给客户端。 | | `agora_channel_prefix` | `call_` | 服务端生成声网频道名前缀。 | -| `agora_uid_mode` | `int` | 首期建议使用数字 UID。 | +| `agora_uid_mode` | `string` | 建议使用 `usr...` 作为声网 user account,避免 BIGINT 用户 ID 超过声网数字 UID 范围。 | ### 3.2 正式模式 @@ -306,13 +306,12 @@ Content-Type: application/json ## 10. 联调顺序建议 -1. 后台配置 `agora_enabled=true`、`agora_auth_mode=app_id`、`agora_app_id`。 -2. 客户端集成声网 RTC SDK,并支持后端返回的空 Token 入会。 +1. 后台配置 `agora_enabled=true`、`agora_auth_mode=token`、`agora_app_id`、`agora_app_certificate`。 +2. 客户端集成声网 RTC SDK,并使用后端返回的 RTC Token 入会。 3. A 发起语音通话,确认 B 能收到 IM 来电信令。 4. A、B 分别调用 `/v1/im/calls/rtc-join`,确认返回同一个 `channel_name`。 5. A、B 使用各自 `uid` 加入声网频道,确认音频可通。 6. 切换视频通话,确认视频流可通。 7. 测试取消、拒接、挂断、超时、网络断开。 -8. 正式环境启用 App 证书,切换 `agora_auth_mode=token`。 -9. 验证 Token 入会和 Token 续期。 -10. 后续再接入声网控制台 REST API 的项目管理能力。 +8. 验证 Token 入会和 Token 续期。 +9. 后续再接入声网控制台 REST API 的项目管理能力。 diff --git a/docs/third/IM-声网音视频通话接入文档.md b/docs/third/IM-声网音视频通话接入文档.md index 1f394f5..5566320 100644 --- a/docs/third/IM-声网音视频通话接入文档.md +++ b/docs/third/IM-声网音视频通话接入文档.md @@ -25,9 +25,9 @@ | 配置 | 说明 | | --- | --- | | `agora_app_id` | 声网项目 App ID,客户端初始化 RTC SDK 使用。 | -| `agora_auth_mode` | 首期填 `app_id`,表示仅 App ID 鉴权。 | +| `agora_auth_mode` | 当前建议填 `token`,由服务端生成 RTC Token。 | | `agora_enabled` | 是否启用声网通话,建议测试环境先设为 `true`。 | -| `agora_uid_mode` | 建议先用 `int`,客户端加入频道时使用数字 UID。 | +| `agora_uid_mode` | 建议用 `string`,客户端加入频道时使用 `usr...` 作为声网 user account,避免 BIGINT 用户 ID 超过声网数字 UID 范围。 | | `agora_channel_prefix` | 建议 `call_`,最终频道名为 `call_{call_id}`。 | ### 3.2 正式模式后续准备 @@ -41,7 +41,7 @@ | `agora_rest_primary_domain` | 国内建议 `https://api.sd-rtn.com`。 | | `agora_rest_backup_domain` | 备用建议 `https://api.agora.io`。 | -配置建议继续存放在 HotGo `hg_sys_config` 的 `im_third_party` 分组,IM Server 通过现有 `store.ThirdPartyConfig.Get("im_third_party", ...)` 读取,并受 Kafka `third_party.updated` 或 `cache.third_party.invalidate` 事件清理进程内缓存。 +配置建议存放在 HotGo `hg_sys_config` 的 `im_shengwang` 分组,IM Server 通过 `store.ThirdPartyConfig.Get("im_shengwang", ...)` 读取,并受 Kafka `third_party.updated` 或 `cache.third_party.invalidate` 事件清理进程内缓存。 ## 4. 后端与声网 API 对接 @@ -286,7 +286,7 @@ Content-Type: application/json | 编号 | 场景 | 预期 | | --- | --- | --- | -| AGORA-01 | 后台只配置 `agora_app_id` 和 `agora_auth_mode=app_id` | `/v1/im/calls/rtc-join` 返回 App ID、频道名、UID,Token 为空。 | +| AGORA-01 | 后台配置 `agora_app_id`、`agora_app_certificate` 和 `agora_auth_mode=token` | `/v1/im/calls/rtc-join` 返回 App ID、频道名、UID 和 RTC Token。 | | AGORA-02 | A 发起语音通话,B 接听 | 双方使用同一 `channel_name` 加入声网频道,IM 通话状态变为 `accepted`。 | | AGORA-03 | A 发起视频通话,B 接听 | 双方可看到/听到媒体流,IM 通话记录为 `video`。 | | AGORA-04 | B 拒接 | IM 替换原通话消息为 `declined`,双方退出声网频道。 | diff --git a/docs/third/IM-第三方配置与文件安全SQL.sql b/docs/third/IM-第三方配置与文件安全SQL.sql index 102de8a..9c71f12 100644 --- a/docs/third/IM-第三方配置与文件安全SQL.sql +++ b/docs/third/IM-第三方配置与文件安全SQL.sql @@ -33,6 +33,21 @@ INSERT INTO hg_sys_dict_type SELECT 0, 'IM 文件审核状态', 'im_file_review_status', 240, 'IM 文件审核任务状态字典', 1, NOW(), NOW() WHERE NOT EXISTS (SELECT 1 FROM hg_sys_dict_type WHERE `type` = 'im_file_review_status'); +INSERT INTO hg_sys_dict_type + (`pid`, `name`, `type`, `sort`, `remark`, `status`, `created_at`, `updated_at`) +SELECT 0, 'IM RTC 服务商', 'im_rtc_provider', 250, 'IM 音视频通话 RTC provider 字典', 1, NOW(), NOW() +WHERE NOT EXISTS (SELECT 1 FROM hg_sys_dict_type WHERE `type` = 'im_rtc_provider'); + +INSERT INTO hg_sys_dict_type + (`pid`, `name`, `type`, `sort`, `remark`, `status`, `created_at`, `updated_at`) +SELECT 0, 'IM RTC 鉴权模式', 'im_rtc_auth_mode', 260, 'IM 音视频通话 RTC 鉴权模式字典', 1, NOW(), NOW() +WHERE NOT EXISTS (SELECT 1 FROM hg_sys_dict_type WHERE `type` = 'im_rtc_auth_mode'); + +INSERT INTO hg_sys_dict_type + (`pid`, `name`, `type`, `sort`, `remark`, `status`, `created_at`, `updated_at`) +SELECT 0, 'IM RTC UID 模式', 'im_rtc_uid_mode', 270, 'IM 音视频通话 RTC UID 模式字典', 1, NOW(), NOW() +WHERE NOT EXISTS (SELECT 1 FROM hg_sys_dict_type WHERE `type` = 'im_rtc_uid_mode'); + -- 2. 后台字典数据 INSERT INTO hg_sys_dict_data (`label`, `value`, `value_type`, `type`, `list_class`, `is_default`, `sort`, `remark`, `status`, `created_at`, `updated_at`) @@ -84,6 +99,31 @@ INSERT INTO hg_sys_dict_data SELECT '失败', 'failed', 'string', 'im_file_review_status', 'warning', 1, 50, '不可重试失败或超过重试次数', 1, NOW(), NOW() WHERE NOT EXISTS (SELECT 1 FROM hg_sys_dict_data WHERE `type` = 'im_file_review_status' AND `value` = 'failed'); +INSERT INTO hg_sys_dict_data + (`label`, `value`, `value_type`, `type`, `list_class`, `is_default`, `sort`, `remark`, `status`, `created_at`, `updated_at`) +SELECT '声网 RTC', 'agora', 'string', 'im_rtc_provider', 'success', 1, 10, '声网实时音视频 RTC', 1, NOW(), NOW() +WHERE NOT EXISTS (SELECT 1 FROM hg_sys_dict_data WHERE `type` = 'im_rtc_provider' AND `value` = 'agora'); + +INSERT INTO hg_sys_dict_data + (`label`, `value`, `value_type`, `type`, `list_class`, `is_default`, `sort`, `remark`, `status`, `created_at`, `updated_at`) +SELECT '仅 App ID', 'app_id', 'string', 'im_rtc_auth_mode', 'warning', 1, 10, '调试模式,客户端入会 Token 为空', 1, NOW(), NOW() +WHERE NOT EXISTS (SELECT 1 FROM hg_sys_dict_data WHERE `type` = 'im_rtc_auth_mode' AND `value` = 'app_id'); + +INSERT INTO hg_sys_dict_data + (`label`, `value`, `value_type`, `type`, `list_class`, `is_default`, `sort`, `remark`, `status`, `created_at`, `updated_at`) +SELECT 'RTC Token', 'token', 'string', 'im_rtc_auth_mode', 'success', 0, 20, '正式模式,后端使用 App Certificate 生成 Token', 1, NOW(), NOW() +WHERE NOT EXISTS (SELECT 1 FROM hg_sys_dict_data WHERE `type` = 'im_rtc_auth_mode' AND `value` = 'token'); + +INSERT INTO hg_sys_dict_data + (`label`, `value`, `value_type`, `type`, `list_class`, `is_default`, `sort`, `remark`, `status`, `created_at`, `updated_at`) +SELECT '数字 UID', 'int', 'string', 'im_rtc_uid_mode', 'success', 1, 10, '客户端使用数字 UID 加入 RTC 频道', 1, NOW(), NOW() +WHERE NOT EXISTS (SELECT 1 FROM hg_sys_dict_data WHERE `type` = 'im_rtc_uid_mode' AND `value` = 'int'); + +INSERT INTO hg_sys_dict_data + (`label`, `value`, `value_type`, `type`, `list_class`, `is_default`, `sort`, `remark`, `status`, `created_at`, `updated_at`) +SELECT '字符串 UID', 'string', 'string', 'im_rtc_uid_mode', 'info', 0, 20, '客户端使用字符串 UID 加入 RTC 频道', 1, NOW(), NOW() +WHERE NOT EXISTS (SELECT 1 FROM hg_sys_dict_data WHERE `type` = 'im_rtc_uid_mode' AND `value` = 'string'); + -- 3. 第三方配置项。配置真值存 hg_sys_config;字典只提供 UI 选项。 INSERT INTO hg_sys_config (`group`, `name`, `type`, `key`, `value`, `default_value`, `sort`, `tip`, `is_default`, `status`, `created_at`, `updated_at`) @@ -175,6 +215,66 @@ INSERT INTO hg_sys_config SELECT 'im_third_party', '阿里云 AccessKey Secret', 'string', 'aliyun_access_key_secret', '', '', 91, '内容安全和恶意文件检测共用的敏感配置,后台和日志必须脱敏展示', 1, 1, NOW(), NOW() WHERE NOT EXISTS (SELECT 1 FROM hg_sys_config WHERE `group` = 'im_third_party' AND `key` = 'aliyun_access_key_secret'); +INSERT INTO hg_sys_config + (`group`, `name`, `type`, `key`, `value`, `default_value`, `sort`, `tip`, `is_default`, `status`, `created_at`, `updated_at`) +SELECT 'im_shengwang', '声网启用状态', 'bool', 'agora_enabled', '0', '0', 100, '是否启用声网 RTC 音视频通话', 1, 1, NOW(), NOW() +WHERE NOT EXISTS (SELECT 1 FROM hg_sys_config WHERE `group` = 'im_shengwang' AND `key` = 'agora_enabled'); + +INSERT INTO hg_sys_config + (`group`, `name`, `type`, `key`, `value`, `default_value`, `sort`, `tip`, `is_default`, `status`, `created_at`, `updated_at`) +SELECT 'im_shengwang', 'RTC 服务商', 'string', 'agora_provider', 'agora', 'agora', 101, '取值来自字典 im_rtc_provider,当前支持 agora', 1, 1, NOW(), NOW() +WHERE NOT EXISTS (SELECT 1 FROM hg_sys_config WHERE `group` = 'im_shengwang' AND `key` = 'agora_provider'); + +INSERT INTO hg_sys_config + (`group`, `name`, `type`, `key`, `value`, `default_value`, `sort`, `tip`, `is_default`, `status`, `created_at`, `updated_at`) +SELECT 'im_shengwang', '声网鉴权模式', 'string', 'agora_auth_mode', 'token', 'token', 102, '取值来自字典 im_rtc_auth_mode;调试 app_id,正式 token', 1, 1, NOW(), NOW() +WHERE NOT EXISTS (SELECT 1 FROM hg_sys_config WHERE `group` = 'im_shengwang' AND `key` = 'agora_auth_mode'); + +INSERT INTO hg_sys_config + (`group`, `name`, `type`, `key`, `value`, `default_value`, `sort`, `tip`, `is_default`, `status`, `created_at`, `updated_at`) +SELECT 'im_shengwang', '声网 App ID', 'string', 'agora_app_id', '', '', 103, '声网项目 App ID,可返回客户端', 1, 1, NOW(), NOW() +WHERE NOT EXISTS (SELECT 1 FROM hg_sys_config WHERE `group` = 'im_shengwang' AND `key` = 'agora_app_id'); + +INSERT INTO hg_sys_config + (`group`, `name`, `type`, `key`, `value`, `default_value`, `sort`, `tip`, `is_default`, `status`, `created_at`, `updated_at`) +SELECT 'im_shengwang', '声网 App Certificate', 'string', 'agora_app_certificate', '', '', 104, '敏感配置,仅后端生成 RTC Token 使用,禁止下发客户端', 1, 1, NOW(), NOW() +WHERE NOT EXISTS (SELECT 1 FROM hg_sys_config WHERE `group` = 'im_shengwang' AND `key` = 'agora_app_certificate'); + +INSERT INTO hg_sys_config + (`group`, `name`, `type`, `key`, `value`, `default_value`, `sort`, `tip`, `is_default`, `status`, `created_at`, `updated_at`) +SELECT 'im_shengwang', '声网 Token TTL', 'int', 'agora_token_ttl_seconds', '3600', '3600', 105, 'RTC Token 有效秒数,正式 token 模式使用', 1, 1, NOW(), NOW() +WHERE NOT EXISTS (SELECT 1 FROM hg_sys_config WHERE `group` = 'im_shengwang' AND `key` = 'agora_token_ttl_seconds'); + +INSERT INTO hg_sys_config + (`group`, `name`, `type`, `key`, `value`, `default_value`, `sort`, `tip`, `is_default`, `status`, `created_at`, `updated_at`) +SELECT 'im_shengwang', '声网频道名前缀', 'string', 'agora_channel_prefix', 'call_', 'call_', 106, '服务端生成频道名时使用,最终为 prefix + call_id', 1, 1, NOW(), NOW() +WHERE NOT EXISTS (SELECT 1 FROM hg_sys_config WHERE `group` = 'im_shengwang' AND `key` = 'agora_channel_prefix'); + +INSERT INTO hg_sys_config + (`group`, `name`, `type`, `key`, `value`, `default_value`, `sort`, `tip`, `is_default`, `status`, `created_at`, `updated_at`) +SELECT 'im_shengwang', '声网 UID 模式', 'string', 'agora_uid_mode', 'string', 'string', 107, '取值来自字典 im_rtc_uid_mode;BIGINT 用户 ID 建议 string', 1, 1, NOW(), NOW() +WHERE NOT EXISTS (SELECT 1 FROM hg_sys_config WHERE `group` = 'im_shengwang' AND `key` = 'agora_uid_mode'); + +INSERT INTO hg_sys_config + (`group`, `name`, `type`, `key`, `value`, `default_value`, `sort`, `tip`, `is_default`, `status`, `created_at`, `updated_at`) +SELECT 'im_shengwang', '声网 REST Customer ID', 'string', 'agora_rest_customer_id', '', '', 108, '声网控制台 REST API 客户 ID,禁止下发客户端', 1, 1, NOW(), NOW() +WHERE NOT EXISTS (SELECT 1 FROM hg_sys_config WHERE `group` = 'im_shengwang' AND `key` = 'agora_rest_customer_id'); + +INSERT INTO hg_sys_config + (`group`, `name`, `type`, `key`, `value`, `default_value`, `sort`, `tip`, `is_default`, `status`, `created_at`, `updated_at`) +SELECT 'im_shengwang', '声网 REST Customer Secret', 'string', 'agora_rest_customer_secret', '', '', 109, '声网控制台 REST API 客户密钥,必须脱敏展示', 1, 1, NOW(), NOW() +WHERE NOT EXISTS (SELECT 1 FROM hg_sys_config WHERE `group` = 'im_shengwang' AND `key` = 'agora_rest_customer_secret'); + +INSERT INTO hg_sys_config + (`group`, `name`, `type`, `key`, `value`, `default_value`, `sort`, `tip`, `is_default`, `status`, `created_at`, `updated_at`) +SELECT 'im_shengwang', '声网 REST 主域名', 'string', 'agora_rest_primary_domain', 'https://api.sd-rtn.com', 'https://api.sd-rtn.com', 110, '国内控制台 REST API 主域名', 1, 1, NOW(), NOW() +WHERE NOT EXISTS (SELECT 1 FROM hg_sys_config WHERE `group` = 'im_shengwang' AND `key` = 'agora_rest_primary_domain'); + +INSERT INTO hg_sys_config + (`group`, `name`, `type`, `key`, `value`, `default_value`, `sort`, `tip`, `is_default`, `status`, `created_at`, `updated_at`) +SELECT 'im_shengwang', '声网 REST 备用域名', 'string', 'agora_rest_backup_domain', 'https://api.agora.io', 'https://api.agora.io', 111, '控制台 REST API 备用域名', 1, 1, NOW(), NOW() +WHERE NOT EXISTS (SELECT 1 FROM hg_sys_config WHERE `group` = 'im_shengwang' AND `key` = 'agora_rest_backup_domain'); + USE tinode; -- 4. 文件安全审核任务表 diff --git a/go.mod b/go.mod index 0eec471..e9dc302 100644 --- a/go.mod +++ b/go.mod @@ -44,6 +44,7 @@ require ( cloud.google.com/go/auth/oauth2adapt v0.2.8 // indirect cloud.google.com/go/monitoring v1.29.0 // indirect filippo.io/edwards25519 v1.2.0 // indirect + github.com/AgoraIO/Tools/DynamicKey/AgoraDynamicKey/go/src v0.0.0-20260726132416-da06bfb83556 // indirect github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.33.0 // indirect github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.57.0 // indirect github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.57.0 // indirect diff --git a/go.sum b/go.sum index 1d4860e..4b52576 100644 --- a/go.sum +++ b/go.sum @@ -28,6 +28,8 @@ filippo.io/edwards25519 v1.2.0 h1:crnVqOiS4jqYleHd9vaKZ+HKtHfllngJIiOpNpoJsjo= filippo.io/edwards25519 v1.2.0/go.mod h1:xzAOLCNug/yB62zG1bQ8uziwrIqIuxhctzJT18Q77mc= firebase.google.com/go v3.13.0+incompatible h1:3TdYC3DDi6aHn20qoRkxwGqNgdjtblwVAyRLQwGn/+4= firebase.google.com/go v3.13.0+incompatible/go.mod h1:xlah6XbEyW6tbfSklcfe5FHJIwjt8toICdV5Wh9ptHs= +github.com/AgoraIO/Tools/DynamicKey/AgoraDynamicKey/go/src v0.0.0-20260726132416-da06bfb83556 h1:4dCykConaK6HYW3wQowoUg+p0F7i58WiLhkrjtqvgBw= +github.com/AgoraIO/Tools/DynamicKey/AgoraDynamicKey/go/src v0.0.0-20260726132416-da06bfb83556/go.mod h1:4bXIK0ntDk9CqAXobmomWd7dedbfNv/aaIpmpqqzt+A= github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.33.0 h1:l7+6kwRMJNwdCvYdDl7Eax+wzEYHSnNY7zrrfbhDdTA= github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.33.0/go.mod h1:pJTkW8hEUIIi3Pf65lPZOnn4Y81yCllX6IWk2jNXdkM= diff --git a/server/im_agora_http.go b/server/im_agora_http.go new file mode 100644 index 0000000..39560cf --- /dev/null +++ b/server/im_agora_http.go @@ -0,0 +1,276 @@ +package main + +import ( + "encoding/json" + "errors" + "math" + "net/http" + "strconv" + "strings" + "time" + + rtctokenbuilder "github.com/AgoraIO/Tools/DynamicKey/AgoraDynamicKey/go/src/rtctokenbuilder2" + "github.com/tinode/chat/server/store" + "github.com/tinode/chat/server/store/types" +) + +const ( + imReasonAgoraDisabled = "AGORA_DISABLED" + imReasonAgoraConfigMissing = "AGORA_CONFIG_MISSING" + imReasonCallNotFound = "CALL_NOT_FOUND" + imReasonCallNotAllowed = "CALL_NOT_ALLOWED" + imReasonCallStateInvalid = "CALL_STATE_INVALID" +) + +type imCallRTCJoinRequest struct { + Topic string `json:"topic"` + CallID string `json:"call_id"` + Seq int64 `json:"seq"` + CallType string `json:"call_type"` + Role string `json:"role"` +} + +type imAgoraConfig struct { + Enabled bool + AuthMode string + AppID string + AppCertificate string + TokenTTLSeconds int + ChannelPrefix string + UIDMode string +} + +func serveIMCallRTCJoin(wrt http.ResponseWriter, req *http.Request) { + if req.Method != http.MethodPost { + writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil) + return + } + principal, ok := authenticateIMHTTPBearer(wrt, req) + if !ok { + return + } + var body imCallRTCJoinRequest + if err := json.NewDecoder(http.MaxBytesReader(wrt, req.Body, 1<<20)).Decode(&body); err != nil { + writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", nil) + return + } + body.Topic = strings.TrimSpace(body.Topic) + body.CallID = strings.TrimSpace(body.CallID) + body.CallType = strings.ToLower(strings.TrimSpace(body.CallType)) + body.Role = strings.ToLower(strings.TrimSpace(body.Role)) + if body.Topic == "" || body.CallID == "" || body.Seq <= 0 || + (body.CallType != constCallTypeAudio && body.CallType != constCallTypeVideo) || + (body.Role != "caller" && body.Role != "callee") { + writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", nil) + return + } + if types.GetTopicCat(body.Topic) != types.TopicCatP2P { + writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "topic"}) + return + } + config, err := loadIMAgoraConfig() + if err != nil { + if errors.Is(err, types.ErrNotFound) || errors.Is(err, types.ErrMalformed) { + writeIMError(wrt, req, http.StatusServiceUnavailable, 30060, imReasonAgoraConfigMissing, "agora config missing", nil) + return + } + writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil) + return + } + if !config.Enabled { + writeIMError(wrt, req, http.StatusServiceUnavailable, 30061, imReasonAgoraDisabled, "agora disabled", nil) + return + } + sub, err := store.Subs.Get(principal.TenantID, body.Topic, principal.Uid, false) + if err != nil { + writeIMRelationError(wrt, req, err) + return + } + if sub == nil || !(sub.ModeGiven & sub.ModeWant).IsReader() { + writeIMError(wrt, req, http.StatusForbidden, 30062, imReasonCallNotAllowed, "call not allowed", nil) + return + } + record, err := findCallRecordForRTCJoin(principal.TenantID, principal.Uid, body) + if err != nil { + switch { + case errors.Is(err, types.ErrNotFound): + writeIMError(wrt, req, http.StatusNotFound, 30063, imReasonCallNotFound, "call not found", nil) + case errors.Is(err, types.ErrPermissionDenied): + writeIMError(wrt, req, http.StatusForbidden, 30062, imReasonCallNotAllowed, "call not allowed", nil) + default: + writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil) + } + return + } + if !callStateAllowsRTCJoin(record.Status) { + writeIMError(wrt, req, http.StatusConflict, 30064, imReasonCallStateInvalid, "call state invalid", map[string]any{"status": record.Status}) + return + } + channelName := config.ChannelPrefix + record.CallID + uid, err := agoraUIDForUser(config, principal.Uid) + if err != nil { + writeIMError(wrt, req, http.StatusServiceUnavailable, 30060, imReasonAgoraConfigMissing, "agora config missing", nil) + return + } + token := "" + var expiresAt any + if config.AuthMode == "token" { + token, expiresAt, err = buildAgoraRTCToken(config, channelName, principal.Uid) + if err != nil { + writeIMError(wrt, req, http.StatusServiceUnavailable, 30060, imReasonAgoraConfigMissing, "agora rtc token generation failed", nil) + return + } + } + writeIMSuccess(wrt, req, map[string]any{ + "provider": "agora", + "auth_mode": config.AuthMode, + "app_id": config.AppID, + "channel_name": channelName, + "uid": uid, + "token": token, + "token_expires_at": expiresAt, + "call_id": record.CallID, + "topic": record.Topic, + "seq": record.SeqID, + "call_type": record.CallType, + "role": body.Role, + }) +} + +func loadIMAgoraConfig() (imAgoraConfig, error) { + values, err := store.ThirdPartyConfig.Get("im_shengwang", + "agora_enabled", + "agora_auth_mode", + "agora_app_id", + "agora_app_certificate", + "agora_token_ttl_seconds", + "agora_channel_prefix", + "agora_uid_mode", + ) + if err != nil { + return imAgoraConfig{}, err + } + config := imAgoraConfig{ + Enabled: imTruthy(values["agora_enabled"]), + AuthMode: strings.ToLower(strings.TrimSpace(values["agora_auth_mode"])), + AppID: strings.TrimSpace(values["agora_app_id"]), + AppCertificate: strings.TrimSpace(values["agora_app_certificate"]), + TokenTTLSeconds: 3600, + ChannelPrefix: strings.TrimSpace(values["agora_channel_prefix"]), + UIDMode: strings.ToLower(strings.TrimSpace(values["agora_uid_mode"])), + } + if config.AuthMode == "" { + config.AuthMode = "app_id" + } + if config.ChannelPrefix == "" { + config.ChannelPrefix = "call_" + } + if config.UIDMode == "" { + config.UIDMode = "string" + } + if ttl := strings.TrimSpace(values["agora_token_ttl_seconds"]); ttl != "" { + parsed, err := strconv.Atoi(ttl) + if err != nil || parsed <= 0 { + return imAgoraConfig{}, types.ErrMalformed + } + config.TokenTTLSeconds = parsed + } + if !config.Enabled { + return config, nil + } + if config.AppID == "" || (config.AuthMode != "app_id" && config.AuthMode != "token") { + return imAgoraConfig{}, types.ErrMalformed + } + if config.AuthMode == "token" && config.AppCertificate == "" { + return imAgoraConfig{}, types.ErrMalformed + } + return config, nil +} + +func findCallRecordForRTCJoin(tenantID types.TenantID, uid types.Uid, body imCallRTCJoinRequest) (*types.CallRecord, error) { + if store.Calls == nil { + return nil, types.ErrUnsupported + } + records, _, err := store.Calls.List(tenantID, uid, body.Topic, "", 100) + if err != nil { + return nil, err + } + for i := range records { + record := &records[i] + if record.CallID != body.CallID || record.Topic != body.Topic || record.SeqID != body.Seq || record.CallType != body.CallType { + continue + } + if record.CallerID != uid && record.CalleeID != uid { + return nil, types.ErrPermissionDenied + } + if body.Role == "caller" && record.CallerID != uid { + return nil, types.ErrPermissionDenied + } + if body.Role == "callee" && record.CalleeID != uid { + return nil, types.ErrPermissionDenied + } + return record, nil + } + return nil, types.ErrNotFound +} + +func callStateAllowsRTCJoin(status string) bool { + switch status { + case types.CallStatusStarted, types.CallStatusAccepted: + return true + default: + return false + } +} + +func agoraUIDForUser(config imAgoraConfig, uid types.Uid) (any, error) { + switch config.UIDMode { + case "int": + decoded := store.DecodeUid(uid) + if decoded <= 0 || decoded > math.MaxUint32 { + return nil, types.ErrMalformed + } + return decoded, nil + case "string": + return uid.UserId(), nil + default: + return nil, types.ErrMalformed + } +} + +func buildAgoraRTCToken(config imAgoraConfig, channelName string, uid types.Uid) (string, string, error) { + ttl := uint32(config.TokenTTLSeconds) + if config.UIDMode == "int" { + decoded := store.DecodeUid(uid) + if decoded <= 0 || decoded > math.MaxUint32 { + return "", "", types.ErrMalformed + } + token, err := rtctokenbuilder.BuildTokenWithUid(config.AppID, config.AppCertificate, channelName, uint32(decoded), rtctokenbuilder.RolePublisher, ttl, ttl) + if err != nil { + return "", "", err + } + return token, agoraTokenExpiresAt(config.TokenTTLSeconds), nil + } + account := uid.UserId() + if account == "" { + return "", "", types.ErrMalformed + } + token, err := rtctokenbuilder.BuildTokenWithUserAccount(config.AppID, config.AppCertificate, channelName, account, rtctokenbuilder.RolePublisher, ttl, ttl) + if err != nil { + return "", "", err + } + return token, agoraTokenExpiresAt(config.TokenTTLSeconds), nil +} + +func imTruthy(value string) bool { + switch strings.ToLower(strings.TrimSpace(value)) { + case "1", "true", "yes", "on": + return true + default: + return false + } +} + +func agoraTokenExpiresAt(ttlSeconds int) string { + return time.Now().UTC().Add(time.Duration(ttlSeconds) * time.Second).Format(time.RFC3339Nano) +} diff --git a/server/im_http_test.go b/server/im_http_test.go index 1f56254..1d40116 100644 --- a/server/im_http_test.go +++ b/server/im_http_test.go @@ -221,6 +221,16 @@ type testCallsStore struct { next string } +type testThirdPartyConfigStore map[string]string + +func (s testThirdPartyConfigStore) Get(_ string, keys ...string) (map[string]string, error) { + result := make(map[string]string, len(keys)) + for _, key := range keys { + result[key] = s[key] + } + return result, nil +} + type testConversationsStore struct { topic string settings *types.ConversationSettings @@ -4741,6 +4751,227 @@ func TestServeIMTopicCallsRejectsUnauthorizedWithoutListing(t *testing.T) { } } +func TestServeIMCallRTCJoinReturnsAgoraAppIDMode(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + expectIMBearer(t, ctrl, "token-caller", 7, 42) + oldSubs := store.Subs + oldCalls := store.Calls + oldThirdParty := store.ThirdPartyConfig + subs := mock_store.NewMockSubsPersistenceInterface(ctrl) + calls := &testCallsStore{items: []types.CallRecord{{ + ID: 9, + TenantID: 7, + CallID: "call-1", + Topic: "p2pTopic", + SeqID: 12, + CallerID: 42, + CalleeID: 43, + CallType: "video", + Status: types.CallStatusStarted, + StartedAt: time.Date(2026, 9, 2, 9, 0, 0, 0, time.UTC), + CreatedAt: time.Date(2026, 9, 2, 9, 0, 0, 0, time.UTC), + UpdatedAt: time.Date(2026, 9, 2, 9, 0, 0, 0, time.UTC), + }}} + store.Subs = subs + store.Calls = calls + store.ThirdPartyConfig = testThirdPartyConfigStore{ + "agora_enabled": "1", + "agora_auth_mode": "app_id", + "agora_app_id": "app123", + "agora_channel_prefix": "call_", + "agora_uid_mode": "string", + "agora_token_ttl_seconds": "3600", + } + defer func() { + store.Subs = oldSubs + store.Calls = oldCalls + store.ThirdPartyConfig = oldThirdParty + }() + subs.EXPECT().Get(types.TenantID(7), "p2pTopic", types.Uid(42), false).Return(&types.Subscription{ + TenantID: 7, User: types.Uid(42).String(), Topic: "p2pTopic", ModeWant: types.ModeCPublic, ModeGiven: types.ModeCPublic, + }, nil) + + req := httptest.NewRequest(http.MethodPost, "/v1/im/calls/rtc-join", strings.NewReader(`{"topic":"p2pTopic","call_id":"call-1","seq":12,"call_type":"video","role":"caller"}`)) + req.Header.Set("Authorization", "Bearer token-caller") + rec := httptest.NewRecorder() + serveIMCallRTCJoin(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("rtc join status: got %d body=%s", rec.Code, rec.Body.String()) + } + if !calls.listed || calls.tenant != 7 || calls.user != 42 || calls.topic != "p2pTopic" || calls.limit != 100 { + t.Fatalf("calls lookup mismatch: %#v", calls) + } + resp := decodeIMHTTPResponse(t, rec) + data := resp.Data.(map[string]any) + if data["provider"] != "agora" || data["auth_mode"] != "app_id" || data["app_id"] != "app123" || + data["channel_name"] != "call_call-1" || data["uid"] != types.Uid(42).UserId() || data["token"] != "" { + t.Fatalf("unexpected rtc join payload: %#v", data) + } +} + +func TestServeIMCallRTCJoinReturnsSameAgoraChannelForCallee(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + oldStore := store.Store + mockStore := mock_store.NewMockPersistentStorageInterface(ctrl) + tokenAuth := mock_auth.NewMockAuthHandler(ctrl) + mockStore.EXPECT().GetLogicalAuthHandler("token").Return(tokenAuth).AnyTimes() + tokenAuth.EXPECT().IsInitialized().Return(true).AnyTimes() + tokenAuth.EXPECT().Authenticate(auth.AuthContext{}, []byte("token-caller")). + Return(&auth.Rec{TenantID: 7, Uid: 42, AuthLevel: auth.LevelAuth}, nil, nil).AnyTimes() + tokenAuth.EXPECT().Authenticate(auth.AuthContext{}, []byte("token-callee")). + Return(&auth.Rec{TenantID: 7, Uid: 43, AuthLevel: auth.LevelAuth}, nil, nil).AnyTimes() + store.Store = mockStore + oldSubs := store.Subs + oldCalls := store.Calls + oldThirdParty := store.ThirdPartyConfig + subs := mock_store.NewMockSubsPersistenceInterface(ctrl) + calls := &testCallsStore{items: []types.CallRecord{{ + ID: 9, + TenantID: 7, + CallID: "call-1", + Topic: "p2pTopic", + SeqID: 12, + CallerID: 42, + CalleeID: 43, + CallType: "audio", + Status: types.CallStatusStarted, + StartedAt: time.Date(2026, 9, 2, 9, 0, 0, 0, time.UTC), + CreatedAt: time.Date(2026, 9, 2, 9, 0, 0, 0, time.UTC), + UpdatedAt: time.Date(2026, 9, 2, 9, 0, 0, 0, time.UTC), + }}} + store.Subs = subs + store.Calls = calls + store.ThirdPartyConfig = testThirdPartyConfigStore{ + "agora_enabled": "1", + "agora_auth_mode": "app_id", + "agora_app_id": "app123", + "agora_channel_prefix": "call_", + "agora_uid_mode": "string", + "agora_token_ttl_seconds": "3600", + } + defer func() { + store.Store = oldStore + store.Subs = oldSubs + store.Calls = oldCalls + store.ThirdPartyConfig = oldThirdParty + }() + subs.EXPECT().Get(types.TenantID(7), "p2pTopic", types.Uid(42), false).Return(&types.Subscription{ + TenantID: 7, User: types.Uid(42).String(), Topic: "p2pTopic", ModeWant: types.ModeCPublic, ModeGiven: types.ModeCPublic, + }, nil) + subs.EXPECT().Get(types.TenantID(7), "p2pTopic", types.Uid(43), false).Return(&types.Subscription{ + TenantID: 7, User: types.Uid(43).String(), Topic: "p2pTopic", ModeWant: types.ModeCPublic, ModeGiven: types.ModeCPublic, + }, nil) + + callerReq := httptest.NewRequest(http.MethodPost, "/v1/im/calls/rtc-join", strings.NewReader(`{"topic":"p2pTopic","call_id":"call-1","seq":12,"call_type":"audio","role":"caller"}`)) + callerReq.Header.Set("Authorization", "Bearer token-caller") + callerRec := httptest.NewRecorder() + serveIMCallRTCJoin(callerRec, callerReq) + if callerRec.Code != http.StatusOK { + t.Fatalf("caller rtc join status: got %d body=%s", callerRec.Code, callerRec.Body.String()) + } + + calleeReq := httptest.NewRequest(http.MethodPost, "/v1/im/calls/rtc-join", strings.NewReader(`{"topic":"p2pTopic","call_id":"call-1","seq":12,"call_type":"audio","role":"callee"}`)) + calleeReq.Header.Set("Authorization", "Bearer token-callee") + calleeRec := httptest.NewRecorder() + serveIMCallRTCJoin(calleeRec, calleeReq) + if calleeRec.Code != http.StatusOK { + t.Fatalf("callee rtc join status: got %d body=%s", calleeRec.Code, calleeRec.Body.String()) + } + + callerData := decodeIMHTTPResponse(t, callerRec).Data.(map[string]any) + calleeData := decodeIMHTTPResponse(t, calleeRec).Data.(map[string]any) + if callerData["channel_name"] != calleeData["channel_name"] { + t.Fatalf("channel mismatch: caller=%#v callee=%#v", callerData, calleeData) + } + if callerData["uid"] == calleeData["uid"] || calleeData["uid"] != types.Uid(43).UserId() { + t.Fatalf("unexpected rtc uid: caller=%#v callee=%#v", callerData, calleeData) + } + if callerData["token"] != "" || calleeData["token"] != "" { + t.Fatalf("app_id mode token must be empty: caller=%#v callee=%#v", callerData, calleeData) + } +} + +func TestServeIMCallRTCJoinReturnsAgoraTokenMode(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + expectIMBearer(t, ctrl, "token-1", 7, 42) + oldSubs := store.Subs + oldCalls := store.Calls + oldThirdParty := store.ThirdPartyConfig + subs := mock_store.NewMockSubsPersistenceInterface(ctrl) + calls := &testCallsStore{items: []types.CallRecord{{ + ID: 9, + TenantID: 7, + CallID: "call-1", + Topic: "p2pTopic", + SeqID: 12, + CallerID: 42, + CalleeID: 43, + CallType: "video", + Status: types.CallStatusStarted, + StartedAt: time.Date(2026, 9, 2, 9, 0, 0, 0, time.UTC), + CreatedAt: time.Date(2026, 9, 2, 9, 0, 0, 0, time.UTC), + UpdatedAt: time.Date(2026, 9, 2, 9, 0, 0, 0, time.UTC), + }}} + store.Subs = subs + store.Calls = calls + store.ThirdPartyConfig = testThirdPartyConfigStore{ + "agora_enabled": "1", + "agora_auth_mode": "token", + "agora_app_id": "00000000000000000000000000000000", + "agora_app_certificate": "11111111111111111111111111111111", + "agora_channel_prefix": "call_", + "agora_uid_mode": "string", + "agora_token_ttl_seconds": "3600", + } + defer func() { + store.Subs = oldSubs + store.Calls = oldCalls + store.ThirdPartyConfig = oldThirdParty + }() + subs.EXPECT().Get(types.TenantID(7), "p2pTopic", types.Uid(42), false).Return(&types.Subscription{ + TenantID: 7, User: types.Uid(42).String(), Topic: "p2pTopic", ModeWant: types.ModeCPublic, ModeGiven: types.ModeCPublic, + }, nil) + + req := httptest.NewRequest(http.MethodPost, "/v1/im/calls/rtc-join", strings.NewReader(`{"topic":"p2pTopic","call_id":"call-1","seq":12,"call_type":"video","role":"caller"}`)) + req.Header.Set("Authorization", "Bearer token-1") + rec := httptest.NewRecorder() + serveIMCallRTCJoin(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("rtc join status: got %d body=%s", rec.Code, rec.Body.String()) + } + data := decodeIMHTTPResponse(t, rec).Data.(map[string]any) + if data["auth_mode"] != "token" || data["token"] == "" || data["token_expires_at"] == nil { + t.Fatalf("unexpected token payload: %#v", data) + } + if _, found := data["app_certificate"]; found { + t.Fatalf("app certificate must not be returned: %#v", data) + } +} + +func TestServeIMCallRTCJoinRejectsDisabledAgora(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + expectIMBearer(t, ctrl, "token-1", 7, 42) + oldThirdParty := store.ThirdPartyConfig + store.ThirdPartyConfig = testThirdPartyConfigStore{"agora_enabled": "0"} + defer func() { store.ThirdPartyConfig = oldThirdParty }() + + req := httptest.NewRequest(http.MethodPost, "/v1/im/calls/rtc-join", strings.NewReader(`{"topic":"p2pTopic","call_id":"call-1","seq":12,"call_type":"video","role":"caller"}`)) + req.Header.Set("Authorization", "Bearer token-1") + rec := httptest.NewRecorder() + serveIMCallRTCJoin(rec, req) + if rec.Code != http.StatusServiceUnavailable { + t.Fatalf("rtc join status: got %d body=%s", rec.Code, rec.Body.String()) + } + resp := decodeIMHTTPResponse(t, rec) + if resp.Reason != imReasonAgoraDisabled { + t.Fatalf("reason: got %q", resp.Reason) + } +} + func TestServeIMMeQRCodesAndResolve(t *testing.T) { ctrl := gomock.NewController(t) defer ctrl.Finish() diff --git a/server/main.go b/server/main.go index f301cb9..96cf30d 100644 --- a/server/main.go +++ b/server/main.go @@ -860,6 +860,7 @@ func main() { mux.HandleFunc(config.ApiPath+"v1/im/system-notifications/", serveIMSystemNotificationRead) mux.HandleFunc(config.ApiPath+"v1/im/favorites", serveIMFavorites) mux.HandleFunc(config.ApiPath+"v1/im/favorites/", serveIMFavoriteResource) + mux.HandleFunc(config.ApiPath+"v1/im/calls/rtc-join", serveIMCallRTCJoin) mux.HandleFunc(config.ApiPath+"v1/im/calls", serveIMCalls) mux.HandleFunc(config.ApiPath+"v1/im/topics/", serveIMTopicMessageAction) mux.HandleFunc(config.ApiPath+"v1/im/conversations/", serveIMConversationContext)