import pick from 'lodash.pick'; import * as v from 'valibot'; import { isDefaultAvatar, isDefaultHeader } from '../utils/accounts'; import { guessFqn } from '../utils/domain'; import { customEmojiSchema } from './custom-emoji'; import { relationshipSchema } from './relationship'; import { roleSchema } from './role'; import { coerceObject, datetimeSchema, filteredArray } from './utils'; const filterBadges = (tags?: string[]) => tags?.filter(tag => tag.startsWith('badge:')).map(tag => v.parse(roleSchema, { id: tag, name: tag.replace(/^badge:/, '') })); const MKLJCZK_ACCOUNTS = ['https://pl.fediverse.pl/users/mkljczk', 'https://gts.mkljczk.pl/users/mkljczk', 'https://gts.mkljczk.pl/@mkljczk']; const paymentOptionSchema = v.variant('type', [ v.object({ /** Payment type */ type: v.literal('link'), /** Link name (only for link type) */ name: v.fallback(v.nullable(v.string()), null), /** Link URL (only for link type) */ href: v.fallback(v.nullable(v.string()), null), /** Unique identifier of a proposal object */ object_id: v.fallback(v.nullable(v.string()), null), }), v.object({ /** Payment type */ type: v.literal('monero-subscription'), /** CAIP-2 chain ID (only for monero-subscription type) */ chain_id: v.fallback(v.nullable(v.string()), null), /** Subscription price (only for monero-subscription type) */ price: v.fallback(v.nullable(v.number()), null), /** Minimum payment amount (only for monero-subscription type) */ amount_min: v.fallback(v.nullable(v.number()), null), /** Unique identifier of a proposal object */ object_id: v.fallback(v.nullable(v.string()), null), }), ]); const preprocessAccount = v.transform((account: any) => { if (!account?.acct) return null; const username = account.username || account.acct.split('@')[0]; const fqn = account.fqn || guessFqn(account); const domain = fqn.split('@')[1] || ''; const isCat = (account.pleroma?.is_cat ?? account.is_cat) || MKLJCZK_ACCOUNTS.includes(account.uri ?? account.url); const speakAsCat = account.pleroma?.speak_as_cat ?? account.speak_as_cat ?? isCat; return { username, fqn, domain, avatar: account.avatar || account.avatar_static, header: account.header || account.header_static, avatar_default: isDefaultAvatar(account.avatar || account.avatar_static || ''), header_default: isDefaultHeader(account.header || account.header_static || ''), local: typeof account.pleroma?.is_local === 'boolean' ? account.pleroma.is_local : account.acct.split('@')[1] === undefined, discoverable: account.discoverable || account.pleroma?.source?.discoverable, verified: account.verified || account.pleroma?.tags?.includes('verified'), ...(account.role?.permissions ? { is_admin: (account.role?.permissions & 0x1) === 0x1, } : {}), ap_id: account.pleroma?.ap_id ?? account.actor_id, ...(pick(account.pleroma || {}, [ 'background_image', 'relationship', 'is_moderator', 'is_admin', 'hide_favorites', 'hide_followers', 'hide_follows', 'hide_followers_count', 'hide_follows_count', 'accepts_chat_messages', 'favicon', 'birthday', 'deactivated', 'avatar_description', 'header_description', 'settings_store', 'chat_token', 'allow_following_move', 'unread_conversation_count', 'unread_notifications_count', 'notification_settings', 'location', ])), ...(pick(account.akkoma || {}, [ 'permit_followback', ])), is_cat: isCat, speak_as_cat: speakAsCat, ...(pick(account.other_settings || {}), ['birthday', 'location']), __meta: pick(account, ['pleroma', 'source']), ...account, display_name: account.display_name?.replace(/^[\s\u180E\u200B-\u200D\u2060\uFEFF]+|[\s\u180E\u200B-\u200D\u2060\uFEFF]+$/g, '').trim() || username, roles: account.roles?.length ? account.roles : filterBadges(account.pleroma?.tags), source: account.source ? { ...(pick(account.pleroma?.source || {}, [ 'show_role', 'no_rich_text', 'discoverable', 'actor_type', 'show_birthday', ])), ...account.source } : undefined, }; }); const fieldSchema = v.object({ name: v.string(), value: v.string(), verified_at: v.fallback(v.nullable(datetimeSchema), null), }); const baseAccountSchema = v.object({ id: v.string(), username: v.fallback(v.string(), ''), acct: v.fallback(v.string(), ''), url: v.pipe(v.string(), v.url()), display_name: v.fallback(v.string(), ''), note: v.fallback(v.pipe(v.string(), v.transform(note => note === '
' ? '' : note)), ''), avatar: v.fallback(v.string(), ''), avatar_static: v.fallback(v.pipe(v.string(), v.url()), ''), header: v.fallback(v.pipe(v.string(), v.url()), ''), header_static: v.fallback(v.pipe(v.string(), v.url()), ''), locked: v.fallback(v.boolean(), false), fields: filteredArray(fieldSchema), emojis: filteredArray(customEmojiSchema), bot: v.fallback(v.boolean(), false), group: v.fallback(v.boolean(), false), discoverable: v.fallback(v.boolean(), false), indexable: v.fallback(v.nullable(v.boolean()), null), noindex: v.fallback(v.nullable(v.boolean()), null), memorial: v.fallback(v.nullable(v.boolean()), null), suspended: v.fallback(v.optional(v.boolean()), undefined), limited: v.fallback(v.optional(v.boolean()), undefined), created_at: v.fallback(datetimeSchema, new Date().toISOString()), last_status_at: v.fallback(v.nullable(v.pipe(v.string(), v.isoDate())), null), statuses_count: v.fallback(v.number(), 0), followers_count: v.fallback(v.number(), 0), following_count: v.fallback(v.number(), 0), roles: filteredArray(roleSchema), hide_collections: v.fallback(v.optional(v.boolean()), undefined), fqn: v.string(), ap_id: v.fallback(v.nullable(v.string()), null), background_image: v.fallback(v.nullable(v.string()), null), relationship: v.fallback(v.optional(relationshipSchema), undefined), is_moderator: v.fallback(v.optional(v.boolean()), undefined), is_admin: v.fallback(v.optional(v.boolean()), undefined), is_suggested: v.fallback(v.optional(v.boolean()), undefined), hide_favorites: v.fallback(v.boolean(), true), hide_followers: v.fallback(v.optional(v.boolean()), undefined), hide_follows: v.fallback(v.optional(v.boolean()), undefined), hide_followers_count: v.fallback(v.optional(v.boolean()), undefined), hide_follows_count: v.fallback(v.optional(v.boolean()), undefined), accepts_chat_messages: v.fallback(v.nullable(v.boolean()), null), favicon: v.fallback(v.optional(v.string()), undefined), birthday: v.fallback(v.optional(v.pipe(v.string(), v.isoDate())), undefined), deactivated: v.fallback(v.optional(v.boolean()), undefined), location: v.fallback(v.optional(v.string()), undefined), local: v.fallback(v.optional(v.boolean()), false), permit_followback: v.fallback(v.optional(v.boolean()), undefined), avatar_description: v.fallback(v.string(), ''), custom_css: v.fallback(v.string(), ''), enable_rss: v.fallback(v.boolean(), false), header_description: v.fallback(v.string(), ''), verified: v.fallback(v.optional(v.boolean()), undefined), domain: v.fallback(v.string(), ''), pronouns: v.fallback(v.array(v.string()), []), /** Mention policy */ mention_policy: v.fallback(v.picklist(['none', 'only_known', 'only_contacts']), 'none'), /** The reported subscribers of this user */ subscribers_count: v.fallback(v.number(), 0), /** Identity proofs */ identity_proofs: filteredArray(v.object({ /** The key of a given field's key-value pair */ name: v.fallback(v.string(), ''), /** The value associated with the name key */ value: v.fallback(v.string(), ''), /** Timestamp of when the server verified the field value */ verified_at: v.fallback(datetimeSchema, new Date().toISOString()), })), /** Payment options */ payment_options: filteredArray(paymentOptionSchema), /** Whether the user is a cat */ is_cat: v.fallback(v.boolean(), false), /** Whether the user's posts should be nyanified */ speak_as_cat: v.fallback(v.boolean(), false), __meta: coerceObject({ pleroma: v.optional(v.any(), undefined), source: v.optional(v.any(), undefined), }), avatar_default: v.fallback(v.boolean(), false), header_default: v.fallback(v.boolean(), false), }); const accountWithMovedAccountSchema = v.object({ ...baseAccountSchema.entries, moved: v.fallback(v.nullable(v.lazy((): typeof baseAccountSchema => accountWithMovedAccountSchema as any)), null), }); /** @see {@link https://docs.joinmastodon.org/entities/Account/} */ const untypedAccountSchema = v.pipe(v.any(), preprocessAccount, accountWithMovedAccountSchema); type WithMoved = { moved: Account | null; }; /** * @category Entity types */ type Account = v.InferOutput