diff --git a/internal/server/api_guilds.go b/internal/server/api_guilds.go
index 35d9b02..d9f6416 100644
--- a/internal/server/api_guilds.go
+++ b/internal/server/api_guilds.go
@@ -62,6 +62,8 @@ type memberPayload struct {
JoinedAt string `json:"joined_at"`
TimeoutUntil string `json:"timeout_until,omitempty"`
RoleIDs []string `json:"role_ids"`
+ // Системные бейджи (AGENT.md 7.2): назначаются только системой.
+ Badges []string `json:"badges"`
// Вычисленное оформление участника на этом сервере (AGENT.md 7.2).
Cosmetics *cosmeticsPayload `json:"cosmetics,omitempty"`
}
@@ -1416,6 +1418,10 @@ func memberFromProfile(profile store.GuildMemberProfile) memberPayload {
IsInstanceAdmin: profile.IsInstanceAdmin,
JoinedAt: profile.JoinedAt.UTC().Format(time.RFC3339),
RoleIDs: make([]string, 0, len(profile.RoleIDs)),
+ Badges: profile.Badges,
+ }
+ if payload.Badges == nil {
+ payload.Badges = []string{}
}
if profile.AvatarFileID != nil {
payload.AvatarFileID = formatSnowflake(*profile.AvatarFileID)
diff --git a/internal/server/cosmetics_test.go b/internal/server/cosmetics_test.go
index d8ee1fe..9ef4f3a 100644
--- a/internal/server/cosmetics_test.go
+++ b/internal/server/cosmetics_test.go
@@ -478,3 +478,46 @@ func TestChannelBackgroundUploadAndDelete(t *testing.T) {
t.Fatalf("фон не снят: %s", removed.Body.String())
}
}
+
+func TestMembersCarrySystemBadges(t *testing.T) {
+ srv, owner, _, guildID, _ := cosmeticsFixture(t)
+ me := doJSON(t, srv, http.MethodGet, "/api/v1/users/@me", "", owner)
+ ownerID := decodeResponse[struct {
+ User struct {
+ ID string `json:"id"`
+ } `json:"user"`
+ }](t, me).User.ID
+ // Бейдж назначает система (AGENT.md 7.2): в тесте ставим его напрямую.
+ id, err := parseID("user_id", ownerID)
+ if err != nil {
+ t.Fatalf("parse user id: %v", err)
+ }
+ if err := srv.store.SetUserBadges(t.Context(), id, []string{"instance_admin"}); err != nil {
+ t.Fatalf("set badges: %v", err)
+ }
+
+ list := doJSON(t, srv, http.MethodGet, "/api/v1/guilds/"+guildID+"/members", "", owner)
+ members := decodeResponse[struct {
+ Members []struct {
+ UserID string `json:"user_id"`
+ Badges []string `json:"badges"`
+ } `json:"members"`
+ }](t, list)
+
+ var found bool
+ for _, entry := range members.Members {
+ if entry.Badges == nil {
+ t.Fatalf("бейджи должны быть списком, а не null")
+ }
+ if entry.UserID != ownerID {
+ continue
+ }
+ found = true
+ if !contains(entry.Badges, "instance_admin") {
+ t.Fatalf("бейджи владельца = %v, ожидался instance_admin", entry.Badges)
+ }
+ }
+ if !found {
+ t.Fatalf("участник %s не найден: %s", ownerID, list.Body.String())
+ }
+}
diff --git a/internal/store/guilds.go b/internal/store/guilds.go
index cc245c0..c1d4943 100644
--- a/internal/store/guilds.go
+++ b/internal/store/guilds.go
@@ -384,13 +384,17 @@ type GuildMemberProfile struct {
JoinedAt time.Time
TimeoutUntil *time.Time
RoleIDs []uint64
+ // Badges — системные бейджи пользователя (AGENT.md 7.2): список
+ // расширяемый, назначается только системой.
+ Badges []string
}
// ListGuildMemberProfiles возвращает участников сервера с профилями и ролями.
func (s *Store) ListGuildMemberProfiles(ctx context.Context, guildID uint64) ([]GuildMemberProfile, error) {
rows, err := s.reader.QueryContext(ctx, `
SELECT m.user_id, u.username, u.display_name, m.nickname, u.avatar_file_id,
- u.status, u.custom_status, u.is_instance_admin, m.joined_at, m.timeout_until
+ u.status, u.custom_status, u.is_instance_admin, m.joined_at, m.timeout_until,
+ u.badges_json
FROM guild_members m
JOIN users u ON u.id = m.user_id
WHERE m.guild_id = ? AND u.deleted_at IS NULL
@@ -409,11 +413,16 @@ func (s *Store) ListGuildMemberProfiles(ctx context.Context, guildID uint64) ([]
isAdmin int
joinedAt string
timeoutUntil sql.NullString
+ badges string
)
if err := rows.Scan(&profile.UserID, &profile.Username, &profile.DisplayName, &nickname,
- &avatarID, &profile.Status, &profile.CustomStatus, &isAdmin, &joinedAt, &timeoutUntil); err != nil {
+ &avatarID, &profile.Status, &profile.CustomStatus, &isAdmin, &joinedAt, &timeoutUntil,
+ &badges); err != nil {
return nil, err
}
+ if err := json.Unmarshal([]byte(badges), &profile.Badges); err != nil {
+ profile.Badges = nil
+ }
if nickname.Valid {
profile.Nickname = nickname.String
}
diff --git a/web/src/api/types.ts b/web/src/api/types.ts
index 902f90a..f3a229e 100644
--- a/web/src/api/types.ts
+++ b/web/src/api/types.ts
@@ -143,6 +143,8 @@ export interface GuildMember {
is_instance_admin: boolean;
joined_at: string;
role_ids: string[];
+ /** Системные бейджи участника (AGENT.md 7.2). */
+ badges?: string[];
/** Вычисленное оформление участника на сервере (AGENT.md 7.2). */
cosmetics?: MemberCosmetics;
}
diff --git a/web/src/components/chat/MessageList.tsx b/web/src/components/chat/MessageList.tsx
index 2e71786..31bddf4 100644
--- a/web/src/components/chat/MessageList.tsx
+++ b/web/src/components/chat/MessageList.tsx
@@ -5,6 +5,7 @@ import { isPendingMessage, messageTypeOf, type Message } from '@/api/messages';
import { MessageItem } from '@/components/chat/MessageItem';
import type { AuthorDirectory } from '@/components/chat/useAuthorDirectory';
import { FramedAvatar, Nickname } from '@/components/profile/Cosmetics';
+import { SystemBadges } from '@/components/profile/SystemBadges';
import { ErrorNotice } from '@/components/ui/ErrorNotice';
import { Button, Card } from '@/components/ui/primitives';
import { SkeletonLines } from '@/components/ui/Skeleton';
@@ -301,6 +302,11 @@ export function MessageList({
/>
)}
+
{headType === 'webhook' ? (
) : null}
- {author.isInstanceAdmin ? (
-
- {t('chat.adminBadge')}
-
- ) : null}
+
- {/* Ник с оформлением «для друзей»: цвет, эффект и иконка. */}
-
+ {/* Ник с оформлением «для друзей»: цвет, эффект, иконка и бейджи. */}
+
+
+
+
@{friend.username}
diff --git a/web/src/components/profile/SystemBadges.tsx b/web/src/components/profile/SystemBadges.tsx
new file mode 100644
index 0000000..caa9bbf
--- /dev/null
+++ b/web/src/components/profile/SystemBadges.tsx
@@ -0,0 +1,48 @@
+import { useTranslation } from 'react-i18next';
+
+import { badgesFor, systemBadge } from '@/lib/badges';
+
+interface SystemBadgesProps {
+ /** Имена бейджей из `badges_json` (AGENT.md 7.2). */
+ badges?: readonly string[] | undefined;
+ isOwner?: boolean;
+ isInstanceAdmin?: boolean;
+ className?: string;
+}
+
+/**
+ * Системные бейджи рядом с ником: администратор инстанса, владелец сервера и
+ * другие метки из `badges_json`. Пользователь их не редактирует.
+ */
+export function SystemBadges({
+ badges,
+ isOwner = false,
+ isInstanceAdmin = false,
+ className = '',
+}: SystemBadgesProps) {
+ const { t } = useTranslation();
+ const list = badgesFor(badges, isOwner, isInstanceAdmin);
+ if (list.length === 0) {
+ return null;
+ }
+ return (
+
+ {list.map((id) => {
+ const badge = systemBadge(id);
+ const label = badge.labelKey === '' ? id : t(badge.labelKey);
+ return (
+
+ {badge.icon}
+
+ );
+ })}
+
+ );
+}
diff --git a/web/src/i18n/locales/en.json b/web/src/i18n/locales/en.json
index 77b692e..fbc12d9 100644
--- a/web/src/i18n/locales/en.json
+++ b/web/src/i18n/locales/en.json
@@ -1115,5 +1115,11 @@
"notImage": "An image file is required",
"tooLarge": "File is larger than 5 MB"
}
+ },
+ "badges": {
+ "instanceAdmin": "Instance administrator",
+ "guildOwner": "Server owner",
+ "earlySupporter": "Early supporter",
+ "verified": "Verified"
}
}
diff --git a/web/src/i18n/locales/ru.json b/web/src/i18n/locales/ru.json
index 7dab358..003d32e 100644
--- a/web/src/i18n/locales/ru.json
+++ b/web/src/i18n/locales/ru.json
@@ -1115,5 +1115,11 @@
"notImage": "Нужен файл изображения",
"tooLarge": "Файл больше 5 МБ"
}
+ },
+ "badges": {
+ "instanceAdmin": "Администратор инстанса",
+ "guildOwner": "Владелец сервера",
+ "earlySupporter": "Ранний сторонник",
+ "verified": "Проверенный"
}
}
diff --git a/web/src/lib/badges.ts b/web/src/lib/badges.ts
new file mode 100644
index 0000000..fccd53b
--- /dev/null
+++ b/web/src/lib/badges.ts
@@ -0,0 +1,52 @@
+/**
+ * Системные бейджи (AGENT.md 7.2): метки рядом с ником, которые назначает
+ * только система. Список расширяемый (`badges_json`), поэтому неизвестные
+ * бейджи показываются нейтральной меткой, а не пропадают.
+ */
+
+export interface SystemBadge {
+ /** Имя бейджа из `badges_json`. */
+ id: string;
+ /** Значок: эмодзи, чтобы не тащить в бандл отдельные картинки. */
+ icon: string;
+ /** Ключ подписи в локалях. */
+ labelKey: string;
+}
+
+/** Известные системные бейджи. */
+export const SYSTEM_BADGES: SystemBadge[] = [
+ { id: 'instance_admin', icon: '🛡', labelKey: 'badges.instanceAdmin' },
+ { id: 'guild_owner', icon: '👑', labelKey: 'badges.guildOwner' },
+ { id: 'early_supporter', icon: '💎', labelKey: 'badges.earlySupporter' },
+ { id: 'verified', icon: '✔', labelKey: 'badges.verified' },
+];
+
+/** Бейдж владельца сервера: система его не хранит, он вычисляется на клиенте. */
+export const GUILD_OWNER_BADGE = 'guild_owner';
+
+/** Описание бейджа: известные — из каталога, остальные — нейтральные. */
+export function systemBadge(id: string): SystemBadge {
+ const known = SYSTEM_BADGES.find((badge) => badge.id === id);
+ if (known !== undefined) {
+ return known;
+ }
+ return { id, icon: '•', labelKey: '' };
+}
+
+/** Список бейджей участника: системные плюс вычисленный бейдж владельца. */
+export function badgesFor(
+ badges: readonly string[] | undefined,
+ isOwner: boolean,
+ isInstanceAdmin = false,
+): string[] {
+ const result = new Set(badges ?? []);
+ // Администратор инстанса всегда помечен, даже если `badges_json` пуст
+ // (бейдж назначается системой при выдаче прав).
+ if (isInstanceAdmin) {
+ result.add('instance_admin');
+ }
+ if (isOwner) {
+ result.add(GUILD_OWNER_BADGE);
+ }
+ return [...result];
+}
diff --git a/web/src/pages/settings/GuildMembersSection.tsx b/web/src/pages/settings/GuildMembersSection.tsx
index 6c6421a..e5ef069 100644
--- a/web/src/pages/settings/GuildMembersSection.tsx
+++ b/web/src/pages/settings/GuildMembersSection.tsx
@@ -2,6 +2,7 @@ import { useTranslation } from 'react-i18next';
import type { GuildMember } from '@/api/types';
import { FramedAvatar, Nickname } from '@/components/profile/Cosmetics';
+import { SystemBadges } from '@/components/profile/SystemBadges';
import { ErrorNotice } from '@/components/ui/ErrorNotice';
import { Badge } from '@/components/ui/primitives';
import { SkeletonLines } from '@/components/ui/Skeleton';
@@ -20,6 +21,8 @@ interface GuildMembersSectionProps {
canTimeout?: boolean;
/** Свой профиль: себя модерировать нельзя. */
currentUserId?: string | undefined;
+ /** Владелец сервера: рядом с ником показывается его бейдж (AGENT.md 7.2). */
+ ownerId?: string | undefined;
}
/** Сколько участников показываем в превью (полный список — в следующих фазах). */
@@ -36,6 +39,7 @@ export function GuildMembersSection({
canBan = false,
canTimeout = false,
currentUserId,
+ ownerId,
}: GuildMembersSectionProps) {
const { t } = useTranslation();
const preview = (members ?? []).slice(0, PREVIEW_LIMIT);
@@ -72,11 +76,18 @@ export function GuildMembersSection({
/>
{/* Оформление участника приходит уже вычисленным (AGENT.md 7.2). */}
-
+
+
+
+
{member.nickname === undefined || member.nickname === ''
? `@${member.username}`
diff --git a/web/src/pages/settings/GuildSettingsPage.tsx b/web/src/pages/settings/GuildSettingsPage.tsx
index a6f0581..91ce486 100644
--- a/web/src/pages/settings/GuildSettingsPage.tsx
+++ b/web/src/pages/settings/GuildSettingsPage.tsx
@@ -212,6 +212,7 @@ function GuildSettingsBody({ guilds }: { guilds: { id: string }[] }) {
canBan={canBan}
canTimeout={canModerateMembers('TIMEOUT_MEMBERS')}
currentUserId={currentUser.data?.id}
+ ownerId={guild.owner_id}
/>
({
@@ -109,6 +110,7 @@ const authors: AuthorDirectory = {
avatarFileId: undefined,
colorHex: null,
isInstanceAdmin: false,
+ isOwner: false,
isMember: false,
}),
resolveMention: (userId) => ({
diff --git a/web/tests/composerFormat.test.tsx b/web/tests/composerFormat.test.tsx
index c3145b8..6e5332a 100644
--- a/web/tests/composerFormat.test.tsx
+++ b/web/tests/composerFormat.test.tsx
@@ -31,6 +31,7 @@ const authors: AuthorDirectory = {
avatarFileId: undefined,
colorHex: null,
isInstanceAdmin: false,
+ isOwner: false,
isMember: true,
}),
resolveWebhook: () => ({
@@ -39,6 +40,7 @@ const authors: AuthorDirectory = {
avatarFileId: undefined,
colorHex: null,
isInstanceAdmin: false,
+ isOwner: false,
isMember: false,
}),
resolveMention: (userId) => ({ name: userId === 'user-2' ? 'Bob' : userId, isMe: false }),
diff --git a/web/tests/guildEmojis.test.tsx b/web/tests/guildEmojis.test.tsx
index 4d175bd..bf6de8a 100644
--- a/web/tests/guildEmojis.test.tsx
+++ b/web/tests/guildEmojis.test.tsx
@@ -72,6 +72,7 @@ const authors: AuthorDirectory = {
avatarFileId: undefined,
colorHex: null,
isInstanceAdmin: false,
+ isOwner: false,
isMember: true,
}),
resolveWebhook: () => ({
@@ -80,6 +81,7 @@ const authors: AuthorDirectory = {
avatarFileId: undefined,
colorHex: null,
isInstanceAdmin: false,
+ isOwner: false,
isMember: false,
}),
resolveMention: (userId) => ({ name: userId, isMe: false }),
diff --git a/web/tests/messageTypes.test.tsx b/web/tests/messageTypes.test.tsx
index 311d9a5..1575b6b 100644
--- a/web/tests/messageTypes.test.tsx
+++ b/web/tests/messageTypes.test.tsx
@@ -19,6 +19,7 @@ const authors: AuthorDirectory = {
avatarFileId: undefined,
colorHex: null,
isInstanceAdmin: false,
+ isOwner: false,
isMember: true,
}),
resolveWebhook: () => ({
@@ -27,6 +28,7 @@ const authors: AuthorDirectory = {
avatarFileId: undefined,
colorHex: null,
isInstanceAdmin: false,
+ isOwner: false,
isMember: false,
}),
resolveMention: (userId) => ({ name: userId === 'user-1' ? 'Аля' : 'Bob', isMe: false }),
@@ -223,6 +225,7 @@ describe('сообщения вебхуков', () => {
avatarUrl: message.webhook_avatar,
colorHex: null,
isInstanceAdmin: false,
+ isOwner: false,
isMember: false,
}),
};
diff --git a/web/tests/profileCosmetics.test.tsx b/web/tests/profileCosmetics.test.tsx
index 53f1b8a..04a343c 100644
--- a/web/tests/profileCosmetics.test.tsx
+++ b/web/tests/profileCosmetics.test.tsx
@@ -4,6 +4,7 @@ import userEvent from '@testing-library/user-event';
import { AnimatedImage } from '@/components/ui/AnimatedImage';
import { FramedAvatar, Nickname } from '@/components/profile/Cosmetics';
+import { SystemBadges } from '@/components/profile/SystemBadges';
import { nickColorStyle, nickEffectClass } from '@/components/profile/cosmeticsStyle';
import {
bodyOf,
@@ -498,3 +499,31 @@ describe('фон комнаты', () => {
expect(image?.getAttribute('data-animated')).toBe('animated');
});
});
+
+describe('системные бейджи', () => {
+ it('показывает щит администратора инстанса и корону владельца', () => {
+ render();
+
+ expect(screen.getByTestId('system-badge-instance_admin')).toHaveTextContent('🛡');
+ expect(screen.getByTestId('system-badge-guild_owner')).toHaveTextContent('👑');
+ expect(screen.getByTestId('system-badge-early_supporter')).toHaveAttribute(
+ 'title',
+ 'Ранний сторонник',
+ );
+ // Администратор инстанса помечен даже без записи в badges_json.
+ expect(screen.getByLabelText('Администратор инстанса')).toBeInTheDocument();
+ });
+
+ it('неизвестный бейдж показывается нейтральной меткой, а не пропадает', () => {
+ render();
+
+ const badge = screen.getByTestId('system-badge-future_badge');
+ expect(badge).toHaveTextContent('•');
+ expect(badge).toHaveAttribute('title', 'future_badge');
+ });
+
+ it('без бейджей ничего не рисует', () => {
+ const { container } = render();
+ expect(container.querySelector('[data-testid="system-badges"]')).toBeNull();
+ });
+});
diff --git a/web/tests/systemMessages.test.tsx b/web/tests/systemMessages.test.tsx
index f747e74..5f7c4f1 100644
--- a/web/tests/systemMessages.test.tsx
+++ b/web/tests/systemMessages.test.tsx
@@ -29,6 +29,7 @@ const authors: AuthorDirectory = {
avatarFileId: undefined,
colorHex: null,
isInstanceAdmin: false,
+ isOwner: false,
isMember: userId === 'user-2',
}),
resolveWebhook: () => ({
@@ -37,6 +38,7 @@ const authors: AuthorDirectory = {
avatarFileId: undefined,
colorHex: null,
isInstanceAdmin: false,
+ isOwner: false,
isMember: false,
}),
resolveMention: (userId) => ({ name: userId === 'user-2' ? 'Боб' : userId, isMe: false }),