Channel Management
Channel and user queries, user status, kicking rules, mute, role switching and ban rules.
Channel management covers two kinds of operations: queries (channels, users, user state) and governance (kick, mute, role switching, ban rules). The platform API uses camelCase fields; /dev/v1/* provides semantically equivalent Agora-compatible entry points, and both read and write the same business facts.
Capability overview
| Capability | Platform API | Agora-compatible |
|---|---|---|
| Channel list | GET /v1/channels | GET /dev/v1/channel/{appid} |
| Users in a channel | GET /v1/channels/{channelName}/users | GET /dev/v1/channel/user/{appid}/{channelName} |
| User state | GET /v1/channels/{channelName}/users/{uid} | GET /dev/v1/channel/user/property/{appid}/{uid}/{channelName} |
| Kick | DELETE /v1/channels/{channelName}/users/{uid} | POST /dev/v1/kicking-rule (join_channel, which also kicks the user offline immediately) |
| Mute / unmute | POST /v1/channels/{channelName}/users/{uid}/mute · /unmute | Expressed through the publish_audio / publish_video privileges of kicking-rule |
| Role switching | POST /v1/channels/{channelName}/users/{uid}/role | Corresponds to the client-side setClientRole semantics; no REST equivalent |
| Ban rules | GET/POST/PATCH/DELETE /v1/ban-rules | GET/POST/PUT/DELETE /dev/v1/kicking-rule |
Querying channels
GET /v1/channels?page_no=0&page_size=100
| Parameter | Location | Description |
|---|---|---|
page_no | query | 0-based, defaults to 0 |
page_size | query | 1–500, defaults to 100 |
The response is { total, pageNo, pageSize, channels: [{ channelName, userCount, profile, createdAt }] }. userCount comes from the live participant count on the media plane, profile is communication or live, and createdAt may be null when the channel was not created through the control plane.
The Agora-compatible entry point GET /dev/v1/channel/{appid}?page_no=&page_size= returns { success: true, data: { channels: [{ channel_name, user_count }], total_size } }.
Querying users in a channel
GET /v1/channels/{channelName}/users → { channel: { name, profile, createdAt }, users: [UserView] }
UserView fields: uid, userId (may be null), role, platform, joinedAt, audioMuted, videoMuted, publishing.{audio,video,screen}.
If the channel does not exist the API returns 404 channel_not_found (not an empty list). The Agora-compatible entry point returns { success: true, data: { channel_exist: false } } for a missing channel — that is Agora's own convention.
The Agora-compatible interface has two shapes:
communicationchannel:{ channel_exist: true, mode: 1, total, users: [uid…] }livechannel:{ channel_exist: true, mode: 2, broadcasters: [uid…], audience: [uid…], audience_total }; adding?hosts_only=trueomits the audience list.
Querying a single user
GET /v1/channels/{channelName}/users/{uid} → UserView; if the user is not in the channel the API returns 404 user_not_found.
The Agora-compatible entry point returns { in_channel, uid, join, role, platform } or { in_channel: false }; role values: 1 for a communication user, 2 for a broadcaster, 3 for an audience member.
Kicking a user
DELETE /v1/channels/{channelName}/users/{uid}
Optional request body: { "durationSeconds": 600, "reason": "policy-violation" } (durationSeconds 0–86430, defaults to 600).
Action sequence (completed within a single call):
- Create a
join_channelban rule; - Remove the participant from the media plane;
- Record leave reason
3(kicked) and emituser_leave/broadcaster_leavecallbacks.
Response: { "kicked": true, "uid": 10001, "banRuleId": 7, "expireAt": "2026-10-26T03:00:00.000Z" }.
Mute and unmute
POST /v1/channels/{channelName}/users/{uid}/mute
| Field | Description |
|---|---|
kind | audio / video / all (defaults to all) |
banPublish | Defaults to true: besides muting the tracks that are already published, also block republishing (creates a publish_audio / publish_video rule) |
durationSeconds | Ban window, defaults to 600 |
Response: { muted: true, uid, kind, mutedTracks: ["TR_10001_a"], banRuleIds: [8] }. mutedTracks lists the track sids that the server muted.
POST .../unmute does two things: it unmutes the tracks and deletes the corresponding publish_* rules, keeping only the privileges that are still needed.
Role switching
POST /v1/channels/{channelName}/users/{uid}/role, request body { "role": "host" | "audience" }.
host:canPublish=true,canPublishData=trueaudience:canPublish=false(still able to subscribe)
On success it returns { uid, role } and emits a callback: 111 role_to_broadcaster when promoted, 112 role_to_audience when demoted.
Ban rules (kicking-rule)
Platform API:
| Method | Path | Description |
|---|---|---|
GET | /v1/ban-rules?channelName=&uid= | List non-expired rules |
POST | /v1/ban-rules | Create; 201 on success |
PATCH | /v1/ban-rules/{id} | Update duration / privilege bits |
DELETE | /v1/ban-rules/{id} | Delete; 204 on success |
Create body: supply at least one of channelName, uid, ip; privileges is required and needs at least one entry (join_channel / publish_audio / publish_video); durationSeconds defaults to 3600.
Semantics:
join_channel: token issuance answers403 banneddirectly, and an online user is kicked immediately (leave reason 3).publish_audio/publish_video: block the corresponding media publication; the block can be lifted explicitly withunmute.- A rule whose
expireAtisnullis permanent (todaydurationSeconds: 0takes this path — see "Known limitations").
The Agora-compatible entry point POST /dev/v1/kicking-rule additionally accepts time (minutes, 0–1440) or time_in_seconds (0–86430); when both are omitted it uses 3600 seconds. PUT locates the rule by appid + id, and so does DELETE.
Agora paths ↔ our paths
| Agora path | Our equivalent path | Notes |
|---|---|---|
GET /dev/v1/channel/{appid} | GET /v1/channels | Agora returns channel_name / user_count / total_size; the platform returns camelCase fields plus pageNo / pageSize |
GET /dev/v1/channel/user/{appid}/{channelName} | GET /v1/channels/{channelName}/users | Agora returns users, or broadcasters/audience depending on the channel profile, and signals a missing channel with channel_exist |
GET /dev/v1/channel/user/property/{appid}/{uid}/{channelName} | GET /v1/channels/{channelName}/users/{uid} | Agora uses in_channel plus a numeric role; we return the full UserView |
POST /dev/v1/kicking-rule | POST /v1/ban-rules | Agora takes time (minutes) / time_in_seconds (seconds); we standardise on durationSeconds. join_channel kicks the user offline immediately |
GET /dev/v1/kicking-rule?appid= | GET /v1/ban-rules | Agora authenticates and lists by appid (query); we list by project credentials and support channelName / uid filters |
PUT /dev/v1/kicking-rule | PATCH /v1/ban-rules/{id} | Both can change the duration and the privilege bits |
DELETE /dev/v1/kicking-rule | DELETE /v1/ban-rules/{id} | Agora uses appid+id in the body (DELETE with a body); we use path parameters |
| (no REST equivalent) | POST /v1/channels/{c}/users/{uid}/mute /unmute | Agora can only express "no publishing" through publish_* rules; we additionally offer immediate server-side mute |
(no REST equivalent; client setClientRole on Agora) | POST /v1/channels/{c}/users/{uid}/role | Server-side role change, which emits the 111/112 callbacks |
(none; Agora achieves it with the join_channel kicking rule) | DELETE /v1/channels/{c}/users/{uid} | Kick = remove the online session + create a join_channel rule, in one call |
End-to-end examples
Queries and the Agora-compatible comparison (safe, read-only):
export const meta = {
id: '06-channel-users',
title: '频道内用户列表与用户详情',
safe: true,
needsApi: true,
};
/**
* GET /v1/channels/{channelName}/users → { channel: { name, profile, createdAt }, users: [UserView] }
* GET /v1/channels/{channelName}/users/{uid} → UserView
*
* 演示项目首启会 seed 三个频道(weekly-sync / design-review / all-hands)与 5 个用户;
* 若以 NIMEET_SEED_DEMO=false 启动,这里会得到 404 channel_not_found —— 这也是文档化的行为。
*/
export async function run(ctx) {
const api = ctx.api;
const channels = await api('GET', '/v1/channels', { query: { page_size: 100 } });
const names = (channels.json.channels ?? []).map((entry) => entry.channelName);
const channelName = names.includes('weekly-sync') ? 'weekly-sync' : names[0];
if (!channelName) {
return { summary: '当前项目没有活跃频道(NIMEET_SEED_DEMO=false 时属预期),跳过用户列表' };
}
const users = await api('GET', `/v1/channels/${encodeURIComponent(channelName)}/users`);
const list = users.json.users ?? [];
ctx.assert(Array.isArray(list), 'users 应为数组');
ctx.assert(users.json.channel?.name === channelName, '响应应回显频道信息');
let detail = null;
if (list.length > 0) {
const first = list[0];
const one = await api('GET', `/v1/channels/${encodeURIComponent(channelName)}/users/${first.uid}`);
detail = one.json;
ctx.assert(detail.uid === first.uid, '用户详情应与列表中的 uid 一致');
}
// 404 分支:不存在的频道必须报 channel_not_found(而不是空列表)。
const missing = await ctx.client('GET', '/v1/channels/no-such-channel/users');
ctx.assert(missing.status === 404, '未知频道应返回 404');
ctx.assert(missing.json.code === 'channel_not_found', `期望 channel_not_found,实际 ${missing.json?.code}`);
return {
summary: `${channelName}: ${list.length} 个用户${detail ? `,首个用户 uid=${detail.uid} role=${detail.role} publishing.video=${detail.publishing?.video}` : ''};未知频道 → 404 channel_not_found`,
details: { channel: users.json.channel, users: list.map((entry) => ({ uid: entry.uid, role: entry.role, userId: entry.userId })) },
};
}export const meta = {
id: '07-agora-compat-channels',
title: '声网兼容:频道 / 用户 / 用户状态',
safe: true,
needsApi: true,
};
/**
* 三个 `/dev/v1` 接口与平台 API 一一对应:
* GET /dev/v1/channel/{appid} → 频道列表
* GET /dev/v1/channel/user/{appid}/{channelName} → 频道内用户(communication 模式返回 users/total)
* GET /dev/v1/channel/user/property/{appid}/{uid}/{cname} → 用户状态(in_channel / role / platform)
*
* 注意 mode/role 的数值映射:communication=1、LIVE_BROADCASTING=2;role 1=communication 用户、2=主播、3=观众。
*/
export async function run(ctx) {
const api = ctx.api;
const appId = ctx.credentials.appId;
const channels = await api('GET', `/dev/v1/channel/${appId}`, { query: { page_size: 100 } });
const list = channels.json.data?.channels ?? [];
const channelName = list.find((entry) => entry.channel_name === 'weekly-sync')?.channel_name ?? list[0]?.channel_name;
if (!channelName) {
return { summary: '没有活跃频道(演示 seed 关闭时属预期),跳过声网用户接口' };
}
const users = await api('GET', `/dev/v1/channel/user/${appId}/${encodeURIComponent(channelName)}`);
const data = users.json.data ?? {};
const uids = data.users ?? data.broadcasters ?? [];
ctx.assert(data.channel_exist === true, 'channel_exist 应为 true');
let status = null;
if (uids.length > 0) {
const uid = uids[0];
const property = await api('GET', `/dev/v1/channel/user/property/${appId}/${uid}/${encodeURIComponent(channelName)}`);
status = property.json.data;
ctx.assert(status.in_channel === true, 'in_channel 应为 true');
ctx.assert(status.uid === uid, '应回显 uid');
}
const absent = await api('GET', `/dev/v1/channel/user/${appId}/definitely-no-such-channel`);
ctx.assert(absent.json.data?.channel_exist === false, '不存在的频道应返回 channel_exist:false');
return {
summary:
`${channelName}: mode=${data.mode} 用户数=${uids.length}` +
`${status ? `;uid=${status.uid} role=${status.role} platform=${status.platform} join=${status.join}` : ''};` +
'未知频道 → channel_exist:false',
details: { channelData: data, userProperty: status },
};
}The full server-side governance flow (it modifies the demo channel state, so the runner needs --all):
import { seedChannel, uniqueChannel } from './_lib.mjs';
export const meta = {
id: '15-moderation',
title: '禁言 / 角色切换 / 封禁规则 / 踢人',
safe: false, // 会修改演示频道内的用户状态
needsApi: true,
};
/**
* 平台接口:
* POST /v1/channels/{c}/users/{uid}/mute | unmute | role
* GET/POST/PATCH/DELETE /v1/ban-rules
* DELETE /v1/channels/{c}/users/{uid} (踢人 + 建 join_channel 规则)
* 声网兼容:POST/GET/PUT/DELETE /dev/v1/kicking-rule
*
* 语义要点:
* - mute 先静音已发布 track,再按 banPublish 建 publish_audio/publish_video 规则阻止重发;
* - join_channel 封禁在“签发 Token 时”即被拒绝(403 banned),并在在线时立刻踢下线(离开原因 3);
* - 踢人默认建 600s 的 join_channel 规则。
*/
export async function run(ctx) {
const api = ctx.api;
const appId = ctx.credentials.appId;
const channelName = uniqueChannel('docs-mod');
const uid = 94001;
await seedChannel(api, appId, channelName, uid);
// ------------------------------------------------------------------ mute
const mute = await api('POST', `/v1/channels/${channelName}/users/${uid}/mute`, {
body: { kind: 'audio', banPublish: true, durationSeconds: 600 },
});
ctx.assert(mute.json.muted === true, 'mute 应返回 muted:true');
ctx.assert(Array.isArray(mute.json.mutedTracks) && mute.json.mutedTracks.length > 0, '应返回被静音的 track sid');
ctx.assert(Array.isArray(mute.json.banRuleIds) && mute.json.banRuleIds.length > 0, 'banPublish 应创建封禁规则');
const unmute = await api('POST', `/v1/channels/${channelName}/users/${uid}/unmute`, { body: { kind: 'audio' } });
ctx.assert(unmute.json.unmuted === true, 'unmute 应返回 unmuted:true');
// ------------------------------------------------------------ role change
const demoted = await api('POST', `/v1/channels/${channelName}/users/${uid}/role`, { body: { role: 'audience' } });
ctx.assert(demoted.json.role === 'audience', '角色应变为 audience');
const promoted = await api('POST', `/v1/channels/${channelName}/users/${uid}/role`, { body: { role: 'host' } });
ctx.assert(promoted.json.role === 'host', '角色应恢复为 host');
// ----------------------------------------------------------- ban rules
const created = await api('POST', '/v1/ban-rules', {
body: { channelName, uid, privileges: ['join_channel'], durationSeconds: 600 },
});
ctx.assert(created.status === 201, `创建封禁规则应返回 201,实际 ${created.status}`);
const ruleId = created.json.id;
const listed = await api('GET', '/v1/ban-rules', { query: { channelName } });
ctx.assert((listed.json.rules ?? []).some((rule) => rule.id === ruleId), '规则应出现在列表中');
const patched = await api('PATCH', `/v1/ban-rules/${ruleId}`, { body: { durationSeconds: 120 } });
ctx.assert(patched.json.expireAt, '更新时长后应返回 expireAt');
const denied = await ctx.client('POST', '/v1/tokens', { body: { channelName, uid } });
ctx.assert(denied.status === 403 && denied.json.code === 'banned', '被 join_channel 封禁的 uid 无法获得 Token');
const removed = await api('DELETE', `/v1/ban-rules/${ruleId}`);
ctx.assert(removed.status === 204, '删除规则应返回 204');
const allowed = await api('POST', '/v1/tokens', { body: { channelName, uid } });
ctx.assert(allowed.status === 200, '解除封禁后应能重新签发 Token');
// ------------------------------------------------------------------ kick
const kick = await api('DELETE', `/v1/channels/${channelName}/users/${uid}`, {
body: { durationSeconds: 600, reason: 'docs-example' },
});
ctx.assert(kick.json.kicked === true, '踢人应返回 kicked:true');
ctx.assert(typeof kick.json.banRuleId === 'number', '踢人应返回 banRuleId');
const gone = await ctx.client('GET', `/v1/channels/${channelName}/users/${uid}`);
ctx.assert(gone.status === 404 && gone.json.code === 'user_not_found', '被踢用户应不在频道内');
// ------------------------------------------------- 声网兼容 kicking-rule
const agoraCreate = await api('POST', '/dev/v1/kicking-rule', {
body: { appid: appId, cname: channelName, uid, time: 10, privileges: ['publish_video'] },
});
ctx.assert(agoraCreate.json.status === 'success', '声网 kicking-rule 创建应返回 status:success');
const agoraId = agoraCreate.json.id;
const agoraList = await api('GET', '/dev/v1/kicking-rule', { query: { appid: appId } });
ctx.assert((agoraList.json.rules ?? []).some((rule) => rule.id === agoraId), '声网规则列表应包含新规则');
const agoraUpdate = await api('PUT', '/dev/v1/kicking-rule', {
body: { appid: appId, id: agoraId, time_in_seconds: 120 },
});
ctx.assert(agoraUpdate.json.status === 'success', '声网规则更新应成功');
const agoraDelete = await api('DELETE', '/dev/v1/kicking-rule', { body: { appid: appId, id: agoraId } });
ctx.assert(agoraDelete.json.status === 'success', '声网规则删除应成功');
return {
summary:
`${channelName}: mute→unmute 成功;role audience↔host;封禁 uid 时 Token 403 banned,解禁后 200;` +
`踢人得到 banRuleId=${kick.json.banRuleId};声网 kicking-rule 创建/更新/删除闭环`,
details: {
mutedTracks: mute.json.mutedTracks,
kick: kick.json,
agoraRuleId: agoraId,
},
};
}curl version:
#!/usr/bin/env bash
# 频道与用户查询:平台 API 与声网兼容 API 对照
set -euo pipefail
: "${NIMEET_API_BASE:=http://127.0.0.1:8787}"
CRED_FILE=$([ -f the control plane class="tok-flag">-credentials.json ] && echo the control plane class="tok-flag">-credentials.json || echo data/demo-credentials.json)
CRED=$(node -e "const c=JSON.parse(require('fs').readFileSync(process.argv[1],'utf8'));process.stdout.write(Buffer.from(c.customerId+':'+c.customerSecret).toString('base64'))" "$CRED_FILE")
APP_ID=$(node -e "process.stdout.write(JSON.parse(require('fs').readFileSync(process.argv[1],'utf8')).appId)" "$CRED_FILE")
# 平台 API:camelCase 字段 + 统一分页
curl -s "$NIMEET_API_BASE/v1/channels?page_no=0&page_size=100" \
-H "Authorization: Basic $CRED"
# 频道内用户(channelName 路径参数)
curl -s "$NIMEET_API_BASE/v1/channels/weekly-sync/users" \
-H "Authorization: Basic $CRED"
# 声网兼容:频道列表(success/data 信封,snake_case 字段)
curl -s "$NIMEET_API_BASE/dev/v1/channel/$APP_ID?page_no=0&page_size=100" \
-H "Authorization: Basic $CRED"
# 声网兼容:频道内用户(communication 模式返回 users/total,LIVE 模式返回 broadcasters/audience)
curl -s "$NIMEET_API_BASE/dev/v1/channel/user/$APP_ID/weekly-sync" \
-H "Authorization: Basic $CRED"
# 声网兼容:用户状态(in_channel / role / platform)
curl -s "$NIMEET_API_BASE/dev/v1/channel/user/property/$APP_ID/10001/weekly-sync" \
-H "Authorization: Basic $CRED"Known limitations
iprules are currently recorded but not enforced:join_channeldecisions are made on(uid, channelName), because there is no trusted source of client IP at token issuance time. If you need IP-based governance, implement it at your gateway.- The target of
mute/role/kickmust be a currently online participant, otherwise the API returns404 user_not_found/channel_not_found. GET /v1/ban-rulesreturns only non-expired rules; managing expired history is not offered yet.- In the implementation
durationSeconds: 0lands asexpireAt: null(permanent), which differs from the common expectation of "kick this session only, the user may rejoin immediately" — pass an explicit positive value when integrating.