拆分前后端,后端用Python重写
37
README.md
@ -1,7 +1,7 @@
|
|||||||
# 交互式AIGC场景 AIGC Demo
|
# 交互式AIGC场景 AIGC Demo
|
||||||
|
|
||||||
此 Demo 为简化版本, 如您有 1.5.x 版本 UI 的诉求, 可切换至 1.5.1 分支。
|
此 Demo 为简化版本, 如您有 1.5.x 版本 UI 的诉求, 可切换至 1.5.1 分支。
|
||||||
跑通阶段时, 无须关心代码实现,仅需按需完成 `Server/scenes/*.json` 的场景信息填充即可。
|
跑通阶段时, 无须关心代码实现,仅需按需完成 `backend/scenes/*.json` 的场景信息填充即可。
|
||||||
|
|
||||||
## 简介
|
## 简介
|
||||||
- 在 AIGC 对话场景下,火山引擎 AIGC-RTC Server 云端服务,通过整合 RTC 音视频流处理,ASR 语音识别,大模型接口调用集成,以及 TTS 语音生成等能力,提供基于流式语音的端到端AIGC能力链路。
|
- 在 AIGC 对话场景下,火山引擎 AIGC-RTC Server 云端服务,通过整合 RTC 音视频流处理,ASR 语音识别,大模型接口调用集成,以及 TTS 语音生成等能力,提供基于流式语音的端到端AIGC能力链路。
|
||||||
@ -9,16 +9,20 @@
|
|||||||
- 同时火山引擎 RTC拥有成熟的音频 3A 处理、视频处理等技术以及大规模音视频聊天能力,可支持 AIGC 产品更便捷的支持多模态交互、多人互动等场景能力,保持交互的自然性和高效性。
|
- 同时火山引擎 RTC拥有成熟的音频 3A 处理、视频处理等技术以及大规模音视频聊天能力,可支持 AIGC 产品更便捷的支持多模态交互、多人互动等场景能力,保持交互的自然性和高效性。
|
||||||
|
|
||||||
## 【必看】环境准备
|
## 【必看】环境准备
|
||||||
**Node 版本: 16.0+**
|
|
||||||
|
> 本项目已重构为 monorepo 结构,前端位于 `frontend/`,Python 后端位于 `backend/`。
|
||||||
|
|
||||||
|
**前端环境:Node 16.0+**
|
||||||
|
**后端环境:Python 3.9+**
|
||||||
|
|
||||||
### 1. 运行环境
|
### 1. 运行环境
|
||||||
需要准备两个 Terminal,分别启动服务端和前端页面。
|
需要准备两个 Terminal,分别启动后端服务和前端页面。
|
||||||
|
|
||||||
### 2. 服务开通
|
### 2. 服务开通
|
||||||
开通 ASR、TTS、LLM、RTC 等服务,可参考 [开通服务](https://www.volcengine.com/docs/6348/1315561?s=g) 进行相关服务的授权与开通。
|
开通 ASR、TTS、LLM、RTC 等服务,可参考 [开通服务](https://www.volcengine.com/docs/6348/1315561?s=g) 进行相关服务的授权与开通。
|
||||||
|
|
||||||
### 3. 场景配置
|
### 3. 场景配置
|
||||||
`Server/scenes/*.json`
|
`backend/scenes/*.json`
|
||||||
|
|
||||||
您可以自定义具体场景, 并按需根据模版填充 `SceneConfig`、`AccountConfig`、`RTCConfig`、`VoiceChat` 中需要的参数。
|
您可以自定义具体场景, 并按需根据模版填充 `SceneConfig`、`AccountConfig`、`RTCConfig`、`VoiceChat` 中需要的参数。
|
||||||
|
|
||||||
@ -35,27 +39,18 @@ Demo 中以 `Custom` 场景为例,您可以自行新增场景。
|
|||||||
- 可通过 [快速跑通 Demo](https://console.volcengine.com/rtc/aigc/run?s=g) 快速获取参数, 跑通后点击右上角 `接入 API` 按钮复制相关代码贴到 JSON 配置文件中即可。
|
- 可通过 [快速跑通 Demo](https://console.volcengine.com/rtc/aigc/run?s=g) 快速获取参数, 跑通后点击右上角 `接入 API` 按钮复制相关代码贴到 JSON 配置文件中即可。
|
||||||
## 快速开始
|
## 快速开始
|
||||||
请注意,服务端和 Web 端都需要启动, 启动步骤如下:
|
请注意,服务端和 Web 端都需要启动, 启动步骤如下:
|
||||||
### 服务端
|
### 后端服务(Python FastAPI)
|
||||||
进到项目根目录
|
|
||||||
#### 安装依赖
|
|
||||||
```shell
|
```shell
|
||||||
cd Server
|
cd backend
|
||||||
yarn
|
pip install -r requirements.txt
|
||||||
```
|
uvicorn main:app --host 0.0.0.0 --port 3001 --reload
|
||||||
#### 运行项目
|
|
||||||
```shell
|
|
||||||
yarn dev
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### 前端页面
|
### 前端页面
|
||||||
进到项目根目录
|
|
||||||
#### 安装依赖
|
|
||||||
```shell
|
```shell
|
||||||
yarn
|
cd frontend
|
||||||
```
|
npm install
|
||||||
#### 运行项目
|
npm run dev
|
||||||
```shell
|
|
||||||
yarn dev
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### 常见问题
|
### 常见问题
|
||||||
@ -67,7 +62,7 @@ yarn dev
|
|||||||
| **[StartVoiceChat]Failed(Reason: The task has been started. Please do not call the startup task interface repeatedly.)** 报错 | 如果设置的 RoomId、UserId 为固定值,重复调用 startAgent 会导致出错,只需先调用 stopAgent 后再重新 startAgent 即可。 |
|
| **[StartVoiceChat]Failed(Reason: The task has been started. Please do not call the startup task interface repeatedly.)** 报错 | 如果设置的 RoomId、UserId 为固定值,重复调用 startAgent 会导致出错,只需先调用 stopAgent 后再重新 startAgent 即可。 |
|
||||||
| 为什么麦克风、摄像头开启失败?浏览器报了`TypeError: Cannot read properties of undefined (reading 'getUserMedia')` | 检查当前页面是否为[安全上下文](https://developer.mozilla.org/zh-CN/docs/Web/Security/Secure_Contexts)(简单来说,检查当前页面是否为 `localhost` 或者 是否为 https 协议)。浏览器[限制](https://developer.mozilla.org/zh-CN/docs/Web/Security/Secure_Contexts/features_restricted_to_secure_contexts) `getUserMedia` 只能在安全上下文中使用。 |
|
| 为什么麦克风、摄像头开启失败?浏览器报了`TypeError: Cannot read properties of undefined (reading 'getUserMedia')` | 检查当前页面是否为[安全上下文](https://developer.mozilla.org/zh-CN/docs/Web/Security/Secure_Contexts)(简单来说,检查当前页面是否为 `localhost` 或者 是否为 https 协议)。浏览器[限制](https://developer.mozilla.org/zh-CN/docs/Web/Security/Secure_Contexts/features_restricted_to_secure_contexts) `getUserMedia` 只能在安全上下文中使用。 |
|
||||||
| 为什么我的麦克风正常、摄像头也正常,但是设备没有正常工作? | 可能是设备权限未授予,详情可参考 [Web 排查设备权限获取失败问题](https://www.volcengine.com/docs/6348/1356355?s=g)。 |
|
| 为什么我的麦克风正常、摄像头也正常,但是设备没有正常工作? | 可能是设备权限未授予,详情可参考 [Web 排查设备权限获取失败问题](https://www.volcengine.com/docs/6348/1356355?s=g)。 |
|
||||||
| 接口调用时, 返回 "Invalid 'Authorization' header, Pls check your authorization header" 错误 | `Server/app.js` 中的 AK/SK 不正确 |
|
| 接口调用时, 返回 "Invalid 'Authorization' header, Pls check your authorization header" 错误 | `backend/scenes/*.json` 中的 AK/SK 不正确 |
|
||||||
| 什么是 RTC | **R**eal **T**ime **C**ommunication, RTC 的概念可参考[官网文档](https://www.volcengine.com/docs/6348/66812?s=g)。 |
|
| 什么是 RTC | **R**eal **T**ime **C**ommunication, RTC 的概念可参考[官网文档](https://www.volcengine.com/docs/6348/66812?s=g)。 |
|
||||||
| 不清楚什么是主账号,什么是子账号 | 可以参考[官方概念](https://www.volcengine.com/docs/6257/64963?hyperlink_open_type=lark.open_in_browser&s=g) 。|
|
| 不清楚什么是主账号,什么是子账号 | 可以参考[官方概念](https://www.volcengine.com/docs/6257/64963?hyperlink_open_type=lark.open_in_browser&s=g) 。|
|
||||||
| 我有自己的服务端了, 我应该怎么让前端调用我的服务端呢 | 修改 `src/config/index.ts` 中的 `AIGC_PROXY_HOST` 请求域名和接口并在 `src/app/api.ts` 中修改接口参数配置 `APIS_CONFIG` |
|
| 我有自己的服务端了, 我应该怎么让前端调用我的服务端呢 | 修改 `src/config/index.ts` 中的 `AIGC_PROXY_HOST` 请求域名和接口并在 `src/app/api.ts` 中修改接口参数配置 `APIS_CONFIG` |
|
||||||
|
|||||||
49
backend/README.md
Normal file
@ -0,0 +1,49 @@
|
|||||||
|
# AIGC Backend (Python FastAPI)
|
||||||
|
|
||||||
|
原 Node.js + Koa 服务的 Python 重写版本,使用 FastAPI 框架。
|
||||||
|
|
||||||
|
## 环境要求
|
||||||
|
|
||||||
|
- Python 3.9+
|
||||||
|
|
||||||
|
## 安装依赖
|
||||||
|
|
||||||
|
```shell
|
||||||
|
pip install -r requirements.txt
|
||||||
|
```
|
||||||
|
|
||||||
|
## 场景配置
|
||||||
|
|
||||||
|
编辑 `scenes/*.json`,填写以下字段:
|
||||||
|
|
||||||
|
| 字段 | 说明 |
|
||||||
|
|------|------|
|
||||||
|
| `AccountConfig.accessKeyId` | 火山引擎 AK,从 https://console.volcengine.com/iam/keymanage/ 获取 |
|
||||||
|
| `AccountConfig.secretKey` | 火山引擎 SK |
|
||||||
|
| `RTCConfig.AppId` | RTC 应用 ID |
|
||||||
|
| `RTCConfig.AppKey` | RTC 应用 Key(用于自动生成 Token) |
|
||||||
|
| `VoiceChat.*` | AIGC 相关配置,参考 https://www.volcengine.com/docs/6348/1558163 |
|
||||||
|
|
||||||
|
## 启动服务
|
||||||
|
|
||||||
|
```shell
|
||||||
|
uvicorn main:app --host 0.0.0.0 --port 3001 --reload
|
||||||
|
```
|
||||||
|
|
||||||
|
服务启动后监听 `http://localhost:3001`。
|
||||||
|
|
||||||
|
## 接口说明
|
||||||
|
|
||||||
|
### POST /getScenes
|
||||||
|
|
||||||
|
返回所有场景列表,自动生成 RoomId/UserId/Token(若未在 JSON 中配置)。
|
||||||
|
|
||||||
|
### POST /proxy?Action={Action}&Version={Version}
|
||||||
|
|
||||||
|
代理转发至火山引擎 RTC OpenAPI。
|
||||||
|
|
||||||
|
支持的 Action:
|
||||||
|
- `StartVoiceChat` — 启动语音对话
|
||||||
|
- `StopVoiceChat` — 停止语音对话
|
||||||
|
|
||||||
|
请求体需包含 `SceneID` 字段,对应 `scenes/` 目录下的 JSON 文件名(不含扩展名)。
|
||||||
190
backend/main.py
Normal file
@ -0,0 +1,190 @@
|
|||||||
|
"""
|
||||||
|
Copyright 2025 Beijing Volcano Engine Technology Co., Ltd. All Rights Reserved.
|
||||||
|
SPDX-license-identifier: BSD-3-Clause
|
||||||
|
|
||||||
|
FastAPI backend — migrated from Server/app.js (Node.js + Koa)
|
||||||
|
"""
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import time
|
||||||
|
import uuid
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
from fastapi import FastAPI, Request
|
||||||
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
|
from fastapi.responses import JSONResponse
|
||||||
|
|
||||||
|
from signer import Signer
|
||||||
|
from token import AccessToken, privileges
|
||||||
|
|
||||||
|
app = FastAPI()
|
||||||
|
|
||||||
|
app.add_middleware(
|
||||||
|
CORSMiddleware,
|
||||||
|
allow_origins=["*"],
|
||||||
|
allow_methods=["*"],
|
||||||
|
allow_headers=["*"],
|
||||||
|
)
|
||||||
|
|
||||||
|
SCENES_DIR = Path(__file__).parent / "scenes"
|
||||||
|
|
||||||
|
|
||||||
|
def load_scenes() -> dict:
|
||||||
|
scenes = {}
|
||||||
|
for p in SCENES_DIR.glob("*.json"):
|
||||||
|
with open(p, encoding="utf-8") as f:
|
||||||
|
scenes[p.stem] = json.load(f)
|
||||||
|
return scenes
|
||||||
|
|
||||||
|
|
||||||
|
Scenes = load_scenes()
|
||||||
|
|
||||||
|
|
||||||
|
def assert_value(value, msg: str):
|
||||||
|
if not value or (isinstance(value, str) and " " in value):
|
||||||
|
raise ValueError(msg)
|
||||||
|
|
||||||
|
|
||||||
|
def error_response(action: str, message: str):
|
||||||
|
return JSONResponse({
|
||||||
|
"ResponseMetadata": {
|
||||||
|
"Action": action,
|
||||||
|
"Error": {"Code": -1, "Message": message},
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/proxy")
|
||||||
|
async def proxy(request: Request):
|
||||||
|
action = request.query_params.get("Action", "")
|
||||||
|
version = request.query_params.get("Version", "2024-12-01")
|
||||||
|
|
||||||
|
try:
|
||||||
|
assert_value(action, "Action 不能为空")
|
||||||
|
assert_value(version, "Version 不能为空")
|
||||||
|
|
||||||
|
body = await request.json()
|
||||||
|
scene_id = body.get("SceneID", "")
|
||||||
|
assert_value(scene_id, "SceneID 不能为空,SceneID 用于指定场景的 JSON")
|
||||||
|
|
||||||
|
json_data = Scenes.get(scene_id)
|
||||||
|
if not json_data:
|
||||||
|
raise ValueError(f"{scene_id} 不存在,请先在 backend/scenes 下定义该场景的 JSON.")
|
||||||
|
|
||||||
|
voice_chat = json_data.get("VoiceChat", {})
|
||||||
|
account_config = json_data.get("AccountConfig", {})
|
||||||
|
assert_value(account_config.get("accessKeyId"), "AccountConfig.accessKeyId 不能为空")
|
||||||
|
assert_value(account_config.get("secretKey"), "AccountConfig.secretKey 不能为空")
|
||||||
|
|
||||||
|
if action == "StartVoiceChat":
|
||||||
|
req_body = voice_chat
|
||||||
|
elif action == "StopVoiceChat":
|
||||||
|
app_id = voice_chat.get("AppId", "")
|
||||||
|
room_id = voice_chat.get("RoomId", "")
|
||||||
|
task_id = voice_chat.get("TaskId", "")
|
||||||
|
assert_value(app_id, "VoiceChat.AppId 不能为空")
|
||||||
|
assert_value(room_id, "VoiceChat.RoomId 不能为空")
|
||||||
|
assert_value(task_id, "VoiceChat.TaskId 不能为空")
|
||||||
|
req_body = {"AppId": app_id, "RoomId": room_id, "TaskId": task_id}
|
||||||
|
else:
|
||||||
|
req_body = {}
|
||||||
|
|
||||||
|
request_data = {
|
||||||
|
"region": "cn-north-1",
|
||||||
|
"method": "POST",
|
||||||
|
"params": {"Action": action, "Version": version},
|
||||||
|
"headers": {
|
||||||
|
"Host": "rtc.volcengineapi.com",
|
||||||
|
"Content-type": "application/json",
|
||||||
|
},
|
||||||
|
"body": req_body,
|
||||||
|
}
|
||||||
|
signer = Signer(request_data, "rtc")
|
||||||
|
signer.add_authorization(account_config)
|
||||||
|
|
||||||
|
async with httpx.AsyncClient() as client:
|
||||||
|
resp = await client.post(
|
||||||
|
f"https://rtc.volcengineapi.com?Action={action}&Version={version}",
|
||||||
|
headers=request_data["headers"],
|
||||||
|
json=req_body,
|
||||||
|
)
|
||||||
|
return JSONResponse(resp.json())
|
||||||
|
|
||||||
|
except ValueError as e:
|
||||||
|
return error_response(action, str(e))
|
||||||
|
except Exception as e:
|
||||||
|
return error_response(action, str(e))
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/getScenes")
|
||||||
|
async def get_scenes():
|
||||||
|
try:
|
||||||
|
scenes_list = []
|
||||||
|
for scene_name, data in Scenes.items():
|
||||||
|
scene_config = data.get("SceneConfig", {})
|
||||||
|
rtc_config = data.get("RTCConfig", {})
|
||||||
|
voice_chat = data.get("VoiceChat", {})
|
||||||
|
|
||||||
|
app_id = rtc_config.get("AppId", "")
|
||||||
|
assert_value(app_id, f"{scene_name} 场景的 RTCConfig.AppId 不能为空")
|
||||||
|
|
||||||
|
token = rtc_config.get("Token", "")
|
||||||
|
user_id = rtc_config.get("UserId", "")
|
||||||
|
room_id = rtc_config.get("RoomId", "")
|
||||||
|
app_key = rtc_config.get("AppKey", "")
|
||||||
|
|
||||||
|
if app_id and (not token or not user_id or not room_id):
|
||||||
|
rtc_config["RoomId"] = voice_chat["RoomId"] = room_id or str(uuid.uuid4())
|
||||||
|
rtc_config["UserId"] = user_id = user_id or str(uuid.uuid4())
|
||||||
|
if voice_chat.get("AgentConfig") and voice_chat["AgentConfig"].get("TargetUserId"):
|
||||||
|
voice_chat["AgentConfig"]["TargetUserId"][0] = rtc_config["UserId"]
|
||||||
|
|
||||||
|
assert_value(app_key, f"自动生成 Token 时,{scene_name} 场景的 AppKey 不可为空")
|
||||||
|
key = AccessToken(app_id, app_key, rtc_config["RoomId"], rtc_config["UserId"])
|
||||||
|
key.add_privilege(privileges["PrivSubscribeStream"], 0)
|
||||||
|
key.add_privilege(privileges["PrivPublishStream"], 0)
|
||||||
|
key.expire_time(int(time.time()) + 24 * 3600)
|
||||||
|
rtc_config["Token"] = key.serialize()
|
||||||
|
|
||||||
|
scene_config["id"] = scene_name
|
||||||
|
scene_config["botName"] = voice_chat.get("AgentConfig", {}).get("UserId")
|
||||||
|
scene_config["isInterruptMode"] = voice_chat.get("Config", {}).get("InterruptMode") == 0
|
||||||
|
scene_config["isVision"] = (
|
||||||
|
voice_chat.get("Config", {}).get("LLMConfig", {}).get("VisionConfig", {}).get("Enable")
|
||||||
|
)
|
||||||
|
scene_config["isScreenMode"] = (
|
||||||
|
voice_chat.get("Config", {}).get("LLMConfig", {})
|
||||||
|
.get("VisionConfig", {}).get("SnapshotConfig", {}).get("StreamType") == 1
|
||||||
|
)
|
||||||
|
scene_config["isAvatarScene"] = (
|
||||||
|
voice_chat.get("Config", {}).get("AvatarConfig", {}).get("Enabled")
|
||||||
|
)
|
||||||
|
scene_config["avatarBgUrl"] = (
|
||||||
|
voice_chat.get("Config", {}).get("AvatarConfig", {}).get("BackgroundUrl")
|
||||||
|
)
|
||||||
|
|
||||||
|
rtc_out = {k: v for k, v in rtc_config.items() if k != "AppKey"}
|
||||||
|
|
||||||
|
scenes_list.append({
|
||||||
|
"scene": scene_config,
|
||||||
|
"rtc": rtc_out,
|
||||||
|
})
|
||||||
|
|
||||||
|
return JSONResponse({
|
||||||
|
"ResponseMetadata": {"Action": "getScenes"},
|
||||||
|
"Result": {"scenes": scenes_list},
|
||||||
|
})
|
||||||
|
|
||||||
|
except ValueError as e:
|
||||||
|
return JSONResponse({
|
||||||
|
"ResponseMetadata": {
|
||||||
|
"Action": "getScenes",
|
||||||
|
"Error": {"Code": -1, "Message": str(e)},
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
import uvicorn
|
||||||
|
uvicorn.run("main:app", host="0.0.0.0", port=3001, reload=True)
|
||||||
4
backend/requirements.txt
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
fastapi>=0.110.0
|
||||||
|
uvicorn[standard]>=0.29.0
|
||||||
|
httpx>=0.27.0
|
||||||
|
python-multipart>=0.0.9
|
||||||
75
backend/scenes/Custom.json
Normal file
@ -0,0 +1,75 @@
|
|||||||
|
{
|
||||||
|
"SceneConfig": {
|
||||||
|
"icon": "https://lf3-rtc-demo.volccdn.com/obj/rtc-aigc-assets/DoubaoAvatar.png",
|
||||||
|
"name": "自定义助手"
|
||||||
|
},
|
||||||
|
"AccountConfig": {
|
||||||
|
"accessKeyId": "",
|
||||||
|
"secretKey": ""
|
||||||
|
},
|
||||||
|
"RTCConfig": {
|
||||||
|
"AppId": "",
|
||||||
|
"AppKey": "",
|
||||||
|
"RoomId": "",
|
||||||
|
"UserId": "",
|
||||||
|
"Token": ""
|
||||||
|
},
|
||||||
|
"VoiceChat": {
|
||||||
|
"AppId": "",
|
||||||
|
"RoomId": "",
|
||||||
|
"TaskId": "",
|
||||||
|
"AgentConfig": {
|
||||||
|
"TargetUserId": [
|
||||||
|
""
|
||||||
|
],
|
||||||
|
"WelcomeMessage": "你好,我是小宁,有什么需要帮忙的吗?",
|
||||||
|
"UserId": "",
|
||||||
|
"EnableConversationStateCallback": true
|
||||||
|
},
|
||||||
|
"Config": {
|
||||||
|
"ASRConfig": {
|
||||||
|
"Provider": "volcano",
|
||||||
|
"ProviderParams": {
|
||||||
|
"Mode": "smallmodel",
|
||||||
|
"AppId": "",
|
||||||
|
"Cluster": "volcengine_streaming_common"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"TTSConfig": {
|
||||||
|
"Provider": "volcano",
|
||||||
|
"ProviderParams": {
|
||||||
|
"app": {
|
||||||
|
"appid": "",
|
||||||
|
"cluster": "volcano_tts"
|
||||||
|
},
|
||||||
|
"audio": {
|
||||||
|
"voice_type": "BV001_streaming",
|
||||||
|
"speed_ratio": 1,
|
||||||
|
"pitch_ratio": 1,
|
||||||
|
"volume_ratio": 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"LLMConfig": {
|
||||||
|
"Mode": "ArkV3",
|
||||||
|
"EndPointId": "",
|
||||||
|
"SystemMessages": [
|
||||||
|
"你是小宁,性格幽默又善解人意。你在表达时需简明扼要,有自己的观点。"
|
||||||
|
],
|
||||||
|
"VisionConfig": {
|
||||||
|
"Enable": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"AvatarConfig": {
|
||||||
|
"Enabled": false,
|
||||||
|
"AvatarType": "3min",
|
||||||
|
"AvatarRole": "250623-zhibo-linyunzhi",
|
||||||
|
"BackgroundUrl": "",
|
||||||
|
"VideoBitrate": 2000,
|
||||||
|
"AvatarAppID": "",
|
||||||
|
"AvatarToken": ""
|
||||||
|
},
|
||||||
|
"InterruptMode": 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
112
backend/signer.py
Normal file
@ -0,0 +1,112 @@
|
|||||||
|
"""
|
||||||
|
Copyright 2025 Beijing Volcano Engine Technology Co., Ltd. All Rights Reserved.
|
||||||
|
SPDX-license-identifier: BSD-3-Clause
|
||||||
|
|
||||||
|
Migrated from @volcengine/openapi Signer (AWS SigV4 compatible)
|
||||||
|
Reference: https://www.volcengine.com/docs/6348/69828
|
||||||
|
"""
|
||||||
|
import hashlib
|
||||||
|
import hmac
|
||||||
|
import json
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from urllib.parse import quote
|
||||||
|
|
||||||
|
|
||||||
|
def _sha256_hex(data: bytes) -> str:
|
||||||
|
return hashlib.sha256(data).hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
def _hmac_sha256(key: bytes, data: str) -> bytes:
|
||||||
|
return hmac.new(key, data.encode("utf-8"), hashlib.sha256).digest()
|
||||||
|
|
||||||
|
|
||||||
|
def _get_signing_key(secret_key: str, date_str: str, region: str, service: str) -> bytes:
|
||||||
|
k_date = _hmac_sha256(("HMAC-SHA256" + secret_key).encode("utf-8"), date_str)
|
||||||
|
k_region = _hmac_sha256(k_date, region)
|
||||||
|
k_service = _hmac_sha256(k_region, service)
|
||||||
|
k_signing = _hmac_sha256(k_service, "request")
|
||||||
|
return k_signing
|
||||||
|
|
||||||
|
|
||||||
|
class Signer:
|
||||||
|
"""
|
||||||
|
Signs requests to Volcengine OpenAPI using AWS SigV4-compatible signing.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, request_data: dict, service: str):
|
||||||
|
"""
|
||||||
|
request_data: {
|
||||||
|
region: str,
|
||||||
|
method: str,
|
||||||
|
params: dict, # query params (Action, Version, ...)
|
||||||
|
headers: dict,
|
||||||
|
body: dict,
|
||||||
|
}
|
||||||
|
service: e.g. "rtc"
|
||||||
|
"""
|
||||||
|
self.region = request_data.get("region", "cn-north-1")
|
||||||
|
self.method = request_data.get("method", "POST").upper()
|
||||||
|
self.params = request_data.get("params", {})
|
||||||
|
self.headers = request_data.get("headers", {})
|
||||||
|
self.body = request_data.get("body", {})
|
||||||
|
self.service = service
|
||||||
|
|
||||||
|
def add_authorization(self, account_config: dict):
|
||||||
|
"""
|
||||||
|
Computes and injects Authorization + X-Date headers into self.headers.
|
||||||
|
account_config: { accessKeyId: str, secretKey: str }
|
||||||
|
"""
|
||||||
|
access_key = account_config["accessKeyId"]
|
||||||
|
secret_key = account_config["secretKey"]
|
||||||
|
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
date_str = now.strftime("%Y%m%d")
|
||||||
|
datetime_str = now.strftime("%Y%m%dT%H%M%SZ")
|
||||||
|
|
||||||
|
self.headers["X-Date"] = datetime_str
|
||||||
|
self.headers["X-Content-Sha256"] = _sha256_hex(
|
||||||
|
json.dumps(self.body, separators=(",", ":"), ensure_ascii=False).encode("utf-8")
|
||||||
|
)
|
||||||
|
|
||||||
|
# Canonical headers: sorted lowercase header names
|
||||||
|
signed_header_names = sorted(k.lower() for k in self.headers)
|
||||||
|
canonical_headers = "".join(
|
||||||
|
f"{k}:{self.headers[next(h for h in self.headers if h.lower() == k)]}\n"
|
||||||
|
for k in signed_header_names
|
||||||
|
)
|
||||||
|
signed_headers_str = ";".join(signed_header_names)
|
||||||
|
|
||||||
|
# Canonical query string
|
||||||
|
sorted_params = sorted(self.params.items())
|
||||||
|
canonical_qs = "&".join(
|
||||||
|
f"{quote(str(k), safe='')}={quote(str(v), safe='')}"
|
||||||
|
for k, v in sorted_params
|
||||||
|
)
|
||||||
|
|
||||||
|
# Canonical request
|
||||||
|
body_hash = self.headers["X-Content-Sha256"]
|
||||||
|
canonical_request = "\n".join([
|
||||||
|
self.method,
|
||||||
|
"/",
|
||||||
|
canonical_qs,
|
||||||
|
canonical_headers,
|
||||||
|
signed_headers_str,
|
||||||
|
body_hash,
|
||||||
|
])
|
||||||
|
|
||||||
|
credential_scope = f"{date_str}/{self.region}/{self.service}/request"
|
||||||
|
string_to_sign = "\n".join([
|
||||||
|
"HMAC-SHA256",
|
||||||
|
datetime_str,
|
||||||
|
credential_scope,
|
||||||
|
_sha256_hex(canonical_request.encode("utf-8")),
|
||||||
|
])
|
||||||
|
|
||||||
|
signing_key = _get_signing_key(secret_key, date_str, self.region, self.service)
|
||||||
|
signature = hmac.new(signing_key, string_to_sign.encode("utf-8"), hashlib.sha256).hexdigest()
|
||||||
|
|
||||||
|
self.headers["Authorization"] = (
|
||||||
|
f"HMAC-SHA256 Credential={access_key}/{credential_scope}, "
|
||||||
|
f"SignedHeaders={signed_headers_str}, "
|
||||||
|
f"Signature={signature}"
|
||||||
|
)
|
||||||
102
backend/token.py
Normal file
@ -0,0 +1,102 @@
|
|||||||
|
"""
|
||||||
|
Copyright 2025 Beijing Volcano Engine Technology Co., Ltd. All Rights Reserved.
|
||||||
|
SPDX-license-identifier: BSD-3-Clause
|
||||||
|
|
||||||
|
Migrated from Server/token.js
|
||||||
|
"""
|
||||||
|
import hashlib
|
||||||
|
import hmac
|
||||||
|
import random
|
||||||
|
import struct
|
||||||
|
import time
|
||||||
|
import base64
|
||||||
|
|
||||||
|
VERSION = "001"
|
||||||
|
VERSION_LENGTH = 3
|
||||||
|
APP_ID_LENGTH = 24
|
||||||
|
|
||||||
|
_random_nonce = random.randint(0, 0xFFFFFFFF)
|
||||||
|
|
||||||
|
privileges = {
|
||||||
|
"PrivPublishStream": 0,
|
||||||
|
"privPublishAudioStream": 1,
|
||||||
|
"privPublishVideoStream": 2,
|
||||||
|
"privPublishDataStream": 3,
|
||||||
|
"PrivSubscribeStream": 4,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class ByteBuf:
|
||||||
|
def __init__(self):
|
||||||
|
self._buf = bytearray()
|
||||||
|
|
||||||
|
def put_uint16(self, v: int) -> "ByteBuf":
|
||||||
|
self._buf += struct.pack("<H", v)
|
||||||
|
return self
|
||||||
|
|
||||||
|
def put_uint32(self, v: int) -> "ByteBuf":
|
||||||
|
self._buf += struct.pack("<I", v)
|
||||||
|
return self
|
||||||
|
|
||||||
|
def put_bytes(self, b: bytes) -> "ByteBuf":
|
||||||
|
self.put_uint16(len(b))
|
||||||
|
self._buf += b
|
||||||
|
return self
|
||||||
|
|
||||||
|
def put_string(self, s: str) -> "ByteBuf":
|
||||||
|
return self.put_bytes(s.encode("utf-8"))
|
||||||
|
|
||||||
|
def put_tree_map_uint32(self, m: dict) -> "ByteBuf":
|
||||||
|
if not m:
|
||||||
|
self.put_uint16(0)
|
||||||
|
return self
|
||||||
|
self.put_uint16(len(m))
|
||||||
|
for key, value in m.items():
|
||||||
|
self.put_uint16(int(key))
|
||||||
|
self.put_uint32(int(value))
|
||||||
|
return self
|
||||||
|
|
||||||
|
def pack(self) -> bytes:
|
||||||
|
return bytes(self._buf)
|
||||||
|
|
||||||
|
|
||||||
|
def _encode_hmac(key: str, message: bytes) -> bytes:
|
||||||
|
return hmac.new(key.encode("utf-8"), message, hashlib.sha256).digest()
|
||||||
|
|
||||||
|
|
||||||
|
class AccessToken:
|
||||||
|
def __init__(self, app_id: str, app_key: str, room_id: str, user_id: str):
|
||||||
|
self.app_id = app_id
|
||||||
|
self.app_key = app_key
|
||||||
|
self.room_id = room_id
|
||||||
|
self.user_id = user_id
|
||||||
|
self.issued_at = int(time.time())
|
||||||
|
self.nonce = _random_nonce
|
||||||
|
self.expire_at = 0
|
||||||
|
self._privileges: dict = {}
|
||||||
|
|
||||||
|
def add_privilege(self, privilege: int, expire_timestamp: int):
|
||||||
|
self._privileges[privilege] = expire_timestamp
|
||||||
|
if privilege == privileges["PrivPublishStream"]:
|
||||||
|
self._privileges[privileges["privPublishVideoStream"]] = expire_timestamp
|
||||||
|
self._privileges[privileges["privPublishAudioStream"]] = expire_timestamp
|
||||||
|
self._privileges[privileges["privPublishDataStream"]] = expire_timestamp
|
||||||
|
|
||||||
|
def expire_time(self, expire_timestamp: int):
|
||||||
|
self.expire_at = expire_timestamp
|
||||||
|
|
||||||
|
def _pack_msg(self) -> bytes:
|
||||||
|
buf = ByteBuf()
|
||||||
|
buf.put_uint32(self.nonce)
|
||||||
|
buf.put_uint32(self.issued_at)
|
||||||
|
buf.put_uint32(self.expire_at)
|
||||||
|
buf.put_string(self.room_id)
|
||||||
|
buf.put_string(self.user_id)
|
||||||
|
buf.put_tree_map_uint32(self._privileges)
|
||||||
|
return buf.pack()
|
||||||
|
|
||||||
|
def serialize(self) -> str:
|
||||||
|
msg = self._pack_msg()
|
||||||
|
signature = _encode_hmac(self.app_key, msg)
|
||||||
|
content = ByteBuf().put_bytes(msg).put_bytes(signature).pack()
|
||||||
|
return VERSION + self.app_id + base64.b64encode(content).decode("utf-8")
|
||||||
|
Before Width: | Height: | Size: 3.2 KiB After Width: | Height: | Size: 3.2 KiB |
|
Before Width: | Height: | Size: 2.3 KiB After Width: | Height: | Size: 2.3 KiB |
|
Before Width: | Height: | Size: 841 B After Width: | Height: | Size: 841 B |
|
Before Width: | Height: | Size: 1.2 KiB After Width: | Height: | Size: 1.2 KiB |
|
Before Width: | Height: | Size: 965 B After Width: | Height: | Size: 965 B |
|
Before Width: | Height: | Size: 440 B After Width: | Height: | Size: 440 B |
|
Before Width: | Height: | Size: 758 B After Width: | Height: | Size: 758 B |
|
Before Width: | Height: | Size: 1.6 KiB After Width: | Height: | Size: 1.6 KiB |
|
Before Width: | Height: | Size: 8.4 KiB After Width: | Height: | Size: 8.4 KiB |
|
Before Width: | Height: | Size: 2.4 KiB After Width: | Height: | Size: 2.4 KiB |
|
Before Width: | Height: | Size: 2.3 KiB After Width: | Height: | Size: 2.3 KiB |
|
Before Width: | Height: | Size: 2.5 KiB After Width: | Height: | Size: 2.5 KiB |
|
Before Width: | Height: | Size: 1.2 KiB After Width: | Height: | Size: 1.2 KiB |
|
Before Width: | Height: | Size: 1.2 KiB After Width: | Height: | Size: 1.2 KiB |
|
Before Width: | Height: | Size: 1.2 KiB After Width: | Height: | Size: 1.2 KiB |
|
Before Width: | Height: | Size: 332 KiB After Width: | Height: | Size: 332 KiB |
|
Before Width: | Height: | Size: 1.1 KiB After Width: | Height: | Size: 1.1 KiB |
|
Before Width: | Height: | Size: 14 KiB After Width: | Height: | Size: 14 KiB |