nimeet Docs
中文

Channel Management

Channel and user queries, user status, kicking rules, mute, role switching and ban rules.

Version v1.1.0 · Updated 2026-10-26 · revision 1

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

CapabilityPlatform APIAgora-compatible
Channel listGET /v1/channelsGET /dev/v1/channel/{appid}
Users in a channelGET /v1/channels/{channelName}/usersGET /dev/v1/channel/user/{appid}/{channelName}
User stateGET /v1/channels/{channelName}/users/{uid}GET /dev/v1/channel/user/property/{appid}/{uid}/{channelName}
KickDELETE /v1/channels/{channelName}/users/{uid}POST /dev/v1/kicking-rule (join_channel, which also kicks the user offline immediately)
Mute / unmutePOST /v1/channels/{channelName}/users/{uid}/mute · /unmuteExpressed through the publish_audio / publish_video privileges of kicking-rule
Role switchingPOST /v1/channels/{channelName}/users/{uid}/roleCorresponds to the client-side setClientRole semantics; no REST equivalent
Ban rulesGET/POST/PATCH/DELETE /v1/ban-rulesGET/POST/PUT/DELETE /dev/v1/kicking-rule

Querying channels

GET /v1/channels?page_no=0&page_size=100

ParameterLocationDescription
page_noquery0-based, defaults to 0
page_sizequery1–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:

  • communication channel: { channel_exist: true, mode: 1, total, users: [uid…] }
  • live channel: { channel_exist: true, mode: 2, broadcasters: [uid…], audience: [uid…], audience_total }; adding ?hosts_only=true omits 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):

  1. Create a join_channel ban rule;
  2. Remove the participant from the media plane;
  3. Record leave reason 3 (kicked) and emit user_leave / broadcaster_leave callbacks.

Response: { "kicked": true, "uid": 10001, "banRuleId": 7, "expireAt": "2026-10-26T03:00:00.000Z" }.

Mute and unmute

POST /v1/channels/{channelName}/users/{uid}/mute

FieldDescription
kindaudio / video / all (defaults to all)
banPublishDefaults to true: besides muting the tracks that are already published, also block republishing (creates a publish_audio / publish_video rule)
durationSecondsBan 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=true
  • audience: 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:

MethodPathDescription
GET/v1/ban-rules?channelName=&uid=List non-expired rules
POST/v1/ban-rulesCreate; 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 answers 403 banned directly, 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 with unmute.
  • A rule whose expireAt is null is permanent (today durationSeconds: 0 takes 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 pathOur equivalent pathNotes
GET /dev/v1/channel/{appid}GET /v1/channelsAgora 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}/usersAgora 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-rulePOST /v1/ban-rulesAgora 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-rulesAgora authenticates and lists by appid (query); we list by project credentials and support channelName / uid filters
PUT /dev/v1/kicking-rulePATCH /v1/ban-rules/{id}Both can change the duration and the privilege bits
DELETE /dev/v1/kicking-ruleDELETE /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 /unmuteAgora 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}/roleServer-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):

javascript
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 })) },
  };
}
javascript
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):

javascript
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:

bash
#!/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

  • ip rules are currently recorded but not enforced: join_channel decisions 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 / kick must be a currently online participant, otherwise the API returns 404 user_not_found / channel_not_found.
  • GET /v1/ban-rules returns only non-expired rules; managing expired history is not offered yet.
  • In the implementation durationSeconds: 0 lands as expireAt: 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.

Generated by the documentation build pipeline · 2026-10-31 01:24:02 UTC · Documentation center v1.1.0.

Content follows the public API definitions.

ni-meet is a self-hosted RTC PaaS with a LiveKit-based media plane (Apache-2.0) and an Agora-compatible API.